From 9470b921501a16fb05a4adbfc27ab8a4cd31f67e Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Fri, 25 Sep 2026 06:41:22 +0000 Subject: [PATCH] fix(cli): write the state log, and name the analysts as users know them - record_decision writes the run's state log, so CLI runs save full_states_log like propagate() does - with both round counts from the environment, the CLI no longer says a chosen research depth was overridden - an unknown --analysts name lists sentiment, not social --- cli/prompts.py | 3 ++- cli/run.py | 16 +++++++++------- cli/selections.py | 13 ++++++++----- tests/test_cli_headless.py | 23 ++++++++++++++++++++++- tests/test_cli_memory_log.py | 24 ++++++++++++++++++++++++ tradingagents/graph/trading_graph.py | 10 +++++----- 6 files changed, 70 insertions(+), 19 deletions(-) diff --git a/cli/prompts.py b/cli/prompts.py index 4be760b9b..549e63e49 100644 --- a/cli/prompts.py +++ b/cli/prompts.py @@ -90,7 +90,8 @@ def parse_analysts(value: str, asset_type: AssetType) -> list[AnalystType]: known = {a.value: a for a in AnalystType} unknown = [n for n in names if n not in known] if unknown: - raise ValueError(f"unknown analyst {', '.join(unknown)}; choose from {', '.join(known)}") + choices = ", ".join("sentiment" if name == "social" else name for name in known) + raise ValueError(f"unknown analyst {', '.join(unknown)}; choose from {choices}") available = filter_analysts_for_asset_type(list(known.values()), asset_type) unavailable = [n for n in names if known[n] not in available] if unavailable: diff --git a/cli/run.py b/cli/run.py index 4070d3e65..bbcd1e0d4 100644 --- a/cli/run.py +++ b/cli/run.py @@ -22,7 +22,7 @@ from cli.display import ( update_display, update_research_team_status, ) -from cli.selections import get_user_selections, unattended_gaps +from cli.selections import depth_from_env, get_user_selections, unattended_gaps from cli.stats_handler import StatsCallbackHandler from tradingagents.agents.rating import is_review, run_rating from tradingagents.dataflows.symbols import safe_ticker_component @@ -67,17 +67,19 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict: # Research depth sets both round counts, but an explicit env override # (TRADINGAGENTS_MAX_DEBATE_ROUNDS / _MAX_RISK_ROUNDS) wins over the # interactive selection — leave the env-applied value in place (#977). - for env_var, key in (("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "max_debate_rounds"), - ("TRADINGAGENTS_MAX_RISK_ROUNDS", "max_risk_discuss_rounds")): - if os.environ.get(env_var): - # The depth prompt still appeared (it is skipped only when both are + rounds = (("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "max_debate_rounds"), + ("TRADINGAGENTS_MAX_RISK_ROUNDS", "max_risk_discuss_rounds")) + depth_was_asked = not depth_from_env() + for env_var, key in rounds: + if not os.environ.get(env_var): + config[key] = selections["research_depth"] + elif depth_was_asked: + # The depth question appeared (it is skipped only when both are # set), so say which half of the answer the environment overrode. console.print( f"[green]✓ {key} from environment:[/green] {config[key]} " f"(set by {env_var}, so the research depth you chose does not apply to it)" ) - else: - config[key] = selections["research_depth"] config["quick_think_llm"] = selections["quick_think_llm"] config["deep_think_llm"] = selections["deep_think_llm"] config["backend_url"] = selections["backend_url"] diff --git a/cli/selections.py b/cli/selections.py index e0e8f96ac..a269158b2 100644 --- a/cli/selections.py +++ b/cli/selections.py @@ -46,6 +46,12 @@ def get_user_selections(flags=None): return selections +def depth_from_env() -> bool: + """Both round counts come from the environment, so the depth question is skipped.""" + return bool(os.environ.get("TRADINGAGENTS_MAX_DEBATE_ROUNDS") + and os.environ.get("TRADINGAGENTS_MAX_RISK_ROUNDS")) + + def unattended_gaps(flags) -> list[str]: """The flags and environment variables a run with no terminal still needs.""" env = os.environ.get @@ -53,7 +59,7 @@ def unattended_gaps(flags) -> list[str]: gaps += [f"--{name} or --no-{name}" for name in ("save", "show") if flags.get(name) is None] if not env("TRADINGAGENTS_OUTPUT_LANGUAGE"): gaps.append("TRADINGAGENTS_OUTPUT_LANGUAGE") - if not (env("TRADINGAGENTS_MAX_DEBATE_ROUNDS") and env("TRADINGAGENTS_MAX_RISK_ROUNDS")): + if not depth_from_env(): gaps.append("TRADINGAGENTS_MAX_DEBATE_ROUNDS and TRADINGAGENTS_MAX_RISK_ROUNDS") if not env("TRADINGAGENTS_LLM_PROVIDER"): gaps.append("TRADINGAGENTS_LLM_PROVIDER") @@ -190,10 +196,7 @@ def _prompt_selections(prefs, flags): # Research depth maps to the debate + risk round counts; when both are # supplied through TRADINGAGENTS_MAX_DEBATE_ROUNDS / _MAX_RISK_ROUNDS we keep # the run non-interactive and honor the env values (#977). - depth_from_env = bool(os.environ.get("TRADINGAGENTS_MAX_DEBATE_ROUNDS")) and bool( - os.environ.get("TRADINGAGENTS_MAX_RISK_ROUNDS") - ) - if depth_from_env: + if depth_from_env(): selected_research_depth = DEFAULT_CONFIG["max_debate_rounds"] console.print( f"[green]✓ Research depth from environment:[/green] " diff --git a/tests/test_cli_headless.py b/tests/test_cli_headless.py index 15f292cc6..89972eab8 100644 --- a/tests/test_cli_headless.py +++ b/tests/test_cli_headless.py @@ -38,7 +38,7 @@ class TestFlagValues: def test_analysts_are_named_and_checked_against_the_asset(self): assert prompts.parse_analysts(" Market, news ", AssetType.STOCK) == [AnalystType.MARKET, AnalystType.NEWS] - with pytest.raises(ValueError, match="market, social, news, fundamentals"): + with pytest.raises(ValueError, match="market, sentiment, news, fundamentals"): prompts.parse_analysts("market,macro", AssetType.STOCK) with pytest.raises(ValueError, match="crypto"): prompts.parse_analysts("fundamentals", AssetType.CRYPTO) @@ -160,3 +160,24 @@ def test_a_closed_stdin_counts_as_no_terminal(monkeypatch, capsys): with pytest.raises(typer.Exit): run.run_analysis(flags={}) assert "--ticker" in capsys.readouterr().out + + +@pytest.mark.unit +def test_rounds_from_the_environment_do_not_claim_a_depth_was_chosen(monkeypatch, capsys): + """With both round counts set, the depth question never appeared.""" + monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "1") + monkeypatch.setenv("TRADINGAGENTS_MAX_RISK_ROUNDS", "1") + selections = {"research_depth": 3, "quick_think_llm": "q", "deep_think_llm": "d", "backend_url": None, + "llm_provider": "openai", "google_thinking_level": None, "openai_reasoning_effort": None, + "anthropic_effort": None, "output_language": "English"} + + run._build_run_config(selections, None) + + assert "the research depth you chose" not in capsys.readouterr().out + + +@pytest.mark.unit +def test_an_unknown_analyst_error_names_the_analysts_as_users_know_them(): + with pytest.raises(ValueError) as caught: + prompts.parse_analysts("macro", AssetType.STOCK) + assert "sentiment" in str(caught.value) and "social" not in str(caught.value) diff --git a/tests/test_cli_memory_log.py b/tests/test_cli_memory_log.py index 4e3170660..4171492ec 100644 --- a/tests/test_cli_memory_log.py +++ b/tests/test_cli_memory_log.py @@ -21,6 +21,7 @@ def _bare_graph(tmp_path): graph = object.__new__(TradingAgentsGraph) graph.config = {"memory_log_path": str(tmp_path / "trading_memory.md")} graph.memory_log = TradingMemoryLog(graph.config) + graph._log_state = lambda *a: None # these tests are about the memory log return graph @@ -187,3 +188,26 @@ def test_a_run_without_checkpointing_says_nothing_about_resuming(tmp_path, monke buffer = _run_cli(monkeypatch, tmp_path, _FakeGraph()) assert not any("resum" in text.lower() or "fresh" in text.lower() for _, _, text in buffer.messages) + + +@pytest.mark.unit +def test_recording_a_run_writes_its_state_log(tmp_path): + """The CLI records a run through record_decision, so the state log is written there.""" + graph = object.__new__(TradingAgentsGraph) + graph.config = {"results_dir": str(tmp_path), "llm_provider": "openai", "deep_think_llm": "d", + "quick_think_llm": "q", "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, + "output_language": "English", "data_vendors": {}, "tool_vendors": {}} + graph.selected_analysts = ("market",) + graph.memory_log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) + state = {"company_of_interest": "NVDA", "trade_date": "2026-09-23", "market_report": "M", + "sentiment_report": "", "news_report": "", "fundamentals_report": "", + "investment_debate_state": {"bull_history": "", "bear_history": "", "history": "", + "current_response": ""}, + "trader_investment_plan": "T", "investment_plan": "P", + "risk_debate_state": {"aggressive_history": "", "conservative_history": "", + "neutral_history": "", "history": ""}, + "final_trade_decision": "**Rating**: Hold", "final_rating": "Hold"} + + graph.record_decision("NVDA", "2026-09-23", state) + + assert list(tmp_path.glob("NVDA/TradingAgentsStrategy_logs/full_states_log_2026-09-23.json")) diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 05953eb82..3757ff5b2 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -312,10 +312,13 @@ class TradingAgentsGraph: settlement.settle_pending(company_name, self.memory_log, self.reflector, self.config) def record_decision(self, company_name, trade_date, final_state): - """Log a finished run's decision for reflection on the next same-ticker run.""" + """Record a finished run: its state log, and its decision in the memory log + for reflection on the next same-ticker run. propagate() and the CLI both end here.""" + self._log_state(trade_date, final_state) decision = final_state.get("final_trade_decision") if not decision: - logger.warning("No final decision for %s on %s; nothing logged", company_name, trade_date) + logger.warning("No final decision for %s on %s; nothing added to the memory log", + company_name, trade_date) return self.memory_log.store_decision( ticker=company_name, trade_date=trade_date, final_trade_decision=decision, @@ -349,9 +352,6 @@ class TradingAgentsGraph: else: final_state = self.graph.invoke(graph_input, **args) - # Log state to disk. - self._log_state(trade_date, final_state) - self.record_decision(company_name, trade_date, final_state) # Clear checkpoint on successful completion to avoid stale state.