From ca2ebbbdcb204a8cc27699ebd0a7744964f76b64 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:12:13 +0000 Subject: [PATCH] fix(graph): ask an analyst for its report once its tool rounds are spent (#1420) - after max_tool_rounds rounds of tool calls (default 20, TRADINGAGENTS_MAX_TOOL_ROUNDS) the analyst takes one last turn with no tools, its tool calls and results given as text, and that turn ends its graph - the three tool-using analysts take their turns through analysts.turn.take_turn - a limit the recursion limit cannot hold is refused when the graph is built --- .env.example | 1 + tests/test_graph_end_to_end.py | 57 ++++++++++++++++++- .../agents/analysts/fundamentals_analyst.py | 10 +--- .../agents/analysts/market_analyst.py | 10 +--- tradingagents/agents/analysts/news_analyst.py | 9 +-- tradingagents/agents/analysts/turn.py | 42 ++++++++++++++ tradingagents/default_config.py | 3 + tradingagents/graph/setup.py | 46 ++++++++++++--- tradingagents/graph/trading_graph.py | 11 +++- 9 files changed, 156 insertions(+), 33 deletions(-) create mode 100644 tradingagents/agents/analysts/turn.py diff --git a/.env.example b/.env.example index 0647b5115..cea96cb0f 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,7 @@ NVIDIA_API_KEY= #TRADINGAGENTS_OUTPUT_LANGUAGE=English #TRADINGAGENTS_MAX_DEBATE_ROUNDS=1 #TRADINGAGENTS_MAX_RISK_ROUNDS=1 +#TRADINGAGENTS_MAX_TOOL_ROUNDS=20 #TRADINGAGENTS_CHECKPOINT_ENABLED=false # Docker: a host folder for results, reports and the memory log (unset: the tradingagents_data volume). diff --git a/tests/test_graph_end_to_end.py b/tests/test_graph_end_to_end.py index b8092327a..3c6ef016f 100644 --- a/tests/test_graph_end_to_end.py +++ b/tests/test_graph_end_to_end.py @@ -12,7 +12,7 @@ import copy import pandas as pd import pytest from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.outputs import ChatGeneration, ChatResult from langchain_core.runnables import RunnableLambda from pydantic import Field @@ -232,3 +232,58 @@ def test_a_streamed_run_reads_its_own_graph_config(tmp_path, monkeypatch, offlin assert seen and set(seen) == {"French"} assert set(between) == {"German"} + + +class LoopingModel(ScriptedModel): + """Calls a tool on every turn it is offered one (#1420).""" + + tool_turns: list = Field(default_factory=list) # shared across bound copies + last_turns: list = Field(default_factory=list) # histories of the turns offered no tools + + def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult: + if not self.tools: + self.last_turns.append(messages) + return super()._generate(messages, stop, run_manager, **kwargs) + self.tool_turns.append(1) + tool = self.tools[0] + call = {"name": tool.name, "id": f"call_{len(self.tool_turns)}", + "args": {k: v for k, v in ARGS.items() + if k in tool.tool_call_schema.model_json_schema()["properties"]}} + return ChatResult(generations=[ChatGeneration(message=AIMessage(content="", tool_calls=[call]))]) + + +@pytest.mark.unit +def test_an_analyst_that_keeps_calling_tools_writes_its_report_at_the_limit(tmp_path, monkeypatch, offline): + """A model that does not stop calling tools must not end the run (#1420).""" + model = LoopingModel(structured=True) + graph = _graph(tmp_path, monkeypatch, model, max_tool_rounds=3) + + final_state, rating = graph.propagate("NVDA", TRADE_DATE) + + assert len(model.tool_turns) == 3 * 3 # three tool-using analysts, three rounds each + for key in ("market_report", "news_report", "fundamentals_report"): + assert final_state[key] == TEXT + assert rating == "Overweight" + + +@pytest.mark.unit +def test_the_last_turn_is_offered_no_tools_and_reads_its_tool_results_as_text(tmp_path, monkeypatch, offline): + """Offered tools, a model may call one whatever it is told; a history holding + tool calls may be refused by a provider when no tools are bound.""" + model = LoopingModel(structured=True) + graph = _graph(tmp_path, monkeypatch, model, max_tool_rounds=2) + + graph.propagate("NVDA", TRADE_DATE) + + wrap_ups = [h for h in model.last_turns if isinstance(h[-1], HumanMessage) and "tool round" in h[-1].content] + assert len(wrap_ups) == 3 + for history in wrap_ups: + assert not any(isinstance(m, ToolMessage) or getattr(m, "tool_calls", None) for m in history) + assert any("returned]" in str(m.content) for m in history) + assert "tool rounds are spent" in history[0].content # the system prompt lists no tools + + +@pytest.mark.unit +def test_a_tool_limit_the_recursion_limit_cannot_hold_is_refused(tmp_path, monkeypatch): + with pytest.raises(ValueError, match="max_tool_rounds"): + _graph(tmp_path, monkeypatch, ScriptedModel(), max_tool_rounds=60, max_recur_limit=100) diff --git a/tradingagents/agents/analysts/fundamentals_analyst.py b/tradingagents/agents/analysts/fundamentals_analyst.py index f368a25de..961d58203 100644 --- a/tradingagents/agents/analysts/fundamentals_analyst.py +++ b/tradingagents/agents/analysts/fundamentals_analyst.py @@ -1,5 +1,6 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from tradingagents.agents.analysts.turn import take_turn from tradingagents.agents.context import get_instrument_context_from_state, get_language_instruction from tradingagents.agents.tools import ( get_balance_sheet, @@ -53,14 +54,7 @@ def create_fundamentals_analyst(llm): prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(instrument_context=instrument_context) - chain = prompt | llm.bind_tools(TOOLS) - - result = chain.invoke(state["messages"]) - - report = "" - - if len(result.tool_calls) == 0: - report = result.content + result, report = take_turn(prompt, llm, TOOLS, state["messages"]) return { "messages": [result], diff --git a/tradingagents/agents/analysts/market_analyst.py b/tradingagents/agents/analysts/market_analyst.py index b8abf576d..3a95df7e8 100644 --- a/tradingagents/agents/analysts/market_analyst.py +++ b/tradingagents/agents/analysts/market_analyst.py @@ -1,5 +1,6 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from tradingagents.agents.analysts.turn import take_turn from tradingagents.agents.context import get_instrument_context_from_state, get_language_instruction from tradingagents.agents.tools import get_indicators, get_stock_data, get_verified_market_snapshot @@ -73,14 +74,7 @@ Write a very detailed and nuanced report of the trends you observe. Provide spec prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(instrument_context=instrument_context) - chain = prompt | llm.bind_tools(TOOLS) - - result = chain.invoke(state["messages"]) - - report = "" - - if len(result.tool_calls) == 0: - report = result.content + result, report = take_turn(prompt, llm, TOOLS, state["messages"]) return { "messages": [result], diff --git a/tradingagents/agents/analysts/news_analyst.py b/tradingagents/agents/analysts/news_analyst.py index e0307c8e1..df80d114e 100644 --- a/tradingagents/agents/analysts/news_analyst.py +++ b/tradingagents/agents/analysts/news_analyst.py @@ -1,5 +1,6 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from tradingagents.agents.analysts.turn import take_turn from tradingagents.agents.context import get_instrument_context_from_state, get_language_instruction from tradingagents.agents.tools import ( get_global_news, @@ -52,13 +53,7 @@ def create_news_analyst(llm): prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(instrument_context=instrument_context) - chain = prompt | llm.bind_tools(TOOLS) - result = chain.invoke(state["messages"]) - - report = "" - - if len(result.tool_calls) == 0: - report = result.content + result, report = take_turn(prompt, llm, TOOLS, state["messages"]) return { "messages": [result], diff --git a/tradingagents/agents/analysts/turn.py b/tradingagents/agents/analysts/turn.py new file mode 100644 index 000000000..3b568b22d --- /dev/null +++ b/tradingagents/agents/analysts/turn.py @@ -0,0 +1,42 @@ +"""One turn of a tool-using analyst, and its last turn once its tool rounds are spent.""" + +import json + +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + +WRAP_UP = ( + "You have used every tool round this report allows. Write your final report " + "now from the tool results above, and say which data you could not retrieve." +) + + +def take_turn(prompt, llm, tools, messages): + """The analyst's reply and its report, which stays empty while it calls tools. + + Told to wrap up, it answers without tools: a model offered tools may call one + whatever it is told. Its earlier calls and their results reach it as text, + since a provider may refuse a history holding tool calls when none are bound. + """ + if messages and isinstance(messages[-1], HumanMessage) and messages[-1].content == WRAP_UP: + # The prompt lists the tools it offers; a model told of tools it cannot + # call may still try, and some providers then answer with nothing. + prompt = prompt.partial(tool_names="none; your tool rounds are spent") + result = (prompt | llm).invoke(_as_text(messages)) + return result, result.content + result = (prompt | llm.bind_tools(tools)).invoke(messages) + return result, "" if result.tool_calls else result.content + + +def _as_text(messages): + """The history with its tool calls and tool results written out as plain messages.""" + written = [] + for message in messages: + if isinstance(message, AIMessage) and message.tool_calls: + said = message.content if isinstance(message.content, str) else "" + calls = "; ".join(f"{c['name']}({json.dumps(c['args'], default=str)})" for c in message.tool_calls) + written.append(AIMessage(content=f"{said}\n[Called {calls}]".strip())) + elif isinstance(message, ToolMessage): + written.append(HumanMessage(content=f"[{message.name or 'Tool'} returned]\n{message.content}")) + else: + written.append(message) + return written diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 8c59c76e3..d33a206f4 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -15,6 +15,7 @@ _ENV_OVERRIDES = { "TRADINGAGENTS_OUTPUT_LANGUAGE": "output_language", "TRADINGAGENTS_MAX_DEBATE_ROUNDS": "max_debate_rounds", "TRADINGAGENTS_MAX_RISK_ROUNDS": "max_risk_discuss_rounds", + "TRADINGAGENTS_MAX_TOOL_ROUNDS": "max_tool_rounds", "TRADINGAGENTS_CHECKPOINT_ENABLED": "checkpoint_enabled", "TRADINGAGENTS_BENCHMARK_TICKER": "benchmark_ticker", "TRADINGAGENTS_TEMPERATURE": "temperature", @@ -115,6 +116,8 @@ DEFAULT_CONFIG = _apply_env_overrides({ "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, "max_recur_limit": 100, + # Rounds of tool calls an analyst may make before it is asked for its report. + "max_tool_rounds": 20, # News / data fetching parameters # Increase for longer lookback strategies or to broaden macro coverage; # decrease to reduce token usage in agent prompts. diff --git a/tradingagents/graph/setup.py b/tradingagents/graph/setup.py index d17215d96..46027e346 100644 --- a/tradingagents/graph/setup.py +++ b/tradingagents/graph/setup.py @@ -1,5 +1,8 @@ +import logging +from collections import Counter from typing import Any, TypedDict +from langchain_core.messages import HumanMessage from langgraph.graph import END, START, StateGraph from langgraph.prebuilt import ToolNode @@ -17,11 +20,14 @@ from tradingagents.agents import ( create_sentiment_analyst, create_trader, ) +from tradingagents.agents.analysts.turn import WRAP_UP from tradingagents.agents.state import AgentState from .analyst_execution import build_analyst_execution_plan from .conditional_logic import ConditionalLogic +logger = logging.getLogger(__name__) + # 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 @@ -44,22 +50,43 @@ def _tools_or_done(state) -> str: return "tools" if state["messages"][-1].tool_calls else END -def _analyst_graph(spec, agent): +def _analyst_graph(spec, agent, max_tool_rounds: int): """One analyst as a graph of its own: the model and its tools, on a private message history. It returns only its report, so analysts running side by side never write the - same key, and its tool calls never reach the other analysts' messages. + same key, and its tool calls never reach the other analysts' messages. After + ``max_tool_rounds`` rounds of tool calls it is told to write its report, and + that turn ends it whatever it answers, so a model that keeps calling tools + cannot run the graph into its recursion limit (#1420). """ output = TypedDict(f"{spec.key.capitalize()}Report", {spec.report_key: str}) graph = StateGraph(AgentState, output_schema=output) graph.add_node("agent", agent) graph.add_edge(START, "agent") - if spec.tools: - graph.add_node("tools", ToolNode(list(spec.tools))) - graph.add_conditional_edges("agent", _tools_or_done, ["tools", END]) - graph.add_edge("tools", "agent") - else: + if not spec.tools: graph.add_edge("agent", END) + return graph.compile() + + def calls(messages): + return [call["name"] for m in messages for call in (getattr(m, "tool_calls", None) or [])] + + def rounds(messages) -> int: + return sum(1 for m in messages if getattr(m, "tool_calls", None)) + + def more_or_wrap_up(state) -> str: + return "wrap_up" if rounds(state["messages"]) >= max_tool_rounds else "agent" + + def wrap_up(state): + repeated = ", ".join(f"{name} x{n}" for name, n in Counter(calls(state["messages"])).most_common()) + logger.warning("%s used its %d tool rounds (%s); asking for its report", + spec.agent_node, max_tool_rounds, repeated) + return agent({**state, "messages": [*state["messages"], HumanMessage(WRAP_UP)]}) + + graph.add_node("tools", ToolNode(list(spec.tools))) + graph.add_node("wrap_up", wrap_up) + graph.add_conditional_edges("agent", _tools_or_done, ["tools", END]) + graph.add_conditional_edges("tools", more_or_wrap_up, ["agent", "wrap_up"]) + graph.add_edge("wrap_up", END) return graph.compile() @@ -71,11 +98,13 @@ class GraphSetup: quick_thinking_llm: Any, deep_thinking_llm: Any, conditional_logic: ConditionalLogic, + max_tool_rounds: int, ): """Initialize with required components.""" self.quick_thinking_llm = quick_thinking_llm self.deep_thinking_llm = deep_thinking_llm self.conditional_logic = conditional_logic + self.max_tool_rounds = max_tool_rounds def setup_graph( self, selected_analysts=("market", "social", "news", "fundamentals") @@ -111,7 +140,8 @@ class GraphSetup: workflow = StateGraph(AgentState) for spec in plan.specs: - workflow.add_node(spec.agent_node, _analyst_graph(spec, analyst_factories[spec.key]())) + workflow.add_node(spec.agent_node, + _analyst_graph(spec, analyst_factories[spec.key](), self.max_tool_rounds)) workflow.add_node("Bull Researcher", bull_researcher_node) workflow.add_node("Bear Researcher", bear_researcher_node) diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index c2872b5ff..b59e8a1be 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -94,14 +94,23 @@ class TradingAgentsGraph: max_debate_rounds=self.config["max_debate_rounds"], max_risk_discuss_rounds=self.config["max_risk_discuss_rounds"], ) + # An analyst takes two graph steps per tool round, plus its first turn + # and its wrap-up; a limit past the recursion limit would end the run + # there instead. + max_tool_rounds, max_recur_limit = self.config["max_tool_rounds"], self.config["max_recur_limit"] + if 2 * max_tool_rounds + 2 >= max_recur_limit: + raise ValueError( + f"max_tool_rounds={max_tool_rounds} needs max_recur_limit above {2 * max_tool_rounds + 2}" + ) self.graph_setup = GraphSetup( self.quick_thinking_llm, self.deep_thinking_llm, self.conditional_logic, + max_tool_rounds, ) self.propagator = Propagator( - max_recur_limit=self.config.get("max_recur_limit", 100), + max_recur_limit=max_recur_limit, ) self.reflector = Reflector(self.quick_thinking_llm)