diff --git a/tests/test_risk_router_path_map.py b/tests/test_risk_router_path_map.py new file mode 100644 index 000000000..328816cba --- /dev/null +++ b/tests/test_risk_router_path_map.py @@ -0,0 +1,81 @@ +"""Shared-router / path_map completeness (#1088). + +Both `should_continue_risk_analysis` (three risk edges) and +`should_continue_debate` (two research-debate edges) are single routers whose +return set is larger than any one edge previously mapped. Each edge now shares a +complete path map (`RISK_ANALYSIS_PATH_MAP` / `DEBATE_PATH_MAP`), so a +fall-through return can never hit a missing entry -- which would crash LangGraph +mid-run on prompt/i18n/refactor drift in the speaker labels. +""" +import pytest + +from tradingagents.graph.conditional_logic import ConditionalLogic +from tradingagents.graph.setup import DEBATE_PATH_MAP, RISK_ANALYSIS_PATH_MAP + + +def _state(latest_speaker, count=0): + return {"risk_debate_state": {"latest_speaker": latest_speaker, "count": count}} + + +def _debate_state(current_response, count=0): + return {"investment_debate_state": {"current_response": current_response, "count": count}} + + +@pytest.mark.unit +@pytest.mark.parametrize("latest_speaker", [ + "Aggressive", "Aggressive Analyst", + "Conservative", "Conservative Analyst", + "Neutral", "Neutral Analyst", + "", # drift: empty label + "Aggressive Risk Analyst", # drift: node renamed + "Agresivo", # drift: i18n / translated label +]) +def test_router_return_always_routable(latest_speaker): + logic = ConditionalLogic(max_risk_discuss_rounds=1) + target = logic.should_continue_risk_analysis(_state(latest_speaker)) + assert target in RISK_ANALYSIS_PATH_MAP + + +@pytest.mark.unit +def test_router_terminates_at_round_limit(): + logic = ConditionalLogic(max_risk_discuss_rounds=1) + # count >= 3 * rounds routes to the Portfolio Manager (debate ends) + assert logic.should_continue_risk_analysis(_state("Neutral", count=3)) == "Portfolio Manager" + + +@pytest.mark.unit +def test_path_map_covers_full_router_range(): + logic = ConditionalLogic(max_risk_discuss_rounds=1) + returns = { + logic.should_continue_risk_analysis(_state(s, c)) + for s in ("Aggressive", "Conservative", "Neutral", "drift") + for c in (0, 99) + } + # Every value the router can emit is a key in the shared map... + assert returns <= set(RISK_ANALYSIS_PATH_MAP) + # ...and the terminal target is reachable. + assert "Portfolio Manager" in returns + + +@pytest.mark.unit +@pytest.mark.parametrize("current_response", [ + "Bull", "Bull Researcher", "Bear", "Bear Researcher", + "", # drift: empty label + "Optimista", # drift: i18n / translated label +]) +def test_debate_router_return_always_routable(current_response): + logic = ConditionalLogic(max_debate_rounds=1) + target = logic.should_continue_debate(_debate_state(current_response)) + assert target in DEBATE_PATH_MAP + + +@pytest.mark.unit +def test_debate_path_map_covers_full_router_range(): + logic = ConditionalLogic(max_debate_rounds=1) + returns = { + logic.should_continue_debate(_debate_state(s, c)) + for s in ("Bull", "Bear", "drift") + for c in (0, 99) + } + assert returns <= set(DEBATE_PATH_MAP) + assert "Research Manager" in returns # terminal reachable diff --git a/tradingagents/graph/setup.py b/tradingagents/graph/setup.py index 3f87e213f..171b65651 100644 --- a/tradingagents/graph/setup.py +++ b/tradingagents/graph/setup.py @@ -25,6 +25,22 @@ from tradingagents.agents.utils.agent_states import AgentState from .analyst_execution import build_analyst_execution_plan from .conditional_logic import ConditionalLogic +# Every target a shared conditional router can return. Each edge driven by the +# router maps all of them, so a fall-through return (e.g. under prompt/i18n/ +# refactor drift in the speaker labels) can never hit a missing path_map entry +# and crash LangGraph mid-run (#1088). +DEBATE_PATH_MAP = { + "Bull Researcher": "Bull Researcher", + "Bear Researcher": "Bear Researcher", + "Research Manager": "Research Manager", +} +RISK_ANALYSIS_PATH_MAP = { + "Aggressive Analyst": "Aggressive Analyst", + "Conservative Analyst": "Conservative Analyst", + "Neutral Analyst": "Neutral Analyst", + "Portfolio Manager": "Portfolio Manager", +} + class GraphSetup: """Handles the setup and configuration of the agent graph.""" @@ -118,49 +134,22 @@ class GraphSetup: else: workflow.add_edge(current_clear, "Bull Researcher") - # Add remaining edges - workflow.add_conditional_edges( - "Bull Researcher", - self.conditional_logic.should_continue_debate, - { - "Bear Researcher": "Bear Researcher", - "Research Manager": "Research Manager", - }, - ) - workflow.add_conditional_edges( - "Bear Researcher", - self.conditional_logic.should_continue_debate, - { - "Bull Researcher": "Bull Researcher", - "Research Manager": "Research Manager", - }, - ) + # Both research-debate edges share the complete DEBATE_PATH_MAP (#1088). + for debate_node in ("Bull Researcher", "Bear Researcher"): + workflow.add_conditional_edges( + debate_node, + self.conditional_logic.should_continue_debate, + DEBATE_PATH_MAP, + ) workflow.add_edge("Research Manager", "Trader") workflow.add_edge("Trader", "Aggressive Analyst") - workflow.add_conditional_edges( - "Aggressive Analyst", - self.conditional_logic.should_continue_risk_analysis, - { - "Conservative Analyst": "Conservative Analyst", - "Portfolio Manager": "Portfolio Manager", - }, - ) - workflow.add_conditional_edges( - "Conservative Analyst", - self.conditional_logic.should_continue_risk_analysis, - { - "Neutral Analyst": "Neutral Analyst", - "Portfolio Manager": "Portfolio Manager", - }, - ) - workflow.add_conditional_edges( - "Neutral Analyst", - self.conditional_logic.should_continue_risk_analysis, - { - "Aggressive Analyst": "Aggressive Analyst", - "Portfolio Manager": "Portfolio Manager", - }, - ) + # All three risk edges share the complete RISK_ANALYSIS_PATH_MAP (#1088). + for risk_node in ("Aggressive Analyst", "Conservative Analyst", "Neutral Analyst"): + workflow.add_conditional_edges( + risk_node, + self.conditional_logic.should_continue_risk_analysis, + RISK_ANALYSIS_PATH_MAP, + ) workflow.add_edge("Portfolio Manager", END)