mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-11-04 00:22:43 +02:00
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:
@@ -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).
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user