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
This commit is contained in:
Yijia-Xiao
2026-09-29 05:57:18 +00:00
parent 0d22cad2af
commit ca2ebbbdcb
9 changed files with 156 additions and 33 deletions
+1
View File
@@ -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).
+56 -1
View File
@@ -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)
@@ -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],
@@ -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],
@@ -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],
+42
View File
@@ -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
+3
View File
@@ -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.
+38 -8
View File
@@ -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)
+10 -1
View File
@@ -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)