Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions frontend/packages/biz-ui/src/components/deep-analysis-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,33 +145,46 @@ 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) {
const tid = runningInfo.trace_id
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)
}
Expand All @@ -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
}
Expand All @@ -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])

Expand Down Expand Up @@ -360,7 +374,7 @@ export function DeepAnalysisModal({
<div className="space-y-3 text-[13px]">
<div className="rounded-lg bg-rose-500/10 border border-rose-500/30 p-3 text-rose-600">
<div className="font-semibold mb-1">{tr('failedTitle')}</div>
<div className="text-[12px]">{error}</div>
<div className="text-[12px] whitespace-pre-wrap break-words">{error}</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleClose}>{tr('close')}</Button>
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/pages/Agents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
39 changes: 35 additions & 4 deletions frontend/tests/analysis/deep-analysis-modal.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
})
Expand All @@ -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(<DeepAnalysisModal {...props} />)
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(<DeepAnalysisModal {...props} />)
await waitFor(() => expect(tradingAgentsApi.getProgress).toHaveBeenCalledWith('failed-trace'))
rerender(<DeepAnalysisModal {...props} stockId={2} stockSymbol="300750" stockName="宁德时代" />)
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 () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/tests/settings/deep-analysis-timeouts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
})

Expand Down
8 changes: 4 additions & 4 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion src/modules/automation/agent_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 财报
Expand Down
6 changes: 3 additions & 3 deletions src/modules/automation/agent_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)})"
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/modules/automation/api/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 11 additions & 4 deletions src/modules/automation/tradingagents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 的版本。
Expand Down Expand Up @@ -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))
Expand All @@ -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()
Expand Down Expand Up @@ -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) 进度回调
Expand Down
4 changes: 2 additions & 2 deletions src/modules/automation/tradingagents/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@


def _read_auto_trigger_config(db: Session) -> dict | None:
"""从 AgentConfig.raw_config 读 auto_trigger 配置。
"""从 AgentConfig.config 读 auto_trigger 配置。

Returns:
{
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/modules/automation/tradingagents/runtime_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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。

Expand Down Expand Up @@ -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 {})
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading