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