From 441e1e5dd2a7f77798c6192f62396d0c3c4c7969 Mon Sep 17 00:00:00 2001 From: Michael Yang Date: Tue, 14 Apr 2026 16:49:22 -0400 Subject: [PATCH] feat: surface claude_agent provider in CLI + runner polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI: add "Claude Agent (Max subscription, no API key)" to the provider picker and register opus/sonnet/haiku model aliases so the CLI flow picks up the provider registered in factory.py. No effort-level step since the SDK doesn't expose that knob. Analyst runner: build a concrete user request from company_of_interest + trade_date instead of echoing the terse ("human", ticker) initial state — the SDK was sitting idle on prompts like just "NVDA". Add opt-in file- based debug logging (TRADINGAGENTS_CLAUDE_AGENT_DEBUG=1 → /tmp/...log) for observability during long adaptive-thinking blocks. Also adds main_claude_agent.py as a ready-to-run example for a Max-only end-to-end invocation (verified 12-min NVDA run → SELL). Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/utils.py | 1 + main_claude_agent.py | 41 ++++++ .../agents/analysts/_claude_agent_runner.py | 132 +++++++++++++++--- tradingagents/llm_clients/model_catalog.py | 10 ++ 4 files changed, 162 insertions(+), 22 deletions(-) create mode 100644 main_claude_agent.py diff --git a/cli/utils.py b/cli/utils.py index 85c282ed..e492a92a 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -235,6 +235,7 @@ def select_llm_provider() -> tuple[str, str | None]: ("OpenAI", "openai", "https://api.openai.com/v1"), ("Google", "google", None), ("Anthropic", "anthropic", "https://api.anthropic.com/"), + ("Claude Agent (Max subscription, no API key)", "claude_agent", None), ("xAI", "xai", "https://api.x.ai/v1"), ("DeepSeek", "deepseek", "https://api.deepseek.com"), ("Qwen", "qwen", "https://dashscope.aliyuncs.com/compatible-mode/v1"), diff --git a/main_claude_agent.py b/main_claude_agent.py new file mode 100644 index 00000000..f5629a1b --- /dev/null +++ b/main_claude_agent.py @@ -0,0 +1,41 @@ +"""Full TradingAgents run using a Claude Max subscription (no API key). + +Requires being logged into Claude Code. Start small: one analyst, one debate +round. The analyst tool loop takes ~1-2 min/analyst via the SDK, so a full +4-analyst run will be ~10 min end-to-end. +""" + +from dotenv import load_dotenv + +from tradingagents.default_config import DEFAULT_CONFIG +from tradingagents.graph.trading_graph import TradingAgentsGraph + +load_dotenv() + +config = DEFAULT_CONFIG.copy() +config["llm_provider"] = "claude_agent" +config["deep_think_llm"] = "sonnet" # or "opus" for slower / higher quality +config["quick_think_llm"] = "sonnet" +config["max_debate_rounds"] = 1 +config["max_risk_discuss_rounds"] = 1 + +# YFinance — no API key needed. +config["data_vendors"] = { + "core_stock_apis": "yfinance", + "technical_indicators": "yfinance", + "fundamental_data": "yfinance", + "news_data": "yfinance", +} + +ta = TradingAgentsGraph( + # Start with one analyst to validate the pipeline before burning minutes + # on the full set. Expand to ["market", "social", "news", "fundamentals"] + # once this works. + selected_analysts=["market"], + debug=True, + config=config, +) + +_, decision = ta.propagate("NVDA", "2025-10-15") +print("\n=== DECISION ===") +print(decision) diff --git a/tradingagents/agents/analysts/_claude_agent_runner.py b/tradingagents/agents/analysts/_claude_agent_runner.py index 468ee011..9223548f 100644 --- a/tradingagents/agents/analysts/_claude_agent_runner.py +++ b/tradingagents/agents/analysts/_claude_agent_runner.py @@ -6,9 +6,19 @@ Claude iteratively invokes the translated MCP tools and returns a final text report. No LangGraph ToolNode involvement — the analyst returns a terminal AIMessage with zero tool_calls, so the existing conditional edges route straight to the message-clear node. + +Debug logging: set ``TRADINGAGENTS_CLAUDE_AGENT_DEBUG=1`` to log SDK activity +to ``/tmp/tradingagents_claude_agent.log`` (or set +``TRADINGAGENTS_CLAUDE_AGENT_DEBUG=/path/to/file`` for a custom path). Tail it +in a second terminal to watch progress in real time: + + tail -f /tmp/tradingagents_claude_agent.log """ import asyncio +import os +import time +from datetime import datetime from typing import Any, Dict, List from langchain_core.messages import AIMessage, HumanMessage @@ -17,23 +27,80 @@ from tradingagents.llm_clients.claude_agent_client import ChatClaudeAgent from tradingagents.llm_clients.mcp_tool_adapter import build_mcp_server -def _build_user_prompt(state: Dict[str, Any]) -> str: - """Extract any human content from the incoming message sequence. +def _debug_path() -> str | None: + val = os.environ.get("TRADINGAGENTS_CLAUDE_AGENT_DEBUG") + if not val: + return None + if val in ("1", "true", "yes", "on"): + return "/tmp/tradingagents_claude_agent.log" + return val - Existing analysts rely on LangGraph feeding tool-call round trips through - state["messages"]. On the SDK path we collapse the incoming messages into a - single user prompt — tool results are consumed by the SDK loop, not via - LangGraph, so only the human-authored content matters here. + +def _log(msg: str) -> None: + path = _debug_path() + if not path: + return + ts = datetime.now().strftime("%H:%M:%S.%f")[:-3] + try: + with open(path, "a") as f: + f.write(f"[{ts}] {msg}\n") + except OSError: + pass + + +def _describe_message(msg: Any) -> str: + """One-line summary of an SDK message for the debug log.""" + try: + name = type(msg).__name__ + content = getattr(msg, "content", None) + if content is None: + return f"{name} (no content)" + if isinstance(content, list): + block_summary = [] + for block in content: + bname = type(block).__name__ + if hasattr(block, "text"): + text = str(block.text) + snippet = text[:80].replace("\n", " ") + block_summary.append(f"{bname}[{len(text)} chars]: {snippet!r}") + elif hasattr(block, "name"): + block_summary.append(f"{bname}(name={block.name!r})") + else: + block_summary.append(bname) + return f"{name} with {len(content)} blocks: " + " | ".join(block_summary) + return f"{name}: {str(content)[:200]!r}" + except Exception as e: + return f"(failed to describe: {e!r})" + + +def _build_user_prompt(state: Dict[str, Any]) -> str: + """Construct a concrete user request from graph state. + + The initial graph state is ``messages = [("human", ticker)]`` — too terse + for Claude to act on unambiguously, which can leave the SDK session idle + waiting for clarification. Build an explicit request from + ``company_of_interest`` + ``trade_date`` so Claude always knows what to do. + Any additional human-authored content in the message stream is appended. """ - parts: List[str] = [] + ticker = state.get("company_of_interest", "") + trade_date = state.get("trade_date", "") + base = ( + f"Produce the requested report for {ticker} as of {trade_date}. " + "Use the available tools to gather the data you need, then write the " + "final report. Do not ask clarifying questions — proceed directly." + ).strip() + + extra: List[str] = [] for msg in state.get("messages", []): - if isinstance(msg, HumanMessage): - content = msg.content - if isinstance(content, str) and content.strip(): - parts.append(content.strip()) - if not parts: - parts.append("Produce the requested report.") - return "\n\n".join(parts) + content = getattr(msg, "content", None) + if isinstance(msg, HumanMessage) and isinstance(content, str): + c = content.strip() + if c and c != ticker: + extra.append(c) + + if extra: + return base + "\n\nAdditional context:\n" + "\n".join(extra) + return base async def _run( @@ -50,7 +117,10 @@ async def _run( query, ) + _log(f"[{server_name}] building MCP server with {len(lc_tools)} tools: " + f"{[t.name for t in lc_tools]}") server, allowed = build_mcp_server(server_name, lc_tools) + _log(f"[{server_name}] allowed_tools={allowed}") options = ClaudeAgentOptions( model=model, @@ -66,12 +136,23 @@ async def _run( permission_mode="bypassPermissions", ) + _log(f"[{server_name}] starting query(model={model!r}, prompt={user_prompt[:120]!r}...)") + start = time.monotonic() + text_parts: List[str] = [] + msg_count = 0 async for msg in query(prompt=user_prompt, options=options): + msg_count += 1 + elapsed = time.monotonic() - start + _log(f"[{server_name}] +{elapsed:.1f}s msg #{msg_count}: {_describe_message(msg)}") if isinstance(msg, AssistantMessage): for block in msg.content: if isinstance(block, TextBlock): text_parts.append(block.text) + + elapsed = time.monotonic() - start + _log(f"[{server_name}] query complete after {elapsed:.1f}s, " + f"{msg_count} messages, {sum(len(t) for t in text_parts)} chars") return "\n".join(text_parts).strip() @@ -85,15 +166,22 @@ def run_sdk_analyst( ) -> Dict[str, Any]: """Run an analyst through the Claude Agent SDK tool loop and build the node output.""" user_prompt = _build_user_prompt(state) - report = asyncio.run( - _run( - system_prompt=system_prompt, - user_prompt=user_prompt, - lc_tools=lc_tools, - server_name=server_name, - model=llm.model, + _log(f"=== run_sdk_analyst start: server={server_name} report_field={report_field} " + f"ticker={state.get('company_of_interest')!r} date={state.get('trade_date')!r} ===") + try: + report = asyncio.run( + _run( + system_prompt=system_prompt, + user_prompt=user_prompt, + lc_tools=lc_tools, + server_name=server_name, + model=llm.model, + ) ) - ) + except Exception as e: + _log(f"[{server_name}] EXCEPTION: {type(e).__name__}: {e}") + raise + _log(f"=== run_sdk_analyst done: {report_field}={len(report)} chars ===") return { "messages": [AIMessage(content=report)], report_field: report, diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index a2c57ed8..c268a921 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -36,6 +36,16 @@ MODEL_OPTIONS: ProviderModeOptions = { ("Claude Sonnet 4.5 - Agents and coding", "claude-sonnet-4-5"), ], }, + "claude_agent": { + "quick": [ + ("Claude Sonnet (via Claude Code, Max subscription)", "sonnet"), + ("Claude Haiku (via Claude Code, Max subscription)", "haiku"), + ], + "deep": [ + ("Claude Opus (via Claude Code, Max subscription)", "opus"), + ("Claude Sonnet (via Claude Code, Max subscription)", "sonnet"), + ], + }, "google": { "quick": [ ("Gemini 3 Flash - Next-gen fast", "gemini-3-flash-preview"),