Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 58 additions & 30 deletions python/valuecell/core/plan/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 (
[],
(
Expand Down
78 changes: 78 additions & 0 deletions python/valuecell/core/plan/tests/test_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
74 changes: 51 additions & 23 deletions python/valuecell/core/super_agent/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,41 +43,69 @@ 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,
add_history_to_context=True,
)
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."
Expand Down
61 changes: 61 additions & 0 deletions python/valuecell/core/super_agent/tests/test_super_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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