From 41fc25ac0d2148015ed8f648626fd1b8195a06d7 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Wed, 23 Sep 2026 21:18:35 +0000 Subject: [PATCH] refactor(graph): wire each analyst from one declaration of its tools - an analyst's TOOLS tuple is both what it is offered and what its tool node runs - one routing function replaces four copies of should_continue_ - the sentiment analyst has no tools, so the unreachable social tool node is gone --- tests/test_market_toolnode.py | 23 ------- .../agents/analysts/fundamentals_analyst.py | 21 +++---- .../agents/analysts/market_analyst.py | 17 +++--- tradingagents/agents/analysts/news_analyst.py | 19 +++--- tradingagents/graph/analyst_execution.py | 22 ++++--- tradingagents/graph/conditional_logic.py | 38 ------------ tradingagents/graph/setup.py | 38 ++++++------ tradingagents/graph/trading_graph.py | 60 ------------------- 8 files changed, 62 insertions(+), 176 deletions(-) delete mode 100644 tests/test_market_toolnode.py diff --git a/tests/test_market_toolnode.py b/tests/test_market_toolnode.py deleted file mode 100644 index 4c4a811e0..000000000 --- a/tests/test_market_toolnode.py +++ /dev/null @@ -1,23 +0,0 @@ -"""The market analyst is bound (and prompt-instructed) to call -get_verified_market_snapshot; if the executor ToolNode doesn't register it, the -call fails and the model reports the tool "unavailable" and skips verification. - -Regression guard for that wiring gap (snapshot bound to the LLM but missing from -the market ToolNode). -""" -import pytest - -from tradingagents.graph.trading_graph import TradingAgentsGraph - - -@pytest.mark.unit -def test_market_toolnode_can_execute_verified_snapshot(): - # _create_tool_nodes does not use self -> call unbound (avoids building LLMs). - nodes = TradingAgentsGraph._create_tool_nodes(None) - market_tools = set(nodes["market"].tools_by_name) - assert "get_verified_market_snapshot" in market_tools, ( - "get_verified_market_snapshot is bound to the market analyst but not " - "registered in the market ToolNode, so the model's call fails." - ) - # the other core market tools must remain too - assert {"get_stock_data", "get_indicators"} <= market_tools diff --git a/tradingagents/agents/analysts/fundamentals_analyst.py b/tradingagents/agents/analysts/fundamentals_analyst.py index 0e46f0fff..9588c0e18 100644 --- a/tradingagents/agents/analysts/fundamentals_analyst.py +++ b/tradingagents/agents/analysts/fundamentals_analyst.py @@ -10,20 +10,21 @@ from tradingagents.agents.utils.agent_utils import ( get_language_instruction, ) +# The tools this analyst is offered; its tool node is built from the same tuple. +TOOLS = ( + get_fundamentals, + get_balance_sheet, + get_cashflow, + get_income_statement, + get_insider_transactions, +) + def create_fundamentals_analyst(llm): def fundamentals_analyst_node(state): current_date = state["trade_date"] instrument_context = get_instrument_context_from_state(state) - tools = [ - get_fundamentals, - get_balance_sheet, - get_cashflow, - get_income_statement, - get_insider_transactions, - ] - system_message = ( "You are a researcher tasked with analyzing fundamental information over the past week about a company. Please write a comprehensive report of the company's fundamental information such as financial documents, company profile, basic company financials, and company financial history to gain a full view of the company's fundamental information to inform traders. Make sure to include as much detail as possible. Provide specific, actionable insights with supporting evidence to help traders make informed decisions." + " Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read." @@ -49,11 +50,11 @@ def create_fundamentals_analyst(llm): ) prompt = prompt.partial(system_message=system_message) - prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools])) + prompt = prompt.partial(tool_names=", ".join([tool.name for tool in TOOLS])) prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(instrument_context=instrument_context) - chain = prompt | llm.bind_tools(tools) + chain = prompt | llm.bind_tools(TOOLS) result = chain.invoke(state["messages"]) diff --git a/tradingagents/agents/analysts/market_analyst.py b/tradingagents/agents/analysts/market_analyst.py index dd41c03db..3fe15dc54 100644 --- a/tradingagents/agents/analysts/market_analyst.py +++ b/tradingagents/agents/analysts/market_analyst.py @@ -8,6 +8,13 @@ from tradingagents.agents.utils.agent_utils import ( get_verified_market_snapshot, ) +# The tools this analyst is offered; its tool node is built from the same tuple. +TOOLS = ( + get_stock_data, + get_indicators, + get_verified_market_snapshot, +) + def create_market_analyst(llm): @@ -15,12 +22,6 @@ def create_market_analyst(llm): current_date = state["trade_date"] instrument_context = get_instrument_context_from_state(state) - tools = [ - get_stock_data, - get_indicators, - get_verified_market_snapshot, - ] - system_message = ( """You are a trading assistant tasked with analyzing financial markets. Your role is to select the **most relevant indicators** for a given market condition or trading strategy from the following list. The goal is to choose up to **8 indicators** that provide complementary insights without redundancy. Categories and each category's indicators are: @@ -73,11 +74,11 @@ Write a very detailed and nuanced report of the trends you observe. Provide spec ) prompt = prompt.partial(system_message=system_message) - prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools])) + prompt = prompt.partial(tool_names=", ".join([tool.name for tool in TOOLS])) prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(instrument_context=instrument_context) - chain = prompt | llm.bind_tools(tools) + chain = prompt | llm.bind_tools(TOOLS) result = chain.invoke(state["messages"]) diff --git a/tradingagents/agents/analysts/news_analyst.py b/tradingagents/agents/analysts/news_analyst.py index 6c7454811..2e701ea0d 100644 --- a/tradingagents/agents/analysts/news_analyst.py +++ b/tradingagents/agents/analysts/news_analyst.py @@ -9,6 +9,14 @@ from tradingagents.agents.utils.agent_utils import ( get_prediction_markets, ) +# The tools this analyst is offered; its tool node is built from the same tuple. +TOOLS = ( + get_news, + get_global_news, + get_macro_indicators, + get_prediction_markets, +) + def create_news_analyst(llm): def news_analyst_node(state): @@ -17,13 +25,6 @@ def create_news_analyst(llm): asset_label = "company" if asset_type == "stock" else "asset" instrument_context = get_instrument_context_from_state(state) - tools = [ - get_news, - get_global_news, - get_macro_indicators, - get_prediction_markets, - ] - system_message = ( f"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(ticker, start_date, end_date) for {asset_label}-specific news by ticker symbol, get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news, get_macro_indicators(indicator, curr_date, look_back_days) to ground macro commentary in actual data from FRED (e.g. 'cpi', 'core_pce', 'unemployment', 'fed_funds_rate', '10y_treasury', 'yield_curve'), and get_prediction_markets(topic, limit) for live market-implied probabilities of forward-looking events (e.g. 'Fed rate cut', 'recession 2026', geopolitical or sector events). Provide specific, actionable insights with supporting evidence to help traders make informed decisions." + """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read.""" @@ -48,11 +49,11 @@ def create_news_analyst(llm): ) prompt = prompt.partial(system_message=system_message) - prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools])) + prompt = prompt.partial(tool_names=", ".join([tool.name for tool in TOOLS])) prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(instrument_context=instrument_context) - chain = prompt | llm.bind_tools(tools) + chain = prompt | llm.bind_tools(TOOLS) result = chain.invoke(state["messages"]) report = "" diff --git a/tradingagents/graph/analyst_execution.py b/tradingagents/graph/analyst_execution.py index 0e653742b..0d40dbb54 100644 --- a/tradingagents/graph/analyst_execution.py +++ b/tradingagents/graph/analyst_execution.py @@ -2,14 +2,21 @@ from collections.abc import Iterable from dataclasses import dataclass from time import monotonic +from tradingagents.agents.analysts import fundamentals_analyst, market_analyst, news_analyst + @dataclass(frozen=True) class AnalystNodeSpec: key: str agent_node: str clear_node: str - tool_node: str report_key: str + tools: tuple = () + + @property + def tool_node(self) -> str | None: + """The node that runs this analyst's tool calls; None when it has no tools.""" + return f"tools_{self.key}" if self.tools else None @dataclass(frozen=True) @@ -22,33 +29,30 @@ ANALYST_NODE_SPECS: dict[str, AnalystNodeSpec] = { key="market", agent_node="Market Analyst", clear_node="Msg Clear Market", - tool_node="tools_market", report_key="market_report", + tools=market_analyst.TOOLS, ), "social": AnalystNodeSpec( - # Wire key stays "social" for saved-config back-compat; the - # user-facing label is "Sentiment Analyst" to match the rename - # that landed in v0.2.5 (sentiment_analyst now ingests news + - # StockTwits + Reddit, not just social media). + # Saved configs select this analyst as "social". It fetches its + # sources before calling the model, so it has no tools. key="social", agent_node="Sentiment Analyst", clear_node="Msg Clear Sentiment", - tool_node="tools_social", report_key="sentiment_report", ), "news": AnalystNodeSpec( key="news", agent_node="News Analyst", clear_node="Msg Clear News", - tool_node="tools_news", report_key="news_report", + tools=news_analyst.TOOLS, ), "fundamentals": AnalystNodeSpec( key="fundamentals", agent_node="Fundamentals Analyst", clear_node="Msg Clear Fundamentals", - tool_node="tools_fundamentals", report_key="fundamentals_report", + tools=fundamentals_analyst.TOOLS, ), } diff --git a/tradingagents/graph/conditional_logic.py b/tradingagents/graph/conditional_logic.py index b03273564..32bade664 100644 --- a/tradingagents/graph/conditional_logic.py +++ b/tradingagents/graph/conditional_logic.py @@ -11,44 +11,6 @@ class ConditionalLogic: self.max_debate_rounds = max_debate_rounds self.max_risk_discuss_rounds = max_risk_discuss_rounds - def should_continue_market(self, state: AgentState): - """Determine if market analysis should continue.""" - messages = state["messages"] - last_message = messages[-1] - if last_message.tool_calls: - return "tools_market" - return "Msg Clear Market" - - def should_continue_social(self, state: AgentState): - """Determine if sentiment-analyst tool round should continue. - - Method name keeps the legacy ``social`` suffix to match the - ``AnalystType.SOCIAL = "social"`` wire value (saved-config - back-compat); the returned ``clear_node`` label uses the v0.2.5 - rename so it matches the node registered by the execution plan. - """ - messages = state["messages"] - last_message = messages[-1] - if last_message.tool_calls: - return "tools_social" - return "Msg Clear Sentiment" - - def should_continue_news(self, state: AgentState): - """Determine if news analysis should continue.""" - messages = state["messages"] - last_message = messages[-1] - if last_message.tool_calls: - return "tools_news" - return "Msg Clear News" - - def should_continue_fundamentals(self, state: AgentState): - """Determine if fundamentals analysis should continue.""" - messages = state["messages"] - last_message = messages[-1] - if last_message.tool_calls: - return "tools_fundamentals" - return "Msg Clear Fundamentals" - def should_continue_debate(self, state: AgentState) -> str: """Determine if debate should continue.""" diff --git a/tradingagents/graph/setup.py b/tradingagents/graph/setup.py index 1b7dd52cf..586ae55d2 100644 --- a/tradingagents/graph/setup.py +++ b/tradingagents/graph/setup.py @@ -42,6 +42,13 @@ RISK_ANALYSIS_PATH_MAP = { } +def _tools_or_clear(spec): + """Route an analyst's turn: run its tool calls, or finish its report.""" + def route(state) -> str: + return spec.tool_node if state["messages"][-1].tool_calls else spec.clear_node + return route + + class GraphSetup: """Handles the setup and configuration of the agent graph.""" @@ -49,13 +56,11 @@ class GraphSetup: self, quick_thinking_llm: Any, deep_thinking_llm: Any, - tool_nodes: dict[str, ToolNode], conditional_logic: ConditionalLogic, ): """Initialize with required components.""" self.quick_thinking_llm = quick_thinking_llm self.deep_thinking_llm = deep_thinking_llm - self.tool_nodes = tool_nodes self.conditional_logic = conditional_logic def setup_graph( @@ -98,7 +103,8 @@ class GraphSetup: for spec in plan.specs: workflow.add_node(spec.agent_node, analyst_factories[spec.key]()) workflow.add_node(spec.clear_node, create_msg_delete()) - workflow.add_node(spec.tool_node, self.tool_nodes[spec.key]) + if spec.tools: + workflow.add_node(spec.tool_node, ToolNode(list(spec.tools))) # Add other nodes workflow.add_node("Bull Researcher", bull_researcher_node) @@ -116,23 +122,17 @@ class GraphSetup: # Connect analysts in sequence for i, spec in enumerate(plan.specs): - current_analyst = spec.agent_node - current_tools = spec.tool_node - current_clear = spec.clear_node - - # Add conditional edges for current analyst - workflow.add_conditional_edges( - current_analyst, - getattr(self.conditional_logic, f"should_continue_{spec.key}"), - [current_tools, current_clear], - ) - workflow.add_edge(current_tools, current_analyst) - - # Connect to next analyst or to Bull Researcher if this is the last analyst - if i < len(plan.specs) - 1: - workflow.add_edge(current_clear, plan.specs[i + 1].agent_node) + if spec.tools: + workflow.add_conditional_edges( + spec.agent_node, _tools_or_clear(spec), [spec.tool_node, spec.clear_node] + ) + workflow.add_edge(spec.tool_node, spec.agent_node) else: - workflow.add_edge(current_clear, "Bull Researcher") + workflow.add_edge(spec.agent_node, spec.clear_node) + + # The last analyst hands over to the research debate. + following = plan.specs[i + 1].agent_node if i < len(plan.specs) - 1 else "Bull Researcher" + workflow.add_edge(spec.clear_node, following) # Both research-debate edges share the complete DEBATE_PATH_MAP (#1088). for debate_node in ("Bull Researcher", "Bear Researcher"): diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 4ccde09dc..d8b63c619 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -8,23 +8,8 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Any -from langgraph.prebuilt import ToolNode - -# Import the abstract tool methods from agent_utils from tradingagents.agents.utils.agent_utils import ( build_instrument_context, - get_balance_sheet, - get_cashflow, - get_fundamentals, - get_global_news, - get_income_statement, - get_indicators, - get_insider_transactions, - get_macro_indicators, - get_news, - get_prediction_markets, - get_stock_data, - get_verified_market_snapshot, resolve_instrument_identity, ) from tradingagents.agents.utils.memory import TradingMemoryLog @@ -144,9 +129,6 @@ class TradingAgentsGraph: self.memory_log = TradingMemoryLog(self.config) - # Create tool nodes - self.tool_nodes = self._create_tool_nodes() - # Initialize components self.conditional_logic = ConditionalLogic( max_debate_rounds=self.config["max_debate_rounds"], @@ -155,7 +137,6 @@ class TradingAgentsGraph: self.graph_setup = GraphSetup( self.quick_thinking_llm, self.deep_thinking_llm, - self.tool_nodes, self.conditional_logic, ) @@ -218,47 +199,6 @@ class TradingAgentsGraph: return kwargs - def _create_tool_nodes(self) -> dict[str, ToolNode]: - """Create tool nodes for different data sources using abstract methods.""" - return { - "market": ToolNode( - [ - # Core stock data tools - get_stock_data, - # Technical indicators - get_indicators, - # Deterministic verification snapshot (bound to the analyst - # LLM and required by its prompt; must be executable here or - # the call fails and the model reports it "unavailable"). - get_verified_market_snapshot, - ] - ), - "social": ToolNode( - [ - # News tools for social media analysis - get_news, - ] - ), - "news": ToolNode( - [ - get_news, - get_global_news, - get_macro_indicators, - get_prediction_markets, - ] - ), - "fundamentals": ToolNode( - [ - # Fundamental analysis tools - get_fundamentals, - get_balance_sheet, - get_cashflow, - get_income_statement, - get_insider_transactions, - ] - ), - } - def _resolve_benchmark(self, ticker: str) -> str: """Pick the benchmark ticker for alpha calculation against ``ticker``.