diff --git a/frontend/packages/biz-ui/src/components/deep-analysis-modal.tsx b/frontend/packages/biz-ui/src/components/deep-analysis-modal.tsx index 24070ec9..e2583f72 100644 --- a/frontend/packages/biz-ui/src/components/deep-analysis-modal.tsx +++ b/frontend/packages/biz-ui/src/components/deep-analysis-modal.tsx @@ -145,19 +145,19 @@ export function DeepAnalysisModal({ setError('') setProgress(null) setTraceId(null) + let cancelled = false // 并发查询运行状态与已保存报告: // - findRunning:这只股票最近 30 分钟有没有运行中的任务 // - getLatestForStock:有没有已保存的最近一次报告 - // 优先级:running > done(已有结果)> idle + // 优先级:running > failed > done(已有结果)> idle Promise.all([ tradingAgentsApi.findRunning(stockSymbol).catch(() => ({ trace_id: null, status: 'none' as const })), tradingAgentsApi.getLatestForStock(stockSymbol).catch(() => null), - ]).then(([runningInfo, latestResult]) => { - // 优先级:running(真在跑) > done(已保存的报告,允许重新分析) > idle - // - stale / failed / success / none 都视为"不在跑" - // - 任何状态下,只要有已保存报告就展示 DoneView(含「忽略缓存重新分析」按钮) - // - 任何状态下,IdleView 的「开始分析」按钮永远可用,后端会做幂等去重 + ]).then(async ([runningInfo, latestResult]) => { + if (cancelled) return + // 优先级:running > failed(保留最新错误) > done(已保存的报告) > idle + // - 失败视图可重试,后端会做幂等去重 // 1) 真正在跑(后端权威源)→ 进入 running if (runningInfo.status === 'running' && runningInfo.trace_id) { @@ -165,13 +165,26 @@ export function DeepAnalysisModal({ setTraceId(tid) setStage('running') // 后端确认在跑;即使采集阶段暂时没有日志,也继续由 SSE/polling 接力 - tradingAgentsApi.getProgress(tid).then(resp => setProgress(resp)) + tradingAgentsApi.getProgress(tid).then(resp => { if (!cancelled) setProgress(resp) }) startWatching(tid) return } - // 2) 后端 stale/failed → 老任务死掉/失败,清掉本地痕迹,继续走缓存判断 - // 不再回到 running,允许用户重新触发 + // Keep a failed run's diagnostic visible when the dialog is reopened. + if (runningInfo.status === 'failed' && runningInfo.trace_id) { + const failedProgress = await tradingAgentsApi.getProgress(runningInfo.trace_id).catch(() => null) + if (cancelled) return + if (failedProgress?.status === 'failed') { + setTraceId(runningInfo.trace_id) + setProgress(failedProgress) + setError(failedProgress.run?.error || tr('errors.analysisFailed')) + setStage('error') + clearRunningTrace(stockSymbol) + return + } + } + + // 2) 后端 stale/failed → 清掉本地运行痕迹,继续走缓存判断 if (runningInfo.status === 'stale' || runningInfo.status === 'failed') { clearRunningTrace(stockSymbol) } @@ -182,7 +195,7 @@ export function DeepAnalysisModal({ if (localTrace) { setTraceId(localTrace) setStage('running') - tradingAgentsApi.getProgress(localTrace).then(resp => setProgress(resp)) + tradingAgentsApi.getProgress(localTrace).then(resp => { if (!cancelled) setProgress(resp) }) startWatching(localTrace) return } @@ -200,6 +213,7 @@ export function DeepAnalysisModal({ // 5) 都没有 → idle(开始分析按钮可用,后端幂等保护) clearRunningTrace(stockSymbol) }) + return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, initialResult, stockSymbol]) @@ -360,7 +374,7 @@ export function DeepAnalysisModal({
{tr('failedTitle')}
-
{error}
+
{error}
diff --git a/frontend/src/pages/Agents.tsx b/frontend/src/pages/Agents.tsx index b98d77b8..a227e7a1 100644 --- a/frontend/src/pages/Agents.tsx +++ b/frontend/src/pages/Agents.tsx @@ -460,8 +460,12 @@ export default function AgentsPage() { const saveTaConfig = async () => { if (!taConfigAgent) return try { + // llm_max_tokens is a legacy setting. Keep accepting it in imported/old + // configs, but stop persisting or forwarding the output cap. + const configWithoutOutputCap = { ...taConfigForm } + delete configWithoutOutputCap.llm_max_tokens const config = { - ...taConfigForm, + ...configWithoutOutputCap, timeout_minutes: timeoutValue(taConfigForm.timeout_minutes, 30, 1, 60), llm_timeout_seconds: timeoutValue(taConfigForm.llm_timeout_seconds, 300, 1, 900), collection_timeout_seconds: timeoutValue(taConfigForm.collection_timeout_seconds, 45, 5, 300), diff --git a/frontend/tests/analysis/deep-analysis-modal.test.tsx b/frontend/tests/analysis/deep-analysis-modal.test.tsx index 786a5434..5d440e07 100644 --- a/frontend/tests/analysis/deep-analysis-modal.test.tsx +++ b/frontend/tests/analysis/deep-analysis-modal.test.tsx @@ -1,14 +1,16 @@ import i18n from '@/i18n' -import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { tradingAgentsApi, type DeepAnalysisResult } from '@panwatch/api' +import { tradingAgentsApi, type DeepAnalysisResult, type ProgressResponse } from '@panwatch/api' import { DeepAnalysisModal } from '@panwatch/biz-ui/components/deep-analysis-modal' -vi.mock('@panwatch/api', () => ({ tradingAgentsApi: { findRunning: vi.fn(), getLatestForStock: vi.fn() }, subscribeSSE: vi.fn() })) +vi.mock('@panwatch/api', () => ({ tradingAgentsApi: { findRunning: vi.fn(), getLatestForStock: vi.fn(), getProgress: vi.fn() }, subscribeSSE: vi.fn() })) vi.mock('@panwatch/base-ui/components/ui/toast', () => ({ useToast: () => ({ toast: vi.fn() }) })) const result = { agent_name: 'tradingagents', title: '广汽集团', content: 'Report', analysis_date: '2026-10-01', generated_at: '2026-10-01T15:30:00+08:00', raw_data: { cost_usd: 0.0378, from_cache: true, suggestion: { action: 'hold', action_label: '持有', confidence: 7 }, token_usage: { input_tokens: 800, output_tokens: 200, total_tokens: 1000, recorded_calls: 2, completed_calls: 2, complete: true }, final_decision: 'PM report' } } as DeepAnalysisResult const props = { open: true, onOpenChange: vi.fn(), stockId: 1, stockName: '广汽集团', stockSymbol: '601238' } -beforeEach(() => { +beforeEach(async () => { + vi.clearAllMocks() + await i18n.changeLanguage('zh-CN') vi.mocked(tradingAgentsApi.findRunning).mockResolvedValue({ trace_id: null, status: 'none' }) vi.mocked(tradingAgentsApi.getLatestForStock).mockResolvedValue(null) }) @@ -32,6 +34,35 @@ describe('deep analysis modal', () => { expect(screen.getByRole('button', { name: '开始分析' }).hasAttribute('disabled')).toBe(false) expect(screen.queryByText(/本月预算|预估成本|预算已用尽/)).toBeNull() }) + + it('restores the actual provider error after closing and reopening a failed run', async () => { + const error = 'AI 服务拒绝处理本次内容,请调整问题后重试。(HTTP 400 · code=1301 · 系统检测到输入或生成内容可能包含不安全或敏感内容)' + vi.mocked(tradingAgentsApi.findRunning).mockResolvedValue({ trace_id: 'failed-trace', status: 'failed' }) + vi.mocked(tradingAgentsApi.getProgress).mockResolvedValue({ trace_id: 'failed-trace', status: 'failed', + stages: [], completed_stages: [], elapsed_sec: 42, total_cost_usd: 0, + run: { agent_name: 'tradingagents', status: 'failed', error, result: '', duration_ms: 42000, model_label: 'GLM', notify_sent: false }, + }) + render() + expect(await screen.findByText(error)).toBeTruthy() + expect(screen.getByRole('button', { name: '重试' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '开始分析' })).toBeNull() + }) + + it('does not apply a previous stock failure when its diagnostic arrives late', async () => { + let resolveProgress!: (value: ProgressResponse) => void + vi.mocked(tradingAgentsApi.findRunning).mockResolvedValueOnce({ trace_id: 'failed-trace', status: 'failed' }) + vi.mocked(tradingAgentsApi.getProgress).mockReturnValueOnce(new Promise(resolve => { resolveProgress = resolve })) + const { rerender } = render() + await waitFor(() => expect(tradingAgentsApi.getProgress).toHaveBeenCalledWith('failed-trace')) + rerender() + await waitFor(() => expect(tradingAgentsApi.findRunning).toHaveBeenCalledWith('300750')) + await act(async () => resolveProgress({ trace_id: 'failed-trace', status: 'failed', + stages: [], completed_stages: [], elapsed_sec: 42, total_cost_usd: 0, + run: { agent_name: 'tradingagents', status: 'failed', error: 'Previous stock diagnostic', result: '', duration_ms: 42000, model_label: 'GLM', notify_sent: false }, + })) + expect(screen.queryByText('Previous stock diagnostic')).toBeNull() + expect(screen.getByRole('button', { name: '开始分析' })).toBeTruthy() + }) }) it('shows REVIEW as a review state in an English historical report', async () => { diff --git a/frontend/tests/settings/deep-analysis-timeouts.test.tsx b/frontend/tests/settings/deep-analysis-timeouts.test.tsx index 841e0b65..9af73927 100644 --- a/frontend/tests/settings/deep-analysis-timeouts.test.tsx +++ b/frontend/tests/settings/deep-analysis-timeouts.test.tsx @@ -38,7 +38,7 @@ it('shows and saves all timeout defaults for older configurations', async () => expect((dialog.getByLabelText('单次模型请求超时(秒)') as HTMLInputElement).value).toBe('300') expect((dialog.getByLabelText('单个数据源采集超时(秒)') as HTMLInputElement).value).toBe('45') await userEvent.click(dialog.getByRole('button', { name: '保存' })) - await waitFor(() => expect(savedConfig()).toEqual({ debate_rounds: 1, llm_max_tokens: 4096, + await waitFor(() => expect(savedConfig()).toEqual({ debate_rounds: 1, timeout_minutes: 30, llm_timeout_seconds: 300, collection_timeout_seconds: 45 })) }) diff --git a/server.py b/server.py index a8457a3e..7ef45ff8 100644 --- a/server.py +++ b/server.py @@ -1305,12 +1305,12 @@ async def trigger_agent(agent_name: str) -> str: ) return result.content except Exception as e: - from src.platform.ai.errors import safe_ai_error_message + from src.platform.ai.errors import diagnostic_ai_error_message record_agent_run( agent_name=agent_name, status="failed", - error=safe_ai_error_message(e), + error=diagnostic_ai_error_message(e), duration_ms=int((time.monotonic() - start) * 1000), trace_id=trace_id, trigger_source="manual", @@ -1429,12 +1429,12 @@ async def trigger_agent_for_stock( model_label=context.model_label, ) except Exception as e: - from src.platform.ai.errors import safe_ai_error_message + from src.platform.ai.errors import diagnostic_ai_error_message record_agent_run( agent_name=agent_name, status="failed", - error=safe_ai_error_message(e), + error=diagnostic_ai_error_message(e), duration_ms=int((time.monotonic() - start) * 1000), trace_id=trace_id, trigger_source="manual", diff --git a/src/modules/automation/agent_catalog.py b/src/modules/automation/agent_catalog.py index 09cde8cd..334dddec 100644 --- a/src/modules/automation/agent_catalog.py +++ b/src/modules/automation/agent_catalog.py @@ -118,7 +118,6 @@ class AgentSeedSpec: "llm_timeout_seconds": 300, # 单次 LLM 请求超时,给慢响应留出时间 "collection_timeout_seconds": 45, # 单个数据源采集超时,失败时降级 "llm_max_retries": 0, # 深度分析失败快速落终态,不在图内重复重试 - "llm_max_tokens": 4096, # 限制模型输出,避免网关空闲超时 "emit_paper_trading_signal": False, # 是否把 BUY 决策写入 StrategySignalRun # 驱动模拟盘自动开仓 (默认关,需用户主动启用) "enable_sec_edgar": False, # 仅美股:优先使用有 filing-date 语义的 SEC EDGAR 财报 diff --git a/src/modules/automation/agent_scheduler.py b/src/modules/automation/agent_scheduler.py index 673708f3..dfa84c59 100644 --- a/src/modules/automation/agent_scheduler.py +++ b/src/modules/automation/agent_scheduler.py @@ -5,7 +5,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from src.modules.automation.base import BaseAgent, AgentContext -from src.platform.ai.errors import safe_ai_error_message +from src.platform.ai.errors import diagnostic_ai_error_message from src.platform.marketdata.collectors.kline_collector import kline_source from src.modules.automation.agent_runs import record_agent_run from src.platform.observability.log_context import log_context @@ -138,7 +138,7 @@ async def _run_agent(self, agent_name: str, stock_keys=None, stock_agent_id=None exc_info=True, ) errors.append( - f"{stock.symbol}: {safe_ai_error_message(e)}" + f"{stock.symbol}: {diagnostic_ai_error_message(e)}" ) logger.info( f"[调度] Agent 单只模式执行完成: {agent.display_name}(执行{processed},跳过{skipped},共{len(targets)})" @@ -194,7 +194,7 @@ async def _run_agent(self, agent_name: str, stock_keys=None, stock_agent_id=None record_agent_run( agent_name=agent_name, status="failed", - error=safe_ai_error_message(e), + error=diagnostic_ai_error_message(e), duration_ms=duration_ms, trace_id=trace_id, trigger_source="schedule", diff --git a/src/modules/automation/api/agents.py b/src/modules/automation/api/agents.py index f9f1f5b8..70701ffa 100644 --- a/src/modules/automation/api/agents.py +++ b/src/modules/automation/api/agents.py @@ -724,7 +724,7 @@ def get_run_progress(trace_id: str, db: Session = Depends(get_db)): "agent_name": run.agent_name, "status": run.status, "result": (run.result or "")[:1000], - "error": (run.error or "")[:500], + "error": (run.error or "")[:2000], "duration_ms": run.duration_ms, "model_label": run.model_label, "notify_sent": run.notify_sent, diff --git a/src/modules/automation/tradingagents/agent.py b/src/modules/automation/tradingagents/agent.py index 03a652e8..d65c5030 100644 --- a/src/modules/automation/tradingagents/agent.py +++ b/src/modules/automation/tradingagents/agent.py @@ -58,7 +58,7 @@ class TradingAgentsUnavailable(RuntimeError): def _bounded_graph_class(graph_cls): """让 TradingAgentsGraph 把请求边界传给 LangChain LLM 客户端。 - TradingAgents 0.5.0 已支持 ``llm_max_retries``/``max_tokens``,但当前 + TradingAgents 0.5.0 已支持 ``llm_max_retries``,但当前 版本的 ``_get_provider_kwargs`` 尚未读取自定义 timeout。通过一个很小的 子类适配该差异,避免直接修改 site-packages,也兼容后续上游自行支持 timeout 的版本。 @@ -100,7 +100,9 @@ def __init__( holding_period_days: int = 5, # 上游决策质量回测/持仓期限语义 llm_timeout_seconds: int = 300, # 单次 LLM 请求超时,给慢响应留出时间 llm_max_retries: int = 0, # 深度分析不在图内重复重试供应商请求 - llm_max_tokens: int = 4096, # 限制推理/报告输出,避免网关空闲超时 + # 保留旧配置字段的兼容入口,但不再向模型传递输出上限。 + llm_max_tokens: int | None = None, + auto_trigger: dict | None = None, # 联动条件由 operations 读取;不影响本次图执行 ): # 校验分析师配置 analysts = list(analyst_types or sorted(VALID_ANALYSTS)) @@ -126,7 +128,6 @@ def __init__( self.holding_period_days = max(1, int(holding_period_days)) self.llm_timeout_seconds = max(1, int(llm_timeout_seconds)) self.llm_max_retries = max(0, int(llm_max_retries)) - self.llm_max_tokens = max(256, int(llm_max_tokens)) # 软依赖检测 self._available, self._import_error = self._check_availability() @@ -307,7 +308,13 @@ async def analyze(self, context: AgentContext, data: dict) -> AnalysisResult: holding_period_days=self.holding_period_days, llm_timeout_seconds=self.llm_timeout_seconds, llm_max_retries=self.llm_max_retries, - llm_max_tokens=self.llm_max_tokens, + ) + logger.info( + "[TA] 执行参数 timeout_minutes=%s llm_timeout_seconds=%s " + "collection_timeout_seconds=%s llm_max_retries=%s deep_model=%s quick_model=%s", + self.timeout_minutes, self.llm_timeout_seconds, + self.collection_timeout_seconds, self.llm_max_retries, + ta_config["deep_think_llm"], ta_config["quick_think_llm"], ) # 3) 进度回调 diff --git a/src/modules/automation/tradingagents/operations.py b/src/modules/automation/tradingagents/operations.py index 412beb05..8cd89542 100644 --- a/src/modules/automation/tradingagents/operations.py +++ b/src/modules/automation/tradingagents/operations.py @@ -49,7 +49,7 @@ def _read_auto_trigger_config(db: Session) -> dict | None: - """从 AgentConfig.raw_config 读 auto_trigger 配置。 + """从 AgentConfig.config 读 auto_trigger 配置。 Returns: { @@ -61,7 +61,7 @@ def _read_auto_trigger_config(db: Session) -> dict | None: agent = db.query(AgentConfig).filter(AgentConfig.name == "tradingagents").first() if not agent: return None - raw = agent.raw_config or {} + raw = agent.config or {} auto = raw.get("auto_trigger") or {} if not auto.get("enabled"): return None diff --git a/src/modules/automation/tradingagents/runtime_support.py b/src/modules/automation/tradingagents/runtime_support.py index b10de78f..3d416142 100644 --- a/src/modules/automation/tradingagents/runtime_support.py +++ b/src/modules/automation/tradingagents/runtime_support.py @@ -46,7 +46,6 @@ def build_ta_llm_config( holding_period_days: int = 5, llm_timeout_seconds: int = 300, llm_max_retries: int = 0, - llm_max_tokens: int = 4096, ) -> dict[str, Any]: """生成 TradingAgents 期望的 config dict。 @@ -80,6 +79,10 @@ def build_ta_llm_config( except ImportError: config = {} + # The upstream default currently contains ``max_tokens=None``. Remove the + # key entirely so TradingAgents does not forward an output cap at all. + config.pop("max_tokens", None) + # 上游 config 含嵌套 vendor 配置;先复制,避免单次运行污染 DEFAULT_CONFIG。 config["data_vendors"] = dict(config.get("data_vendors") or {}) config["tool_vendors"] = dict(config.get("tool_vendors") or {}) @@ -139,7 +142,6 @@ def build_ta_llm_config( # 连接断开或模型持续输出会让整个 LangGraph 永久停在当前 analyst。 "llm_timeout_seconds": max(1, int(llm_timeout_seconds)), "llm_max_retries": max(0, int(llm_max_retries)), - "max_tokens": max(256, int(llm_max_tokens)), }) return config diff --git a/src/platform/ai/errors.py b/src/platform/ai/errors.py index eb3c5445..c79c8b66 100644 --- a/src/platform/ai/errors.py +++ b/src/platform/ai/errors.py @@ -2,12 +2,13 @@ Provider exceptions often contain useful diagnostics, but their raw text can also include request fragments, endpoints, or account details. This module keeps the -raw exception available to server logs while exposing only a small set of stable -codes and reviewed messages to HTTP/SSE clients. +raw exception available to server logs. HTTP errors use stable reviewed messages; +AgentRun diagnostics add bounded, redacted provider codes and messages. """ from __future__ import annotations +import re from dataclasses import dataclass from typing import Any @@ -211,10 +212,12 @@ def classify_ai_service_error(error: BaseException) -> AIErrorDescriptor: if _matches( text, "content_policy", + "contentfilter", "content filter", "content_filter", "safety policy", "moderation", + "sensitive content", ): return _ERRORS["ai_content_rejected"] if isinstance(error, NotFoundError) or status == 404 or _matches( @@ -243,3 +246,61 @@ def as_ai_service_error(error: BaseException) -> AIServiceError: def safe_ai_error_message(error: BaseException) -> str: return classify_ai_service_error(error).message + + +_SECRET_DETAIL_RE = re.compile( + r"(?i)\b(api[_ -]?key|authorization|access[_ -]?token|password|secret|cookie)" + r"[\"']?\s*[:=]\s*(?:\"[^\"]*\"|'[^']*'|(?:bearer\s+)?[^\s,;]+)" +) + + +def _diagnostic_text(value: Any, limit: int) -> str: + if not isinstance(value, (str, int, float)) or isinstance(value, bool): + return "" + text = _SECRET_DETAIL_RE.sub(r"\1=[redacted]", str(value)) + text = re.sub(r"(?i)\bbearer\s+[A-Za-z0-9._~+/-]+", "Bearer [redacted]", text) + text = re.sub(r"\bsk-[A-Za-z0-9_-]+", "[redacted]", text) + return " ".join(text.split())[:limit] + + +def diagnostic_ai_error_message(error: BaseException) -> str: + """Return a bounded provider diagnostic suitable for an AgentRun/UI. + + The normal error message remains stable and translated. Agent progress needs + enough provider detail to distinguish content filters, invalid requests and + gateway failures, but must not expose headers, keys, prompts or a full + exception traceback. + """ + descriptor = classify_ai_service_error(error) + body = getattr(error, "body", None) + nested = body.get("error") if isinstance(body, dict) else None + if not isinstance(nested, dict): + nested = {} + + provider_code = ( + nested.get("code") + or (body.get("code") if isinstance(body, dict) else None) + or getattr(error, "code", None) + ) + provider_message = nested.get("message") or ( + body.get("message") if isinstance(body, dict) else None + ) + if not provider_message and isinstance(body, dict) and isinstance(body.get("error"), str): + provider_message = body["error"] + if not provider_message and not isinstance(body, dict): + provider_message = getattr(error, "message", None) or str(error) + + status = getattr(error, "status_code", None) + parts: list[str] = [] + if isinstance(status, int): + parts.append(f"HTTP {status}") + code = _diagnostic_text(provider_code, 64) + if code: + parts.append(f"code={code}") + detail = _diagnostic_text(provider_message, 800) + if detail and detail != descriptor.message: + parts.append(detail) + + if not parts: + return descriptor.message + return f"{descriptor.message}({' · '.join(parts)})" diff --git a/tests/test_ai_errors.py b/tests/test_ai_errors.py index cb461569..bf6c249c 100644 --- a/tests/test_ai_errors.py +++ b/tests/test_ai_errors.py @@ -3,7 +3,7 @@ import httpx from openai import AuthenticationError, BadRequestError, RateLimitError -from src.platform.ai.errors import classify_ai_service_error +from src.platform.ai.errors import classify_ai_service_error, diagnostic_ai_error_message from src.web.errors import ai_api_error @@ -58,3 +58,51 @@ def test_api_error_never_exposes_raw_provider_text(): assert response.status_code == 502 assert response.detail["code"] == "ai_authentication_failed" assert "sk-secret-value" not in response.detail["message"] + + +def test_content_filter_exposes_bounded_provider_diagnostic(): + rejected = _provider_error( + BadRequestError, + 400, + "request rejected", + { + "contentFilter": [{"level": 1, "role": "assistant"}], + "error": { + "code": "1301", + "message": "系统检测到输入或生成内容可能包含不安全或敏感内容", + }, + }, + ) + + assert classify_ai_service_error(rejected).code == "ai_content_rejected" + detail = diagnostic_ai_error_message(rejected) + assert "HTTP 400" in detail + assert "code=1301" in detail + assert "不安全或敏感内容" in detail + assert "contentFilter" not in detail + + +def test_provider_diagnostic_redacts_secrets_and_ignores_other_body_fields(): + invalid = _provider_error( + BadRequestError, + 400, + "request rejected", + {"error": {"code": "bad_parameter", "message": ( + "Unsupported request api_key=private-key Authorization: Bearer private-token " + "invalid api key sk-secret-value" + )}, "prompt": "private prompt", "headers": {"Authorization": "private-header"}}, + ) + detail = diagnostic_ai_error_message(invalid) + assert "Unsupported request" in detail + assert "code=bad_parameter" in detail + for secret in ("private-key", "private-token", "sk-secret-value", "private prompt", "private-header"): + assert secret not in detail + + +def test_unstructured_error_diagnostic_is_bounded_and_keeps_timeout_reason(): + from openai import APITimeoutError + + timed_out = APITimeoutError(_REQUEST) + assert "Request timed out" in diagnostic_ai_error_message(timed_out) + invalid = _provider_error(BadRequestError, 400, "bad parameter", {"message": "x" * 3000}) + assert len(diagnostic_ai_error_message(invalid)) < 1000 diff --git a/tests/test_tradingagents_agent.py b/tests/test_tradingagents_agent.py index bb48b660..b6d9c9ab 100644 --- a/tests/test_tradingagents_agent.py +++ b/tests/test_tradingagents_agent.py @@ -71,7 +71,7 @@ def test_build_ta_llm_config_basic(self): self.assertFalse(config["checkpoint_enabled"]) def test_build_ta_llm_config_bounds_provider_calls(self): - """LLM 请求必须有明确超时、重试和输出上限,避免图永远卡在单次调用。""" + """LLM 请求必须有明确超时和重试,输出上限交给供应商。""" ai_client = MagicMock() ai_client.base_url = "https://api.example.com" ai_client.model = "test-model" @@ -81,7 +81,7 @@ def test_build_ta_llm_config_bounds_provider_calls(self): self.assertEqual(config["llm_timeout_seconds"], 300) self.assertEqual(config["llm_max_retries"], 0) - self.assertEqual(config["max_tokens"], 4096) + self.assertNotIn("max_tokens", config) def test_build_ta_llm_config_rejects_invalid_analyst(self): """非法分析师名 — 抛 ValueError""" @@ -460,7 +460,24 @@ def test_agent_init_has_bounded_llm_defaults(self): agent = TradingAgentsAgent() self.assertEqual(agent.llm_timeout_seconds, 300) self.assertEqual(agent.llm_max_retries, 0) - self.assertEqual(agent.llm_max_tokens, 4096) + self.assertFalse(hasattr(agent, "llm_max_tokens")) + + def test_legacy_output_cap_is_ignored_without_resetting_timeout(self): + """旧数据库配置中的输出上限不能触发回退到默认 Agent。""" + agent = TradingAgentsAgent(llm_timeout_seconds=600, llm_max_tokens=4096) + self.assertEqual(agent.llm_timeout_seconds, 600) + self.assertFalse(hasattr(agent, "llm_max_tokens")) + + def test_auto_trigger_config_does_not_reset_execution_settings(self): + agent = TradingAgentsAgent( + timeout_minutes=60, llm_timeout_seconds=600, + collection_timeout_seconds=90, deep_model="glm-5.3-flash", + auto_trigger={"enabled": True, "change_pct_threshold": 5, "cooldown_hours": 24}, + ) + self.assertEqual(agent.timeout_minutes, 60) + self.assertEqual(agent.llm_timeout_seconds, 600) + self.assertEqual(agent.collection_timeout_seconds, 90) + self.assertEqual(agent.deep_model, "glm-5.3-flash") def test_graph_class_forwards_request_timeout_to_langchain(self): """上游未读取 timeout 配置时,适配类仍需把它传给 ChatOpenAI。""" diff --git a/tests/test_tradingagents_auto_trigger.py b/tests/test_tradingagents_auto_trigger.py index 81052507..9c4d433e 100644 --- a/tests/test_tradingagents_auto_trigger.py +++ b/tests/test_tradingagents_auto_trigger.py @@ -9,10 +9,9 @@ from src.modules.automation.tradingagents import operations as auto_trigger -def _make_agent(raw_config: dict): - agent = MagicMock() - agent.raw_config = raw_config - return agent +def _make_agent(config: dict): + from src.platform.persistence.models import AgentConfig + return AgentConfig(name="tradingagents", display_name="TradingAgents", config=config) def test_no_change_pct_skips(): diff --git a/tests/test_tradingagents_progress_recovery.py b/tests/test_tradingagents_progress_recovery.py index 4302a7ad..c9c2b863 100644 --- a/tests/test_tradingagents_progress_recovery.py +++ b/tests/test_tradingagents_progress_recovery.py @@ -72,6 +72,28 @@ def test_running_agent_run_is_stale_after_lifecycle_timeout(): assert find_active_tradingagents_trace(_db(old_run), "AAPL") is None +def test_progress_preserves_the_full_bounded_provider_diagnostic(): + from src.modules.automation.api.agents import get_run_progress + + run = _run(status="failed") + run.error = "AI 服务拒绝处理本次内容。(HTTP 400 · code=1301 · " + "供应商错误详情" * 100 + ")" + db = MagicMock() + log_query = MagicMock() + log_query.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [] + run_query = MagicMock() + run_query.filter.return_value.order_by.return_value.first.return_value = run + db.query.side_effect = [log_query, run_query] + + with patch( + "src.modules.automation.tradingagents.observability.aggregate_progress", + return_value={"stages": []}, + ): + result = get_run_progress(run.trace_id, db) + + assert result["status"] == "failed" + assert result["run"]["error"] == run.error + + def test_configured_hour_long_analysis_is_not_treated_as_stale_after_45_minutes(): from src.modules.automation.agent_runs import find_active_tradingagents_trace