diff --git a/python/valuecell/core/plan/planner.py b/python/valuecell/core/plan/planner.py index 081a1f7dc..eb54d2d81 100644 --- a/python/valuecell/core/plan/planner.py +++ b/python/valuecell/core/plan/planner.py @@ -88,31 +88,48 @@ def __init__( agent_connections: RemoteConnections, ): self.agent_connections = agent_connections - # Fetch model via utils module reference so tests can monkeypatch it reliably - model = model_utils_mod.get_model_for_agent("super_agent") - self.agent = Agent( - model=model, - tools=[ - # TODO: enable UserControlFlowTools when stable - # UserControlFlowTools(), - self.tool_get_agent_description, - self.tool_get_enabled_agents, - ], - debug_mode=agent_debug_mode_enabled(), - instructions=[PLANNER_INSTRUCTION], - # output format - markdown=False, - output_schema=PlannerResponse, - expected_output=PLANNER_EXPECTED_OUTPUT, - use_json_mode=model_utils_mod.model_should_use_json_mode(model), - # context - db=InMemoryDb(), - add_datetime_to_context=True, - add_history_to_context=True, - num_history_runs=5, - read_chat_history=True, - enable_session_summaries=True, - ) + # Lazy initialize agent to avoid startup failures when API keys are missing + self.agent = None + + def _get_or_init_agent(self) -> Optional[Agent]: + """Create the planning agent on first use. + + Returns the initialized Agent or None if initialization fails. + """ + if self.agent is not None: + return self.agent + + try: + # Fetch model via utils module reference so tests can monkeypatch it reliably + model = model_utils_mod.get_model_for_agent("super_agent") + self.agent = Agent( + model=model, + tools=[ + # TODO: enable UserControlFlowTools when stable + # UserControlFlowTools(), + self.tool_get_agent_description, + self.tool_get_enabled_agents, + ], + debug_mode=agent_debug_mode_enabled(), + instructions=[PLANNER_INSTRUCTION], + # output format + markdown=False, + output_schema=PlannerResponse, + expected_output=PLANNER_EXPECTED_OUTPUT, + use_json_mode=model_utils_mod.model_should_use_json_mode(model), + # context + db=InMemoryDb(), + add_datetime_to_context=True, + add_history_to_context=True, + num_history_runs=5, + read_chat_history=True, + enable_session_summaries=True, + ) + return self.agent + except Exception as exc: + logger.exception("Failed to initialize planner agent: %s", exc) + self.agent = None + return None async def create_plan( self, @@ -182,8 +199,15 @@ async def _analyze_input_and_create_tasks( A tuple of (list of Task objects, optional guidance message). If plan is inadequate, returns empty list with guidance message. """ - # Execute planning with the agent - run_response = self.agent.run( + # Execute planning with the agent (lazy init) + agent = self._get_or_init_agent() + if agent is None: + return [], ( + "Planner is unavailable: failed to initialize model/provider. " + "Please configure a valid API key or provider settings and retry." + ) + + run_response = agent.run( PlannerInput( target_agent_name=user_input.target_agent_name, query=user_input.query, @@ -206,7 +230,7 @@ async def _analyze_input_and_create_tasks( field.value = user_value # Continue agent execution with updated inputs - run_response = self.agent.continue_run( + run_response = agent.continue_run( # TODO: rollback to `run_id=run_response.run_id` when bug fixed by Agno run_response=run_response, updated_tools=run_response.tools, @@ -218,8 +242,12 @@ async def _analyze_input_and_create_tasks( # Parse planning result and create tasks plan_raw = run_response.content if not isinstance(plan_raw, PlannerResponse): - model = self.agent.model - model_description = f"{model.id} (via {model.provider})" + # Be robust if model attributes are unavailable + try: + model = agent.model + model_description = f"{model.id} (via {model.provider})" + except Exception: + model_description = "unknown model/provider" return ( [], ( diff --git a/python/valuecell/core/plan/tests/test_planner.py b/python/valuecell/core/plan/tests/test_planner.py index 01bce7a2e..2e53a81b8 100644 --- a/python/valuecell/core/plan/tests/test_planner.py +++ b/python/valuecell/core/plan/tests/test_planner.py @@ -239,3 +239,81 @@ def __init__(self): # Not found branch missing = planner.tool_get_agent_description("MissingAgent") assert "could not be found" in missing + + +@pytest.mark.asyncio +async def test_lazy_init_failure_returns_guidance(monkeypatch: pytest.MonkeyPatch): + """When planner agent cannot initialize, return guidance instead of crashing.""" + + # Cause model creation to fail + def _raise(*_args, **_kwargs): + raise RuntimeError("no model") + + monkeypatch.setattr(model_utils_mod, "get_model_for_agent", _raise) + monkeypatch.setattr(planner_mod, "agent_debug_mode_enabled", lambda: False) + + class DummyConn: + pass + + planner = ExecutionPlanner(DummyConn()) + + user_input = UserInput( + query="plan this", + target_agent_name="", + meta=UserInputMetadata(conversation_id="conv-lazy", user_id="user-lazy"), + ) + + async def callback(_): + # Should not be invoked when agent is unavailable + raise AssertionError("callback should not be invoked") + + plan = await planner.create_plan(user_input, callback, "thread-lazy") + + assert plan.tasks == [] + assert plan.guidance_message + assert "Planner is unavailable" in plan.guidance_message + + +@pytest.mark.asyncio +async def test_malformed_response_unknown_model_description( + monkeypatch: pytest.MonkeyPatch, +): + """Malformed planner response falls back to 'unknown model/provider' when model info missing.""" + + malformed_content = "oops-not-planner-response" + + class FakeAgent: + def __init__(self, *args, **kwargs): + # No model attribute to trigger unknown provider path + pass + + def run(self, *args, **kwargs): + return SimpleNamespace( + is_paused=False, + tools_requiring_user_input=[], + tools=[], + content=malformed_content, + ) + + monkeypatch.setattr(planner_mod, "Agent", FakeAgent) + monkeypatch.setattr( + model_utils_mod, "get_model_for_agent", lambda *args, **kwargs: "stub-model" + ) + monkeypatch.setattr(planner_mod, "agent_debug_mode_enabled", lambda: False) + + planner = ExecutionPlanner(StubConnections()) + # Ensure lazy init creates our FakeAgent + planner.agent = None + + user_input = UserInput( + query="malformed please", + target_agent_name="", + meta=UserInputMetadata(conversation_id="conv-x", user_id="user-x"), + ) + + async def callback(_): + raise AssertionError("callback should not be invoked") + + plan = await planner.create_plan(user_input, callback, "thread-x") + assert plan.guidance_message + assert "unknown model/provider" in plan.guidance_message diff --git a/python/valuecell/core/super_agent/core.py b/python/valuecell/core/super_agent/core.py index b7ac74296..ca91bd1cd 100644 --- a/python/valuecell/core/super_agent/core.py +++ b/python/valuecell/core/super_agent/core.py @@ -43,32 +43,57 @@ class SuperAgent: name: str = "ValueCellAgent" def __init__(self) -> None: - model = model_utils_mod.get_model_for_agent("super_agent") - self.agent = Agent( - model=model, - # TODO: enable tools when needed - # tools=[Crawl4aiTools()], - markdown=False, - debug_mode=agent_debug_mode_enabled(), - instructions=[SUPER_AGENT_INSTRUCTION], - # output format - expected_output=SUPER_AGENT_EXPECTED_OUTPUT, - output_schema=SuperAgentOutcome, - use_json_mode=model_utils_mod.model_should_use_json_mode(model), - # context - db=InMemoryDb(), - add_datetime_to_context=True, - add_history_to_context=True, - num_history_runs=5, - read_chat_history=True, - enable_session_summaries=True, - ) + # Lazy initialize: avoid constructing Agent at startup + self.agent: Optional[Agent] = None + + def _get_or_init_agent(self) -> Optional[Agent]: + """Create the underlying agent on first use. + + Returns the initialized Agent or None if initialization fails. + """ + if self.agent is not None: + return self.agent + + try: + model = model_utils_mod.get_model_for_agent("super_agent") + self.agent = Agent( + model=model, + # TODO: enable tools when needed + # tools=[Crawl4aiTools()], + markdown=False, + debug_mode=agent_debug_mode_enabled(), + instructions=[SUPER_AGENT_INSTRUCTION], + # output format + expected_output=SUPER_AGENT_EXPECTED_OUTPUT, + output_schema=SuperAgentOutcome, + use_json_mode=model_utils_mod.model_should_use_json_mode(model), + # context + db=InMemoryDb(), + add_datetime_to_context=True, + add_history_to_context=True, + num_history_runs=5, + read_chat_history=True, + enable_session_summaries=True, + ) + return self.agent + except Exception: + # Swallow to avoid startup failure; will fallback in run() + self.agent = None + return None async def run(self, user_input: UserInput) -> SuperAgentOutcome: """Run super agent triage.""" await asyncio.sleep(0) + agent = self._get_or_init_agent() + if agent is None: + # Fallback: handoff directly to planner without super agent model + return SuperAgentOutcome( + decision=SuperAgentDecision.HANDOFF_TO_PLANNER, + enriched_query=user_input.query, + reason="SuperAgent unavailable: missing model/provider configuration", + ) - response = await self.agent.arun( + response = await agent.arun( user_input.query, session_id=user_input.meta.conversation_id, user_id=user_input.meta.user_id, @@ -76,8 +101,11 @@ async def run(self, user_input: UserInput) -> SuperAgentOutcome: ) outcome = response.content if not isinstance(outcome, SuperAgentOutcome): - model = self.agent.model - model_description = f"{model.id} (via {model.provider})" + try: + model = agent.model + model_description = f"{model.id} (via {model.provider})" + except Exception: + model_description = "unknown model/provider" answer_content = ( f"SuperAgent produced a malformed response: `{outcome}`. " f"Please check the capabilities of your model `{model_description}` and try again later." diff --git a/python/valuecell/core/super_agent/tests/test_super_agent.py b/python/valuecell/core/super_agent/tests/test_super_agent.py index db045f923..578f36e44 100644 --- a/python/valuecell/core/super_agent/tests/test_super_agent.py +++ b/python/valuecell/core/super_agent/tests/test_super_agent.py @@ -128,3 +128,64 @@ def __init__(self, *args, **kwargs): assert outcome.decision == SuperAgentDecision.ANSWER assert "malformed response" in outcome.answer_content assert "fake-model (via fake-provider)" in outcome.answer_content + + +@pytest.mark.asyncio +async def test_super_agent_lazy_init_failure_handoff_to_planner( + monkeypatch: pytest.MonkeyPatch, +): + """When SuperAgent cannot initialize, it hands off directly to Planner.""" + + def _raise(*_args, **_kwargs): + raise RuntimeError("no model") + + monkeypatch.setattr(super_agent_mod.model_utils_mod, "get_model_for_agent", _raise) + monkeypatch.setattr(super_agent_mod, "agent_debug_mode_enabled", lambda: False) + + sa = SuperAgent() + + user_input = UserInput( + query="please plan", + target_agent_name=sa.name, + meta=UserInputMetadata(conversation_id="conv-fallback", user_id="user-x"), + ) + + outcome = await sa.run(user_input) + assert outcome.decision == SuperAgentDecision.HANDOFF_TO_PLANNER + assert outcome.enriched_query == "please plan" + assert outcome.reason and "missing model/provider" in outcome.reason + + +@pytest.mark.asyncio +async def test_super_agent_malformed_response_unknown_provider( + monkeypatch: pytest.MonkeyPatch, +): + """Malformed response with missing model info uses 'unknown model/provider' label.""" + + # Return a malformed content (not a SuperAgentOutcome instance) + fake_response = SimpleNamespace(content=SimpleNamespace(raw="oops")) + + class FakeAgent: + def __init__(self, *args, **kwargs): + self.arun = AsyncMock(return_value=fake_response) + # No model attribute to trigger unknown path + # self.model = missing + + monkeypatch.setattr(super_agent_mod, "Agent", FakeAgent) + monkeypatch.setattr( + super_agent_mod.model_utils_mod, + "get_model_for_agent", + lambda *args, **kwargs: "stub-model", + ) + monkeypatch.setattr(super_agent_mod, "agent_debug_mode_enabled", lambda: False) + + sa = SuperAgent() + user_input = UserInput( + query="give answer", + target_agent_name=sa.name, + meta=UserInputMetadata(conversation_id="conv", user_id="user"), + ) + + outcome = await sa.run(user_input) + assert outcome.decision == SuperAgentDecision.ANSWER + assert "unknown model/provider" in outcome.answer_content