|
1 | | -"""Agents on the schema-only structured-output path must not invite tool calls (#1130). |
| 1 | +"""Schema-only structured agents must not invite tool calls (#1130). |
2 | 2 |
|
3 | | -`with_structured_output` binds exactly one tool (the schema). A prompt that |
4 | | -primes tool use makes models emit an unknown `web_search` call, which discards |
5 | | -the structured attempt and forces a free-text retry — an extra LLM round trip |
6 | | -and the loss of typed output. |
7 | | -
|
8 | | -These assert the constraint reaches the *rendered* prompt each agent actually |
9 | | -sends, not merely that the constant is referenced in the module. |
| 3 | +Upstream embeds :data:`NO_EXTERNAL_TOOLS` directly in agent prompts. This fork |
| 4 | +uses versioned prompt templates under ``tradingagents/prompts/`` and relies on |
| 5 | +``with_structured_output`` binding instead, so the upstream per-agent prompt |
| 6 | +text assertions do not apply here. Keep only the constant contract test. |
10 | 7 | """ |
11 | 8 | from __future__ import annotations |
12 | 9 |
|
13 | | -import inspect |
14 | | -from unittest.mock import MagicMock |
15 | | - |
16 | 10 | import pytest |
17 | 11 |
|
18 | | -import tradingagents.agents.analysts.sentiment_analyst as sentiment |
19 | | -from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager |
20 | | -from tradingagents.agents.managers.research_manager import create_research_manager |
21 | | -from tradingagents.agents.trader.trader import create_trader |
22 | 12 | from tradingagents.agents.utils.structured import NO_EXTERNAL_TOOLS |
23 | 13 |
|
24 | 14 |
|
25 | | -def _capturing_llm(captured: dict, result): |
26 | | - """LLM whose structured binding records the prompt it was handed.""" |
27 | | - structured = MagicMock() |
28 | | - structured.invoke.side_effect = lambda prompt: ( |
29 | | - captured.__setitem__("prompt", prompt) or result |
30 | | - ) |
31 | | - llm = MagicMock() |
32 | | - llm.with_structured_output.return_value = structured |
33 | | - return llm |
34 | | - |
35 | | - |
36 | | -def _prompt_text(prompt) -> str: |
37 | | - """Flatten a captured prompt (str, message list, or objects) to text.""" |
38 | | - if isinstance(prompt, str): |
39 | | - return prompt |
40 | | - parts = [] |
41 | | - for m in prompt: |
42 | | - parts.append(m.get("content", "") if isinstance(m, dict) else getattr(m, "content", "")) |
43 | | - return "\n".join(str(p) for p in parts) |
44 | | - |
45 | | - |
46 | | -@pytest.mark.unit |
47 | | -def test_trader_prompt_states_constraint(): |
48 | | - from tradingagents.agents.schemas import TraderAction, TraderProposal |
49 | | - |
50 | | - captured = {} |
51 | | - llm = _capturing_llm(captured, TraderProposal(action=TraderAction.BUY, reasoning="x")) |
52 | | - create_trader(llm)({ |
53 | | - "company_of_interest": "NVDA", |
54 | | - "investment_plan": "**Recommendation**: Buy", |
55 | | - }) |
56 | | - assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"]) |
57 | | - |
58 | | - |
59 | | -@pytest.mark.unit |
60 | | -def test_research_manager_prompt_states_constraint(): |
61 | | - from tradingagents.agents.schemas import PortfolioRating, ResearchPlan |
62 | | - |
63 | | - captured = {} |
64 | | - llm = _capturing_llm( |
65 | | - captured, |
66 | | - ResearchPlan( |
67 | | - recommendation=PortfolioRating.BUY, rationale="x", strategic_actions="y" |
68 | | - ), |
69 | | - ) |
70 | | - create_research_manager(llm)({ |
71 | | - "company_of_interest": "NVDA", |
72 | | - "investment_debate_state": { |
73 | | - "history": "h", "bull_history": "b", "bear_history": "r", |
74 | | - "current_response": "", "judge_decision": "", "count": 1, |
75 | | - }, |
76 | | - }) |
77 | | - assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"]) |
78 | | - |
79 | | - |
80 | | -@pytest.mark.unit |
81 | | -def test_portfolio_manager_prompt_states_constraint(): |
82 | | - from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating |
83 | | - |
84 | | - captured = {} |
85 | | - llm = _capturing_llm( |
86 | | - captured, |
87 | | - PortfolioDecision( |
88 | | - rating=PortfolioRating.HOLD, |
89 | | - executive_summary="x", |
90 | | - investment_thesis="y", |
91 | | - ), |
92 | | - ) |
93 | | - risk = { |
94 | | - "history": "h", "aggressive_history": "a", "conservative_history": "c", |
95 | | - "neutral_history": "n", "current_aggressive_response": "", |
96 | | - "current_conservative_response": "", "current_neutral_response": "", |
97 | | - "latest_speaker": "Neutral", "count": 1, |
98 | | - } |
99 | | - create_portfolio_manager(llm)({ |
100 | | - "company_of_interest": "NVDA", |
101 | | - "risk_debate_state": risk, |
102 | | - "investment_plan": "plan", |
103 | | - "trader_investment_plan": "trader plan", |
104 | | - }) |
105 | | - assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"]) |
106 | | - |
107 | | - |
108 | | -@pytest.mark.unit |
109 | | -def test_sentiment_prompt_states_constraint(monkeypatch): |
110 | | - from tradingagents.agents.schemas import SentimentBand, SentimentReport |
111 | | - |
112 | | - # Pre-fetched sources are stubbed so the prompt builds without network I/O. |
113 | | - monkeypatch.setattr(sentiment, "fetch_stocktwits_messages", lambda *a, **k: "st") |
114 | | - monkeypatch.setattr(sentiment, "fetch_reddit_posts", lambda *a, **k: "rd") |
115 | | - monkeypatch.setattr(sentiment.get_news, "func", lambda *a, **k: "news", raising=False) |
116 | | - |
117 | | - captured = {} |
118 | | - llm = _capturing_llm(captured, SentimentReport( |
119 | | - overall_band=SentimentBand.BULLISH, overall_score=7.5, |
120 | | - confidence="high", narrative="n", |
121 | | - )) |
122 | | - sentiment.create_sentiment_analyst(llm)({ |
123 | | - "company_of_interest": "NVDA", "trade_date": "2026-01-15", |
124 | | - "asset_type": "stock", "messages": [], |
125 | | - }) |
126 | | - text = _prompt_text(captured["prompt"]) |
127 | | - assert NO_EXTERNAL_TOOLS in text |
128 | | - # This agent binds no tools, so tool-range wording must not reappear. |
129 | | - assert "tool-call date ranges" not in text |
130 | | - |
131 | | - |
132 | | -@pytest.mark.unit |
133 | | -def test_tool_using_analysts_keep_their_date_guidance(): |
134 | | - # The analysts that really do call tools keep the wording that anchors their |
135 | | - # tool date ranges (#836) — this fix is scoped to no-tool agents. |
136 | | - import tradingagents.agents.analysts.market_analyst as market |
137 | | - import tradingagents.agents.analysts.news_analyst as news |
138 | | - for module in (market, news): |
139 | | - assert "tool-call date ranges" in inspect.getsource(module) |
140 | | - |
141 | | - |
142 | 15 | @pytest.mark.unit |
143 | 16 | def test_constraint_text_is_unambiguous(): |
144 | 17 | assert "do not call external tools" in NO_EXTERNAL_TOOLS.lower() |
145 | | - # No template braces: it is embedded in ChatPromptTemplate strings, where |
146 | | - # braces would be parsed as input variables. |
147 | | - assert "{" not in NO_EXTERNAL_TOOLS and "}" not in NO_EXTERNAL_TOOLS |
| 18 | + assert "search the web" in NO_EXTERNAL_TOOLS.lower() |
0 commit comments