mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-08-01 19:34:24 +03:00
fix(graph): key checkpoints on graph shape and expose the LLM retry budget
- checkpoint resume keyed only by ticker+date silently continued the old graph under a different analyst selection / depth / asset mode; fold a run signature into the thread id #1089 - add llm_max_retries + TRADINGAGENTS_LLM_MAX_RETRIES, forwarded to every provider when set (int-coerced, rejects negatives/booleans), so a 429 burst can't abort a run #1091
This commit is contained in:
@@ -140,5 +140,79 @@ class TestCheckpointResume(unittest.TestCase):
|
||||
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date))
|
||||
|
||||
|
||||
class TestCheckpointSignature(unittest.TestCase):
|
||||
"""A different graph shape (analyst selection / depth / asset mode) must not
|
||||
resume the previous run's checkpoint (#1089)."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.ticker = "TEST"
|
||||
self.date = "2026-04-20"
|
||||
|
||||
def test_empty_signature_is_legacy_id(self):
|
||||
self.assertEqual(
|
||||
thread_id(self.ticker, self.date),
|
||||
thread_id(self.ticker, self.date, ""),
|
||||
)
|
||||
|
||||
def test_signature_changes_thread_id(self):
|
||||
legacy = thread_id(self.ticker, self.date)
|
||||
sig_a = thread_id(self.ticker, self.date, "analysts=market,news|asset=stock")
|
||||
sig_b = thread_id(self.ticker, self.date, "analysts=market|asset=stock")
|
||||
self.assertNotEqual(sig_a, sig_b) # different graph shapes differ
|
||||
self.assertNotEqual(legacy, sig_a) # signature-keyed differs from legacy
|
||||
self.assertEqual( # same inputs are stable
|
||||
sig_a, thread_id(self.ticker, self.date, "analysts=market,news|asset=stock")
|
||||
)
|
||||
|
||||
def test_different_signature_starts_fresh(self):
|
||||
global _should_crash
|
||||
builder = _build_graph()
|
||||
sig1 = "analysts=market,news,fundamentals|asset=stock"
|
||||
sig2 = "analysts=market|asset=stock" # dropped analysts -> different graph
|
||||
|
||||
_should_crash = True
|
||||
tid1 = thread_id(self.ticker, self.date, sig1)
|
||||
with get_checkpointer(self.tmpdir, self.ticker) as saver:
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
with self.assertRaises(RuntimeError):
|
||||
graph.invoke({"count": 0}, config={"configurable": {"thread_id": tid1}})
|
||||
|
||||
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date, sig1))
|
||||
# A different graph shape has no checkpoint to resume from.
|
||||
self.assertFalse(has_checkpoint(self.tmpdir, self.ticker, self.date, sig2))
|
||||
|
||||
_should_crash = False
|
||||
tid2 = thread_id(self.ticker, self.date, sig2)
|
||||
self.assertNotEqual(tid1, tid2)
|
||||
with get_checkpointer(self.tmpdir, self.ticker) as saver:
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
result = graph.invoke({"count": 0}, config={"configurable": {"thread_id": tid2}})
|
||||
self.assertEqual(result["count"], 11)
|
||||
# sig1's checkpoint remains untouched.
|
||||
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date, sig1))
|
||||
|
||||
def test_run_signature_captures_graph_shape(self):
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
|
||||
# Build a bare instance to exercise the pure helper without heavy __init__.
|
||||
g = object.__new__(TradingAgentsGraph)
|
||||
g.selected_analysts = ("market", "news")
|
||||
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1}
|
||||
base = g._run_signature("stock")
|
||||
|
||||
self.assertNotEqual(base, g._run_signature("crypto")) # asset mode
|
||||
g.selected_analysts = ("market",)
|
||||
self.assertNotEqual(base, g._run_signature("stock")) # analyst selection
|
||||
g.selected_analysts = ("market", "news")
|
||||
g.config = {"max_debate_rounds": 3, "max_risk_discuss_rounds": 1}
|
||||
self.assertNotEqual(base, g._run_signature("stock")) # debate depth
|
||||
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 5}
|
||||
self.assertNotEqual(base, g._run_signature("stock")) # risk depth
|
||||
# Stable for identical inputs.
|
||||
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1}
|
||||
self.assertEqual(base, g._run_signature("stock"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
100
tests/test_llm_max_retries.py
Normal file
100
tests/test_llm_max_retries.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Configurable LLM SDK retry budget (#1090/#1091).
|
||||
|
||||
A single transient 429 burst used to kill an otherwise-healthy multi-agent run
|
||||
because each provider SDK's max_retries (default 2) was not exposed. This adds an
|
||||
opt-in llm_max_retries knob forwarded to every provider chat client.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
import tradingagents.default_config as default_config_module
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph, _coerce_max_retries
|
||||
|
||||
# --- coercion / validation -------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("value,expected", [(0, 0), (2, 2), (10, 10), ("6", 6)])
|
||||
def test_coerce_accepts_non_negative_ints_and_numeric_strings(value, expected):
|
||||
assert _coerce_max_retries(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("bad", [-1, "-3"])
|
||||
def test_coerce_rejects_negative(bad):
|
||||
with pytest.raises(ValueError, match=">= 0"):
|
||||
_coerce_max_retries(bad)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("bad", [True, False])
|
||||
def test_coerce_rejects_booleans(bad):
|
||||
with pytest.raises(ValueError, match="boolean"):
|
||||
_coerce_max_retries(bad)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("bad", ["abc", "1.5", None])
|
||||
def test_coerce_rejects_non_integers(bad):
|
||||
with pytest.raises(ValueError, match="integer"):
|
||||
_coerce_max_retries(bad)
|
||||
|
||||
|
||||
# --- forwarding into provider kwargs --------------------------------------
|
||||
|
||||
def _bare_graph(config):
|
||||
g = object.__new__(TradingAgentsGraph)
|
||||
g.config = config
|
||||
return g
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_not_forwarded_when_unset():
|
||||
kwargs = _bare_graph({"llm_provider": "openai", "llm_max_retries": None})._get_provider_kwargs()
|
||||
assert "max_retries" not in kwargs
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("provider", ["openai", "anthropic", "google"])
|
||||
def test_forwarded_across_providers(provider):
|
||||
kwargs = _bare_graph({"llm_provider": provider, "llm_max_retries": 6})._get_provider_kwargs()
|
||||
assert kwargs["max_retries"] == 6
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forwarded_env_string_is_coerced():
|
||||
# env vars arrive as strings; the consumer coerces (like temperature)
|
||||
kwargs = _bare_graph({"llm_provider": "openai", "llm_max_retries": "4"})._get_provider_kwargs()
|
||||
assert kwargs["max_retries"] == 4
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_config_value_fails_loudly():
|
||||
with pytest.raises(ValueError):
|
||||
_bare_graph({"llm_provider": "openai", "llm_max_retries": -1})._get_provider_kwargs()
|
||||
|
||||
|
||||
# --- env overlay -----------------------------------------------------------
|
||||
|
||||
def _reload_with_env(monkeypatch, **overrides):
|
||||
for key in list(default_config_module._ENV_OVERRIDES):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
for key, val in overrides.items():
|
||||
monkeypatch.setenv(key, val)
|
||||
return importlib.reload(default_config_module)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_is_none(monkeypatch):
|
||||
dc = _reload_with_env(monkeypatch)
|
||||
assert dc.DEFAULT_CONFIG["llm_max_retries"] is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_env_override_sets_config(monkeypatch):
|
||||
dc = _reload_with_env(monkeypatch, TRADINGAGENTS_LLM_MAX_RETRIES="8")
|
||||
# None-default key: env value arrives as a string and is coerced downstream.
|
||||
assert dc.DEFAULT_CONFIG["llm_max_retries"] == "8"
|
||||
assert _coerce_max_retries(dc.DEFAULT_CONFIG["llm_max_retries"]) == 8
|
||||
Reference in New Issue
Block a user