diff --git a/python/configs/providers/openrouter.yaml b/python/configs/providers/openrouter.yaml index 192b42251..b0660fc58 100644 --- a/python/configs/providers/openrouter.yaml +++ b/python/configs/providers/openrouter.yaml @@ -1,50 +1,62 @@ - # ============================================ - # OpenRouter Provider Configuration - # ============================================ - name: "OpenRouter" - provider_type: "openrouter" - - enabled: true # Default is true if not specified - - # Connection Configuration - connection: - base_url: "https://openrouter.ai/api/v1" - api_key_env: "OPENROUTER_API_KEY" - - # Default model if none specified - default_model: "anthropic/claude-haiku-4.5" - - # Model Parameters Defaults +# ============================================ +# OpenRouter Provider Configuration +# ============================================ +name: "OpenRouter" +provider_type: "openrouter" + +enabled: true # Default is true if not specified + +# Connection Configuration +connection: + base_url: "https://openrouter.ai/api/v1" + api_key_env: "OPENROUTER_API_KEY" + +# Default model if none specified +default_model: "anthropic/claude-haiku-4.5" + +# Model Parameters Defaults +defaults: + temperature: 0.5 + +# Extra headers for OpenRouter API +extra_headers: + HTTP-Referer: "https://valuecell.ai" + X-Title: "ValueCell" + +# Available Models (commonly used) +models: + - id: "anthropic/claude-haiku-4.5" + name: "Claude Haiku 4.5" + - id: "x-ai/grok-4" + name: "Grok 4" + - id: "qwen/qwen-max" + name: "Qwen Max" + - id: "openai/gpt-5" + name: "GPT-5" + - id: "google/gemini-2.5-flash" + name: "Gemini 2.5 Flash" + - id: "google/gemini-2.5-pro" + name: "Gemini 2.5 Pro" + +# ============================================ +# Embedding Models Configuration +# ============================================ +# OpenRouter provides embedding models +embedding: + # Default embedding model + default_model: "qwen/qwen3-embedding-4b" + + # Default parameters defaults: - temperature: 0.5 - - # Extra headers for OpenRouter API - extra_headers: - HTTP-Referer: "https://valuecell.ai" - X-Title: "ValueCell" + dimensions: 2560 + encoding_format: "float" - # Available Models (commonly used) + # Available embedding models models: - - - id: "anthropic/claude-haiku-4.5" - name: "Claude Haiku 4.5" - - - id: "x-ai/grok-4" - name: "Grok 4" - - - id: "qwen/qwen-max" - name: "Qwen Max" - - - id: "openai/gpt-5" - name: "GPT-5" - - - id: "google/gemini-2.5-flash" - name: "Gemini 2.5 Flash" - - - id: "google/gemini-2.5-pro" - name: "Gemini 2.5 Pro" - - # Note: OpenRouter does not support embedding models - # For embedding, use other providers like OpenAI or SiliconFlow etc + - id: "qwen/qwen3-embedding-4b" + name: "Qwen3 Embedding 4B" + dimensions: 2560 + max_input: 32768 + description: "Qwen3 Embedding Model" diff --git a/python/valuecell/agents/research_agent/core.py b/python/valuecell/agents/research_agent/core.py index 1c83777a1..858f06bd9 100644 --- a/python/valuecell/agents/research_agent/core.py +++ b/python/valuecell/agents/research_agent/core.py @@ -7,7 +7,7 @@ from loguru import logger import valuecell.utils.model as model_utils_mod -from valuecell.agents.research_agent.knowledge import knowledge +from valuecell.agents.research_agent.knowledge import get_knowledge from valuecell.agents.research_agent.prompts import ( KNOWLEDGE_AGENT_EXPECTED_OUTPUT, KNOWLEDGE_AGENT_INSTRUCTION, @@ -37,6 +37,8 @@ def __init__(self, **kwargs): # search_crypto_vcs, # search_crypto_people, ] + # Lazily obtain knowledge; disable search if unavailable + knowledge = get_knowledge() self.knowledge_research_agent = Agent( model=model_utils_mod.get_model_for_agent("research_agent"), instructions=[KNOWLEDGE_AGENT_INSTRUCTION], @@ -45,7 +47,7 @@ def __init__(self, **kwargs): knowledge=knowledge, db=InMemoryDb(), # context - search_knowledge=True, + search_knowledge=knowledge is not None, add_datetime_to_context=True, add_history_to_context=True, num_history_runs=3, @@ -54,7 +56,14 @@ def __init__(self, **kwargs): # configuration debug_mode=agent_debug_mode_enabled(), ) - set_identity(os.getenv("SEC_EMAIL")) + # Configure EDGAR identity only when SEC_EMAIL is present + sec_email = os.getenv("SEC_EMAIL") + if sec_email: + set_identity(sec_email) + else: + logger.warning( + "SEC_EMAIL not set; EDGAR identity is not configured for ResearchAgent." + ) async def stream( self, diff --git a/python/valuecell/agents/research_agent/knowledge.py b/python/valuecell/agents/research_agent/knowledge.py index 8be8447ff..82d6bd8bc 100644 --- a/python/valuecell/agents/research_agent/knowledge.py +++ b/python/valuecell/agents/research_agent/knowledge.py @@ -5,13 +5,44 @@ from agno.knowledge.knowledge import Knowledge from agno.knowledge.reader.markdown_reader import MarkdownReader from agno.knowledge.reader.pdf_reader import PDFReader +from loguru import logger + +from .vdb import get_vector_db + +_knowledge_cache: Optional[Knowledge] = None + + +def get_knowledge() -> Optional[Knowledge]: + """Lazily create and cache the Knowledge instance. + + Returns None when embeddings/vector DB are unavailable, enabling a + tools-only mode without knowledge search. + """ + global _knowledge_cache + if _knowledge_cache is not None: + return _knowledge_cache + + vdb = get_vector_db() + if vdb is None: + logger.warning( + "ResearchAgent Knowledge disabled: vector DB unavailable (no embeddings)." + ) + return None + + try: + _knowledge_cache = Knowledge( + vector_db=vdb, + max_results=10, + ) + return _knowledge_cache + except Exception as e: + logger.warning( + "Failed to create Knowledge for ResearchAgent; disabling. Error: {}", + e, + ) + return None -from .vdb import vector_db -knowledge = Knowledge( - vector_db=vector_db, - max_results=10, -) md_reader = MarkdownReader(chunking_strategy=MarkdownChunking()) pdf_reader = PDFReader(chunking_strategy=MarkdownChunking()) @@ -19,6 +50,12 @@ async def insert_md_file_to_knowledge( name: str, path: Path, metadata: Optional[dict] = None ): + knowledge = get_knowledge() + if knowledge is None: + logger.warning( + "Skipping markdown insertion: Knowledge disabled (no embeddings configured)." + ) + return await knowledge.add_content_async( name=name, path=path, @@ -28,6 +65,12 @@ async def insert_md_file_to_knowledge( async def insert_pdf_file_to_knowledge(url: str, metadata: Optional[dict] = None): + knowledge = get_knowledge() + if knowledge is None: + logger.warning( + "Skipping PDF insertion: Knowledge disabled (no embeddings configured)." + ) + return await knowledge.add_content_async( url=url, metadata=metadata, diff --git a/python/valuecell/agents/research_agent/tests/test_fault_tolerance.py b/python/valuecell/agents/research_agent/tests/test_fault_tolerance.py new file mode 100644 index 000000000..9dd5e2809 --- /dev/null +++ b/python/valuecell/agents/research_agent/tests/test_fault_tolerance.py @@ -0,0 +1,230 @@ +import pytest + + +def _raise_unavailable(*args, **kwargs): + raise ValueError("No embedding provider configured") + + +def test_get_vector_db_returns_none_when_embedder_unavailable(monkeypatch): + # Cause embedder creation to fail + import valuecell.agents.research_agent.vdb as vdb + + monkeypatch.setattr( + vdb.model_utils_mod, "get_embedder_for_agent", _raise_unavailable + ) + + assert vdb.get_vector_db() is None + + +def test_get_knowledge_returns_none_without_embeddings(monkeypatch): + # Cause embedder creation to fail + import valuecell.agents.research_agent.vdb as vdb + + monkeypatch.setattr( + vdb.model_utils_mod, "get_embedder_for_agent", _raise_unavailable + ) + + from valuecell.agents.research_agent.knowledge import get_knowledge + + assert get_knowledge() is None + + +def test_research_agent_initializes_without_knowledge_when_embeddings_missing( + monkeypatch, +): + # Cause embedder creation to fail + import valuecell.agents.research_agent.vdb as vdb + + monkeypatch.setattr( + vdb.model_utils_mod, "get_embedder_for_agent", _raise_unavailable + ) + + # Stub model creation to avoid provider requirements + import valuecell.utils.model as model_utils_mod + + monkeypatch.setattr(model_utils_mod, "get_model_for_agent", lambda name: object()) + + # Replace Agent with a dummy capturing params + import valuecell.agents.research_agent.core as core_mod + + class DummyAgent: + def __init__( + self, + *, + model, + instructions, + expected_output, + tools, + knowledge, + db, + search_knowledge, + **kwargs, + ): + self.model = model + self.instructions = instructions + self.expected_output = expected_output + self.tools = tools + self.knowledge = knowledge + self.db = db + self.search_knowledge = search_knowledge + + monkeypatch.setattr(core_mod, "Agent", DummyAgent) + + from valuecell.agents.research_agent.core import ResearchAgent + + ra = ResearchAgent() + assert ra.knowledge_research_agent.knowledge is None + assert ra.knowledge_research_agent.search_knowledge is False + + +def test_get_vector_db_success_path(monkeypatch): + import valuecell.agents.research_agent.vdb as vdb + + # Provide a fake embedder and LanceDb to exercise success path + monkeypatch.setattr( + vdb.model_utils_mod, "get_embedder_for_agent", lambda name: object() + ) + + class DummyLanceDb: + def __init__(self, **kwargs): + self.kwargs = kwargs + + monkeypatch.setattr(vdb, "LanceDb", DummyLanceDb) + + db = vdb.get_vector_db() + assert isinstance(db, DummyLanceDb) + + # Exercise failure path in LanceDb constructor to cover exception branch + class RaisingLanceDb: + def __init__(self, **kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(vdb, "LanceDb", RaisingLanceDb) + assert vdb.get_vector_db() is None + + +@pytest.mark.asyncio +async def test_insert_functions_noop_when_disabled(monkeypatch, tmp_path): + # Cause embedder creation to fail + import valuecell.agents.research_agent.vdb as vdb + + monkeypatch.setattr( + vdb.model_utils_mod, "get_embedder_for_agent", _raise_unavailable + ) + + from valuecell.agents.research_agent.knowledge import ( + insert_md_file_to_knowledge, + insert_pdf_file_to_knowledge, + ) + + md_file = tmp_path / "doc.md" + md_file.write_text("# Title\nBody") + + # Should not raise even though knowledge is disabled + await insert_md_file_to_knowledge("doc", md_file) + await insert_pdf_file_to_knowledge("https://example.com/doc.pdf") + + +@pytest.mark.asyncio +async def test_insert_functions_invoke_add_content_when_enabled(monkeypatch, tmp_path): + # Patch get_knowledge to return a dummy knowledge object + from valuecell.agents.research_agent import knowledge as knowledge_mod + + class DummyKnowledge: + def __init__(self): + self.calls = [] + + async def add_content_async(self, **kwargs): + self.calls.append(kwargs) + + dummy = DummyKnowledge() + monkeypatch.setattr(knowledge_mod, "_knowledge_cache", None) + monkeypatch.setattr(knowledge_mod, "get_knowledge", lambda: dummy) + + from valuecell.agents.research_agent.knowledge import ( + insert_md_file_to_knowledge, + insert_pdf_file_to_knowledge, + ) + + md_file = tmp_path / "doc2.md" + md_file.write_text("# Title\nBody") + + await insert_md_file_to_knowledge("doc2", md_file, metadata={"k": "v"}) + await insert_pdf_file_to_knowledge("https://example.com/doc2.pdf") + + # Verify that add_content_async was called for both inserts + assert len(dummy.calls) == 2 + assert any("path" in c for c in dummy.calls) + assert any("url" in c for c in dummy.calls) + + +def test_get_knowledge_success_path_creates_and_caches(monkeypatch): + from valuecell.agents.research_agent import knowledge as knowledge_mod + + # Use a dummy Knowledge to avoid external dependency behavior + class DummyKnowledge: + def __init__(self, *, vector_db, max_results): + self.vector_db = vector_db + self.max_results = max_results + + monkeypatch.setattr(knowledge_mod, "Knowledge", DummyKnowledge) + monkeypatch.setattr(knowledge_mod, "_knowledge_cache", None) + monkeypatch.setattr(knowledge_mod, "get_vector_db", lambda: object()) + + from valuecell.agents.research_agent.knowledge import get_knowledge + + k1 = get_knowledge() + k2 = get_knowledge() + assert isinstance(k1, DummyKnowledge) + assert k1 is k2 # cached + + +@pytest.mark.asyncio +async def test_stream_yields_events_without_knowledge(monkeypatch): + # Cause embedder creation to fail and stub model creation + import valuecell.agents.research_agent.vdb as vdb + + monkeypatch.setattr( + vdb.model_utils_mod, "get_embedder_for_agent", _raise_unavailable + ) + + import valuecell.utils.model as model_utils_mod + + monkeypatch.setattr(model_utils_mod, "get_model_for_agent", lambda name: object()) + + import types as _types + import valuecell.agents.research_agent.core as core_mod + + class DummyAgent: + def __init__(self, **kwargs): + pass + + async def arun(self, *args, **kwargs): + # Yield three events to exercise stream handling + yield _types.SimpleNamespace(event="RunContent", content="hello") + yield _types.SimpleNamespace( + event="ToolCallStarted", + tool=_types.SimpleNamespace(tool_call_id="id1", tool_name="foo"), + ) + yield _types.SimpleNamespace( + event="ToolCallCompleted", + tool=_types.SimpleNamespace( + result="ok", tool_call_id="id1", tool_name="foo" + ), + ) + + monkeypatch.setattr(core_mod, "Agent", DummyAgent) + from valuecell.agents.research_agent.core import ResearchAgent + + ra = ResearchAgent() + # Iterate the stream to ensure branches are executed + events = [] + async for ev in ra.stream( + query="q", + conversation_id="c", + task_id="t", + dependencies={"a": 1}, + ): + events.append(ev) + # Should have yielded message chunk, tool start, tool complete, and done + assert len(events) == 4 diff --git a/python/valuecell/agents/research_agent/vdb.py b/python/valuecell/agents/research_agent/vdb.py index 4aa04f4ac..f60b9b480 100644 --- a/python/valuecell/agents/research_agent/vdb.py +++ b/python/valuecell/agents/research_agent/vdb.py @@ -1,44 +1,53 @@ """ Vector database configuration for Research Agent. -This module uses the centralized configuration system to create an embedder -and vector database. It automatically: -1. Selects an available provider with embedding support (OpenAI, SiliconFlow, etc.) -2. Uses the provider's API key from .env -3. Falls back to other providers if the primary fails -4. Respects environment variable overrides (EMBEDDER_MODEL_ID, EMBEDDER_DIMENSION) - -Configuration Priority (highest to lowest): -1. Environment Variables (EMBEDDER_MODEL_ID, EMBEDDER_DIMENSION, etc.) -2. .env file (OPENROUTER_API_KEY, SILICONFLOW_API_KEY, etc.) -3. YAML files (configs/agents/research_agent.yaml, configs/providers/*.yaml) +Fault-tolerant, lazy initialization: +- Attempts to create an embedder and LanceDb only when requested. +- If no embedding provider/API key is available, returns None instead of raising. +- Respects environment variable overrides (e.g., EMBEDDER_MODEL_ID). + +This prevents import-time failures and allows the ResearchAgent to run in a +"tools-only" mode without knowledge search when embeddings are not configured. """ +from typing import Optional + from agno.vectordb.lancedb import LanceDb from agno.vectordb.search import SearchType +from loguru import logger import valuecell.utils.model as model_utils_mod from valuecell.utils.db import resolve_lancedb_uri -# Create embedder using the configuration system -# This will: -# - Check EMBEDDER_MODEL_ID env var first -# - Auto-select provider with embedding support (e.g., SiliconFlow if SILICONFLOW_API_KEY is set) -# - Use provider's default embedding model if not specified -# - Fall back to other providers if primary fails -embedder = model_utils_mod.get_embedder_for_agent("research_agent") - -# Alternative usage examples: -# embedder = get_embedder() # Use default env key -# embedder = get_embedder("EMBEDDER_MODEL_ID", dimensions=3072) # Override dimensions -# embedder = get_embedder_for_agent("research_agent") # Use agent-specific config - -# Create vector database with the configured embedder -vector_db = LanceDb( - table_name="research_agent_knowledge_base", - uri=resolve_lancedb_uri(), - embedder=embedder, - # reranker=reranker, # Optional: can be configured later, reranker config in modelprovider yaml file if needed - search_type=SearchType.hybrid, - use_tantivy=False, -) + +def get_vector_db() -> Optional[LanceDb]: + """Create and return the LanceDb instance, or None if embeddings are unavailable. + + This function is safe to call at runtime; it will not raise during normal + missing-configuration scenarios. Unexpected errors are logged and result in + a None return to enable graceful degradation. + """ + try: + embedder = model_utils_mod.get_embedder_for_agent("research_agent") + except Exception as e: + logger.warning( + "ResearchAgent embeddings unavailable; disabling knowledge search. Error: {}", + e, + ) + return None + + try: + return LanceDb( + table_name="research_agent_knowledge_base", + uri=resolve_lancedb_uri(), + embedder=embedder, + # reranker=reranker, # Optional: can be configured later + search_type=SearchType.hybrid, + use_tantivy=False, + ) + except Exception as e: + logger.warning( + "Failed to initialize LanceDb for ResearchAgent; disabling knowledge. Error: {}", + e, + ) + return None