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_<analyst>
- the sentiment analyst has no tools, so the unreachable social tool node is gone
This commit is contained in:
Yijia-Xiao
2026-09-24 04:31:05 +00:00
parent 4e1faf6465
commit 41fc25ac0d
8 changed files with 62 additions and 176 deletions
-23
View File
@@ -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
@@ -10,20 +10,21 @@ from tradingagents.agents.utils.agent_utils import (
get_language_instruction, 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 create_fundamentals_analyst(llm):
def fundamentals_analyst_node(state): def fundamentals_analyst_node(state):
current_date = state["trade_date"] current_date = state["trade_date"]
instrument_context = get_instrument_context_from_state(state) instrument_context = get_instrument_context_from_state(state)
tools = [
get_fundamentals,
get_balance_sheet,
get_cashflow,
get_income_statement,
get_insider_transactions,
]
system_message = ( 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." "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." + " 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(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(current_date=current_date)
prompt = prompt.partial(instrument_context=instrument_context) prompt = prompt.partial(instrument_context=instrument_context)
chain = prompt | llm.bind_tools(tools) chain = prompt | llm.bind_tools(TOOLS)
result = chain.invoke(state["messages"]) result = chain.invoke(state["messages"])
@@ -8,6 +8,13 @@ from tradingagents.agents.utils.agent_utils import (
get_verified_market_snapshot, 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): def create_market_analyst(llm):
@@ -15,12 +22,6 @@ def create_market_analyst(llm):
current_date = state["trade_date"] current_date = state["trade_date"]
instrument_context = get_instrument_context_from_state(state) instrument_context = get_instrument_context_from_state(state)
tools = [
get_stock_data,
get_indicators,
get_verified_market_snapshot,
]
system_message = ( 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: """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(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(current_date=current_date)
prompt = prompt.partial(instrument_context=instrument_context) prompt = prompt.partial(instrument_context=instrument_context)
chain = prompt | llm.bind_tools(tools) chain = prompt | llm.bind_tools(TOOLS)
result = chain.invoke(state["messages"]) result = chain.invoke(state["messages"])
+10 -9
View File
@@ -9,6 +9,14 @@ from tradingagents.agents.utils.agent_utils import (
get_prediction_markets, 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 create_news_analyst(llm):
def news_analyst_node(state): def news_analyst_node(state):
@@ -17,13 +25,6 @@ def create_news_analyst(llm):
asset_label = "company" if asset_type == "stock" else "asset" asset_label = "company" if asset_type == "stock" else "asset"
instrument_context = get_instrument_context_from_state(state) instrument_context = get_instrument_context_from_state(state)
tools = [
get_news,
get_global_news,
get_macro_indicators,
get_prediction_markets,
]
system_message = ( 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." 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.""" + """ 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(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(current_date=current_date)
prompt = prompt.partial(instrument_context=instrument_context) prompt = prompt.partial(instrument_context=instrument_context)
chain = prompt | llm.bind_tools(tools) chain = prompt | llm.bind_tools(TOOLS)
result = chain.invoke(state["messages"]) result = chain.invoke(state["messages"])
report = "" report = ""
+13 -9
View File
@@ -2,14 +2,21 @@ from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from time import monotonic from time import monotonic
from tradingagents.agents.analysts import fundamentals_analyst, market_analyst, news_analyst
@dataclass(frozen=True) @dataclass(frozen=True)
class AnalystNodeSpec: class AnalystNodeSpec:
key: str key: str
agent_node: str agent_node: str
clear_node: str clear_node: str
tool_node: str
report_key: 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) @dataclass(frozen=True)
@@ -22,33 +29,30 @@ ANALYST_NODE_SPECS: dict[str, AnalystNodeSpec] = {
key="market", key="market",
agent_node="Market Analyst", agent_node="Market Analyst",
clear_node="Msg Clear Market", clear_node="Msg Clear Market",
tool_node="tools_market",
report_key="market_report", report_key="market_report",
tools=market_analyst.TOOLS,
), ),
"social": AnalystNodeSpec( "social": AnalystNodeSpec(
# Wire key stays "social" for saved-config back-compat; the # Saved configs select this analyst as "social". It fetches its
# user-facing label is "Sentiment Analyst" to match the rename # sources before calling the model, so it has no tools.
# that landed in v0.2.5 (sentiment_analyst now ingests news +
# StockTwits + Reddit, not just social media).
key="social", key="social",
agent_node="Sentiment Analyst", agent_node="Sentiment Analyst",
clear_node="Msg Clear Sentiment", clear_node="Msg Clear Sentiment",
tool_node="tools_social",
report_key="sentiment_report", report_key="sentiment_report",
), ),
"news": AnalystNodeSpec( "news": AnalystNodeSpec(
key="news", key="news",
agent_node="News Analyst", agent_node="News Analyst",
clear_node="Msg Clear News", clear_node="Msg Clear News",
tool_node="tools_news",
report_key="news_report", report_key="news_report",
tools=news_analyst.TOOLS,
), ),
"fundamentals": AnalystNodeSpec( "fundamentals": AnalystNodeSpec(
key="fundamentals", key="fundamentals",
agent_node="Fundamentals Analyst", agent_node="Fundamentals Analyst",
clear_node="Msg Clear Fundamentals", clear_node="Msg Clear Fundamentals",
tool_node="tools_fundamentals",
report_key="fundamentals_report", report_key="fundamentals_report",
tools=fundamentals_analyst.TOOLS,
), ),
} }
-38
View File
@@ -11,44 +11,6 @@ class ConditionalLogic:
self.max_debate_rounds = max_debate_rounds self.max_debate_rounds = max_debate_rounds
self.max_risk_discuss_rounds = max_risk_discuss_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: def should_continue_debate(self, state: AgentState) -> str:
"""Determine if debate should continue.""" """Determine if debate should continue."""
+19 -19
View File
@@ -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: class GraphSetup:
"""Handles the setup and configuration of the agent graph.""" """Handles the setup and configuration of the agent graph."""
@@ -49,13 +56,11 @@ class GraphSetup:
self, self,
quick_thinking_llm: Any, quick_thinking_llm: Any,
deep_thinking_llm: Any, deep_thinking_llm: Any,
tool_nodes: dict[str, ToolNode],
conditional_logic: ConditionalLogic, conditional_logic: ConditionalLogic,
): ):
"""Initialize with required components.""" """Initialize with required components."""
self.quick_thinking_llm = quick_thinking_llm self.quick_thinking_llm = quick_thinking_llm
self.deep_thinking_llm = deep_thinking_llm self.deep_thinking_llm = deep_thinking_llm
self.tool_nodes = tool_nodes
self.conditional_logic = conditional_logic self.conditional_logic = conditional_logic
def setup_graph( def setup_graph(
@@ -98,7 +103,8 @@ class GraphSetup:
for spec in plan.specs: for spec in plan.specs:
workflow.add_node(spec.agent_node, analyst_factories[spec.key]()) workflow.add_node(spec.agent_node, analyst_factories[spec.key]())
workflow.add_node(spec.clear_node, create_msg_delete()) 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 # Add other nodes
workflow.add_node("Bull Researcher", bull_researcher_node) workflow.add_node("Bull Researcher", bull_researcher_node)
@@ -116,23 +122,17 @@ class GraphSetup:
# Connect analysts in sequence # Connect analysts in sequence
for i, spec in enumerate(plan.specs): for i, spec in enumerate(plan.specs):
current_analyst = spec.agent_node if spec.tools:
current_tools = spec.tool_node workflow.add_conditional_edges(
current_clear = spec.clear_node spec.agent_node, _tools_or_clear(spec), [spec.tool_node, spec.clear_node]
)
# Add conditional edges for current analyst workflow.add_edge(spec.tool_node, spec.agent_node)
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)
else: 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). # Both research-debate edges share the complete DEBATE_PATH_MAP (#1088).
for debate_node in ("Bull Researcher", "Bear Researcher"): for debate_node in ("Bull Researcher", "Bear Researcher"):
-60
View File
@@ -8,23 +8,8 @@ from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from langgraph.prebuilt import ToolNode
# Import the abstract tool methods from agent_utils
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
build_instrument_context, 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, resolve_instrument_identity,
) )
from tradingagents.agents.utils.memory import TradingMemoryLog from tradingagents.agents.utils.memory import TradingMemoryLog
@@ -144,9 +129,6 @@ class TradingAgentsGraph:
self.memory_log = TradingMemoryLog(self.config) self.memory_log = TradingMemoryLog(self.config)
# Create tool nodes
self.tool_nodes = self._create_tool_nodes()
# Initialize components # Initialize components
self.conditional_logic = ConditionalLogic( self.conditional_logic = ConditionalLogic(
max_debate_rounds=self.config["max_debate_rounds"], max_debate_rounds=self.config["max_debate_rounds"],
@@ -155,7 +137,6 @@ class TradingAgentsGraph:
self.graph_setup = GraphSetup( self.graph_setup = GraphSetup(
self.quick_thinking_llm, self.quick_thinking_llm,
self.deep_thinking_llm, self.deep_thinking_llm,
self.tool_nodes,
self.conditional_logic, self.conditional_logic,
) )
@@ -218,47 +199,6 @@ class TradingAgentsGraph:
return kwargs 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: def _resolve_benchmark(self, ticker: str) -> str:
"""Pick the benchmark ticker for alpha calculation against ``ticker``. """Pick the benchmark ticker for alpha calculation against ``ticker``.