From 0bb4f43a2bd4a18a54e148a45e6f05f5819fd05b Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:25:04 +0800 Subject: [PATCH 01/20] docs: add platform overhaul task plan and progress tracking --- progress.md | 42 +++++ task_plan.md | 428 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 470 insertions(+) create mode 100644 progress.md create mode 100644 task_plan.md diff --git a/progress.md b/progress.md new file mode 100644 index 0000000..a3ac1bf --- /dev/null +++ b/progress.md @@ -0,0 +1,42 @@ +# Progress Log + +## Session: 2025-04 + +### Phase 0: Planning +- [x] Deep codebase exploration completed +- [x] Product direction confirmed with user +- [x] task_plan.md created + +### Phase 1: P0 Backend Core (LLM + Real Data) +- [ ] P0-1: LLM Provider package +- [ ] P0-2: intent-service LLM rewrite +- [ ] P0-3: planning-service LLM rewrite +- [ ] P0-4: analysis-service LLM rewrite +- [ ] P0-5: write tools expansion +- [ ] P0-6: Alpaca Market Data integration +- [ ] P0-7: backtest real data connection + +### Phase 2: P1 Agent UI + Risk +- [ ] P1-1: AgentPage redesign +- [ ] P1-5: risk guards implementation +- [ ] P1-6: live order approval flow +- [ ] P1-7: Settings risk panel + +### Phase 3: P2 UI Overhaul +- [ ] P2-1: Dashboard redesign +- [ ] P2-4: design system rebuild +- [ ] P2-5: navigation redesign +- [ ] P2-6: table/list modernization +- [ ] P2-7: chart enhancements +- [ ] P2-8: Zustand migration + +### Phase 4: P3 Interaction +- [ ] P3-1: Command Palette +- [ ] P3-2: global approval drawer +- [ ] P3-3: feedback system +- [ ] P3-4: keyboard navigation + +## Errors Log +| Error | Attempt | Resolution | +|-------|---------|------------| +| - | - | - | diff --git a/task_plan.md b/task_plan.md new file mode 100644 index 0000000..eebbfc4 --- /dev/null +++ b/task_plan.md @@ -0,0 +1,428 @@ +# QuantPilot 全面改造计划 + +> 分支:`feat/platform-overhaul` +> 目标:将 QuantPilot 从功能 demo 改造为真实可用的 AI-native 个人量化交易协作平台 + +--- + +## 产品方向确认 + +| 维度 | 决策 | +|------|------| +| 用户定位 | 个人用户,无需专业量化/交易知识,依赖 Agent 辅助决策 | +| Agent 定位 | 核心价值,真实 LLM 驱动决策和自主执行 | +| 交互主线 | 自然语言描述策略 → LLM 分析 → 可操作建议 → 执行 | +| 结果呈现 | 收益/结果为主视图,图表醒目直接 | +| LLM | Claude API + OpenAI 双支持,Settings 可切换 | +| Broker | Alpaca(Paper 全自动,Live 必须手动审批) | +| 市场数据 | Alpaca Market Data API(替换 mock 数据) | +| 资产类型 | 美股(US Stocks) | +| UI 风格 | 全面重设计,轻量消费级但专业 | +| 风控 | 默认仓位占比 5% + 日亏损 5% 止停,用户可在 Settings 自定义 | + +--- + +## 改造优先级总览 + +### P0 — 核心价值补全(Agent + LLM + 数据真实化) +- [ ] P0-1: LLM Provider 抽象层(Claude / OpenAI 双支持,可切换) +- [ ] P0-2: Agent 意图理解 → LLM 驱动重写(intent-service) +- [ ] P0-3: Agent 规划 → LLM 驱动重写(planning-service) +- [ ] P0-4: Agent 分析 → LLM 驱动重写(analysis-service,工具调用) +- [ ] P0-5: Agent 写操作 Tools 扩展(执行、下单) +- [ ] P0-6: Alpaca Market Data API 集成(替换合成 OHLCV 数据) +- [ ] P0-7: 回测引擎对接真实历史数据(移除 mock metrics) + +### P1 — Agent 页面完全重设计 +- [ ] P1-1: Agent 页面架构重设计(三步主流程:自然语言 → 分析 → 操作建议) +- [ ] P1-2: 策略建议卡片(Insight Card)设计和实现 +- [ ] P1-3: 执行路径 UI(一键下单 / 发起审批) +- [ ] P1-4: Agent 会话历史和策略管理 + +### P1 — 风控体系 +- [ ] P1-5: 默认风控规则实现(仓位占比 5%、日亏损 5% 止停) +- [ ] P1-6: Live 下单手动审批强制流程 +- [ ] P1-7: Settings 风控参数自定义面板 + +### P2 — Dashboard 全面重设计 +- [ ] P2-1: Dashboard 以收益结果为主视图重设计 +- [ ] P2-2: 权益曲线图表增强(基准对比、信号标注) +- [ ] P2-3: KPI 卡片醒目化(字号、对比度、颜色编码) + +### P2 — UI 整体风格重设计 +- [ ] P2-4: 设计系统重建(颜色系统、字体、间距、组件库) +- [ ] P2-5: 导航栏/侧边栏重设计 +- [ ] P2-6: 表格和列表组件现代化 +- [ ] P2-7: 图表增强(技术指标、信号箭头) + +### P2 — 状态管理迁移 +- [ ] P2-8: Zustand 替换 React Context(TradingSystemProvider 拆分) + +### P3 — 交互体验提升 +- [ ] P3-1: 全局 Command Palette(/ 快捷键) +- [ ] P3-2: 全局审批抽屉(任何页面触发) +- [ ] P3-3: 操作反馈体系(Toast + 确认弹窗 + Loading 状态) +- [ ] P3-4: 键盘导航(表格 j/k,Enter 展开,Esc 关闭) + +### P3 — 代码质量 +- [ ] P3-5: shared-types 按领域拆分 +- [ ] P3-6: mock 数据统一标记和清理 +- [ ] P3-7: 统一 API 错误模型 + +--- + +## 详细规划 + +### P0-1: LLM Provider 抽象层 + +**目标**:在后端建立可插拔的 LLM Provider 接口,支持 Claude API 和 OpenAI,通过环境变量或 Settings 切换。 + +**技术方案**: +``` +packages/llm-provider/ 新包 + src/ + types.ts LLMMessage, LLMTool, LLMResponse 接口定义 + provider.ts LLMProvider 抽象接口 + claude-provider.ts Claude API 实现(@anthropic-ai/sdk) + openai-provider.ts OpenAI 实现(openai SDK) + factory.ts 根据 env QUANTPILOT_LLM_PROVIDER 构造 + index.ts 导出 +``` + +**接口设计**: +```typescript +interface LLMProvider { + chat(messages: LLMMessage[], options?: ChatOptions): Promise; + chatWithTools(messages: LLMMessage[], tools: LLMTool[], options?: ChatOptions): Promise; + model: string; + provider: 'claude' | 'openai'; +} +``` + +**环境变量**: +- `QUANTPILOT_LLM_PROVIDER=claude|openai` +- `ANTHROPIC_API_KEY=...` +- `OPENAI_API_KEY=...` +- `QUANTPILOT_LLM_MODEL=...`(可选,默认 claude-sonnet-4-6) + +**验证**:`packages/llm-provider/src/__tests__/` 单元测试(mock HTTP 调用) + +--- + +### P0-2: Agent 意图理解 → LLM 重写(intent-service) + +**目标**:用 LLM 替换正则关键词匹配,理解用户自然语言输入,提取结构化意图。 + +**当前问题**:`parseAgentIntent` 用正则匹配 `risk/risk_explanation/backtest` 等关键词,无法理解复杂自然语言。 + +**改造方案**: +1. 构建 system prompt:描述可用意图类型(request_execution_prep / request_backtest_review / request_risk_explanation / general_analysis / build_strategy / execute_trade) +2. 新增意图类型 `build_strategy`(自然语言构建策略)和 `execute_trade`(触发自主交易) +3. LLM 以 JSON 格式返回结构化 intent:kind / targetType / targetId / urgency / requiresApproval / requestedMode / extractedStrategy + +**System Prompt 设计**: +- 角色:量化交易分析助手 +- 可用意图类型说明 +- 输出格式:JSON schema +- 示例对话(few-shot) + +**Fallback**:LLM 调用失败时回退到规则引擎(保持现有逻辑作为 fallback) + +--- + +### P0-3: Agent 规划 → LLM 重写(planning-service) + +**目标**:用 LLM 生成动态 plan steps,而非硬编码 switch-case。 + +**改造方案**: +1. LLM 根据解析出的 intent,结合可用 tools 清单,生成执行步骤列表 +2. 每个 step 包含:description / toolName / toolParams / expectedOutput +3. LLM 以 JSON array 返回 steps + +**可用 Tools 清单注入到 Prompt**: +- 只读类:strategy.catalog.list / backtest.summary.get / risk.events.list / market.quotes.get / market.history.get(新增) +- 写操作类:execution.create / order.submit(需权限且需审批) + +--- + +### P0-4: Agent 分析 → LLM 驱动重写(analysis-service) + +**目标**:LLM 基于工具调用结果进行真实推理,生成结构化分析报告。 + +**改造方案**: + +Phase 1(工具调用循环): +1. 将用户意图 + plan + 工具结果注入到 LLM 上下文 +2. LLM 使用 function calling / tool use 按需调用数据工具 +3. 循环直到 LLM 决定停止调用工具 + +Phase 2(报告生成): +1. LLM 基于所有工具结果,生成结构化报告 +2. 输出格式: + ```typescript + interface AnalysisReport { + thesis: string; // 一句话核心结论 + rationale: string[]; // 支撑论点(3-5 条) + warnings: string[]; // 风险警告 + strategy?: { // 如果是 build_strategy 意图 + name: string; + description: string; + signals: string[]; + riskLevel: 'low' | 'medium' | 'high'; + suggestedPositionSize: number; // 占比 % + symbols: string[]; + }; + recommendedNextStep: string; + requiresAction: boolean; + actionType?: 'paper_trade' | 'live_trade_request' | 'backtest_request'; + } + ``` + +--- + +### P0-5: Agent 写操作 Tools 扩展 + +**目标**:让 LLM 具备触发交易的能力(Paper 自动执行,Live 需审批)。 + +**新增 Tools**: + +`execution.paper.submit`:提交 paper trading 订单 +- 参数:symbol / side (buy|sell) / qty / orderType / price? +- Paper 模式:LLM 调用后直接执行,无需审批 +- 返回:order confirmation + +`execution.live.request`:请求 live trading 审批 +- 参数:同上 + rationale(LLM 的决策理由) +- 触发 operator 审批工作流 +- 返回:approval request ID + +`strategy.backtest.queue`:队列一个新的回测任务 +- 参数:strategyDescription / symbols / dateRange / initialCapital +- LLM 构建策略后可直接触发回测 + +**安全边界**: +- Paper 写操作:所有治理模式下均可(full_auto / bounded_auto) +- Live 写操作:仅 full_auto / ask_first,且必须走审批流 + +--- + +### P0-6: Alpaca Market Data API 集成 + +**目标**:用真实历史和实时数据替换合成价格数据。 + +**改造范围**: +1. `apps/api/src/gateways/alpaca.ts` 扩展市场数据方法: + - `getHistoricalBars(symbol, timeframe, start, end)` → Alpaca `/v2/stocks/{symbol}/bars` + - `getLatestBars(symbols)` → Alpaca `/v2/stocks/bars/latest` + - `getMultiBars(symbols, timeframe, start, end)` +2. Worker `runSchedulerTickTask` 中市场数据获取从 gateway 读取,替换 `generateSyntheticQuotes` +3. `packages/trading-engine/src/backtest/data.ts` 中 `generateHistoricalOhlcv` 改为调用 Alpaca API + +**注意**:Alpaca Paper Trading 账户包含免费市场数据订阅,不需要额外付费。 + +--- + +### P0-7: 回测引擎真实数据对接 + +**目标**:回测使用真实历史价格数据,指标计算结果真实可信。 + +**改造范围**: +1. `packages/task-workflow-engine/src/index.ts` 中 `executeBacktestWorkflow` 走真实引擎路径,移除 `buildMockBacktestMetrics` 调用 +2. `packages/trading-engine/src/backtest/engine.ts` 中 `BacktestEngine.run()` 使用从 Alpaca 获取的真实 OHLCV 数据 +3. 保留 mock 路径作为 `QUANTPILOT_USE_MOCK_DATA=true` 的开关(用于 CI 测试) + +--- + +### P1-1: Agent 页面完全重设计 + +**目标**:重新设计 Agent 页面,以「自然语言 → 分析 → 可操作建议」三步为主交互主线。 + +**新页面布局**: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Agent 工作台 [历史会话 ▼] │ +├──────────────────────┬──────────────────────────────────┤ +│ │ │ +│ 左侧:对话 + 输入区 │ 右侧:分析结果主视图 │ +│ │ │ +│ [当前会话消息列表] │ ┌─ 核心结论 ──────────────────┐ │ +│ │ │ [一句话 thesis,大字号] │ │ +│ │ └─────────────────────────────┘ │ +│ [用户输入框] │ ┌─ 策略详情 ──────────────────┐ │ +│ "我想买入科技股..." │ │ Symbol / 仓位 / 方向 │ │ +│ │ │ 风险评级 / 建议持仓时长 │ │ +│ [发送 / 快捷策略建议] │ └─────────────────────────────┘ │ +│ │ ┌─ 支撑依据 ──────────────────┐ │ +│ │ │ ① 理由一 │ │ +│ │ │ ② 理由二 │ │ +│ │ │ ⚠ 风险警告 │ │ +│ │ └─────────────────────────────┘ │ +│ │ ┌─ 执行操作 ──────────────────┐ │ +│ │ │ [Paper 执行] [申请 Live] │ │ +│ │ │ [加入回测队列] │ │ +│ │ └─────────────────────────────┘ │ +└──────────────────────┴──────────────────────────────────┘ +``` + +**关键 UI 元素**: +- 快捷策略建议(预设 prompt chips):「分析当前市场走势」「推荐本周机会」「评估我的持仓风险」 +- 分析进度展示(步骤 indicator):理解中 → 分析中 → 生成建议 +- 策略卡片:symbol tags / 方向 badge / 仓位占比 / 风险等级 +- 执行按钮:Paper(绿,立即执行)/ Live(橙,需审批)/ 回测(蓝,入队) + +--- + +### P1-5: 风控体系 + +**默认规则**(开箱即用): +- 单笔仓位占比上限:总资产 5% +- 当日最大亏损:总资产 5%(触发后 Agent 自动停止当日所有交易) +- Live 下单:必须经过 operator 手动审批 +- Paper 下单:全自动执行 + +**实现位置**: +1. `packages/trading-engine/src/risk/` 新增 `risk-guards.ts` + - `checkPositionSizeLimit(account, symbol, qty, price)` + - `checkDailyLossLimit(account, dailyPnL)` +2. `packages/control-plane-runtime/src/` 风控检查注入执行路径 +3. Agent 调用 `execution.paper.submit` 前自动运行风控检查,拦截超限操作 + +**Settings 自定义面板**: +- `maxPositionPercent`(默认 5%) +- `maxDailyLossPercent`(默认 5%) +- 每项带「恢复默认」按钮和说明文字 + +--- + +### P2-1: Dashboard 重设计 + +**目标**:以收益结果为核心,让用户第一眼看到「当前赚了多少、趋势如何」。 + +**新布局**: +``` +┌──────── Hero KPI ─────────────────────────────────────────┐ +│ 总资产: $128,450 今日收益: +$1,240 (+0.97%) 周收益: +2.3% │ +│ [月收益图:迷你折线] [风险等级:NORMAL 绿标] │ +└───────────────────────────────────────────────────────────┘ +┌── 权益曲线(主图,占 60% 宽度)──┐ ┌── Agent 最新建议 ──┐ +│ Paper + Live 双曲线 │ │ [最近一次分析结论] │ +│ 基准(SPY)对比线 │ │ [一键查看详情] │ +│ 月/周/日 切换 │ └────────────────────┘ +└──────────────────────────────────┘ ┌── 持仓快照 ────────┐ + │ top 3 持仓 │ + │ % change + PnL │ + └────────────────────┘ +``` + +--- + +### P2-4: 设计系统重建 + +**颜色系统**(从金融终端风格转向轻量专业): +- 背景层级:`#0a0a0f`(最深) → `#111118`(卡片) → `#1a1a24`(悬浮) +- 强调色:`#6366f1`(主品牌,indigo)替换橙色 +- 成功/收益:`#22c55e`(绿) +- 危险/亏损:`#ef4444`(红) +- 警告:`#f59e0b`(琥珀) +- 文字:`#f8fafc`(主)→ `#94a3b8`(次)→ `#475569`(辅助) +- 边框:`#1e293b` + +**字体**: +- UI 文字:Inter(替换 Sora,更现代可读) +- 数据/Mono:JetBrains Mono(保留) + +**组件规范**: +- 卡片:`border-radius: 12px`,`border: 1px solid #1e293b`,`backdrop-filter` 效果 +- 按钮:3 个尺寸(sm/md/lg),4 个变体(primary/secondary/ghost/danger) +- 徽章/标签:统一 status badge 系统 +- 进度指示器:步骤 indicator 组件 + +--- + +### P2-8: Zustand 状态管理迁移 + +**目标**:将 `TradingSystemProvider`(Context + useState)迁移到 Zustand,按更新频率拆分 store。 + +**Store 拆分**: +``` +apps/web/src/stores/ + trading-store.ts 股票行情、账户数据(高频,SSE 驱动) + session-store.ts 用户会话、权限(低频) + agent-store.ts Agent 会话状态、分析结果(中频) + ui-store.ts Modal、Toast、选中状态(UI 局部) +``` + +**迁移策略**: +1. 保持现有 Context API 的外部接口不变(向后兼容) +2. 内部替换为 Zustand,逐步移除 Context wrapper +3. 高频数据(stockStates)使用细粒度选择器防止整树重渲染 + +--- + +### P3-1: Command Palette + +**触发**:`/` 或 `Cmd+K` + +**功能**: +- 快速导航(Go to Dashboard / Agent / Risk...) +- 快速操作(New Agent Analysis / Approve Pending / Risk Off...) +- 符号搜索(Search AAPL...) + +**实现**:全局浮层组件,注册到 `AppRouter` 层,通过 `ui-store` 控制显示。 + +--- + +## 实施顺序(按阶段提交) + +``` +Phase 1 (P0 后端核心) + Commit 1: feat: add llm-provider package with Claude and OpenAI support + Commit 2: feat: rewrite agent intent-service with LLM reasoning + Commit 3: feat: rewrite agent planning-service with LLM tool planning + Commit 4: feat: rewrite agent analysis-service with LLM tool-use loop + Commit 5: feat: add agent write tools (paper trade, live trade request) + Commit 6: feat: integrate Alpaca market data API + Commit 7: feat: connect backtest engine to real market data + +Phase 2 (P1 Agent 页面 + 风控) + Commit 8: feat: redesign AgentPage with 3-step interaction flow + Commit 9: feat: implement risk guards with default thresholds + Commit 10: feat: add risk settings panel with customizable thresholds + +Phase 3 (P2 UI 重设计) + Commit 11: feat: rebuild design system (colors, fonts, components) + Commit 12: feat: redesign Dashboard with result-first layout + Commit 13: feat: enhance charts (benchmark, signals, indicators) + Commit 14: feat: modernize navigation and layout shell + Commit 15: feat: migrate state management to Zustand + +Phase 4 (P3 交互体验) + Commit 16: feat: add global Command Palette + Commit 17: feat: add global approval drawer + Commit 18: feat: add unified feedback system (toast, confirm, loading) + Commit 19: feat: add keyboard navigation for tables + Commit 20: refactor: split shared-types and unify API error model +``` + +--- + +## 风险和依赖 + +| 风险 | 影响 | 缓解 | +|------|------|------| +| LLM API 延迟(2-10s) | Agent 交互体验差 | 流式输出 + 步骤进度展示 | +| Alpaca 数据限速 | 回测慢 | 本地缓存 + 增量更新 | +| UI 全面重设计工作量大 | 进度风险 | 复用现有 Vanilla Extract 体系,只替换 token 值 | +| Zustand 迁移 | 潜在状态 bug | 逐步迁移,保持接口兼容 | + +--- + +## 状态 + +- [x] 需求确认 +- [x] 计划制定 +- [ ] Phase 1: P0 后端核心 +- [ ] Phase 2: P1 Agent + 风控 +- [ ] Phase 3: P2 UI 重设计 +- [ ] Phase 4: P3 交互体验 From 7b088069e9c00abe9fe457ba17bb7f9fbfcef3db Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:29:26 +0800 Subject: [PATCH 02/20] feat: add llm-provider package with Claude and OpenAI support --- apps/api/package.json | 1 + package-lock.json | 468 ++++++++++++++++++- packages/llm-provider/package.json | 13 + packages/llm-provider/src/claude-provider.js | 124 +++++ packages/llm-provider/src/factory.js | 69 +++ packages/llm-provider/src/index.js | 5 + packages/llm-provider/src/openai-provider.js | 142 ++++++ packages/llm-provider/src/types.js | 78 ++++ 8 files changed, 888 insertions(+), 12 deletions(-) create mode 100644 packages/llm-provider/package.json create mode 100644 packages/llm-provider/src/claude-provider.js create mode 100644 packages/llm-provider/src/factory.js create mode 100644 packages/llm-provider/src/index.js create mode 100644 packages/llm-provider/src/openai-provider.js create mode 100644 packages/llm-provider/src/types.js diff --git a/apps/api/package.json b/apps/api/package.json index e28c685..24cbcf3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,6 +7,7 @@ }, "dependencies": { "@hono/node-server": "^1.19.14", + "@quantpilot/llm-provider": "*", "hono": "^4.12.12", "jose": "^6.2.2" } diff --git a/package-lock.json b/package-lock.json index 0d61591..7fd59a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "name": "@quantpilot/api", "dependencies": { "@hono/node-server": "^1.19.14", + "@quantpilot/llm-provider": "*", "hono": "^4.12.12", "jose": "^6.2.2" } @@ -51,6 +52,15 @@ "apps/worker": { "name": "@quantpilot/worker" }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.54.0.tgz", + "integrity": "sha512-xyoCtHJnt/qg5GG6IgK+UJEndz8h8ljzt/caKXmq3LfBF81nC/BW6E4x2rOWCZcvsLyVW+e8U5mtIr6UCE/kJw==", + "license": "MIT", + "bin": { + "anthropic-ai-sdk": "bin/cli" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -121,12 +131,6 @@ } } }, - "node_modules/@babel/core/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", @@ -367,12 +371,6 @@ } } }, - "node_modules/@babel/traverse/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -1571,6 +1569,10 @@ "resolved": "packages/db", "link": true }, + "node_modules/@quantpilot/llm-provider": { + "resolved": "packages/llm-provider", + "link": true + }, "node_modules/@quantpilot/shared-types": { "resolved": "packages/shared-types", "link": true @@ -2292,6 +2294,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -2510,6 +2522,18 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2522,6 +2546,18 @@ "node": ">=0.4.0" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2532,6 +2568,12 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2671,6 +2713,19 @@ "node": ">=20.19.0" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001777", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", @@ -2707,6 +2762,18 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -2790,6 +2857,15 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3299,6 +3375,20 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.307", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", @@ -3314,12 +3404,57 @@ "once": "^1.4.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3392,6 +3527,15 @@ "node": ">= 0.8" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -3456,6 +3600,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -3476,6 +3655,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3485,6 +3673,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", @@ -3504,6 +3729,57 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hono": { "version": "4.12.12", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", @@ -3513,6 +3789,15 @@ "node": ">=16.9.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -3894,6 +4179,15 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-query-parser": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/media-query-parser/-/media-query-parser-2.0.2.tgz", @@ -3903,6 +4197,27 @@ "@babel/runtime": "^7.12.5" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -3948,6 +4263,12 @@ "integrity": "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==", "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3996,6 +4317,46 @@ "node": ">=10" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -4021,6 +4382,51 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4582,6 +4988,12 @@ "node": ">=14.0.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5774,6 +6186,31 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -5958,6 +6395,13 @@ } } }, + "packages/llm-provider": { + "name": "@quantpilot/llm-provider", + "dependencies": { + "@anthropic-ai/sdk": "^0.54.0", + "openai": "^4.104.0" + } + }, "packages/shared-types": { "name": "@quantpilot/shared-types" }, diff --git a/packages/llm-provider/package.json b/packages/llm-provider/package.json new file mode 100644 index 0000000..98eb39d --- /dev/null +++ b/packages/llm-provider/package.json @@ -0,0 +1,13 @@ +{ + "name": "@quantpilot/llm-provider", + "private": true, + "type": "module", + "main": "./src/index.js", + "exports": { + ".": "./src/index.js" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.54.0", + "openai": "^4.104.0" + } +} diff --git a/packages/llm-provider/src/claude-provider.js b/packages/llm-provider/src/claude-provider.js new file mode 100644 index 0000000..23c087a --- /dev/null +++ b/packages/llm-provider/src/claude-provider.js @@ -0,0 +1,124 @@ +// @ts-nocheck +import Anthropic from '@anthropic-ai/sdk'; +import { DEFAULT_MODELS, PROVIDERS } from './types.js'; + +const DEFAULT_MAX_TOKENS = 4096; + +/** + * Claude API provider implementation. + * Uses Anthropic SDK with tool_use support. + */ +export class ClaudeProvider { + constructor(options = {}) { + this.provider = PROVIDERS.CLAUDE; + this.model = options.model || DEFAULT_MODELS[PROVIDERS.CLAUDE]; + this._client = new Anthropic({ + apiKey: options.apiKey || process.env.ANTHROPIC_API_KEY, + }); + } + + /** + * Send a chat request without tools. + * @param {import('./types.js').LLMMessage[]} messages + * @param {import('./types.js').ChatOptions} [options] + * @returns {Promise} + */ + async chat(messages, options = {}) { + try { + const systemPrompt = options.systemPrompt || ''; + const anthropicMessages = messages + .filter((m) => m.role !== 'system') + .map((m) => ({ role: m.role, content: m.content })); + + const response = await this._client.messages.create({ + model: this.model, + max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS, + temperature: options.temperature, + system: systemPrompt || undefined, + messages: anthropicMessages, + }); + + const textBlock = response.content.find((b) => b.type === 'text'); + return { + ok: true, + content: textBlock?.text || '', + model: response.model, + stopReason: response.stop_reason, + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }, + }; + } catch (err) { + return { + ok: false, + content: '', + error: err?.message || 'claude_api_error', + }; + } + } + + /** + * Send a chat request with tool use support. + * @param {import('./types.js').LLMMessage[]} messages + * @param {import('./types.js').LLMTool[]} tools + * @param {import('./types.js').ChatOptions} [options] + * @returns {Promise} + */ + async chatWithTools(messages, tools, options = {}) { + try { + const systemPrompt = options.systemPrompt || ''; + const anthropicMessages = messages + .filter((m) => m.role !== 'system') + .map((m) => { + if (typeof m.content === 'string') { + return { role: m.role, content: m.content }; + } + return { role: m.role, content: m.content }; + }); + + const anthropicTools = tools.map((t) => ({ + name: t.name, + description: t.description, + input_schema: t.inputSchema, + })); + + const response = await this._client.messages.create({ + model: this.model, + max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS, + temperature: options.temperature, + system: systemPrompt || undefined, + messages: anthropicMessages, + tools: anthropicTools, + }); + + const textBlocks = response.content.filter((b) => b.type === 'text'); + const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use'); + + const textContent = textBlocks.map((b) => b.text).join('\n'); + const toolCalls = toolUseBlocks.map((b) => ({ + id: b.id, + name: b.name, + input: b.input, + })); + + return { + ok: true, + content: textContent, + toolCalls, + stopReason: response.stop_reason, + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }, + }; + } catch (err) { + return { + ok: false, + content: '', + toolCalls: [], + error: err?.message || 'claude_api_error', + }; + } + } +} diff --git a/packages/llm-provider/src/factory.js b/packages/llm-provider/src/factory.js new file mode 100644 index 0000000..3f31f17 --- /dev/null +++ b/packages/llm-provider/src/factory.js @@ -0,0 +1,69 @@ +// @ts-nocheck +import { ClaudeProvider } from './claude-provider.js'; +import { OpenAIProvider } from './openai-provider.js'; +import { DEFAULT_MODELS, PROVIDERS } from './types.js'; + +/** + * Create an LLM provider instance based on environment configuration. + * + * Priority: + * 1. Explicit options.provider + * 2. QUANTPILOT_LLM_PROVIDER env var + * 3. Fallback: claude (if ANTHROPIC_API_KEY set), openai (if OPENAI_API_KEY set) + * 4. Last resort: null provider (no-op, returns rule-based fallback signal) + * + * @param {Object} [options] + * @param {'claude'|'openai'} [options.provider] + * @param {string} [options.model] + * @param {string} [options.apiKey] + * @returns {ClaudeProvider|OpenAIProvider|null} + */ +export function createLLMProvider(options = {}) { + const providerName = + options.provider || + process.env.QUANTPILOT_LLM_PROVIDER || + _inferProvider(); + + const model = + options.model || + process.env.QUANTPILOT_LLM_MODEL || + DEFAULT_MODELS[providerName]; + + if (providerName === PROVIDERS.CLAUDE) { + const apiKey = options.apiKey || process.env.ANTHROPIC_API_KEY; + if (!apiKey) return null; + return new ClaudeProvider({ model, apiKey }); + } + + if (providerName === PROVIDERS.OPENAI) { + const apiKey = options.apiKey || process.env.OPENAI_API_KEY; + if (!apiKey) return null; + return new OpenAIProvider({ model, apiKey }); + } + + return null; +} + +function _inferProvider() { + if (process.env.ANTHROPIC_API_KEY) return PROVIDERS.CLAUDE; + if (process.env.OPENAI_API_KEY) return PROVIDERS.OPENAI; + return null; +} + +/** + * Get the currently configured provider name from environment. + * @returns {'claude'|'openai'|null} + */ +export function getConfiguredProvider() { + const name = process.env.QUANTPILOT_LLM_PROVIDER || _inferProvider(); + if (name === PROVIDERS.CLAUDE || name === PROVIDERS.OPENAI) return name; + return null; +} + +/** + * Check if any LLM provider is configured and available. + * @returns {boolean} + */ +export function isLLMAvailable() { + return _inferProvider() !== null || Boolean(process.env.QUANTPILOT_LLM_PROVIDER); +} diff --git a/packages/llm-provider/src/index.js b/packages/llm-provider/src/index.js new file mode 100644 index 0000000..9cbae19 --- /dev/null +++ b/packages/llm-provider/src/index.js @@ -0,0 +1,5 @@ +// @ts-nocheck +export { ClaudeProvider } from './claude-provider.js'; +export { createLLMProvider, getConfiguredProvider, isLLMAvailable } from './factory.js'; +export { OpenAIProvider } from './openai-provider.js'; +export { DEFAULT_MODELS, PROVIDERS } from './types.js'; diff --git a/packages/llm-provider/src/openai-provider.js b/packages/llm-provider/src/openai-provider.js new file mode 100644 index 0000000..39073d6 --- /dev/null +++ b/packages/llm-provider/src/openai-provider.js @@ -0,0 +1,142 @@ +// @ts-nocheck +import OpenAI from 'openai'; +import { DEFAULT_MODELS, PROVIDERS } from './types.js'; + +const DEFAULT_MAX_TOKENS = 4096; + +/** + * OpenAI provider implementation. + * Uses OpenAI SDK with function_call / tool_calls support. + */ +export class OpenAIProvider { + constructor(options = {}) { + this.provider = PROVIDERS.OPENAI; + this.model = options.model || DEFAULT_MODELS[PROVIDERS.OPENAI]; + this._client = new OpenAI({ + apiKey: options.apiKey || process.env.OPENAI_API_KEY, + }); + } + + /** + * Send a chat request without tools. + * @param {import('./types.js').LLMMessage[]} messages + * @param {import('./types.js').ChatOptions} [options] + * @returns {Promise} + */ + async chat(messages, options = {}) { + try { + const openaiMessages = []; + if (options.systemPrompt) { + openaiMessages.push({ role: 'system', content: options.systemPrompt }); + } + for (const m of messages) { + openaiMessages.push({ role: m.role, content: m.content }); + } + + const response = await this._client.chat.completions.create({ + model: this.model, + max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS, + temperature: options.temperature, + messages: openaiMessages, + }); + + const choice = response.choices[0]; + return { + ok: true, + content: choice?.message?.content || '', + model: response.model, + stopReason: choice?.finish_reason === 'stop' ? 'end_turn' : choice?.finish_reason, + usage: { + inputTokens: response.usage?.prompt_tokens || 0, + outputTokens: response.usage?.completion_tokens || 0, + }, + }; + } catch (err) { + return { + ok: false, + content: '', + error: err?.message || 'openai_api_error', + }; + } + } + + /** + * Send a chat request with tool use support. + * @param {import('./types.js').LLMMessage[]} messages + * @param {import('./types.js').LLMTool[]} tools + * @param {import('./types.js').ChatOptions} [options] + * @returns {Promise} + */ + async chatWithTools(messages, tools, options = {}) { + try { + const openaiMessages = []; + if (options.systemPrompt) { + openaiMessages.push({ role: 'system', content: options.systemPrompt }); + } + for (const m of messages) { + if (typeof m.content === 'string') { + openaiMessages.push({ role: m.role, content: m.content }); + } else { + openaiMessages.push({ role: m.role, content: m.content }); + } + } + + const openaiTools = tools.map((t) => ({ + type: 'function', + function: { + name: t.name, + description: t.description, + parameters: t.inputSchema, + }, + })); + + const response = await this._client.chat.completions.create({ + model: this.model, + max_tokens: options.maxTokens || DEFAULT_MAX_TOKENS, + temperature: options.temperature, + messages: openaiMessages, + tools: openaiTools, + tool_choice: 'auto', + }); + + const choice = response.choices[0]; + const message = choice?.message; + const textContent = message?.content || ''; + const toolCalls = (message?.tool_calls || []).map((tc) => ({ + id: tc.id, + name: tc.function.name, + input: (() => { + try { + return JSON.parse(tc.function.arguments); + } catch { + return {}; + } + })(), + })); + + const stopReason = (() => { + if (choice?.finish_reason === 'tool_calls') return 'tool_use'; + if (choice?.finish_reason === 'stop') return 'end_turn'; + return choice?.finish_reason; + })(); + + return { + ok: true, + content: textContent, + toolCalls, + stopReason, + usage: { + inputTokens: response.usage?.prompt_tokens || 0, + outputTokens: response.usage?.completion_tokens || 0, + }, + }; + } catch (err) { + return { + ok: false, + content: '', + toolCalls: [], + error: err?.message || 'openai_api_error', + }; + } + } +} diff --git a/packages/llm-provider/src/types.js b/packages/llm-provider/src/types.js new file mode 100644 index 0000000..3820955 --- /dev/null +++ b/packages/llm-provider/src/types.js @@ -0,0 +1,78 @@ +// @ts-nocheck + +/** + * LLM Provider types shared across all provider implementations. + */ + +/** + * A single message in a conversation. + * @typedef {'user'|'assistant'|'system'} MessageRole + */ + +/** + * @typedef {Object} LLMMessage + * @property {'user'|'assistant'|'system'} role + * @property {string} content + */ + +/** + * @typedef {Object} LLMToolParameter + * @property {string} type + * @property {string} [description] + * @property {Object} [properties] + * @property {string[]} [required] + * @property {Object} [items] + * @property {string[]} [enum] + */ + +/** + * A tool definition that can be passed to the LLM for function/tool calling. + * @typedef {Object} LLMTool + * @property {string} name + * @property {string} description + * @property {Object} inputSchema - JSON Schema for the tool's input parameters + */ + +/** + * @typedef {Object} LLMResponse + * @property {boolean} ok + * @property {string} content + * @property {string} [model] + * @property {'end_turn'|'max_tokens'|'tool_use'|'stop'} [stopReason] + * @property {{inputTokens: number, outputTokens: number}} [usage] + * @property {string} [error] + */ + +/** + * @typedef {Object} LLMToolCall + * @property {string} id + * @property {string} name + * @property {Object} input + */ + +/** + * @typedef {Object} LLMToolResponse + * @property {boolean} ok + * @property {string} content + * @property {LLMToolCall[]} [toolCalls] + * @property {'end_turn'|'max_tokens'|'tool_use'|'stop'} [stopReason] + * @property {{inputTokens: number, outputTokens: number}} [usage] + * @property {string} [error] + */ + +/** + * @typedef {Object} ChatOptions + * @property {number} [maxTokens] + * @property {number} [temperature] + * @property {string} [systemPrompt] + */ + +export const PROVIDERS = /** @type {const} */ ({ + CLAUDE: 'claude', + OPENAI: 'openai', +}); + +export const DEFAULT_MODELS = { + [PROVIDERS.CLAUDE]: 'claude-sonnet-4-6', + [PROVIDERS.OPENAI]: 'gpt-4o', +}; From e134895d99902c65e59883bdcbae5f1fd3fc1702 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:41:47 +0800 Subject: [PATCH 03/20] feat: rewrite agent services with LLM reasoning (intent, planning, analysis) --- .../src/app/routes/routers/agent-router.ts | 9 +- .../agent/services/analysis-service.js | 451 ++++++++++++++++++ .../agent/services/analysis-service.ts | 394 +-------------- .../domains/agent/services/intent-service.js | 216 +++++++++ .../domains/agent/services/intent-service.ts | 298 +----------- .../agent/services/planning-service.js | 185 +++++++ .../agent/services/planning-service.ts | 293 +----------- .../api/src/domains/agent/services/prompts.js | 119 +++++ 8 files changed, 986 insertions(+), 979 deletions(-) create mode 100644 apps/api/src/domains/agent/services/analysis-service.js create mode 100644 apps/api/src/domains/agent/services/intent-service.js create mode 100644 apps/api/src/domains/agent/services/planning-service.js create mode 100644 apps/api/src/domains/agent/services/prompts.js diff --git a/apps/api/src/app/routes/routers/agent-router.ts b/apps/api/src/app/routes/routers/agent-router.ts index bba5dd8..21bef04 100644 --- a/apps/api/src/app/routes/routers/agent-router.ts +++ b/apps/api/src/app/routes/routers/agent-router.ts @@ -75,23 +75,26 @@ export async function handleAgentRoutes({ req, reqUrl, res, readJsonBody, writeJ return true; } + // LLM-powered intent parsing (now async) if (req.method === 'POST' && reqUrl.pathname === '/api/agent/intent') { const body = await readJsonBody(req); - const result = parseAgentIntent(body); + const result = await parseAgentIntent(body); writeJson(res, result.ok ? 200 : 400, result); return true; } + // LLM-powered plan creation (now async) if (req.method === 'POST' && reqUrl.pathname === '/api/agent/plans') { const body = await readJsonBody(req); - const result = createAgentPlan(body); + const result = await createAgentPlan(body); writeJson(res, result.ok ? 200 : 400, result); return true; } + // LLM-powered analysis with tool-use loop (now async) if (req.method === 'POST' && reqUrl.pathname === '/api/agent/analysis-runs') { const body = await readJsonBody(req); - const result = runAgentAnalysis(body); + const result = await runAgentAnalysis(body); writeJson(res, result.ok ? 200 : 400, result); return true; } diff --git a/apps/api/src/domains/agent/services/analysis-service.js b/apps/api/src/domains/agent/services/analysis-service.js new file mode 100644 index 0000000..10213c6 --- /dev/null +++ b/apps/api/src/domains/agent/services/analysis-service.js @@ -0,0 +1,451 @@ +// @ts-nocheck +import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; +import { createLLMProvider } from '../../../../../../packages/llm-provider/src/index.js'; +import { listActiveAgentInstructions } from './instruction-service.js'; +import { createAgentPlan } from './planning-service.js'; +import { executeAgentTool } from './tools-service.js'; +import { ANALYSIS_SYSTEM_PROMPT } from './prompts.js'; + +/** + * Tool definitions for LLM function/tool calling. + * These map to executeAgentTool() implementations. + */ +const LLM_TOOLS = [ + { + name: 'strategy_catalog_list', + description: 'List all trading strategies in the catalog with their current status and metrics.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + { + name: 'backtest_summary_get', + description: 'Get the backtest center summary: total runs, pending reviews, and health metrics.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + { + name: 'backtest_runs_list', + description: 'List recent backtest runs with performance metrics like Sharpe ratio and max drawdown.', + inputSchema: { + type: 'object', + properties: { + status: { type: 'string', description: 'Filter by status: needs_review, completed, failed', enum: ['needs_review', 'completed', 'failed'] }, + }, + required: [], + }, + }, + { + name: 'risk_events_list', + description: 'List recent risk events and alerts from the risk monitoring system.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max number of events to return (default 10)' }, + }, + required: [], + }, + }, + { + name: 'execution_plans_list', + description: 'List execution plans and their current approval/review status.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max number of plans to return (default 10)' }, + }, + required: [], + }, + }, + { + name: 'market_quotes_get', + description: 'Get current market quotes and price data for one or more stock symbols.', + inputSchema: { + type: 'object', + properties: { + symbols: { type: 'array', items: { type: 'string' }, description: 'List of ticker symbols e.g. ["AAPL", "NVDA"]' }, + }, + required: ['symbols'], + }, + }, + { + name: 'market_history_get', + description: 'Get historical OHLCV (Open/High/Low/Close/Volume) price data for a symbol.', + inputSchema: { + type: 'object', + properties: { + symbol: { type: 'string', description: 'Ticker symbol e.g. "AAPL"' }, + days: { type: 'number', description: 'Number of calendar days of history (default 30)' }, + }, + required: ['symbol'], + }, + }, +]; + +/** + * Map LLM tool name (underscore format) to executeAgentTool dot-format names. + */ +function llmToolNameToAgentTool(name) { + return name.replace(/_/g, '.').replace(/\.list$/, 's.list').replace(/\.get$/, '.get'); +} + +/** + * Execute a single tool call from LLM and return the result. + */ +function executeLLMToolCall(toolName, toolInput) { + const dotName = (() => { + switch (toolName) { + case 'strategy_catalog_list': return 'strategy.catalog.list'; + case 'backtest_summary_get': return 'backtest.summary.get'; + case 'backtest_runs_list': return 'backtest.runs.list'; + case 'risk_events_list': return 'risk.events.list'; + case 'execution_plans_list': return 'execution.plans.list'; + case 'market_quotes_get': return 'market.quotes.get'; + case 'market_history_get': return 'market.history.get'; + default: return toolName; + } + })(); + + return executeAgentTool({ tool: dotName, args: toolInput || {} }); +} + +/** + * Serialize tool results for LLM context (keep it concise). + */ +function serializeToolResult(result) { + if (!result.ok) return `Error: ${result.summary}`; + const data = result.data || {}; + return JSON.stringify(data, null, 2).slice(0, 3000); +} + +/** + * Build the initial analysis prompt with intent context. + */ +function buildAnalysisPrompt(intent, dailyBias) { + const biasNote = dailyBias?.length + ? `\n\nActive daily bias instructions:\n${dailyBias.map((b) => `- ${b.body}`).join('\n')}` + : ''; + + return `Analyze the user's trading request and provide actionable recommendations. + +User's intent: ${intent.summary} +Intent kind: ${intent.kind} +${intent.extractedStrategy ? `Strategy description: ${intent.extractedStrategy.description}` : ''} +${intent.extractedTrade ? `Requested trade: ${intent.extractedTrade.side} ${intent.extractedTrade.symbol || 'unspecified'}` : ''} +${biasNote} + +Please use the available tools to gather relevant data, then provide your analysis in the required JSON format.`; +} + +/** + * Rule-based fallback narrative when LLM is unavailable. + */ +function buildFallbackNarrative(intent, toolResults = []) { + const resultMap = Object.fromEntries(toolResults.map((r) => [r.tool, r])); + const strategies = resultMap['strategy.catalog.list']?.data?.strategies || []; + const backtestSummary = resultMap['backtest.summary.get']?.data || {}; + const riskEvents = resultMap['risk.events.list']?.data?.events || []; + const executionPlans = resultMap['execution.plans.list']?.data?.plans || []; + + const elevatedRisk = riskEvents.some((e) => e.status === 'risk-off' || e.status === 'attention'); + const thesis = elevatedRisk + ? 'Risk posture is elevated. Review risk events before taking action.' + : `Analysis complete. Found ${strategies.length} strategies and ${Number(backtestSummary.completedRuns || 0)} completed backtests.`; + + return { + thesis, + rationale: [ + `${strategies.length} strategies available in catalog.`, + `${Number(backtestSummary.completedRuns || 0)} completed backtests tracked.`, + `${riskEvents.length} recent risk events checked.`, + ], + warnings: elevatedRisk ? ['Elevated risk events are active. Proceed with caution.'] : [], + recommendedNextStep: elevatedRisk + ? 'Review the risk console before requesting any action.' + : 'Refine your request for more specific analysis.', + requiresAction: false, + actionType: 'none', + }; +} + +/** + * Run the LLM tool-use loop: gather data → analyze → produce structured report. + * Max 5 tool call rounds to prevent runaway loops. + */ +async function runLLMAnalysisLoop(intent, dailyBias, sessionId) { + const llm = createLLMProvider(); + if (!llm) return null; + + const messages = [ + { role: 'user', content: buildAnalysisPrompt(intent, dailyBias) }, + ]; + + const toolCallLog = []; + const MAX_ROUNDS = 5; + + for (let round = 0; round < MAX_ROUNDS; round++) { + const response = await llm.chatWithTools(messages, LLM_TOOLS, { + systemPrompt: ANALYSIS_SYSTEM_PROMPT, + maxTokens: 4096, + temperature: 0.2, + }); + + if (!response.ok) { + console.error('[analysis-service] LLM error in round', round, response.error); + return null; + } + + // If LLM has tool calls, execute them and continue + if (response.stopReason === 'tool_use' && response.toolCalls?.length > 0) { + // Add assistant message with tool calls + const assistantContent = []; + if (response.content) { + assistantContent.push({ type: 'text', text: response.content }); + } + for (const tc of response.toolCalls) { + assistantContent.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input }); + } + messages.push({ role: 'assistant', content: assistantContent }); + + // Execute all tool calls and collect results + const toolResultContent = []; + for (const tc of response.toolCalls) { + const result = executeLLMToolCall(tc.name, tc.input); + toolCallLog.push({ tool: tc.name, input: tc.input, result }); + + toolResultContent.push({ + type: 'tool_result', + tool_use_id: tc.id, + content: serializeToolResult(result), + }); + } + messages.push({ role: 'user', content: toolResultContent }); + continue; + } + + // LLM has stopped using tools — parse the final JSON response + const finalContent = response.content?.trim() || ''; + try { + // Handle markdown code blocks if LLM wraps JSON in ``` + const jsonStr = finalContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, ''); + const parsed = JSON.parse(jsonStr); + return { + narrative: { + thesis: parsed.thesis || 'Analysis complete.', + rationale: Array.isArray(parsed.rationale) ? parsed.rationale : [], + warnings: Array.isArray(parsed.warnings) ? parsed.warnings : [], + strategy: parsed.strategy || null, + recommendedNextStep: parsed.recommendedNextStep || '', + requiresAction: Boolean(parsed.requiresAction), + actionType: parsed.actionType || 'none', + }, + toolCallLog, + }; + } catch (parseErr) { + console.error('[analysis-service] Failed to parse LLM JSON response:', parseErr.message); + console.error('[analysis-service] Raw content:', finalContent.slice(0, 500)); + return null; + } + } + + console.error('[analysis-service] Exceeded max tool call rounds'); + return null; +} + +export async function runAgentAnalysis(payload = {}) { + const planned = payload.planId + ? { + ok: true, + session: payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null, + intent: payload.intent || null, + plan: controlPlaneRuntime.getAgentPlan(payload.planId), + } + : await createAgentPlan(payload); + + if (!planned.ok) return planned; + + const plan = planned.plan || controlPlaneRuntime.getAgentPlan(payload.planId); + const session = + planned.session || + (plan?.sessionId ? controlPlaneRuntime.getAgentSession(plan.sessionId) : null); + const intent = planned.intent || session?.latestIntent || null; + + if (!plan || !session || !intent) { + return { + ok: false, + error: 'missing_analysis_context', + message: 'Agent analysis requires a session, intent, and plan.', + }; + } + + // Mark session and plan as running + controlPlaneRuntime.updateAgentSession(session.id, { status: 'running' }); + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'system', + kind: 'analysis_status', + title: 'Analysis started', + body: 'Gathering data and reasoning with AI...', + requestedBy: payload.requestedBy || session.requestedBy || 'agent', + metadata: { agentPlanId: plan.id, status: 'running' }, + }); + controlPlaneRuntime.updateAgentPlan(plan.id, { + status: 'running', + steps: plan.steps.map((s) => ({ ...s, status: s.toolName ? 'running' : s.status })), + }); + + // Load daily bias instructions + const dailyBias = listActiveAgentInstructions({ sessionId: session.id, kind: 'daily_bias' }); + + // Run LLM analysis loop (with tool calls) + let analysisResult = await runLLMAnalysisLoop(intent, dailyBias, session.id); + + // Fallback: gather tool data the old way and use rule-based narrative + const toolResults = []; + if (!analysisResult) { + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'system', + kind: 'analysis_status', + title: 'Using rule-based analysis', + body: 'LLM unavailable. Using built-in analysis engine.', + requestedBy: payload.requestedBy || session.requestedBy || 'agent', + metadata: { agentPlanId: plan.id }, + }); + + for (const step of plan.steps) { + if (!step.toolName) continue; + const args = step.toolName === 'risk.events.list' ? { limit: 12 } + : step.toolName === 'execution.plans.list' ? { limit: 12 } + : step.toolName === 'backtest.runs.list' && intent.kind === 'request_backtest_review' ? { status: 'needs_review' } + : {}; + const result = executeAgentTool({ tool: step.toolName, args }); + toolResults.push(result); + } + + analysisResult = { + narrative: buildFallbackNarrative(intent, toolResults), + toolCallLog: toolResults.map((r) => ({ tool: r.tool, input: {}, result: r })), + }; + } + + const { narrative, toolCallLog } = analysisResult; + + // Build tool call records for storage + const llmToolCalls = toolCallLog.map((entry) => ({ + tool: entry.tool, + status: entry.result?.ok ? 'completed' : 'failed', + summary: entry.result?.summary || '', + metadata: { dataKeys: Object.keys(entry.result?.data || {}) }, + })); + + const evidence = toolCallLog + .filter((entry) => entry.result?.ok) + .map((entry) => ({ + kind: 'tool_result', + title: entry.tool, + summary: entry.result?.summary || '', + source: entry.tool, + sourceId: entry.tool, + metadata: { keys: Object.keys(entry.result?.data || {}) }, + })); + + // Mark steps as completed + const finalizedSteps = plan.steps.map((step) => ({ + ...step, + status: 'completed', + outputSummary: step.kind === 'explain' ? narrative.thesis + : step.kind === 'request_action' ? narrative.recommendedNextStep + : llmToolCalls.find((tc) => tc.tool === step.toolName)?.summary || 'Completed.', + })); + + const planStatus = 'completed'; + const runStatus = 'completed'; + const completedAt = new Date().toISOString(); + + // Build the full explanation object (compatible with existing UI) + const explanation = { + thesis: narrative.thesis, + rationale: narrative.rationale, + warnings: narrative.warnings, + recommendedNextStep: narrative.recommendedNextStep, + strategy: narrative.strategy || null, + requiresAction: narrative.requiresAction, + actionType: narrative.actionType, + }; + + const run = controlPlaneRuntime.recordAgentAnalysisRun({ + sessionId: session.id, + planId: plan.id, + status: runStatus, + summary: narrative.thesis, + conclusion: narrative.thesis, + requestedBy: payload.requestedBy || session.requestedBy || 'operator', + toolCalls: llmToolCalls, + evidence, + explanation, + metadata: { + intentKind: intent.kind, + targetType: intent.targetType, + targetId: intent.targetId, + source: 'agent-analysis-llm', + hasStrategy: Boolean(narrative.strategy), + }, + completedAt, + }); + + const updatedPlan = controlPlaneRuntime.updateAgentPlan(plan.id, { + status: planStatus, + steps: finalizedSteps, + metadata: { latestAnalysisRunId: run.id }, + }); + const updatedSession = controlPlaneRuntime.updateAgentSession(session.id, { + status: 'completed', + latestAnalysisRunId: run.id, + metadata: { latestAnalysisCompletedAt: completedAt }, + }); + + // Record the main assistant response message + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'assistant', + kind: 'analysis_result', + title: narrative.thesis, + body: [ + narrative.thesis, + ...(narrative.rationale || []), + ...(narrative.warnings || []), + narrative.recommendedNextStep ? `Next: ${narrative.recommendedNextStep}` : '', + ].filter(Boolean).join(' '), + requestedBy: payload.requestedBy || session.requestedBy || 'agent', + metadata: { + agentPlanId: plan.id, + agentAnalysisRunId: run.id, + status: runStatus, + toolCallCount: llmToolCalls.length, + hasStrategy: Boolean(narrative.strategy), + requiresAction: narrative.requiresAction, + actionType: narrative.actionType, + }, + }); + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'system', + kind: 'analysis_status', + title: plan.requiresApproval ? 'Ready for approval' : 'Analysis ready', + body: plan.requiresApproval + ? 'Ready for approval once you request a controlled handoff.' + : 'Analysis complete. Ask a follow-up or take action.', + requestedBy: payload.requestedBy || session.requestedBy || 'agent', + metadata: { + agentPlanId: plan.id, + agentAnalysisRunId: run.id, + requiresApproval: plan.requiresApproval, + }, + }); + + return { + ok: true, + session: updatedSession || session, + intent, + plan: updatedPlan || plan, + run, + }; +} diff --git a/apps/api/src/domains/agent/services/analysis-service.ts b/apps/api/src/domains/agent/services/analysis-service.ts index 09b2c2c..5fc4de9 100644 --- a/apps/api/src/domains/agent/services/analysis-service.ts +++ b/apps/api/src/domains/agent/services/analysis-service.ts @@ -1,392 +1,4 @@ // @ts-nocheck -import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; -import { listActiveAgentInstructions } from './instruction-service.js'; -import { createAgentPlan } from './planning-service.js'; -import { executeAgentTool } from './tools-service.js'; - -function summarizeToolData(tool, data = {}) { - switch (tool) { - case 'strategy.catalog.list': - return `${Array.isArray(data.strategies) ? data.strategies.length : 0} strategy entries loaded.`; - case 'backtest.summary.get': - return `${Number(data.completedRuns || 0)} completed backtests and ${Number(data.reviewQueue || 0)} pending reviews.`; - case 'backtest.runs.list': - return `${Array.isArray(data.runs) ? data.runs.length : 0} backtest runs loaded.`; - case 'risk.events.list': - return `${Array.isArray(data.events) ? data.events.length : 0} risk events loaded.`; - case 'execution.plans.list': - return `${Array.isArray(data.plans) ? data.plans.length : 0} execution plans loaded.`; - default: - return 'Tool result loaded.'; - } -} - -function buildEvidenceFromToolResult(result) { - return { - kind: 'tool_result', - title: result.tool, - summary: result.summary, - source: result.tool, - sourceId: result.tool, - metadata: { - keys: Object.keys(result.data || {}), - }, - }; -} - -function buildAnalysisNarrative(intent, toolResults = []) { - const resultMap = Object.fromEntries(toolResults.map((item) => [item.tool, item])); - const backtestSummary = resultMap['backtest.summary.get']?.data || {}; - const backtestRuns = resultMap['backtest.runs.list']?.data?.runs || []; - const riskEvents = resultMap['risk.events.list']?.data?.events || []; - const executionPlans = resultMap['execution.plans.list']?.data?.plans || []; - const strategies = resultMap['strategy.catalog.list']?.data?.strategies || []; - - switch (intent.kind) { - case 'request_execution_prep': { - const targetStrategy = strategies.find((item) => item.id === intent.targetId) || null; - const existingPlans = executionPlans.filter((item) => item.strategyId === intent.targetId); - const reviewQueue = Number(backtestSummary.reviewQueue || 0); - const thesis = - existingPlans.length > 0 - ? 'Execution readiness needs review because the strategy already has persisted execution plans.' - : 'Execution readiness can be reviewed through the controlled action path.'; - const rationale = [ - targetStrategy - ? `Strategy ${targetStrategy.name || targetStrategy.id} is available in the strategy catalog.` - : 'No target strategy was matched from the prompt, so this remains a general execution-prep review.', - reviewQueue > 0 - ? `${reviewQueue} research items are still in review, so downstream execution should stay gated.` - : 'No outstanding research review queue was found in the backtest summary.', - existingPlans.length > 0 - ? `${existingPlans.length} execution plans already exist for this strategy.` - : 'No persisted execution plans were found for this strategy.', - ]; - const warnings = []; - if (reviewQueue > 0) - warnings.push( - 'Pending research reviews still need operator attention before action handoff.' - ); - if (existingPlans.length > 0) - warnings.push( - 'Avoid creating duplicate execution requests without reviewing existing plans.' - ); - return { - summary: thesis, - conclusion: thesis, - explanation: { - thesis, - rationale, - warnings, - recommendedNextStep: - 'If posture still looks acceptable, queue a controlled execution-plan request instead of direct execution.', - }, - }; - } - case 'request_risk_explanation': { - const elevatedEvents = riskEvents.filter( - (item) => item.status === 'risk-off' || item.status === 'attention' - ); - const thesis = - elevatedEvents.length > 0 - ? 'Risk posture is elevated and should be reviewed before any downstream action.' - : 'Risk posture looks stable from the current event feed.'; - return { - summary: thesis, - conclusion: thesis, - explanation: { - thesis, - rationale: [ - `${riskEvents.length} recent risk events were loaded from the control plane.`, - `${executionPlans.length} execution plans were checked for overlapping approval posture.`, - ], - warnings: - elevatedEvents.length > 0 - ? ['Recent elevated risk events are still active in the control plane.'] - : [], - recommendedNextStep: - elevatedEvents.length > 0 - ? 'Review the risk console and linked execution approvals before requesting action.' - : 'Continue with read-only review or prepare a controlled follow-up if new evidence appears.', - }, - }; - } - case 'request_backtest_review': { - const pendingRuns = backtestRuns.filter((item) => item.status === 'needs_review'); - const thesis = - pendingRuns.length > 0 - ? 'Backtest review backlog remains and should be cleared before promotion or execution prep.' - : 'Backtest posture looks stable from the current run summary.'; - return { - summary: thesis, - conclusion: thesis, - explanation: { - thesis, - rationale: [ - `${Number(backtestSummary.completedRuns || 0)} completed backtests are currently tracked.`, - `${pendingRuns.length} recent backtest runs still require manual review.`, - ], - warnings: pendingRuns.length > 0 ? ['Manual backtest review is still pending.'] : [], - recommendedNextStep: - pendingRuns.length > 0 - ? 'Review the pending run before promoting or preparing execution.' - : 'Use the result as supporting research context for the next controlled action.', - }, - }; - } - default: { - const thesis = - 'Read-only analysis completed using the current strategy and research context.'; - return { - summary: thesis, - conclusion: thesis, - explanation: { - thesis, - rationale: [ - `${strategies.length} strategies were available in the catalog snapshot.`, - `${Number(backtestSummary.completedRuns || 0)} completed backtests were visible in the summary feed.`, - ], - warnings: [], - recommendedNextStep: - 'Refine the prompt or create a more specific plan if a controlled follow-up is needed.', - }, - }; - } - } -} - -function resolveToolArgs(step = {}, intent = {}) { - if (step.toolName === 'risk.events.list') { - return { limit: 12 }; - } - if (step.toolName === 'execution.plans.list') { - return { limit: 12 }; - } - if (step.toolName === 'backtest.runs.list') { - return intent.kind === 'request_backtest_review' ? { status: 'needs_review' } : {}; - } - return {}; -} - -export function runAgentAnalysis(payload = {}) { - const planned = payload.planId - ? { - ok: true, - session: payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null, - intent: payload.intent || null, - plan: controlPlaneRuntime.getAgentPlan(payload.planId), - } - : createAgentPlan(payload); - - if (!planned.ok) { - return planned; - } - - const plan = planned.plan || controlPlaneRuntime.getAgentPlan(payload.planId); - const session = - planned.session || - (plan?.sessionId ? controlPlaneRuntime.getAgentSession(plan.sessionId) : null); - const intent = planned.intent || session?.latestIntent || null; - - if (!plan || !session || !intent) { - return { - ok: false, - error: 'missing_analysis_context', - message: 'Agent analysis requires a session, intent, and plan.', - }; - } - - const runningSteps = plan.steps.map((step) => ({ - ...step, - status: step.toolName ? 'running' : step.status, - })); - - controlPlaneRuntime.updateAgentSession(session.id, { - status: 'running', - }); - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'system', - kind: 'analysis_status', - title: 'Analysis started', - body: 'Intent parsed and plan execution started against allowlisted read-only tools.', - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - status: 'running', - }, - }); - controlPlaneRuntime.updateAgentPlan(plan.id, { - status: 'running', - steps: runningSteps, - }); - - const toolResults = []; - const completedSteps = runningSteps.map((step) => { - if (!step.toolName) { - return step; - } - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'system', - kind: 'analysis_status', - title: 'Reading tool context', - body: `Reading ${step.title}.`, - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - planStepId: step.id, - toolName: step.toolName, - }, - }); - const result = executeAgentTool({ - tool: step.toolName, - args: resolveToolArgs(step, intent), - }); - toolResults.push(result); - return { - ...step, - status: result.ok ? 'completed' : 'failed', - outputSummary: result.summary, - metadata: { - ...step.metadata, - executedTool: result.tool, - }, - }; - }); - - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'system', - kind: 'analysis_status', - title: 'Summarizing findings', - body: 'Summarizing tool findings into a structured recommendation.', - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - toolCallCount: toolResults.length, - }, - }); - - const narrative = buildAnalysisNarrative(intent, toolResults); - - const dailyBias = listActiveAgentInstructions({ sessionId: session.id, kind: 'daily_bias' }); - const biasSummary = dailyBias.map((item) => item.body).join(' '); - if (Array.isArray(narrative.explanation?.rationale)) { - narrative.explanation.rationale.push( - biasSummary - ? `Current daily bias: ${biasSummary}` - : 'No active daily bias is affecting this session.' - ); - } - const finalizedSteps = completedSteps.map((step) => { - if (step.kind === 'explain') { - return { - ...step, - status: 'completed', - outputSummary: narrative.explanation.thesis, - }; - } - if (step.kind === 'request_action') { - return { - ...step, - status: 'completed', - outputSummary: narrative.explanation.recommendedNextStep, - }; - } - return step; - }); - - const planStatus = finalizedSteps.some((step) => step.status === 'failed') - ? 'failed' - : 'completed'; - const runStatus = planStatus === 'failed' ? 'failed' : 'completed'; - const completedAt = new Date().toISOString(); - - const run = controlPlaneRuntime.recordAgentAnalysisRun({ - sessionId: session.id, - planId: plan.id, - status: runStatus, - summary: narrative.summary, - conclusion: narrative.conclusion, - requestedBy: payload.requestedBy || session.requestedBy || 'operator', - toolCalls: toolResults.map((item) => ({ - tool: item.tool, - status: item.ok ? 'completed' : 'failed', - summary: item.summary, - metadata: { - dataKeys: Object.keys(item.data || {}), - }, - })), - evidence: toolResults.map((item) => buildEvidenceFromToolResult(item)), - explanation: narrative.explanation, - metadata: { - intentKind: intent.kind, - targetType: intent.targetType, - targetId: intent.targetId, - source: 'agent-analysis-runner', - }, - completedAt, - }); - - const updatedPlan = controlPlaneRuntime.updateAgentPlan(plan.id, { - status: planStatus, - steps: finalizedSteps, - metadata: { - latestAnalysisRunId: run.id, - }, - }); - const updatedSession = controlPlaneRuntime.updateAgentSession(session.id, { - status: runStatus === 'completed' ? 'completed' : 'failed', - latestAnalysisRunId: run.id, - metadata: { - latestAnalysisCompletedAt: completedAt, - }, - }); - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'assistant', - kind: 'analysis_result', - title: narrative.explanation.thesis || 'Analysis completed', - body: [ - narrative.summary || '', - ...(Array.isArray(narrative.explanation?.rationale) ? narrative.explanation.rationale : []), - ...(Array.isArray(narrative.explanation?.warnings) ? narrative.explanation.warnings : []), - narrative.explanation?.recommendedNextStep - ? `Next step: ${narrative.explanation.recommendedNextStep}` - : '', - ] - .filter(Boolean) - .join(' '), - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - agentAnalysisRunId: run.id, - status: runStatus, - toolCallCount: toolResults.length, - }, - }); - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'system', - kind: 'analysis_status', - title: plan.requiresApproval ? 'Ready for approval' : 'Analysis ready', - body: plan.requiresApproval - ? 'Ready for approval once the operator requests a controlled handoff.' - : 'Analysis ready for the next follow-up question or read-only review.', - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - agentAnalysisRunId: run.id, - requiresApproval: plan.requiresApproval, - }, - }); - - return { - ok: true, - session: updatedSession || session, - intent, - plan: updatedPlan || plan, - run, - }; -} +// This file is superseded by analysis-service.js (LLM-powered rewrite). +// Re-exporting from the new implementation for backward compatibility. +export { runAgentAnalysis } from './analysis-service.js'; diff --git a/apps/api/src/domains/agent/services/intent-service.js b/apps/api/src/domains/agent/services/intent-service.js new file mode 100644 index 0000000..db54c89 --- /dev/null +++ b/apps/api/src/domains/agent/services/intent-service.js @@ -0,0 +1,216 @@ +// @ts-nocheck +import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; +import { createLLMProvider } from '../../../../../../packages/llm-provider/src/index.js'; +import { INTENT_SYSTEM_PROMPT } from './prompts.js'; + +function normalizePrompt(prompt = '') { + return String(prompt || '') + .replace(/\s+/g, ' ') + .trim(); +} + +function createSessionTitle(prompt) { + const trimmed = normalizePrompt(prompt); + if (!trimmed) return 'Agent collaboration session'; + return trimmed.length > 72 ? `${trimmed.slice(0, 69)}...` : trimmed; +} + +/** + * Rule-based fallback intent inference (used when LLM is unavailable). + */ +function inferIntentFromRules(prompt, explicitTargetId = '') { + const normalized = prompt.toLowerCase(); + const urgency = /(urgent|immediately|asap|now|立刻|马上|尽快)/.test(normalized) + ? 'high' + : /(today|tomorrow|before open|开盘前|盘前)/.test(normalized) + ? 'normal' + : 'low'; + + if (/(buy|sell|购买|卖出|下单|买入|买|卖)/.test(normalized)) { + return { + kind: 'execute_trade', + summary: 'User wants to execute a trade.', + targetType: 'symbol', + targetId: explicitTargetId || '', + extractedTrade: { symbol: '', side: /sell|卖/.test(normalized) ? 'sell' : 'buy', sizeHint: 'unspecified' }, + urgency, + requiresApproval: false, + requestedMode: 'execute_paper', + confidence: 0.6, + metadata: { source: 'rule_fallback' }, + }; + } + + if (/(策略|strategy|构建|build|设计|create.*strat|momentum|value|mean reversion)/.test(normalized)) { + return { + kind: 'build_strategy', + summary: 'User wants to build a trading strategy.', + targetType: 'unknown', + targetId: explicitTargetId || '', + extractedStrategy: { description: prompt, symbols: [], style: 'general' }, + urgency, + requiresApproval: false, + requestedMode: 'read_only', + confidence: 0.6, + metadata: { source: 'rule_fallback' }, + }; + } + + if (/(回测|backtest|research|评估|evaluation|review run|review result)/.test(normalized)) { + return { + kind: 'request_backtest_review', + summary: 'Review research and backtest posture.', + targetType: 'backtest_run', + targetId: explicitTargetId || '', + urgency, + requiresApproval: false, + requestedMode: 'read_only', + confidence: 0.7, + metadata: { source: 'rule_fallback' }, + }; + } + + if (/(风控|risk|drawdown|回撤|compliance|explain risk|风险)/.test(normalized)) { + return { + kind: 'request_risk_explanation', + summary: 'Explain the current risk posture.', + targetType: 'unknown', + targetId: explicitTargetId || '', + urgency, + requiresApproval: false, + requestedMode: 'read_only', + confidence: 0.7, + metadata: { source: 'rule_fallback' }, + }; + } + + return { + kind: 'read_only_analysis', + summary: 'Read current platform context and summarize findings.', + targetType: 'unknown', + targetId: explicitTargetId || '', + urgency, + requiresApproval: false, + requestedMode: 'read_only', + confidence: 0.4, + metadata: { source: 'rule_fallback' }, + }; +} + +/** + * LLM-powered intent parsing with rule-based fallback. + */ +async function inferIntentWithLLM(prompt, explicitTargetId = '') { + const llm = createLLMProvider(); + if (!llm) { + return inferIntentFromRules(prompt, explicitTargetId); + } + + const contextNote = explicitTargetId + ? `\n\nContext: The user has pre-selected target ID: ${explicitTargetId}` + : ''; + + const response = await llm.chat( + [{ role: 'user', content: `User request: "${prompt}"${contextNote}` }], + { + systemPrompt: INTENT_SYSTEM_PROMPT, + maxTokens: 1024, + temperature: 0.1, + } + ); + + if (!response.ok) { + console.error('[intent-service] LLM error, falling back to rules:', response.error); + return inferIntentFromRules(prompt, explicitTargetId); + } + + try { + const parsed = JSON.parse(response.content.trim()); + return { + kind: parsed.kind || 'read_only_analysis', + summary: parsed.summary || prompt, + targetType: parsed.targetType || 'unknown', + targetId: parsed.targetId || explicitTargetId || '', + extractedStrategy: parsed.extractedStrategy || null, + extractedTrade: parsed.extractedTrade || null, + urgency: parsed.urgency || 'low', + requiresApproval: Boolean(parsed.requiresApproval), + requestedMode: parsed.requestedMode || 'read_only', + confidence: parsed.confidence || 0.8, + metadata: { source: 'llm', model: llm.model, provider: llm.provider }, + }; + } catch (parseErr) { + console.error('[intent-service] JSON parse error, falling back to rules:', parseErr.message); + return inferIntentFromRules(prompt, explicitTargetId); + } +} + +export async function parseAgentIntent(payload = {}) { + const prompt = normalizePrompt(payload.prompt); + if (!prompt) { + return { + ok: false, + error: 'missing_prompt', + message: 'Agent intent parsing requires a non-empty prompt.', + }; + } + + const requestedBy = payload.requestedBy || 'operator'; + const existingSession = payload.sessionId + ? controlPlaneRuntime.getAgentSession(payload.sessionId) + : null; + + const intent = await inferIntentWithLLM(prompt, payload.targetId || ''); + + const session = existingSession + ? controlPlaneRuntime.updateAgentSession(existingSession.id, { + prompt, + requestedBy, + title: existingSession.title || createSessionTitle(prompt), + status: 'ready', + latestIntent: intent, + metadata: { + intentParsedAt: new Date().toISOString(), + intentSource: intent.metadata?.source || 'unknown', + }, + }) + : controlPlaneRuntime.recordAgentSession({ + title: createSessionTitle(prompt), + prompt, + requestedBy, + status: 'ready', + latestIntent: intent, + metadata: { + source: 'agent-intent-parser', + intentSource: intent.metadata?.source || 'unknown', + }, + }); + + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'user', + kind: 'prompt', + title: 'Analysis request', + body: prompt, + requestedBy, + metadata: { source: 'agent-intent-parser' }, + }); + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'system', + kind: 'intent', + title: 'Intent parsed', + body: intent.summary, + requestedBy, + metadata: { + intentKind: intent.kind, + targetType: intent.targetType, + targetId: intent.targetId, + requestedMode: intent.requestedMode, + confidence: intent.confidence, + intentSource: intent.metadata?.source, + }, + }); + + return { ok: true, session, intent }; +} diff --git a/apps/api/src/domains/agent/services/intent-service.ts b/apps/api/src/domains/agent/services/intent-service.ts index 04652aa..dea5ad1 100644 --- a/apps/api/src/domains/agent/services/intent-service.ts +++ b/apps/api/src/domains/agent/services/intent-service.ts @@ -1,296 +1,4 @@ // @ts-nocheck -import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; -import { listBacktestRuns } from '../../backtest/services/runs-service.js'; -import { listExecutionPlans } from '../../execution/services/query-service.js'; -import { listRiskEvents } from '../../risk/services/feed-service.js'; -import { listStrategyCatalog } from '../../strategy/services/catalog-service.js'; - -function normalizePrompt(prompt = '') { - return String(prompt || '') - .replace(/\s+/g, ' ') - .trim(); -} - -function createSessionTitle(prompt) { - const trimmed = normalizePrompt(prompt); - if (!trimmed) return 'Agent collaboration session'; - return trimmed.length > 72 ? `${trimmed.slice(0, 69)}...` : trimmed; -} - -function findStrategyTarget(prompt, explicitTargetId = '') { - if (explicitTargetId) { - return { - targetType: 'strategy', - targetId: explicitTargetId, - }; - } - - const normalized = prompt.toLowerCase(); - const snapshot = listStrategyCatalog(); - const matched = snapshot.strategies.find( - (item) => - normalized.includes(item.id.toLowerCase()) || - normalized.includes(String(item.name || '').toLowerCase()) - ); - if (!matched) { - return { - targetType: 'unknown', - targetId: '', - }; - } - - return { - targetType: 'strategy', - targetId: matched.id, - }; -} - -function findBacktestTarget(prompt, explicitTargetId = '') { - if (explicitTargetId) { - return { - targetType: 'backtest_run', - targetId: explicitTargetId, - }; - } - - const normalized = prompt.toLowerCase(); - const snapshot = listBacktestRuns(); - const matched = snapshot.runs.find( - (item) => - normalized.includes(item.id.toLowerCase()) || - normalized.includes(String(item.strategyId || '').toLowerCase()) || - normalized.includes(String(item.strategyName || '').toLowerCase()) - ); - - return { - targetType: matched ? 'backtest_run' : 'unknown', - targetId: matched?.id || '', - }; -} - -function findExecutionTarget(prompt, explicitTargetId = '') { - if (explicitTargetId) { - return { - targetType: 'strategy', - targetId: explicitTargetId, - }; - } - - const fromStrategy = findStrategyTarget(prompt); - if (fromStrategy.targetId) { - return fromStrategy; - } - - const normalized = prompt.toLowerCase(); - const plans = listExecutionPlans(30); - const matchedPlan = plans.find( - (item) => - normalized.includes(item.id.toLowerCase()) || - normalized.includes(String(item.strategyId || '').toLowerCase()) || - normalized.includes(String(item.strategyName || '').toLowerCase()) - ); - if (matchedPlan) { - return { - targetType: 'execution_plan', - targetId: matchedPlan.id, - }; - } - - return { - targetType: 'unknown', - targetId: '', - }; -} - -function findRiskTarget(prompt, explicitTargetId = '') { - if (explicitTargetId) { - return { - targetType: 'risk_event', - targetId: explicitTargetId, - }; - } - - const normalized = prompt.toLowerCase(); - const events = listRiskEvents(30); - const matched = events.find( - (item) => - normalized.includes(item.id.toLowerCase()) || - normalized.includes(String(item.title || '').toLowerCase()) || - normalized.includes(String(item.message || '').toLowerCase()) - ); - if (matched) { - return { - targetType: 'risk_event', - targetId: matched.id, - }; - } - - return findStrategyTarget(prompt); -} - -function inferUrgency(prompt) { - const normalized = prompt.toLowerCase(); - if (/(urgent|immediately|asap|now|立刻|马上|尽快)/.test(normalized)) return 'high'; - if (/(today|tomorrow|before open|开盘前|盘前)/.test(normalized)) return 'normal'; - return 'low'; -} - -function inferIntentFromPrompt(prompt, explicitTargetId = '') { - const normalized = prompt.toLowerCase(); - const urgency = inferUrgency(prompt); - - if (/(回测|backtest|research|评估|evaluation|review run|review result)/.test(normalized)) { - const target = findBacktestTarget(prompt, explicitTargetId); - return { - kind: 'request_backtest_review', - summary: 'Review research and backtest posture before promoting or rerunning a strategy.', - targetType: target.targetType, - targetId: target.targetId, - urgency, - requiresApproval: false, - requestedMode: 'read_only', - metadata: { - matchedDomain: 'backtest', - }, - }; - } - - if ( - /(执行计划|execution plan|route|routing|下单|trade prep|prepare execution|执行准备|approve order)/.test( - normalized - ) - ) { - const target = findExecutionTarget(prompt, explicitTargetId); - return { - kind: 'request_execution_prep', - summary: - 'Prepare an execution-readiness review that can later become a controlled action request.', - targetType: target.targetType, - targetId: target.targetId, - urgency, - requiresApproval: true, - requestedMode: 'prepare_action', - metadata: { - matchedDomain: 'execution', - proposedActionRequestType: 'prepare_execution_plan', - }, - }; - } - - if (/(风控|risk|drawdown|回撤|compliance|explain risk|风险解释|风险说明)/.test(normalized)) { - const target = findRiskTarget(prompt, explicitTargetId); - return { - kind: 'request_risk_explanation', - summary: 'Explain the current risk posture and the control-plane signals behind it.', - targetType: target.targetType, - targetId: target.targetId, - urgency, - requiresApproval: false, - requestedMode: 'read_only', - metadata: { - matchedDomain: 'risk', - }, - }; - } - - if (/(scheduler|schedule|盘前|盘后|window|tick|调度|runbook)/.test(normalized)) { - return { - kind: 'request_scheduler_action', - summary: - 'Review scheduler posture and identify whether a controlled orchestration action is needed.', - targetType: explicitTargetId ? 'scheduler_window' : 'unknown', - targetId: explicitTargetId || '', - urgency, - requiresApproval: true, - requestedMode: 'prepare_action', - metadata: { - matchedDomain: 'scheduler', - }, - }; - } - - const target = findStrategyTarget(prompt, explicitTargetId); - return { - kind: 'read_only_analysis', - summary: 'Read current platform context and summarize the most relevant findings.', - targetType: target.targetType, - targetId: target.targetId, - urgency, - requiresApproval: false, - requestedMode: 'read_only', - metadata: { - matchedDomain: target.targetId ? 'strategy' : 'general', - }, - }; -} - -export function parseAgentIntent(payload = {}) { - const prompt = normalizePrompt(payload.prompt); - if (!prompt) { - return { - ok: false, - error: 'missing_prompt', - message: 'Agent intent parsing requires a non-empty prompt.', - }; - } - - const requestedBy = payload.requestedBy || 'operator'; - const existingSession = payload.sessionId - ? controlPlaneRuntime.getAgentSession(payload.sessionId) - : null; - const intent = inferIntentFromPrompt(prompt, payload.targetId || ''); - - const session = existingSession - ? controlPlaneRuntime.updateAgentSession(existingSession.id, { - prompt, - requestedBy, - title: existingSession.title || createSessionTitle(prompt), - status: 'ready', - latestIntent: intent, - metadata: { - intentParsedAt: new Date().toISOString(), - }, - }) - : controlPlaneRuntime.recordAgentSession({ - title: createSessionTitle(prompt), - prompt, - requestedBy, - status: 'ready', - latestIntent: intent, - metadata: { - source: 'agent-intent-parser', - }, - }); - - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'user', - kind: 'prompt', - title: 'Analysis request', - body: prompt, - requestedBy, - metadata: { - source: 'agent-intent-parser', - }, - }); - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'system', - kind: 'intent', - title: 'Intent parsed', - body: intent.summary, - requestedBy, - metadata: { - intentKind: intent.kind, - targetType: intent.targetType, - targetId: intent.targetId, - requestedMode: intent.requestedMode, - }, - }); - - return { - ok: true, - session, - intent, - }; -} +// This file is superseded by intent-service.js (LLM-powered rewrite). +// Re-exporting from the new implementation for backward compatibility. +export { parseAgentIntent } from './intent-service.js'; diff --git a/apps/api/src/domains/agent/services/planning-service.js b/apps/api/src/domains/agent/services/planning-service.js new file mode 100644 index 0000000..33fcdc5 --- /dev/null +++ b/apps/api/src/domains/agent/services/planning-service.js @@ -0,0 +1,185 @@ +// @ts-nocheck +import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; +import { createLLMProvider } from '../../../../../../packages/llm-provider/src/index.js'; +import { parseAgentIntent } from './intent-service.js'; +import { PLANNING_SYSTEM_PROMPT } from './prompts.js'; + +const AVAILABLE_TOOLS_DESCRIPTION = ` +Read tools: +- strategy.catalog.list: List all strategies +- backtest.summary.get: Get backtest statistics +- backtest.runs.list: List recent backtest runs +- risk.events.list: List risk events +- execution.plans.list: List execution plans +- market.quotes.get: Get current market quotes (args: {symbols: string[]}) +- market.history.get: Get historical OHLCV (args: {symbol: string, days: number}) + +Action tools: +- execution.paper.submit: Submit paper trade (args: {symbol, side, qty, orderType}) +- execution.live.request: Request live trade approval (args: {symbol, side, qty, rationale}) +- backtest.queue: Queue a backtest (args: {strategyDescription, symbols, days}) +`; + +/** + * Build hardcoded fallback steps when LLM is unavailable. + */ +function buildFallbackSteps(intent = {}) { + const baseReadSteps = [ + { kind: 'read', title: 'Load strategy catalog', toolName: 'strategy.catalog.list', description: 'Read strategy catalog.', outputSummary: '', metadata: { domain: 'strategy' } }, + { kind: 'read', title: 'Load backtest summary', toolName: 'backtest.summary.get', description: 'Check research posture.', outputSummary: '', metadata: { domain: 'backtest' } }, + ]; + + switch (intent.kind) { + case 'execute_trade': + return [ + { kind: 'read', title: 'Get market quote', toolName: 'market.quotes.get', description: 'Get current price for the target symbol.', outputSummary: '', metadata: { domain: 'market' } }, + { kind: 'read', title: 'Load risk events', toolName: 'risk.events.list', description: 'Check risk posture before execution.', outputSummary: '', metadata: { domain: 'risk' } }, + { kind: 'execute', title: 'Submit paper order', toolName: 'execution.paper.submit', description: 'Submit order to paper account.', outputSummary: '', metadata: { domain: 'execution' } }, + ]; + case 'build_strategy': + return [ + { kind: 'read', title: 'Load market context', toolName: 'market.quotes.get', description: 'Get current quotes for analysis.', outputSummary: '', metadata: { domain: 'market' } }, + { kind: 'read', title: 'Load strategy catalog', toolName: 'strategy.catalog.list', description: 'Check existing strategies.', outputSummary: '', metadata: { domain: 'strategy' } }, + { kind: 'explain', title: 'Build strategy plan', toolName: '', description: 'Generate strategy based on user description.', outputSummary: '', metadata: { deliverable: 'strategy-plan' } }, + ]; + case 'request_backtest_review': + return [ + { kind: 'read', title: 'Load backtest summary', toolName: 'backtest.summary.get', description: 'Read research summary.', outputSummary: '', metadata: { domain: 'backtest' } }, + { kind: 'read', title: 'Load recent runs', toolName: 'backtest.runs.list', description: 'Inspect run outcomes.', outputSummary: '', metadata: { domain: 'backtest' } }, + { kind: 'explain', title: 'Summarize research posture', toolName: '', description: 'Explain findings.', outputSummary: '', metadata: { deliverable: 'backtest-review' } }, + ]; + case 'request_risk_explanation': + return [ + { kind: 'read', title: 'Load risk events', toolName: 'risk.events.list', description: 'Inspect risk signals.', outputSummary: '', metadata: { domain: 'risk' } }, + { kind: 'read', title: 'Load execution posture', toolName: 'execution.plans.list', description: 'Correlate risk with execution.', outputSummary: '', metadata: { domain: 'execution' } }, + { kind: 'explain', title: 'Explain risk posture', toolName: '', description: 'Produce risk explanation.', outputSummary: '', metadata: { deliverable: 'risk-explanation' } }, + ]; + default: + return [ + ...baseReadSteps, + { kind: 'explain', title: 'Summarize findings', toolName: '', description: 'Prepare read-only analysis.', outputSummary: '', metadata: { deliverable: 'general-analysis' } }, + ]; + } +} + +/** + * Use LLM to generate dynamic plan steps based on intent. + */ +async function buildLLMSteps(intent = {}) { + const llm = createLLMProvider(); + if (!llm) { + return buildFallbackSteps(intent); + } + + const intentContext = ` +Intent kind: ${intent.kind} +Summary: ${intent.summary} +Target: ${intent.targetType} / ${intent.targetId || 'none'} +${intent.extractedStrategy ? `Strategy description: ${intent.extractedStrategy.description}` : ''} +${intent.extractedTrade ? `Trade: ${intent.extractedTrade.side} ${intent.extractedTrade.symbol || 'unspecified'}` : ''} +`; + + const response = await llm.chat( + [{ role: 'user', content: `Create execution steps for this intent:\n${intentContext}\n\nAvailable tools:\n${AVAILABLE_TOOLS_DESCRIPTION}` }], + { + systemPrompt: PLANNING_SYSTEM_PROMPT, + maxTokens: 1024, + temperature: 0.1, + } + ); + + if (!response.ok) { + console.error('[planning-service] LLM error, using fallback steps:', response.error); + return buildFallbackSteps(intent); + } + + try { + const rawSteps = JSON.parse(response.content.trim()); + if (!Array.isArray(rawSteps) || rawSteps.length === 0) { + return buildFallbackSteps(intent); + } + return rawSteps.map((step) => ({ + kind: step.kind || 'read', + title: step.title || 'Step', + toolName: step.toolName || '', + description: step.description || '', + outputSummary: '', + status: 'pending', + metadata: step.metadata || {}, + })); + } catch (parseErr) { + console.error('[planning-service] JSON parse error, using fallback steps:', parseErr.message); + return buildFallbackSteps(intent); + } +} + +function buildPlanSummary(intent = {}) { + switch (intent.kind) { + case 'execute_trade': return `Execute a ${intent.extractedTrade?.side || 'trade'} order${intent.extractedTrade?.symbol ? ` for ${intent.extractedTrade.symbol}` : ''}.`; + case 'build_strategy': return 'Build and analyze a new trading strategy based on user description.'; + case 'request_backtest_review': return 'Review recent research outputs and backtest posture.'; + case 'request_execution_prep': return 'Prepare execution readiness review for a controlled action request.'; + case 'request_risk_explanation': return 'Explain current risk posture using recent signals.'; + default: return 'Read current platform context and prepare a concise analysis.'; + } +} + +export async function createAgentPlan(payload = {}) { + const parsed = payload.intent + ? { ok: true, session: payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null, intent: payload.intent } + : await parseAgentIntent(payload); + + if (!parsed.ok) return parsed; + + const session = + parsed.session || + (payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null); + + if (!session) { + return { ok: false, error: 'missing_session', message: 'Agent planning requires a persisted session.' }; + } + + const intent = parsed.intent; + const steps = await buildLLMSteps(intent); + const requiresApproval = intent.requiresApproval || intent.requestedMode === 'request_live'; + + const plan = controlPlaneRuntime.recordAgentPlan({ + sessionId: session.id, + status: 'ready', + summary: buildPlanSummary(intent), + requiresApproval, + requestedBy: payload.requestedBy || session.requestedBy || 'operator', + steps: steps.map((s) => ({ ...s, status: 'pending' })), + metadata: { + intentKind: intent.kind, + targetType: intent.targetType, + targetId: intent.targetId, + requestedMode: intent.requestedMode, + source: 'agent-planner-llm', + confidence: intent.confidence, + }, + }); + + const updatedSession = controlPlaneRuntime.updateAgentSession(session.id, { + status: 'ready', + latestIntent: intent, + latestPlanId: plan.id, + metadata: { planCreatedAt: plan.createdAt }, + }); + controlPlaneRuntime.recordAgentSessionMessage({ + sessionId: session.id, + role: 'assistant', + kind: 'plan', + title: 'Plan prepared', + body: buildPlanSummary(intent), + requestedBy: payload.requestedBy || session.requestedBy || 'agent', + metadata: { + agentPlanId: plan.id, + requiresApproval: plan.requiresApproval, + stepCount: plan.steps.length, + intentKind: intent.kind, + }, + }); + + return { ok: true, session: updatedSession || session, intent, plan }; +} diff --git a/apps/api/src/domains/agent/services/planning-service.ts b/apps/api/src/domains/agent/services/planning-service.ts index 58e6f26..25f0186 100644 --- a/apps/api/src/domains/agent/services/planning-service.ts +++ b/apps/api/src/domains/agent/services/planning-service.ts @@ -1,291 +1,4 @@ // @ts-nocheck -import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; -import { parseAgentIntent } from './intent-service.js'; - -function buildPlanSteps(intent = {}) { - switch (intent.kind) { - case 'request_backtest_review': - return [ - { - kind: 'read', - title: 'Load backtest center summary', - status: 'pending', - toolName: 'backtest.summary.get', - description: 'Read the research summary before inspecting individual runs.', - outputSummary: '', - metadata: { - domain: 'backtest', - }, - }, - { - kind: 'read', - title: 'Load recent backtest runs', - status: 'pending', - toolName: 'backtest.runs.list', - description: 'Inspect recent run outcomes and review posture.', - outputSummary: '', - metadata: { - domain: 'backtest', - }, - }, - { - kind: 'explain', - title: 'Summarize research posture', - status: 'pending', - toolName: '', - description: 'Explain what is healthy, blocked, or needs operator review.', - outputSummary: '', - metadata: { - deliverable: 'backtest-review-summary', - }, - }, - ]; - case 'request_execution_prep': - return [ - { - kind: 'read', - title: 'Load strategy catalog context', - status: 'pending', - toolName: 'strategy.catalog.list', - description: - 'Read lifecycle stage, readiness, and research posture for the target strategy.', - outputSummary: '', - metadata: { - domain: 'strategy', - }, - }, - { - kind: 'read', - title: 'Load backtest center summary', - status: 'pending', - toolName: 'backtest.summary.get', - description: 'Confirm recent research activity and pending reviews.', - outputSummary: '', - metadata: { - domain: 'backtest', - }, - }, - { - kind: 'read', - title: 'Load execution plan posture', - status: 'pending', - toolName: 'execution.plans.list', - description: 'Check whether the strategy already has active or blocked execution plans.', - outputSummary: '', - metadata: { - domain: 'execution', - }, - }, - { - kind: 'request_action', - title: 'Prepare controlled action handoff', - status: 'pending', - toolName: '', - description: - 'If posture is acceptable, recommend a gated action request instead of direct execution.', - outputSummary: '', - metadata: { - proposedActionRequestType: 'prepare_execution_plan', - }, - }, - ]; - case 'request_risk_explanation': - return [ - { - kind: 'read', - title: 'Load recent risk events', - status: 'pending', - toolName: 'risk.events.list', - description: 'Inspect the most recent risk signals and review-level alerts.', - outputSummary: '', - metadata: { - domain: 'risk', - }, - }, - { - kind: 'read', - title: 'Load execution plan posture', - status: 'pending', - toolName: 'execution.plans.list', - description: - 'Correlate active approvals and execution posture with current risk signals.', - outputSummary: '', - metadata: { - domain: 'execution', - }, - }, - { - kind: 'explain', - title: 'Explain current risk posture', - status: 'pending', - toolName: '', - description: 'Produce a concise explanation with warnings and next-step guidance.', - outputSummary: '', - metadata: { - deliverable: 'risk-explanation', - }, - }, - ]; - case 'request_scheduler_action': - return [ - { - kind: 'read', - title: 'Load recent risk signals', - status: 'pending', - toolName: 'risk.events.list', - description: 'Use current risk posture as context for scheduler review.', - outputSummary: '', - metadata: { - domain: 'risk', - }, - }, - { - kind: 'read', - title: 'Load execution plan posture', - status: 'pending', - toolName: 'execution.plans.list', - description: - 'Check whether approvals or blocked plans coincide with scheduler attention.', - outputSummary: '', - metadata: { - domain: 'execution', - }, - }, - { - kind: 'request_action', - title: 'Recommend scheduler runbook action', - status: 'pending', - toolName: '', - description: - 'Produce a reviewed scheduler recommendation instead of directly mutating scheduler state.', - outputSummary: '', - metadata: { - proposedActionRequestType: 'scheduler_review', - }, - }, - ]; - default: - return [ - { - kind: 'read', - title: 'Load strategy catalog context', - status: 'pending', - toolName: 'strategy.catalog.list', - description: 'Read the current strategy catalog and lifecycle posture.', - outputSummary: '', - metadata: { - domain: 'strategy', - }, - }, - { - kind: 'read', - title: 'Load backtest center summary', - status: 'pending', - toolName: 'backtest.summary.get', - description: 'Read current research coverage and backlog posture.', - outputSummary: '', - metadata: { - domain: 'backtest', - }, - }, - { - kind: 'explain', - title: 'Summarize findings', - status: 'pending', - toolName: '', - description: 'Prepare a concise read-only analysis with next-step suggestions.', - outputSummary: '', - metadata: { - deliverable: 'general-analysis', - }, - }, - ]; - } -} - -function buildPlanSummary(intent = {}) { - switch (intent.kind) { - case 'request_backtest_review': - return 'Review recent research outputs and explain whether a backtest needs operator attention.'; - case 'request_execution_prep': - return 'Check research, execution, and approval posture before preparing a controlled execution request.'; - case 'request_risk_explanation': - return 'Explain the current risk posture using recent risk and execution signals.'; - case 'request_scheduler_action': - return 'Review scheduler-adjacent posture and prepare a controlled recommendation.'; - default: - return 'Read current platform context and prepare a concise analysis plan.'; - } -} - -export function createAgentPlan(payload = {}) { - const parsed = payload.intent - ? { - ok: true, - session: payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null, - intent: payload.intent, - } - : parseAgentIntent(payload); - - if (!parsed.ok) { - return parsed; - } - - const session = - parsed.session || - (payload.sessionId ? controlPlaneRuntime.getAgentSession(payload.sessionId) : null); - if (!session) { - return { - ok: false, - error: 'missing_session', - message: 'Agent planning requires a persisted session.', - }; - } - - const intent = parsed.intent; - const plan = controlPlaneRuntime.recordAgentPlan({ - sessionId: session.id, - status: 'ready', - summary: buildPlanSummary(intent), - requiresApproval: intent.requiresApproval, - requestedBy: payload.requestedBy || session.requestedBy || 'operator', - steps: buildPlanSteps(intent), - metadata: { - intentKind: intent.kind, - targetType: intent.targetType, - targetId: intent.targetId, - requestedMode: intent.requestedMode, - source: 'agent-planner', - }, - }); - - const updatedSession = controlPlaneRuntime.updateAgentSession(session.id, { - status: 'ready', - latestIntent: intent, - latestPlanId: plan.id, - metadata: { - planCreatedAt: plan.createdAt, - }, - }); - controlPlaneRuntime.recordAgentSessionMessage({ - sessionId: session.id, - role: 'assistant', - kind: 'plan', - title: 'Plan prepared', - body: buildPlanSummary(intent), - requestedBy: payload.requestedBy || session.requestedBy || 'agent', - metadata: { - agentPlanId: plan.id, - requiresApproval: plan.requiresApproval, - stepCount: plan.steps.length, - intentKind: intent.kind, - }, - }); - - return { - ok: true, - session: updatedSession || session, - intent, - plan, - }; -} +// This file is superseded by planning-service.js (LLM-powered rewrite). +// Re-exporting from the new implementation for backward compatibility. +export { createAgentPlan } from './planning-service.js'; diff --git a/apps/api/src/domains/agent/services/prompts.js b/apps/api/src/domains/agent/services/prompts.js new file mode 100644 index 0000000..1aeeeed --- /dev/null +++ b/apps/api/src/domains/agent/services/prompts.js @@ -0,0 +1,119 @@ +// @ts-nocheck +/** + * LLM system prompts for the Agent analysis pipeline. + * Centralized here for easy tuning and A/B testing. + */ + +export const INTENT_SYSTEM_PROMPT = `You are an AI assistant embedded in QuantPilot, a personal quantitative trading platform for individual investors. + +Your role is to understand what the user wants to do with their portfolio and classify their intent into structured actions. + +The user may NOT have professional finance or trading knowledge. Your job is to understand their natural language goals and translate them into platform actions. + +Available intent kinds: +- "build_strategy": User wants to create/define a new trading strategy using natural language description +- "execute_trade": User wants to buy or sell specific stocks directly +- "request_backtest_review": User wants to review backtest results or research analysis +- "request_execution_prep": User wants to prepare an execution plan for a strategy +- "request_risk_explanation": User wants to understand their current risk situation +- "read_only_analysis": General analysis, market overview, portfolio review + +Response format (JSON only, no markdown): +{ + "kind": "", + "summary": "", + "targetType": "", + "targetId": "", + "extractedStrategy": { + "description": "", + "symbols": [""], + "style": "" + }, + "extractedTrade": { + "symbol": "", + "side": "", + "sizeHint": "" + }, + "urgency": "", + "requiresApproval": , + "requestedMode": "", + "confidence": <0.0 to 1.0> +} + +Rules: +- requiresApproval: true for execute_trade (live), request_execution_prep. false for read-only intents. +- requestedMode: "execute_paper" for paper trading. "request_live" for live trading requests (needs approval). +- If the user says "buy" or "sell" without saying "paper" or "live", default to "execute_paper". +- confidence: your confidence in the classification (0.0-1.0). +- Always respond with valid JSON only.`; + +export const PLANNING_SYSTEM_PROMPT = `You are a trading AI assistant. Given a parsed user intent, create an execution plan with specific steps. + +Available read tools: +- strategy.catalog.list: List all strategies in the catalog +- backtest.summary.get: Get backtest center summary statistics +- backtest.runs.list: List recent backtest runs with metrics +- risk.events.list: List recent risk events and alerts +- execution.plans.list: List execution plans and approval status +- market.quotes.get: Get current market quotes for symbols +- market.history.get: Get historical OHLCV data for a symbol + +Available action tools (require approval for live): +- execution.paper.submit: Submit a paper trading order immediately +- execution.live.request: Request a live trading order (needs operator approval) +- backtest.queue: Queue a new backtest run for a strategy + +Rules for step planning: +- Always start with relevant read steps to gather context +- For build_strategy: include market.quotes.get and backtest.queue steps +- For execute_trade: include market.quotes.get for price check, then execution tool +- For analysis: only use read tools +- Keep steps minimal (2-4 steps) and focused + +Response format (JSON array only, no markdown): +[ + { + "kind": "", + "title": "", + "toolName": "", + "description": "", + "metadata": {} + } +]`; + +export const ANALYSIS_SYSTEM_PROMPT = `You are QuantPilot AI, a trading assistant for individual investors who may not have professional finance knowledge. + +Your job is to analyze the gathered data and produce clear, actionable insights. + +Guidelines: +- Use plain language. Avoid jargon. Explain what things mean. +- Be direct about risks. Do not sugarcoat danger signals. +- When suggesting trades, always mention the risk and that past performance doesn't guarantee future results. +- Structure your response as a clear recommendation. + +Response format (JSON only, no markdown): +{ + "thesis": "", + "rationale": [ + "", + "", + "" + ], + "warnings": [ + "" + ], + "strategy": { + "name": "", + "description": "", + "symbols": [""], + "riskLevel": "", + "suggestedPositionSizePercent": <1-10>, + "expectedHoldingPeriod": "" + }, + "recommendedNextStep": "", + "requiresAction": , + "actionType": "" +} + +The "strategy" field is only required for build_strategy and execute_trade intents. +For analysis/review intents, omit the strategy field.`; From 87e2340b17895ff98f369e90687083f46203dc6c Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:46:43 +0800 Subject: [PATCH 04/20] feat: add agent write tools for paper trade submit and live trade request --- .../domains/agent/services/tools-service.ts | 325 +++++++++++++++--- 1 file changed, 268 insertions(+), 57 deletions(-) diff --git a/apps/api/src/domains/agent/services/tools-service.ts b/apps/api/src/domains/agent/services/tools-service.ts index 5fa5c65..f5c35a0 100644 --- a/apps/api/src/domains/agent/services/tools-service.ts +++ b/apps/api/src/domains/agent/services/tools-service.ts @@ -1,61 +1,41 @@ // @ts-nocheck +import { controlPlaneRuntime } from '../../../../../../packages/control-plane-runtime/src/index.js'; import { listBacktestRuns } from '../../backtest/services/runs-service.js'; import { getBacktestSummary } from '../../backtest/services/summary-service.js'; import { listExecutionPlans } from '../../execution/services/query-service.js'; +import { assessExecutionCandidate } from '../../risk/services/assessment-service.js'; import { listRiskEvents } from '../../risk/services/feed-service.js'; import { listStrategyCatalog } from '../../strategy/services/catalog-service.js'; const AGENT_TOOLS = [ - { - name: 'strategy.catalog.list', - category: 'strategy', - description: 'Read the current strategy catalog, scores, and promotion stages.', - access: 'read', - }, - { - name: 'backtest.summary.get', - category: 'backtest', - description: 'Read the aggregated backtest center summary.', - access: 'read', - }, - { - name: 'backtest.runs.list', - category: 'backtest', - description: 'Read recent backtest runs and their statuses.', - access: 'read', - }, - { - name: 'risk.events.list', - category: 'risk', - description: 'Read recent risk events generated by the control plane.', - access: 'read', - }, - { - name: 'execution.plans.list', - category: 'execution', - description: 'Read persisted execution plans and their current review state.', - access: 'read', - }, + // Read tools + { name: 'strategy.catalog.list', category: 'strategy', description: 'Read the current strategy catalog, scores, and promotion stages.', access: 'read' }, + { name: 'backtest.summary.get', category: 'backtest', description: 'Read the aggregated backtest center summary.', access: 'read' }, + { name: 'backtest.runs.list', category: 'backtest', description: 'Read recent backtest runs and their statuses.', access: 'read' }, + { name: 'risk.events.list', category: 'risk', description: 'Read recent risk events generated by the control plane.', access: 'read' }, + { name: 'execution.plans.list', category: 'execution', description: 'Read persisted execution plans and their current review state.', access: 'read' }, + { name: 'market.quotes.get', category: 'market', description: 'Get current market quotes for symbols.', access: 'read' }, + { name: 'market.history.get', category: 'market', description: 'Get historical OHLCV data for a symbol.', access: 'read' }, + // Write tools (paper = auto-execute, live = requires approval) + { name: 'execution.paper.submit', category: 'execution', description: 'Submit a paper trading order immediately (no approval required).', access: 'write', mode: 'paper' }, + { name: 'execution.live.request', category: 'execution', description: 'Request a live trading order (requires operator approval).', access: 'write', mode: 'live' }, + { name: 'backtest.queue', category: 'backtest', description: 'Queue a new backtest run for a strategy description.', access: 'write' }, ]; export function listAgentTools() { - return { - ok: true, - tools: AGENT_TOOLS, - }; + return { ok: true, tools: AGENT_TOOLS }; } +// ─── Read Tools ─────────────────────────────────────────────────────────────── + function executeStrategyCatalogTool() { const snapshot = listStrategyCatalog(); return { ok: true, tool: 'strategy.catalog.list', summary: `Loaded ${snapshot.strategies.length} strategy catalog entries.`, - data: { - asOf: snapshot.asOf, - strategies: snapshot.strategies, - }, + data: { asOf: snapshot.asOf, strategies: snapshot.strategies }, }; } @@ -77,10 +57,7 @@ function executeBacktestRunsTool(args = {}) { ok: true, tool: 'backtest.runs.list', summary: `Loaded ${runs.length} backtest runs${status ? ` with status ${status}` : ''}.`, - data: { - asOf: snapshot.asOf, - runs, - }, + data: { asOf: snapshot.asOf, runs }, }; } @@ -91,9 +68,7 @@ function executeRiskEventsTool(args = {}) { ok: true, tool: 'risk.events.list', summary: `Loaded ${events.length} risk events.`, - data: { - events, - }, + data: { events }, }; } @@ -104,32 +79,268 @@ function executeExecutionPlansTool(args = {}) { ok: true, tool: 'execution.plans.list', summary: `Loaded ${plans.length} execution plans.`, + data: { plans }, + }; +} + +function executeMarketQuotesTool(args = {}) { + const symbols = Array.isArray(args.symbols) ? args.symbols : []; + if (symbols.length === 0) { + return { ok: false, tool: 'market.quotes.get', summary: 'No symbols provided.', data: {} }; + } + // Return from control plane state (real-time data from Worker sync) + const state = controlPlaneRuntime.getLatestSystemState ? controlPlaneRuntime.getLatestSystemState() : null; + const stockStates = state?.stockStates || []; + const quotes = symbols.map((sym) => { + const found = stockStates.find((s) => s.symbol?.toUpperCase() === sym.toUpperCase()); + if (found) { + return { + symbol: sym.toUpperCase(), + price: found.price || 0, + change: found.change || 0, + changePct: found.changePct || 0, + volume: found.volume || 0, + signal: found.signal || 'hold', + score: found.score || 0, + }; + } + return { symbol: sym.toUpperCase(), price: null, signal: 'unknown', note: 'Not in current universe' }; + }); + return { + ok: true, + tool: 'market.quotes.get', + summary: `Loaded quotes for ${symbols.join(', ')}.`, + data: { quotes, asOf: new Date().toISOString() }, + }; +} + +function executeMarketHistoryTool(args = {}) { + const symbol = args.symbol || ''; + const days = Math.min(Number(args.days) || 30, 365); + if (!symbol) { + return { ok: false, tool: 'market.history.get', summary: 'Symbol is required.', data: {} }; + } + // Historical data will come from Alpaca in P0-6; for now return a note + return { + ok: true, + tool: 'market.history.get', + summary: `Historical data for ${symbol} over ${days} days. (Alpaca integration in progress)`, + data: { + symbol: symbol.toUpperCase(), + days, + note: 'Real historical data available after Alpaca Market Data integration.', + bars: [], + }, + }; +} + +// ─── Write Tools ────────────────────────────────────────────────────────────── + +function executePaperOrderTool(args = {}) { + const { symbol, side, qty, orderType = 'market', price = null, rationale = '' } = args; + + if (!symbol || !side || !qty || qty <= 0) { + return { + ok: false, + tool: 'execution.paper.submit', + summary: 'Missing required fields: symbol, side, qty.', + data: {}, + }; + } + + const normalizedSide = side.toLowerCase(); + if (!['buy', 'sell'].includes(normalizedSide)) { + return { ok: false, tool: 'execution.paper.submit', summary: 'side must be buy or sell.', data: {} }; + } + + const capital = price ? qty * price : qty * 100; + const candidate = { + strategyId: `agent-paper-${symbol.toLowerCase()}-${normalizedSide}`, + strategyName: `Agent Paper ${normalizedSide.toUpperCase()} ${symbol}`, + mode: 'paper', + capital, + status: 'paper', + metrics: { score: 60, expectedReturnPct: 8, maxDrawdownPct: 8, sharpe: 1.2 }, + orders: [{ + symbol: symbol.toUpperCase(), + side: normalizedSide.toUpperCase(), + weight: 1.0, + qty: Number(qty), + price: price || null, + orderType, + rationale: rationale || `Agent paper ${normalizedSide} ${qty} ${symbol}`, + }], + summary: `Agent Paper ${normalizedSide.toUpperCase()} ${qty} ${symbol}${price ? ` @ $${price}` : ''}`, + metadata: { source: 'agent-paper-submit', requestedBy: 'agent' }, + }; + + // Risk assessment before executing + const riskAssessment = assessExecutionCandidate(candidate); + + if (riskAssessment.riskStatus === 'blocked') { + return { + ok: false, + tool: 'execution.paper.submit', + summary: `Order blocked by risk assessment: ${riskAssessment.summary}`, + data: { riskStatus: 'blocked', riskSummary: riskAssessment.summary }, + }; + } + + const handoff = { + id: `handoff-agent-paper-${Date.now()}`, + strategyId: candidate.strategyId, + strategyName: candidate.strategyName, + mode: 'paper', + capital: candidate.capital, + orders: candidate.orders, + summary: candidate.summary, + riskStatus: riskAssessment.riskStatus, + approvalState: 'auto_approved', + riskSummary: riskAssessment.summary, + metadata: candidate.metadata, + createdAt: new Date().toISOString(), + }; + + controlPlaneRuntime.appendExecutionCandidateHandoff(handoff); + + return { + ok: true, + tool: 'execution.paper.submit', + summary: `Paper order submitted: ${candidate.summary}. Risk: ${riskAssessment.riskStatus}.`, data: { - plans, + handoffId: handoff.id, + order: candidate.orders[0], + riskStatus: riskAssessment.riskStatus, + mode: 'paper', }, }; } +function executeLiveOrderRequestTool(args = {}) { + const { symbol, side, qty, orderType = 'market', price = null, rationale = '' } = args; + + if (!symbol || !side || !qty || qty <= 0) { + return { + ok: false, + tool: 'execution.live.request', + summary: 'Missing required fields: symbol, side, qty.', + data: {}, + }; + } + + const normalizedSide = side.toLowerCase(); + const capital = price ? qty * price : qty * 100; + + const candidate = { + strategyId: `agent-live-${symbol.toLowerCase()}-${normalizedSide}`, + strategyName: `Agent Live ${normalizedSide.toUpperCase()} ${symbol}`, + mode: 'live', + capital, + status: 'live', + metrics: { score: 60, expectedReturnPct: 8, maxDrawdownPct: 8, sharpe: 1.2 }, + orders: [{ + symbol: symbol.toUpperCase(), + side: normalizedSide.toUpperCase(), + weight: 1.0, + qty: Number(qty), + price: price || null, + orderType, + rationale: rationale || `Agent live ${normalizedSide} ${qty} ${symbol}`, + }], + summary: `Agent Live ${normalizedSide.toUpperCase()} ${qty} ${symbol}${price ? ` @ $${price}` : ''}`, + metadata: { source: 'agent-live-request', requestedBy: 'agent', requiresApproval: true }, + }; + + const riskAssessment = assessExecutionCandidate(candidate); + + // Live orders always go into the approval queue + const handoff = { + id: `handoff-agent-live-${Date.now()}`, + strategyId: candidate.strategyId, + strategyName: candidate.strategyName, + mode: 'live', + capital: candidate.capital, + orders: candidate.orders, + summary: candidate.summary, + riskStatus: riskAssessment.riskStatus, + approvalState: 'pending_approval', + riskSummary: riskAssessment.summary, + metadata: candidate.metadata, + createdAt: new Date().toISOString(), + }; + + controlPlaneRuntime.appendExecutionCandidateHandoff(handoff); + + return { + ok: true, + tool: 'execution.live.request', + summary: `Live order request submitted for operator approval: ${candidate.summary}.`, + data: { + handoffId: handoff.id, + order: candidate.orders[0], + approvalState: 'pending_approval', + mode: 'live', + message: 'This order requires your manual approval before execution. Check the Execution page.', + }, + }; +} + +function executeBacktestQueueTool(args = {}) { + const { strategyDescription = '', symbols = [], days = 90 } = args; + if (!strategyDescription) { + return { ok: false, tool: 'backtest.queue', summary: 'strategyDescription is required.', data: {} }; + } + + const strategyId = `agent-backtest-${Date.now()}`; + // Queue a backtest workflow + controlPlaneRuntime.recordWorkflowRun?.({ + kind: 'task-orchestrator.backtest-run', + status: 'pending', + payload: { + strategyId, + strategyDescription, + symbols: symbols.length > 0 ? symbols : ['AAPL', 'MSFT', 'NVDA'], + days, + requestedBy: 'agent', + }, + }); + + return { + ok: true, + tool: 'backtest.queue', + summary: `Backtest queued for: "${strategyDescription.slice(0, 80)}".`, + data: { + strategyId, + strategyDescription, + symbols: symbols.length > 0 ? symbols : ['AAPL', 'MSFT', 'NVDA'], + days, + status: 'queued', + }, + }; +} + +// ─── Main dispatcher ────────────────────────────────────────────────────────── + export function executeAgentTool(payload = {}) { const tool = payload.tool || ''; const args = payload.args || {}; switch (tool) { - case 'strategy.catalog.list': - return executeStrategyCatalogTool(); - case 'backtest.summary.get': - return executeBacktestSummaryTool(); - case 'backtest.runs.list': - return executeBacktestRunsTool(args); - case 'risk.events.list': - return executeRiskEventsTool(args); - case 'execution.plans.list': - return executeExecutionPlansTool(args); + case 'strategy.catalog.list': return executeStrategyCatalogTool(); + case 'backtest.summary.get': return executeBacktestSummaryTool(); + case 'backtest.runs.list': return executeBacktestRunsTool(args); + case 'risk.events.list': return executeRiskEventsTool(args); + case 'execution.plans.list': return executeExecutionPlansTool(args); + case 'market.quotes.get': return executeMarketQuotesTool(args); + case 'market.history.get': return executeMarketHistoryTool(args); + case 'execution.paper.submit': return executePaperOrderTool(args); + case 'execution.live.request': return executeLiveOrderRequestTool(args); + case 'backtest.queue': return executeBacktestQueueTool(args); default: return { ok: false, tool, - summary: `Tool ${tool || 'unknown'} is not allowed for Agent access.`, + summary: `Tool ${tool || 'unknown'} is not registered for Agent access.`, data: {}, }; } From 46c6ecab603c817481b3f9c1c9c2f2289db9ab51 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:57:37 +0800 Subject: [PATCH 05/20] feat: integrate Alpaca market data API with synthetic fallback --- .env.example | 9 ++ .../agent/services/analysis-service.js | 6 +- .../domains/agent/services/tools-service.ts | 21 +-- .../market/services/market-data-service.js | 147 ++++++++++++++++++ apps/api/src/gateways/alpaca.ts | 49 ++++++ 5 files changed, 219 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/domains/market/services/market-data-service.js diff --git a/.env.example b/.env.example index 88fc519..043d9d1 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,12 @@ JWT_SECRET=your-secret-key-at-least-32-chars BROKER_KEY_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 DEMO_USERNAME=admin DEMO_PASSWORD=changeme + +# LLM Provider (claude | openai) +QUANTPILOT_LLM_PROVIDER=claude +QUANTPILOT_LLM_MODEL= +ANTHROPIC_API_KEY= +OPENAI_API_KEY= + +# Mock data (true = use synthetic data, false = use Alpaca) +QUANTPILOT_USE_MOCK_DATA=false diff --git a/apps/api/src/domains/agent/services/analysis-service.js b/apps/api/src/domains/agent/services/analysis-service.js index 10213c6..41dbd6c 100644 --- a/apps/api/src/domains/agent/services/analysis-service.js +++ b/apps/api/src/domains/agent/services/analysis-service.js @@ -89,7 +89,7 @@ function llmToolNameToAgentTool(name) { /** * Execute a single tool call from LLM and return the result. */ -function executeLLMToolCall(toolName, toolInput) { +async function executeLLMToolCall(toolName, toolInput) { const dotName = (() => { switch (toolName) { case 'strategy_catalog_list': return 'strategy.catalog.list'; @@ -207,7 +207,7 @@ async function runLLMAnalysisLoop(intent, dailyBias, sessionId) { // Execute all tool calls and collect results const toolResultContent = []; for (const tc of response.toolCalls) { - const result = executeLLMToolCall(tc.name, tc.input); + const result = await executeLLMToolCall(tc.name, tc.input); toolCallLog.push({ tool: tc.name, input: tc.input, result }); toolResultContent.push({ @@ -316,7 +316,7 @@ export async function runAgentAnalysis(payload = {}) { : step.toolName === 'execution.plans.list' ? { limit: 12 } : step.toolName === 'backtest.runs.list' && intent.kind === 'request_backtest_review' ? { status: 'needs_review' } : {}; - const result = executeAgentTool({ tool: step.toolName, args }); + const result = await executeAgentTool({ tool: step.toolName, args }); toolResults.push(result); } diff --git a/apps/api/src/domains/agent/services/tools-service.ts b/apps/api/src/domains/agent/services/tools-service.ts index f5c35a0..d2fd1c2 100644 --- a/apps/api/src/domains/agent/services/tools-service.ts +++ b/apps/api/src/domains/agent/services/tools-service.ts @@ -4,6 +4,7 @@ import { controlPlaneRuntime } from '../../../../../../packages/control-plane-ru import { listBacktestRuns } from '../../backtest/services/runs-service.js'; import { getBacktestSummary } from '../../backtest/services/summary-service.js'; import { listExecutionPlans } from '../../execution/services/query-service.js'; +import { getHistoricalBars, getMarketQuotes } from '../../market/services/market-data-service.js'; import { assessExecutionCandidate } from '../../risk/services/assessment-service.js'; import { listRiskEvents } from '../../risk/services/feed-service.js'; import { listStrategyCatalog } from '../../strategy/services/catalog-service.js'; @@ -114,22 +115,22 @@ function executeMarketQuotesTool(args = {}) { }; } -function executeMarketHistoryTool(args = {}) { +async function executeMarketHistoryTool(args = {}) { const symbol = args.symbol || ''; - const days = Math.min(Number(args.days) || 30, 365); + const days = Math.min(Number(args.days) || 90, 365); if (!symbol) { return { ok: false, tool: 'market.history.get', summary: 'Symbol is required.', data: {} }; } - // Historical data will come from Alpaca in P0-6; for now return a note + const result = await getHistoricalBars(symbol, days); return { - ok: true, + ok: result.ok, tool: 'market.history.get', - summary: `Historical data for ${symbol} over ${days} days. (Alpaca integration in progress)`, + summary: `Loaded ${result.bars?.length || 0} bars for ${symbol} (${days} days, source: ${result.source}).`, data: { - symbol: symbol.toUpperCase(), - days, - note: 'Real historical data available after Alpaca Market Data integration.', - bars: [], + symbol: result.symbol, + bars: result.bars || [], + source: result.source, + timeframe: result.timeframe || '1Day', }, }; } @@ -321,7 +322,7 @@ function executeBacktestQueueTool(args = {}) { // ─── Main dispatcher ────────────────────────────────────────────────────────── -export function executeAgentTool(payload = {}) { +export async function executeAgentTool(payload = {}) { const tool = payload.tool || ''; const args = payload.args || {}; diff --git a/apps/api/src/domains/market/services/market-data-service.js b/apps/api/src/domains/market/services/market-data-service.js new file mode 100644 index 0000000..5198de3 --- /dev/null +++ b/apps/api/src/domains/market/services/market-data-service.js @@ -0,0 +1,147 @@ +// @ts-nocheck +/** + * Market data service — fetches from Alpaca when configured, falls back to + * trading-engine synthetic data when QUANTPILOT_USE_MOCK_DATA=true or no credentials. + */ +import { generateHistoricalOhlcv } from '../../../../../../packages/trading-engine/src/backtest/data.js'; + +const ALPACA_DATA_BASE = 'https://data.alpaca.markets'; +const ALPACA_KEY_ID = () => process.env.ALPACA_KEY_ID || ''; +const ALPACA_SECRET_KEY = () => process.env.ALPACA_SECRET_KEY || ''; +const ALPACA_DATA_FEED = () => process.env.ALPACA_DATA_FEED || 'iex'; +const USE_MOCK = () => + process.env.QUANTPILOT_USE_MOCK_DATA === 'true' || + !ALPACA_KEY_ID() || + !ALPACA_SECRET_KEY(); + +function alpacaHeaders() { + return { + Accept: 'application/json', + 'APCA-API-KEY-ID': ALPACA_KEY_ID(), + 'APCA-API-SECRET-KEY': ALPACA_SECRET_KEY(), + }; +} + +function normalizeAlpacaBar(bar) { + return { + time: bar.t ? bar.t.split('T')[0] : '', + open: Number(bar.o || 0), + high: Number(bar.h || 0), + low: Number(bar.l || 0), + close: Number(bar.c || 0), + volume: Number(bar.v || 0), + }; +} + +/** + * Get historical OHLCV bars for a symbol. + * Uses Alpaca API when credentials are configured, otherwise synthetic data. + * + * @param {string} symbol - Ticker symbol e.g. "AAPL" + * @param {number} days - Number of calendar days of history + * @param {string} [timeframe] - "1Day" | "1Hour" | "15Min" (default: "1Day") + * @returns {Promise<{ok: boolean, symbol: string, bars: Array, source: string}>} + */ +export async function getHistoricalBars(symbol, days = 90, timeframe = '1Day') { + if (!symbol) { + return { ok: false, symbol: '', bars: [], source: 'none', error: 'symbol is required' }; + } + + const upperSymbol = symbol.toUpperCase(); + + if (USE_MOCK()) { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + const bars = generateHistoricalOhlcv( + upperSymbol, + startDate.toISOString().split('T')[0], + endDate.toISOString().split('T')[0] + ); + return { ok: true, symbol: upperSymbol, bars, source: 'synthetic', timeframe }; + } + + try { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + const url = new URL(`/v2/stocks/${encodeURIComponent(upperSymbol)}/bars`, ALPACA_DATA_BASE); + url.searchParams.set('timeframe', timeframe); + url.searchParams.set('start', startDate.toISOString().split('T')[0]); + url.searchParams.set('end', endDate.toISOString().split('T')[0]); + url.searchParams.set('limit', String(Math.min(days, 1000))); + url.searchParams.set('feed', ALPACA_DATA_FEED()); + url.searchParams.set('sort', 'asc'); + + const response = await fetch(url.toString(), { headers: alpacaHeaders() }); + + if (!response.ok) { + // Fallback to synthetic data on API error + console.warn(`[market-data] Alpaca bars error HTTP ${response.status} for ${upperSymbol}, using synthetic fallback`); + const endD = new Date(); + const startD = new Date(); + startD.setDate(startD.getDate() - days); + const bars = generateHistoricalOhlcv(upperSymbol, startD.toISOString().split('T')[0], endD.toISOString().split('T')[0]); + return { ok: true, symbol: upperSymbol, bars, source: 'synthetic_fallback', timeframe }; + } + + const payload = await response.json(); + const bars = Array.isArray(payload?.bars) ? payload.bars.map(normalizeAlpacaBar) : []; + + if (bars.length === 0) { + // Symbol might not be in Alpaca universe, use synthetic + const endD = new Date(); + const startD = new Date(); + startD.setDate(startD.getDate() - days); + const syntheticBars = generateHistoricalOhlcv(upperSymbol, startD.toISOString().split('T')[0], endD.toISOString().split('T')[0]); + return { ok: true, symbol: upperSymbol, bars: syntheticBars, source: 'synthetic_fallback', timeframe }; + } + + return { ok: true, symbol: upperSymbol, bars, source: 'alpaca', timeframe }; + } catch (err) { + console.error('[market-data] Error fetching bars:', err.message); + const endD = new Date(); + const startD = new Date(); + startD.setDate(startD.getDate() - days); + const bars = generateHistoricalOhlcv(upperSymbol, startD.toISOString().split('T')[0], endD.toISOString().split('T')[0]); + return { ok: true, symbol: upperSymbol, bars, source: 'synthetic_fallback', timeframe }; + } +} + +/** + * Get current market snapshots for multiple symbols. + * Returns from Alpaca when configured, otherwise returns empty (upstream from Worker sync). + */ +export async function getMarketQuotes(symbols = []) { + if (!symbols.length) return { ok: false, quotes: [], error: 'No symbols provided' }; + if (USE_MOCK()) return { ok: true, quotes: [], source: 'none', note: 'Mock mode: quotes come from Worker market sync' }; + + try { + const url = new URL('/v2/stocks/snapshots', ALPACA_DATA_BASE); + url.searchParams.set('symbols', symbols.join(',')); + url.searchParams.set('feed', ALPACA_DATA_FEED()); + + const response = await fetch(url.toString(), { headers: alpacaHeaders() }); + if (!response.ok) return { ok: false, quotes: [], error: `Alpaca snapshots HTTP ${response.status}` }; + + const payload = await response.json(); + const quotes = Object.entries(payload?.snapshots || {}).map(([sym, snap]) => { + const price = Number(snap?.minuteBar?.c ?? snap?.latestTrade?.p ?? snap?.dailyBar?.c ?? 0); + const prevClose = Number(snap?.prevDailyBar?.c ?? snap?.dailyBar?.o ?? price); + const change = price - prevClose; + const changePct = prevClose > 0 ? (change / prevClose) * 100 : 0; + return { + symbol: sym, + price, + change: parseFloat(change.toFixed(2)), + changePct: parseFloat(changePct.toFixed(2)), + volume: Number(snap?.dailyBar?.v ?? 0), + }; + }); + + return { ok: true, quotes, source: 'alpaca' }; + } catch (err) { + return { ok: false, quotes: [], error: err.message }; + } +} diff --git a/apps/api/src/gateways/alpaca.ts b/apps/api/src/gateways/alpaca.ts index 9a4294f..2c5368a 100644 --- a/apps/api/src/gateways/alpaca.ts +++ b/apps/api/src/gateways/alpaca.ts @@ -281,6 +281,51 @@ function normalizeAlpacaSnapshot(symbol, snapshot) { }; } +function normalizeAlpacaBar(bar) { + return { + time: bar.t ? bar.t.split('T')[0] : '', + open: Number(bar.o || 0), + high: Number(bar.h || 0), + low: Number(bar.l || 0), + close: Number(bar.c || 0), + volume: Number(bar.v || 0), + }; +} + +async function handleHistoricalBars(config, reqUrl, res) { + if (!ensureConfigured(config)) { + writeJson(res, 503, { message: 'Alpaca credentials are not configured.', bars: [] }); + return; + } + const symbol = reqUrl.searchParams.get('symbol') || ''; + const timeframe = reqUrl.searchParams.get('timeframe') || '1Day'; + const start = reqUrl.searchParams.get('start') || ''; + const end = reqUrl.searchParams.get('end') || ''; + const limit = reqUrl.searchParams.get('limit') || '252'; + + if (!symbol) { + writeJson(res, 400, { message: 'symbol is required', bars: [] }); + return; + } + + const upstream = new URL(`/v2/stocks/${encodeURIComponent(symbol)}/bars`, config.alpacaDataBase); + upstream.searchParams.set('timeframe', timeframe); + if (start) upstream.searchParams.set('start', start); + if (end) upstream.searchParams.set('end', end); + upstream.searchParams.set('limit', limit); + upstream.searchParams.set('feed', config.alpacaDataFeed); + upstream.searchParams.set('sort', 'asc'); + + const response = await fetch(upstream, { headers: alpacaHeaders(config, false) }); + if (!response.ok) { + writeJson(res, response.status, { message: `Alpaca bars error: HTTP ${response.status}`, bars: [] }); + return; + } + const payload = await response.json(); + const bars = Array.isArray(payload?.bars) ? payload.bars.map(normalizeAlpacaBar) : []; + writeJson(res, 200, { symbol, timeframe, bars, message: `Loaded ${bars.length} bars for ${symbol}` }); +} + async function handleSnapshots(config, reqUrl, res) { if (!ensureConfigured(config)) { writeJson(res, 503, { @@ -552,6 +597,10 @@ export function createGatewayHandler(options = {}) { await handleSnapshots(config, reqUrl, res); return; } + if (req.method === 'GET' && reqUrl.pathname === '/api/alpaca/market/bars') { + await handleHistoricalBars(config, reqUrl, res); + return; + } if (req.method === 'POST' && reqUrl.pathname === '/api/alpaca/broker/orders') { await handleSubmitOrders(config, req, res); return; From f3a11f9d662127a086cd87071a6d4b529029d40d Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:03:17 +0800 Subject: [PATCH 06/20] feat: connect backtest engine to real market data - Add OhlcvBar type and externalBars optional field to BacktestConfig - Update backtest engine to use externalBars when provided, fallback to synthetic - Add fetchAlpacaBarsForBacktest() to workflow engine for parallel symbol fetch - Wire real Alpaca bars into executeBacktestRunWorkflow via externalBars injection --- packages/task-workflow-engine/src/index.ts | 80 ++++++++++++++++++- .../trading-engine/src/backtest/engine.ts | 7 +- packages/trading-engine/src/backtest/types.ts | 11 +++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/task-workflow-engine/src/index.ts b/packages/task-workflow-engine/src/index.ts index 96f6b82..c87c579 100644 --- a/packages/task-workflow-engine/src/index.ts +++ b/packages/task-workflow-engine/src/index.ts @@ -8,6 +8,73 @@ import { buildCyclePayload, } from '../../trading-engine/src/runtime.js'; +const USE_MOCK = () => process.env.QUANTPILOT_USE_MOCK_DATA === 'true'; + +/** + * Fetch real historical bars from Alpaca for all symbols in the universe. + * Returns a map of symbol -> OhlcvBar[]. + * Falls back gracefully to undefined (engine uses synthetic data). + */ +async function fetchAlpacaBarsForBacktest(symbols, startDate, endDate) { + if (USE_MOCK() || !process.env.ALPACA_KEY_ID || !process.env.ALPACA_SECRET_KEY) { + return undefined; + } + try { + const ALPACA_DATA_BASE = 'https://data.alpaca.markets'; + const headers = { + Accept: 'application/json', + 'APCA-API-KEY-ID': process.env.ALPACA_KEY_ID, + 'APCA-API-SECRET-KEY': process.env.ALPACA_SECRET_KEY, + }; + const feed = process.env.ALPACA_DATA_FEED || 'iex'; + const externalBars = {}; + + await Promise.all( + symbols.map(async (symbol) => { + try { + const url = new URL(`/v2/stocks/${encodeURIComponent(symbol)}/bars`, ALPACA_DATA_BASE); + url.searchParams.set('timeframe', '1Day'); + url.searchParams.set('start', startDate); + url.searchParams.set('end', endDate); + url.searchParams.set('limit', '1000'); + url.searchParams.set('feed', feed); + url.searchParams.set('sort', 'asc'); + + const response = await fetch(url.toString(), { headers }); + if (!response.ok) return; + + const payload = await response.json(); + const bars = Array.isArray(payload?.bars) + ? payload.bars.map((b) => ({ + time: b.t ? b.t.split('T')[0] : '', + open: Number(b.o || 0), + high: Number(b.h || 0), + low: Number(b.l || 0), + close: Number(b.c || 0), + volume: Number(b.v || 0), + })) + : []; + + if (bars.length > 0) { + externalBars[symbol] = bars; + } + } catch { + // Silently skip failed symbols — engine will use synthetic data for them + } + }) + ); + + const fetchedCount = Object.keys(externalBars).length; + if (fetchedCount > 0) { + console.log(`[backtest-workflow] Fetched real Alpaca data for ${fetchedCount}/${symbols.length} symbols`); + } + return fetchedCount > 0 ? externalBars : undefined; + } catch (err) { + console.warn('[backtest-workflow] Alpaca data fetch failed, using synthetic:', err.message); + return undefined; + } +} + function parseWindowLabel(label) { const parts = (label || '').split(' -> '); if (parts.length === 2 && parts[0] && parts[1]) { @@ -787,7 +854,7 @@ async function executeAgentActionRequestWorkflow(payload, context, options = {}) rationale: payload.rationale || '', requestedBy: payload.requestedBy || context.getOperatorName(), metadata: { - ...(payload.metadata || {}), + ...payload.metadata, channel: 'agent', reasons: gate.reasons, }, @@ -909,20 +976,27 @@ async function executeBacktestRunWorkflow(payload, context, options = {}) { latestCheckpoint: 'Workflow worker started the research task.', }); - // buildMockBacktestMetrics(strategy, run.id) — replaced by real engine below + // Try to fetch real Alpaca data; falls back to synthetic if unavailable const windowDates = parseWindowLabel(run.windowLabel); + const universe = STOCK_UNIVERSE.map((s) => s.symbol); + const externalBars = await fetchAlpacaBarsForBacktest( + universe, + windowDates.startDate, + windowDates.endDate + ); const engineResult = runBacktestEngine({ strategyId: strategy.id, runId: run.id, startDate: windowDates.startDate, endDate: windowDates.endDate, initialCapital: 100000, - universe: STOCK_UNIVERSE.map((s) => s.symbol), + universe, buyThreshold: DEFAULT_ENGINE_CONFIG.buyThreshold, sellThreshold: DEFAULT_ENGINE_CONFIG.sellThreshold, maxPositionWeight: DEFAULT_ENGINE_CONFIG.maxPositionWeight, slippagePct: 0.001, commissionPct: 0.001, + externalBars, }); const metrics = { status: engineResult.status, diff --git a/packages/trading-engine/src/backtest/engine.ts b/packages/trading-engine/src/backtest/engine.ts index 3ba2d60..cb854a4 100644 --- a/packages/trading-engine/src/backtest/engine.ts +++ b/packages/trading-engine/src/backtest/engine.ts @@ -7,7 +7,7 @@ import { calcTurnover, calcWinRate, } from './metrics.js'; -import type { BacktestConfig, BacktestResult, BacktestTrade, DailyEquityPoint } from './types.js'; +import type { BacktestConfig, BacktestResult, BacktestTrade, DailyEquityPoint, OhlcvBar } from './types.js'; type Holding = { qty: number; @@ -66,7 +66,10 @@ export function runBacktestEngine(config: BacktestConfig): BacktestResult { // Build per-symbol OHLCV maps const symbolStates: SymbolState[] = universe.map((symbol) => { - const rawBars = generateHistoricalOhlcv(symbol, startDate, endDate); + // Use external bars if provided (from Alpaca), otherwise generate synthetic data + const rawBars: OhlcvBar[] = config.externalBars?.[symbol] + ? config.externalBars[symbol] + : generateHistoricalOhlcv(symbol, startDate, endDate); const bars = new Map(rawBars.map((b) => [b.time, b])); const tradingDates = rawBars.map((b) => b.time); diff --git a/packages/trading-engine/src/backtest/types.ts b/packages/trading-engine/src/backtest/types.ts index 6d6a8bb..d0d7acb 100644 --- a/packages/trading-engine/src/backtest/types.ts +++ b/packages/trading-engine/src/backtest/types.ts @@ -1,3 +1,12 @@ +export type OhlcvBar = { + time: string; + open: number; + high: number; + low: number; + close: number; + volume: number; +}; + export type BacktestConfig = { strategyId: string; runId: string; @@ -10,6 +19,8 @@ export type BacktestConfig = { maxPositionWeight: number; // default 0.24 slippagePct: number; // default 0.001 commissionPct: number; // default 0.001 + /** Optional pre-fetched bars per symbol. If provided, skips synthetic data generation. */ + externalBars?: Record; }; export type DailyEquityPoint = { From 1d68169d6fb05ca0035f6c6c0e5f988fc758196d Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:12:30 +0800 Subject: [PATCH 07/20] feat: redesign AgentPage with consumer-friendly 3-step UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add hero section with title, subtitle, and quick-prompt chips row - Add 3-step analysis stepper (Intent → Planning → Analysis) with pulse animation - Rebuild chat transcript with indigo accent colors replacing deep cyan - Add analysis insight card as primary right-rail view: thesis, rationale list, warning pills, recommended-next-step, Paper/Live/Backtest action buttons - Simplify composer: clean textarea, ⌘Enter shortcut, indigo send button - Remove legacy hero-grid and verbose info panels from top of page - Governance section moved below main workspace, session list always visible - Update tests to match new UI structure and labels --- apps/web/src/modules/agent/AgentPage.css.ts | 607 ++++++--- apps/web/src/modules/agent/AgentPage.tsx | 1146 ++++++++--------- .../web/src/modules/agent/agent-page.test.tsx | 54 +- 3 files changed, 972 insertions(+), 835 deletions(-) diff --git a/apps/web/src/modules/agent/AgentPage.css.ts b/apps/web/src/modules/agent/AgentPage.css.ts index feb38d1..2b1c950 100644 --- a/apps/web/src/modules/agent/AgentPage.css.ts +++ b/apps/web/src/modules/agent/AgentPage.css.ts @@ -1,14 +1,137 @@ -import { globalStyle, style } from '@vanilla-extract/css'; +import { globalStyle, keyframes, style } from '@vanilla-extract/css'; -/* ── AGENT LAYOUT ───────────────────────────────────────── */ +/* ── AGENT PAGE LAYOUT ──────────────────────────────────── */ -export const agentDialogueSection = style({ marginTop: '28px' }); +export const agentPageHero = style({ + display: 'grid', + gap: '12px', + padding: '24px 0 4px', +}); + +export const agentHeroTitle = style({ + font: '700 22px/1.2 var(--font-ui)', + color: 'var(--text)', + letterSpacing: '-0.02em', +}); + +export const agentHeroSub = style({ + font: '400 14px/1.5 var(--font-ui)', + color: 'var(--muted-strong)', + marginTop: '2px', +}); + +/* ── QUICK CHIPS ────────────────────────────────────────── */ + +export const agentQuickChips = style({ + display: 'flex', + gap: '8px', + flexWrap: 'wrap', + marginBottom: '4px', +}); + +export const agentQuickChip = style({ + padding: '6px 14px', + borderRadius: '999px', + border: '1px solid var(--line)', + background: 'rgba(255,255,255,0.03)', + color: 'var(--muted-strong)', + font: '13px/1 var(--font-ui)', + cursor: 'pointer', + transition: 'border-color 140ms ease, background 140ms ease, color 140ms ease', + whiteSpace: 'nowrap', + ':hover': { + borderColor: 'rgba(99,102,241,0.5)', + background: 'rgba(99,102,241,0.08)', + color: 'var(--text)', + }, + ':active': { transform: 'scale(0.97)' }, +}); + +/* ── ANALYSIS STEPPER ───────────────────────────────────── */ + +const pulse = keyframes({ + '0%,100%': { opacity: 1 }, + '50%': { opacity: 0.35 }, +}); + +export const agentStepper = style({ + display: 'flex', + alignItems: 'center', + gap: 0, + padding: '14px 20px', + borderRadius: 'var(--radius)', + border: '1px solid var(--line)', + background: 'rgba(8,10,24,0.6)', + marginBottom: '16px', + overflow: 'hidden', +}); + +export const agentStepperItem = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', + flex: 1, + minWidth: 0, +}); + +export const agentStepperDot = style({ + width: '8px', + height: '8px', + borderRadius: '50%', + background: 'var(--muted)', + flexShrink: 0, + transition: 'background 200ms ease', +}); + +export const agentStepperDotActive = style({ + background: '#6366f1', + boxShadow: '0 0 8px rgba(99,102,241,0.6)', + animationName: pulse, + animationDuration: '1.2s', + animationIterationCount: 'infinite', +}); + +export const agentStepperDotDone = style({ + background: '#22c55e', + boxShadow: '0 0 6px rgba(34,197,94,0.4)', +}); + +export const agentStepperLabel = style({ + font: '600 11px/1 var(--font-data)', + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: 'var(--muted)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + transition: 'color 200ms ease', +}); + +export const agentStepperLabelActive = style({ + color: '#818cf8', +}); + +export const agentStepperLabelDone = style({ + color: '#4ade80', +}); + +export const agentStepperConnector = style({ + width: '32px', + height: '1px', + background: 'var(--line)', + flexShrink: 0, + margin: '0 8px', +}); + +/* ── MAIN DUAL VIEW ─────────────────────────────────────── */ + +export const agentDialogueSection = style({ marginTop: '0' }); export const agentDualViewPanel = style({ overflow: 'hidden' }); export const agentDualView = style({ display: 'grid', - gridTemplateColumns: 'minmax(0, 1.6fr) minmax(280px, 1fr)', + gridTemplateColumns: 'minmax(0, 1.55fr) minmax(300px, 1fr)', gap: '20px', alignItems: 'stretch', }); @@ -17,160 +140,391 @@ export const agentDialogueStage = style({ display: 'flex', flexDirection: 'column', minWidth: 0, - height: '680px', + height: '640px', }); -/* ── AGENT STAGE HEADER ─────────────────────────────────── */ +/* ── CHAT TRANSCRIPT ─────────────────────────────────────── */ -export const agentStageHeader = style({ +export const agentChatTranscript = style({ + flex: 1, + minHeight: 0, + overflowY: 'auto', + border: '1px solid var(--line)', + borderRadius: 'var(--radius-lg)', + background: 'rgba(4,5,14,0.9)', + padding: '20px', + display: 'flex', + flexDirection: 'column', + gap: '10px', + animation: 'fade-in 200ms ease', +}); + +export const agentChatMessage = style({ + maxWidth: 'min(88%, 660px)', + border: '1px solid var(--line)', + borderRadius: 'var(--radius)', + padding: '12px 15px', + animation: 'fade-up 200ms ease both', +}); + +export const agentChatUser = style({ + justifySelf: 'end', + background: 'rgba(99,102,241,0.08)', + borderColor: 'rgba(99,102,241,0.28)', + borderLeft: '2px solid #6366f1', +}); + +export const agentChatAssistant = style({ + justifySelf: 'start', + background: 'rgba(255,255,255,0.03)', + borderColor: 'rgba(255,255,255,0.08)', + borderLeft: '2px solid rgba(255,255,255,0.2)', +}); + +export const agentChatSystem = style({ + justifySelf: 'center', + width: '100%', + maxWidth: 'none', + background: 'rgba(8,10,24,0.5)', + borderLeft: '2px solid var(--muted)', + borderColor: 'rgba(255,255,255,0.06)', +}); + +export const agentChatMuted = style({ + opacity: 0.65, +}); + +export const agentChatWarn = style({ + borderColor: 'rgba(251,191,36,0.3)', + borderLeft: '2px solid #f59e0b', + background: 'rgba(251,191,36,0.05)', +}); + +export const agentChatMeta = style({ display: 'flex', justifyContent: 'space-between', - gap: '18px', - alignItems: 'flex-start', + gap: '12px', + marginBottom: '6px', + color: 'var(--muted)', + font: '11px/1 var(--font-data)', + letterSpacing: '0.07em', + textTransform: 'uppercase', +}); + +export const agentChatBody = style({ + color: 'var(--text)', + fontSize: '14px', + lineHeight: '1.7', +}); + +/* ── COMPOSER ────────────────────────────────────────────── */ + +export const agentChatComposer = style({ + display: 'grid', + gap: '10px', padding: '14px 16px', - border: '1px solid rgba(0, 212, 255, 0.12)', - borderRadius: 'var(--radius)', - background: 'rgba(0, 212, 255, 0.03)', - transition: 'border-color 160ms ease', + border: '1px solid var(--line)', + borderRadius: 'var(--radius-lg)', + background: 'rgba(8,10,24,0.7)', flexShrink: 0, - marginBottom: '12px', - ':hover': { borderColor: 'rgba(40, 120, 220, 0.2)' }, + marginTop: '10px', + transition: 'border-color 160ms ease', + ':focus-within': { + borderColor: 'rgba(99,102,241,0.35)', + }, }); -export const agentStagePills = style({ +export const agentChatComposerActions = style({ display: 'flex', - gap: '8px', - flexWrap: 'wrap', - justifyContent: 'flex-end', + alignItems: 'center', + justifyContent: 'space-between', + gap: '12px', +}); + +export const agentChatTextarea = style({ + width: '100%', + minHeight: '52px', + padding: '10px 13px', + border: '1px solid transparent', + borderRadius: 'var(--radius)', + background: 'transparent', + color: 'var(--text)', + font: '14px/1.6 var(--font-ui)', + resize: 'none', + outline: 'none', + '::placeholder': { color: 'var(--muted)' }, }); -/* ── AGENT INSIGHT RAIL ─────────────────────────────────── */ +export const agentSendButton = style({ + display: 'flex', + alignItems: 'center', + gap: '6px', + padding: '9px 20px', + borderRadius: 'var(--radius)', + border: 'none', + background: '#6366f1', + color: '#fff', + font: '600 13px/1 var(--font-ui)', + cursor: 'pointer', + flexShrink: 0, + transition: 'background 150ms ease, transform 120ms ease, opacity 150ms ease', + ':hover': { background: '#4f46e5' }, + ':active': { transform: 'scale(0.97)' }, + ':disabled': { opacity: 0.45, cursor: 'not-allowed', transform: 'none' }, +}); + +/* ── INSIGHT RAIL ────────────────────────────────────────── */ export const agentInsightRail = style({ display: 'grid', gap: '14px', alignContent: 'start', overflowY: 'auto', - maxHeight: '680px', + maxHeight: '640px', }); export const agentInsightCard = style({ display: 'grid', gap: '14px', - padding: '16px', + padding: '18px', border: '1px solid var(--line)', borderRadius: 'var(--radius)', - background: 'rgba(8, 18, 38, 0.85)', - transition: 'border-color 160ms ease, box-shadow 160ms ease', - ':hover': { - borderColor: 'rgba(40, 120, 220, 0.2)', - boxShadow: '0 0 16px rgba(0, 212, 255, 0.05)', - }, + background: 'rgba(8,10,24,0.7)', + transition: 'border-color 150ms ease', + ':hover': { borderColor: 'rgba(99,102,241,0.2)' }, }); export const agentInsightHeader = style({ display: 'flex', justifyContent: 'space-between', - gap: '14px', + gap: '12px', alignItems: 'flex-start', }); -/* ── AGENT PULSE GRID ───────────────────────────────────── */ +/* ── INSIGHT ANALYSIS RESULT ─────────────────────────────── */ + +export const agentThesis = style({ + font: '600 15px/1.5 var(--font-ui)', + color: 'var(--text)', + letterSpacing: '-0.01em', +}); + +export const agentRationaleList = style({ + display: 'grid', + gap: '6px', + paddingLeft: '0', + listStyle: 'none', +}); + +export const agentRationaleItem = style({ + display: 'flex', + alignItems: 'flex-start', + gap: '8px', + font: '13px/1.5 var(--font-ui)', + color: 'var(--muted-strong)', + '::before': { + content: '"·"', + color: '#6366f1', + fontWeight: 700, + flexShrink: 0, + marginTop: '1px', + }, +}); + +export const agentWarningItem = style({ + display: 'flex', + alignItems: 'flex-start', + gap: '8px', + padding: '8px 10px', + borderRadius: 'var(--radius)', + background: 'rgba(251,191,36,0.06)', + border: '1px solid rgba(251,191,36,0.15)', + font: '13px/1.5 var(--font-ui)', + color: '#fbbf24', + '::before': { + content: '"⚠"', + flexShrink: 0, + fontSize: '11px', + marginTop: '1px', + }, +}); + +export const agentNextStep = style({ + padding: '10px 12px', + borderRadius: 'var(--radius)', + background: 'rgba(99,102,241,0.06)', + border: '1px solid rgba(99,102,241,0.18)', + font: '13px/1.5 var(--font-ui)', + color: '#a5b4fc', +}); + +/* ── ACTION BUTTONS ──────────────────────────────────────── */ + +export const agentActionButtons = style({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: '8px', +}); + +export const agentActionBtn = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '4px', + padding: '12px 8px', + borderRadius: 'var(--radius)', + border: '1px solid var(--line)', + background: 'rgba(255,255,255,0.025)', + cursor: 'pointer', + transition: 'border-color 140ms ease, background 140ms ease, transform 120ms ease', + ':hover': { + borderColor: 'rgba(99,102,241,0.4)', + background: 'rgba(99,102,241,0.07)', + transform: 'translateY(-1px)', + }, + ':active': { transform: 'translateY(0) scale(0.98)' }, + ':disabled': { opacity: 0.4, cursor: 'not-allowed', transform: 'none' }, +}); + +export const agentActionBtnLabel = style({ + font: '600 11px/1 var(--font-data)', + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: 'var(--muted-strong)', +}); + +export const agentActionBtnSub = style({ + font: '11px/1 var(--font-data)', + color: 'var(--muted)', + textAlign: 'center', +}); + +export const agentActionBtnIcon = style({ + fontSize: '16px', + lineHeight: 1, + marginBottom: '2px', +}); + +/* ── PULSE GRID ──────────────────────────────────────────── */ export const agentPulseGrid = style({ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', - gap: '10px', + gap: '8px', }); export const agentPulseItem = style({ display: 'grid', gap: '4px', - padding: '12px 13px', + padding: '10px 12px', borderRadius: 'var(--radius)', border: '1px solid var(--line)', - background: 'rgba(2, 6, 18, 0.75)', - transition: 'border-color 150ms ease', - ':hover': { borderColor: 'rgba(40, 120, 220, 0.22)' }, + background: 'rgba(4,5,14,0.6)', + transition: 'border-color 140ms ease', + ':hover': { borderColor: 'rgba(99,102,241,0.2)' }, }); globalStyle(`${agentPulseItem} span`, { color: 'var(--muted)', font: '600 10px/1 var(--font-data)', - letterSpacing: '0.12em', + letterSpacing: '0.1em', textTransform: 'uppercase', }); globalStyle(`${agentPulseItem} strong`, { - font: '600 13px/1 var(--font-data)', + font: '600 12px/1.2 var(--font-data)', color: 'var(--text)', - animation: 'tick-up 200ms ease 100ms both', + wordBreak: 'break-all', }); -/* ── AGENT STEP STACK ───────────────────────────────────── */ +/* ── PLAN STEPS ──────────────────────────────────────────── */ -export const agentStepStack = style({ display: 'grid', gap: '10px' }); +export const agentStepStack = style({ display: 'grid', gap: '8px' }); export const agentStepCard = style({ display: 'grid', - gap: '8px', - padding: '12px 13px', + gap: '6px', + padding: '10px 12px', borderRadius: 'var(--radius)', border: '1px solid var(--line)', - background: 'rgba(2, 6, 18, 0.75)', - transition: 'border-color 150ms ease, background 150ms ease', - ':hover': { - borderColor: 'rgba(40, 120, 220, 0.22)', - background: 'rgba(0, 212, 255, 0.02)', - }, + background: 'rgba(4,5,14,0.6)', + transition: 'border-color 140ms ease', + ':hover': { borderColor: 'rgba(99,102,241,0.2)' }, }); export const agentStepTop = style({ display: 'flex', justifyContent: 'space-between', - gap: '10px', + gap: '8px', alignItems: 'flex-start', }); +globalStyle(`${agentStepTop} strong`, { + font: '600 12px/1.4 var(--font-ui)', + color: 'var(--text)', + flex: 1, +}); + globalStyle(`${agentStepTop} span`, { - color: 'var(--muted)', font: '600 10px/1 var(--font-data)', - letterSpacing: '0.12em', + letterSpacing: '0.08em', textTransform: 'uppercase', + color: 'var(--muted)', + flexShrink: 0, + paddingTop: '1px', }); export const agentStepCopy = style({ color: 'var(--muted-strong)', - fontSize: '13px', - lineHeight: '1.6', + fontSize: '12px', + lineHeight: '1.5', }); -/* ── AGENT HANDOFF & SUGGESTIONS ────────────────────────── */ +/* ── HANDOFF SECTION ─────────────────────────────────────── */ -export const agentHandoffActions = style({ display: 'grid', gap: '12px' }); -export const agentSuggestionList = style({ display: 'grid', gap: '8px' }); +export const agentHandoffActions = style({ display: 'grid', gap: '10px' }); + +export const agentRequestApprovalBtn = style({ + width: '100%', + padding: '11px', + borderRadius: 'var(--radius)', + border: '1px solid rgba(251,191,36,0.3)', + background: 'rgba(251,191,36,0.06)', + color: '#fbbf24', + font: '600 13px/1 var(--font-ui)', + cursor: 'pointer', + transition: 'border-color 140ms ease, background 140ms ease', + ':hover': { + borderColor: 'rgba(251,191,36,0.5)', + background: 'rgba(251,191,36,0.1)', + }, + ':disabled': { opacity: 0.4, cursor: 'not-allowed' }, +}); + +/* ── SUGGESTION LIST ─────────────────────────────────────── */ + +export const agentSuggestionList = style({ display: 'grid', gap: '6px' }); export const agentSuggestionButton = style({ width: '100%', textAlign: 'left', - padding: '12px 13px 12px 15px', + padding: '10px 12px 10px 14px', borderRadius: 'var(--radius)', border: '1px solid var(--line)', - background: 'rgba(2, 6, 18, 0.75)', - color: 'var(--text)', - font: 'inherit', + background: 'rgba(4,5,14,0.5)', + color: 'var(--muted-strong)', + font: '13px/1.4 var(--font-ui)', cursor: 'pointer', position: 'relative', overflow: 'hidden', - transition: - 'border-color 150ms ease, box-shadow 150ms ease, background 150ms ease, transform 120ms ease', + transition: 'border-color 130ms ease, background 130ms ease, color 130ms ease', ':hover': { - borderColor: 'rgba(0, 212, 255, 0.22)', - boxShadow: '0 0 14px rgba(0, 212, 255, 0.08)', - background: 'rgba(0, 212, 255, 0.03)', - transform: 'translateX(3px)', + borderColor: 'rgba(99,102,241,0.35)', + background: 'rgba(99,102,241,0.06)', + color: 'var(--text)', }, - ':active': { transform: 'translateX(2px) scale(0.99)' }, + ':active': { transform: 'scale(0.99)' }, }); globalStyle(`${agentSuggestionButton}::before`, { @@ -180,124 +534,39 @@ globalStyle(`${agentSuggestionButton}::before`, { top: 0, bottom: 0, width: '2px', - background: 'var(--accent)', + background: '#6366f1', transform: 'scaleY(0)', transformOrigin: 'center', - transition: 'transform 150ms ease', + transition: 'transform 130ms ease', }); globalStyle(`${agentSuggestionButton}:hover::before`, { transform: 'scaleY(1)', }); -/* ── AGENT CHAT TRANSCRIPT ──────────────────────────────── */ +/* ── SESSION STAGE HEADER ────────────────────────────────── */ -export const agentChatTranscript = style({ - flex: 1, - minHeight: 0, - overflowY: 'auto', - border: '1px solid rgba(0, 212, 255, 0.16)', - borderRadius: 'var(--radius-lg)', - background: 'rgba(1, 3, 10, 0.92)', - padding: '20px', +export const agentStageHeader = style({ display: 'flex', - flexDirection: 'column', - gap: '12px', - animation: 'fade-in 200ms ease', - boxShadow: - 'inset 0 0 60px rgba(0,0,0,.5), 0 0 28px rgba(0,212,255,.06), 0 0 0 1px rgba(0,212,255,.04)', -}); - -export const agentChatMessage = style({ - maxWidth: 'min(92%, 680px)', + justifyContent: 'space-between', + gap: '16px', + alignItems: 'flex-start', + padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 'var(--radius)', - padding: '14px 16px', - animation: 'fade-up 200ms ease both', - transition: 'border-color 150ms ease', - ':hover': { borderColor: 'rgba(40, 120, 220, 0.22)' }, -}); - -export const agentChatUser = style({ - justifySelf: 'end', - background: 'rgba(0, 212, 255, 0.06)', - borderColor: 'rgba(0, 212, 255, 0.18)', - borderLeft: '2px solid var(--accent)', - boxShadow: 'inset -4px 0 20px rgba(0,212,255,.05), 0 2px 8px rgba(0,0,0,.3)', -}); - -export const agentChatAssistant = style({ - justifySelf: 'start', - background: 'rgba(255, 183, 0, 0.04)', - borderColor: 'rgba(255, 183, 0, 0.12)', - borderLeft: '2px solid var(--accent-2)', - boxShadow: '0 2px 8px rgba(0,0,0,.3)', -}); - -export const agentChatSystem = style({ - justifySelf: 'center', - width: '100%', - maxWidth: 'none', - background: 'rgba(8, 18, 38, 0.8)', - borderLeft: '2px solid var(--muted)', -}); - -export const agentChatMuted = style({ borderColor: 'var(--line)' }); - -export const agentChatWarn = style({ - borderColor: 'rgba(255, 183, 0, 0.2)', - borderLeft: '2px solid var(--hold)', -}); - -export const agentChatMeta = style({ - display: 'flex', - justifyContent: 'space-between', - gap: '12px', - marginBottom: '8px', - color: 'var(--muted)', - font: '11px/1 var(--font-data)', - letterSpacing: '0.08em', - textTransform: 'uppercase', -}); - -export const agentChatBody = style({ - color: 'var(--text)', - fontSize: '14px', - lineHeight: '1.7', -}); - -export const agentChatComposer = style({ - display: 'grid', - gap: '12px', - padding: '16px', - border: '1px solid rgba(0, 212, 255, 0.1)', - borderRadius: 'var(--radius-lg)', - background: 'rgba(4, 10, 24, 0.8)', + background: 'rgba(8,10,24,0.5)', flexShrink: 0, - marginTop: '12px', + marginBottom: '10px', }); -export const agentChatComposerActions = style({ +export const agentStagePills = style({ display: 'flex', + gap: '6px', + flexWrap: 'wrap', + justifyContent: 'flex-end', alignItems: 'center', - justifyContent: 'space-between', - gap: '16px', }); -export const agentChatTextarea = style({ - width: '100%', - minHeight: '56px', - padding: '10px 13px', - border: '1px solid var(--line)', - borderRadius: 'var(--radius)', - background: 'rgba(2, 6, 18, 0.85)', - color: 'var(--text)', - font: '14px/1.6 var(--font-ui)', - resize: 'vertical', - outline: 'none', - transition: 'border-color 160ms ease, box-shadow 160ms ease', - ':focus': { - borderColor: 'rgba(0, 212, 255, 0.32)', - boxShadow: '0 0 0 2px rgba(0, 212, 255, 0.06)', - }, -}); +/* ── KEEP LEGACY EXPORTS for compatibility ───────────────── */ +// (used in governance / other panels that reference old names) +export const agentInsightRailLegacy = agentInsightRail; diff --git a/apps/web/src/modules/agent/AgentPage.tsx b/apps/web/src/modules/agent/AgentPage.tsx index 9867514..c0cf086 100644 --- a/apps/web/src/modules/agent/AgentPage.tsx +++ b/apps/web/src/modules/agent/AgentPage.tsx @@ -1,14 +1,19 @@ +import { useRef } from 'react'; import { useState } from 'react'; import { EmptyState, SectionHeader, - TabPanel, TopMeta, } from '../../components/layout/ConsoleChrome.tsx'; import { useTradingSystem } from '../../store/trading-system/TradingSystemProvider.tsx'; import { copy, useLocale } from '../console/console.i18n.tsx'; import { translateRiskLevel } from '../console/console.utils.ts'; import { + agentActionBtn, + agentActionBtnIcon, + agentActionBtnLabel, + agentActionBtnSub, + agentActionButtons, agentChatAssistant, agentChatBody, agentChatComposer, @@ -26,37 +31,144 @@ import { agentDualView, agentDualViewPanel, agentHandoffActions, + agentHeroSub, + agentHeroTitle, agentInsightCard, agentInsightHeader, agentInsightRail, + agentNextStep, + agentPageHero, agentPulseGrid, agentPulseItem, + agentQuickChip, + agentQuickChips, + agentRationaleItem, + agentRationaleList, + agentRequestApprovalBtn, + agentSendButton, agentStageHeader, agentStagePills, agentStepCard, agentStepCopy, agentStepStack, agentStepTop, + agentStepper, + agentStepperConnector, + agentStepperDot, + agentStepperDotActive, + agentStepperDotDone, + agentStepperItem, + agentStepperLabel, + agentStepperLabelActive, + agentStepperLabelDone, agentSuggestionButton, agentSuggestionList, + agentThesis, + agentWarningItem, } from './AgentPage.css.ts'; import { useAgentTools } from './useAgentTools.ts'; const promptSuggestions = { zh: [ + '帮我分析 AAPL 近期走势', '总结今天亏损原因', '给我一个更稳健的参数组合', '把回撤控制在 8% 内重算策略', '明天开盘前生成执行计划', ], en: [ + 'Analyze recent AAPL price action', "Summarize today's loss drivers", 'Suggest a more robust parameter set', - 'Recompute the strategy with max drawdown capped at 8%', - "Generate the execution plan before tomorrow's open", + 'Recompute strategy with max drawdown capped at 8%', + "Generate execution plan before tomorrow's open", ], }; +type StepperState = 'pending' | 'active' | 'done'; + +function AnalysisStepper({ + locale, + running, + sessionStatus, + planStatus, + analysisStatus, +}: { + locale: 'zh' | 'en'; + running: boolean; + sessionStatus: string; + planStatus: string; + analysisStatus: string; +}) { + const intentDone = Boolean(sessionStatus && sessionStatus !== ''); + const planDone = planStatus === 'completed'; + const analysisDone = analysisStatus === 'completed'; + + let intentState: StepperState = 'pending'; + let planState: StepperState = 'pending'; + let analysisState: StepperState = 'pending'; + + if (running) { + if (!intentDone) { + intentState = 'active'; + } else if (!planDone) { + intentState = 'done'; + planState = 'active'; + } else { + intentState = 'done'; + planState = 'done'; + analysisState = 'active'; + } + } else if (analysisDone) { + intentState = 'done'; + planState = 'done'; + analysisState = 'done'; + } else if (planDone) { + intentState = 'done'; + planState = 'done'; + } else if (intentDone) { + intentState = 'done'; + } + + const steps: { key: string; label: { zh: string; en: string }; state: StepperState }[] = [ + { key: 'intent', label: { zh: '意图解析', en: 'Intent' }, state: intentState }, + { key: 'plan', label: { zh: '制定计划', en: 'Planning' }, state: planState }, + { key: 'analysis', label: { zh: 'AI 分析', en: 'Analysis' }, state: analysisState }, + ]; + + return ( +
+ {steps.map((step, idx) => ( +
+
+
+ + {step.label[locale]} + +
+ {idx < steps.length - 1 &&
} +
+ ))} +
+ ); +} + function buildAgentConversation({ locale, prompt, @@ -116,16 +228,19 @@ function buildAgentConversation({ } const fallbackMessages = []; - fallbackMessages.push({ - key: 'system-session', - role: 'system', - label: locale === 'zh' ? '系统' : 'System', - body: - locale === 'zh' - ? `当前会话状态:${sessionStatus || '未选择'};最近 intent:${intentKind || '--'};plan:${planStatus || '--'}。` - : `Current session status: ${sessionStatus || 'unselected'}; latest intent: ${intentKind || '--'}; plan: ${planStatus || '--'}.`, - tone: 'muted', - }); + + if (sessionStatus) { + fallbackMessages.push({ + key: 'system-session', + role: 'system', + label: locale === 'zh' ? '系统' : 'System', + body: + locale === 'zh' + ? `会话状态:${sessionStatus};intent:${intentKind || '--'};plan:${planStatus || '--'}` + : `Session: ${sessionStatus}; intent: ${intentKind || '--'}; plan: ${planStatus || '--'}`, + tone: 'muted', + }); + } if (prompt.trim()) { fallbackMessages.push({ @@ -141,31 +256,22 @@ function buildAgentConversation({ fallbackMessages.push({ key: 'assistant-running', role: 'assistant', - label: locale === 'zh' ? 'Agent' : 'Agent', + label: 'Agent', body: locale === 'zh' - ? '正在解析意图、生成计划,并串行执行白名单只读工具。' - : 'Parsing intent, creating a plan, and running allowlisted read-only tools.', + ? '正在解析意图、生成计划,并调用工具收集数据中…' + : 'Parsing intent, building a plan, and gathering data with tools…', tone: 'muted', }); } - if ( - thesis || - summary || - rationale.length || - warnings.length || - recommendedNextStep || - actionRequestSummary - ) { + if (thesis || summary || rationale.length || warnings.length || recommendedNextStep || actionRequestSummary) { const body = [ - thesis ? thesis : locale === 'zh' ? '本轮分析已经完成。' : 'This analysis run has completed.', - summary ? summary : null, - rationale.length ? `${locale === 'zh' ? '理由' : 'Rationale'}: ${rationale.join(' ')}` : null, - warnings.length ? `${locale === 'zh' ? '警告' : 'Warnings'}: ${warnings.join(' ')}` : null, - recommendedNextStep - ? `${locale === 'zh' ? '下一步' : 'Next step'}: ${recommendedNextStep}` - : null, + thesis || (locale === 'zh' ? '本轮分析已完成。' : 'Analysis complete.'), + summary || null, + rationale.length ? `${locale === 'zh' ? '分析依据' : 'Rationale'}: ${rationale.join(' ')}` : null, + warnings.length ? `${locale === 'zh' ? '风险提示' : 'Warnings'}: ${warnings.join(' ')}` : null, + recommendedNextStep ? `${locale === 'zh' ? '建议下一步' : 'Next step'}: ${recommendedNextStep}` : null, actionRequestSummary ? `${locale === 'zh' ? '审批请求' : 'Action request'}: ${actionRequestSummary} (${actionRequestStatus || '--'})` : null, @@ -176,7 +282,7 @@ function buildAgentConversation({ fallbackMessages.push({ key: 'assistant-summary', role: 'assistant', - label: locale === 'zh' ? 'Agent' : 'Agent', + label: 'Agent', body, tone: warnings.length ? 'warn' : 'default', }); @@ -186,7 +292,7 @@ function buildAgentConversation({ fallbackMessages.push({ key: 'system-error', role: 'system', - label: locale === 'zh' ? '工作台提示' : 'Workbench Alert', + label: locale === 'zh' ? '提示' : 'Notice', body: error, tone: 'warn', }); @@ -198,8 +304,9 @@ function buildAgentConversation({ export default function AgentPage() { const { state, session } = useTradingSystem(); const { locale } = useLocale(); + const transcriptRef = useRef(null); const { - tools, + tools: _tools, workbench, sessionDetail, selectedSessionId, @@ -212,7 +319,7 @@ export default function AgentPage() { requestAction, refresh, } = useAgentTools(); - const [prompt, setPrompt] = useState(promptSuggestions[locale][0]); + const [prompt, setPrompt] = useState(''); const summary = workbench?.summary; const authorityState = workbench?.authorityState || null; @@ -224,14 +331,6 @@ export default function AgentPage() { const recentSessions = Array.isArray(workbench?.queues.recentSessions) ? workbench?.queues.recentSessions : []; - const pendingRequests = Array.isArray(workbench?.queues.pendingActionRequests) - ? workbench?.queues.pendingActionRequests - : []; - const recentExplanations = Array.isArray(workbench?.recentExplanations) - ? workbench.recentExplanations - : []; - const timeline = Array.isArray(sessionDetail?.timeline) ? sessionDetail.timeline : []; - const runbook = Array.isArray(workbench?.runbook) ? workbench.runbook : []; const latestRationale = Array.isArray(latestExplanation?.rationale) ? latestExplanation.rationale : []; @@ -244,6 +343,7 @@ export default function AgentPage() { const evidence = Array.isArray(sessionDetail?.latestAnalysisRun?.evidence) ? sessionDetail.latestAnalysisRun.evidence : []; + const conversation = buildAgentConversation({ locale, prompt, @@ -261,6 +361,7 @@ export default function AgentPage() { actionRequestSummary: latestActionRequest?.summary || '', actionRequestStatus: latestActionRequest?.status || '', }); + const canRequestAction = Boolean( sessionDetail?.session.id && sessionDetail?.latestPlan?.requiresApproval && @@ -269,11 +370,21 @@ export default function AgentPage() { latestActionRequest?.status !== 'pending_review' ); + const hasAnalysis = Boolean(latestExplanation?.thesis); + const submitPrompt = async () => { - const result = await runPrompt(prompt, session?.user.id); + const trimmed = prompt.trim(); + if (!trimmed) return; + const result = await runPrompt(trimmed, session?.user.id); if (result?.session?.prompt) { - setPrompt(result.session.prompt); + setPrompt(''); } + // scroll transcript to bottom + requestAnimationFrame(() => { + if (transcriptRef.current) { + transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; + } + }); }; const submitActionRequest = async () => { @@ -303,199 +414,112 @@ export default function AgentPage() { value: String(summary?.runningSessions ?? 0), }, { - label: locale === 'zh' ? '待审批请求' : 'Pending Requests', + label: locale === 'zh' ? '待审批' : 'Pending', value: String(summary?.pendingActionRequests ?? 0), }, ]} /> -
-
-
{locale === 'zh' ? 'Workbench' : 'Workbench'}
-
- {locale === 'zh' - ? `${summary?.completedSessions ?? 0} 个会话已完成分析` - : `${summary?.completedSessions ?? 0} sessions completed analysis`} + {/* Hero */} +
+
+
+ {locale === 'zh' ? 'AI 投研助手' : 'AI Research Assistant'}
-
+
{locale === 'zh' - ? '这里现在已经是正式的 Agent 协作工作台:会话、解释、待审批请求和 operator trail 都来自后端 workbench 聚合,不再只是工具演示页。' - : 'This is now a real Agent collaboration workbench: sessions, explanations, pending requests, and the operator trail all come from backend workbench aggregation instead of a simple tool demo.'} + ? '用自然语言描述你的交易想法,Agent 负责分析、制定计划、并提供可执行建议。' + : 'Describe your trading idea in plain language. Agent analyzes, plans, and delivers actionable insights.'}
-
+
+ + {/* Quick prompt chips */} +
+ {promptSuggestions[locale].map((item) => ( - - {locale === 'zh' ? '最近会话' : 'Recent Sessions'} - - - {locale === 'zh' ? '解释详情' : 'Explanation Detail'} - - - {locale === 'zh' ? '轨迹时间线' : 'Operator Timeline'} - -
+ ))}
-
-
- {locale === 'zh' ? 'Latest Explanation' : 'Latest Explanation'} -
-
- {latestExplanation?.thesis || - (locale === 'zh' ? '等待新的分析结果' : 'Waiting for the next analysis result')} -
-
- {locale === 'zh' - ? latestExplanation?.recommendedNextStep || - '运行一次新的分析后,这里会显示结构化解释和建议的下一步动作。' - : latestExplanation?.recommendedNextStep || - 'Run a new analysis to surface a structured explanation and the recommended next action here.'} -
-
-
+
-
-
-
-
-
- {locale === 'zh' ? 'Agent Governance' : 'Agent Governance'} -
-
- {locale === 'zh' - ? '查看当前 Agent 授权模式(Authority Mode)和今日运营指令(Daily Bias),确保 Agent 行为在人工监督范围内。' - : 'Review the current Agent authority mode and active daily bias instructions to keep Agent behaviour within human-supervised bounds.'} -
-
- - {authorityState?.mode || 'manual_only'} - -
-
-
-
- {locale === 'zh' ? 'Authority Mode' : 'Authority Mode'} - - {authorityState?.reason || - (locale === 'zh' - ? '尚未配置 Agent 治理策略。' - : 'No agent governance policy configured.')} - -
-
- {locale === 'zh' ? '模式' : 'Mode'} - {authorityState?.mode || 'manual_only'} -
-
- {locale === 'zh' ? '策略数' : 'Policies'} - {authorityState?.policies?.length ?? 0} -
-
-
-
- {locale === 'zh' ? 'Daily Bias' : 'Daily Bias'} - - {dailyBiasInstructions.length - ? locale === 'zh' - ? `${dailyBiasInstructions.length} 条活跃的今日运营指令正在影响本次会话。` - : `${dailyBiasInstructions.length} active daily bias instruction${dailyBiasInstructions.length > 1 ? 's' : ''} affecting this session.` - : locale === 'zh' - ? '当前没有活跃的今日运营指令。' - : 'No active daily bias instructions for this session.'} - -
-
- {locale === 'zh' ? '条数' : 'Count'} - {dailyBiasInstructions.length} -
-
- {dailyBiasInstructions.map((item) => ( -
-
- {item.title} - {item.body} -
-
- {locale === 'zh' ? '有效至' : 'Active Until'} - {item.activeUntil ? item.activeUntil.slice(0, 10) : '--'} -
-
- ))} -
-
-
+ {/* Analysis stepper */} + + {/* Dual-panel main area */}
- {locale === 'zh' ? 'Agent Dialogue' : 'Agent Dialogue'} + {locale === 'zh' ? '对话工作台' : 'Agent Workspace'}
{locale === 'zh' - ? '左侧保持连续对话,右侧固定展示当前会话洞察、计划步骤和受控交接,让聊天和运营工作台同时成立。' - : 'Keep the running conversation on the left while the right rail stays anchored on session insight, plan steps, and controlled handoff.'} + ? '左侧保持连续对话,右侧展示当前会话的洞察卡、计划步骤和操作入口。' + : 'Continuous conversation on the left; session insight, plan steps, and actions on the right.'}
- {running ? 'RUNNING' : 'READY'} + {running ? (locale === 'zh' ? '运行中' : 'RUNNING') : locale === 'zh' ? '就绪' : 'READY'}
+
+ {/* Left: chat */}
- {locale === 'zh' ? 'Conversation Thread' : 'Conversation Thread'} + {locale === 'zh' ? '对话记录' : 'Conversation'}
-
+
{locale === 'zh' - ? '把每一次请求、规划、读取工具和最终解释都沉淀成连续线程。' - : 'Capture every request, planning step, tool read, and final explanation as one continuous thread.'} + ? '每次请求、规划、工具调用和分析结果都沉淀在此。' + : 'Every request, plan, tool call, and analysis result is recorded here.'}
- {sessionDetail?.session.status || - (locale === 'zh' ? '未选择会话' : 'No session')} - - - {sessionDetail?.session.latestIntent.kind || '--'} + {sessionDetail?.session.status || (locale === 'zh' ? '无会话' : 'No session')} + {sessionDetail?.session.latestIntent.kind && ( + + {sessionDetail.session.latestIntent.kind} + + )}
-
- {!conversation.length ? ( + +
+ {!conversation.length && ( - ) : null} + )} {conversation.map((message) => (
))}
- + + {/* Composer */}