Files
UHD_Media_Tool/docs/runbook/agent.md
T
rootandClaude Opus 5.5 414ea7c890 feat(智能体): 不设硬性预算上限,花费改为可见
- 去掉每天次数、每天花费、单次花费、单次 token 四个上限的拦截;设置项登记为弃用,旧值兼容读取、不生效
- 保留单次工具调用次数与时长两个防跑飞限制,缺省调宽(120 次 / 1800 秒,后台调查 20 次)
- 新增 GET /agent/usage:今天与最近 7 天按会话类型(对话 / 巡检 / 后台调查 / 整理识别 / 子会话)的次数、token 与费用
- 设置 agent.autoIdentifyOrganize(整理识别不到时叫智能体识别,缺省开)一并加在智能体设置里
- runner 里加整理识别会话的能力白名单与调查会话去重键解析(investigationDecisionId)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 16:47:18 +08:00

406 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Agent 运行手册(W2-D)
实现:`apps/server/src/modules/agent/`(会话 API、runAgent / resolveDecision 任务、授权与账本)、
`apps/server/src/modules/acquisition-organize/capabilities.ts`、`apps/server/src/modules/sources-identity/capabilities.ts`、
`packages/agent`(Pi 接入、FakeModelProvider)、迁移 `0021_agent_runtime`。
## 能力
启动时装配 41 个能力,注册数不等于 `CAPABILITY_IDS.length` 时进程启动失败。Web(`POST /capabilities/{id}/invoke`)、Pi 工具和后台任务都走 `ctx.capabilities.invoke` 这一条路径。描述符在 `packages/contracts/src/capability.ts`,每条描述都带一句"何时用 / 何时不用"。
| 分组 | 能力 |
|---|---|
| 查询(read) | `catalog.lookup`、`release.search`、`release.inspect`、`inventory.query`、`subscription.list`、`decision.list`、`download.list`、`download.inspect`、`downloader.list`、`task.inspect`、`site.list`、`site.user_data` |
| 计划(preview / propose) | `identity.resolve`、`download.plan`、`organize.plan`、`policy.preview` |
| 执行(execute) | `download.submit`、`download.recheck`、`organize.execute`、`task.retry`、`task.cancel`、`policy.apply`、`site.health_check`、`site.refresh_login` |
| 原站浏览器 | `browser.snapshot`、`browser.navigate`、`browser.interact`、`browser.download`、`browser.screenshot`、`browser.click_at`、`browser.type_at`、`browser.scroll`、`browser.wait`、`browser.solve_challenge`(见 [browser.md](browser.md));站点请求遇 Cloudflare 复选框时系统也会发起 `solve_challenge` 视觉子流程(发起人 `system:cloudflare`、trigger event、没有父会话,预算取设置,见 browser.md §13) |
| 会话 | `agent.plan`、`agent.propose_options`、`agent.spawn`、`agent.await` |
| 网页搜索 | `web.search`、`web.fetch`(见 [websearch.md](websearch.md)) |
| 元数据补全 | `metadata.enrich`(propose,只出建议;网页搜索开启才提供,见 [identity.md](identity.md#元数据补全meta-1)) |
AG-3 补的只读摘要(类别 read,scope `read`,都在后台调查 / 巡检白名单里):
- `subscription.list {status?, mediaKind?, query?, groupBy=status|mediaKind|none, limit≤200}`:订阅分组计数 + 条目(作品、模式、状态、优先级、版本目标总数 / 已满足数、上次 / 下次搜索)。查单个作品的文件与版本目标仍用 `inventory.query`。
- `decision.list {status?, kind?, limit≤100}`:待处理事项摘要,缺省只列 open / investigating / awaiting_user,按严重度排序;每条带可选处理 `options`。只读,处理仍由用户在待处理中心选。
- `download.inspect {downloadTaskId? | hash?}`:按下载任务 ID 或种子 hash(v1 / v2,大小写均可)读下载任务、关联计划、资源、业务任务(提交下载 / 核验入库,ID 可交给 `task.inspect` / `task.retry`)、整理记录与待处理事项;同 hash 在多个下载器实例时其余 ID 放 `otherMatches`。
- `downloader.list {includeDisabled?}`(FIX-7):已配置的下载器与媒体服务器——名称、类型、启用、最近连通状态(`health_status` / `last_seen_at`)、版本,下载器附本工具管理的任务数(总数 / 进行中 / 失败)。只读本地记录,不连下载器,不含地址与凭证。此前智能体只能看 `site.list`(不含下载器),会把"没看到"汇报成"下载器不在系统内"。
- `download.list {status?[], query?, mediaEntityId?, includeExternal=false, limit≤50}`(AG-5):列下载任务,附按状态计数(`byStatus[].label` 是界面用词);每条带下载任务 ID(交给 `download.inspect` / `download.recheck`)、名称、作品名、`statusLabel`、`progress`("63%")、`size`("18.6 GB")、下载器名、相对时间(`lastProgress` / `noProgressSince` / `completed`)与错误一句话,不含 hash 与原始字节数。缺省只列本工具管理、未移除的。解决"要用户给下载任务 ID / 种子 hash"(见 `docs/design/agent-report-sample-bad.md`)。
- `task.inspect` 只接受业务任务(`task` 表)ID;传入下载任务 ID 时返回 `invalid_input`:"这是下载任务 ID…用 download.inspect 查看"。
检索词(`catalog.lookup` / `release.search` / `inventory.query` / `subscription.list` 的 `query`)去掉首尾空白后不能为空、不能是单个英文字母 / 数字 / 符号、不能只有通配符与标点(`invalid_input`:"检索词太短…");中文单字片名允许。`catalog.lookup.externalId.externalId` 一律是字符串。
- **授权**(`agent/authorize.ts`):propose / approve / execute 类必须满足以下之一:
- 单次审批 `approval`:状态为 approved,绑定计划类型 + 计划 ID + payload hash + revision,且未过期。执行成功后审批标记为 consumed。
- 持续授权 `automation_grant`:`action` 覆盖该能力(比如 `download.submit`,或策略发布自动生成的 `policy.acquisition`,或 `*`),状态 active 且未过期,绑定的策略版本仍是 published。`scope.mediaEntityIds`、`scope.mediaKinds`、`scope.capabilities` 要包含目标;`budget.maxInvocationsPerDay`、`budget.maxBytesPerDay` 还有余量。
模型参数里写"用户已同意"不起作用。输入里的 `approvalId` 只是一个指针,授权结果以库里的审批记录为准。控制台管理员从 Web 手动调用(actor=`user:*`)算人工授权,API Key、Agent 和后台任务都必须有授权记录。自动化总开关关闭或处于只读对账时,自动来源的 execute 一律拒绝。
- **审计**:每次调用都写一条 `audit_event`,action 为 `capability.invoke` 或 `capability.denied`,payload 包含 outcome、授权 ID、幂等键和脱敏后的输入摘要。
- **幂等键与操作账本**:有副作用的调用由服务端生成幂等键,格式为 `id:v版本:<agentRunId|taskId>:sha256(规范化输入)`,并记入 `capability_invocation`。状态为 succeeded 时直接返回记录的结果。状态为 running(进程在调用途中崩溃)时,用同一个键重跑处理器来对账,处理器本身是幂等的。
- `download.plan` 只生成 **draft** 计划:不预留 Slot,不计入预算,也不会被后台巡检补提交。`requiresApproval` 按授权范围判定:有覆盖本计划的持续授权(action 覆盖 `download.submit`、对象 / 类型范围含目标作品、绑定策略仍发布、每日次数 / 字节预算有余量,与 authorize 同一函数 `coveringGrant`)时为 false,否则为 true;没生成计划时按 Slot 的作品判定。`download.submit` 在授权通过后先把 draft 转成 reserved(Slot revision 必须与生成计划时一致),再入队 `submitDownload`,和自动路径共用幂等键 `plan:<planId>`。
- `organize.plan` 把冻结的整理计划存成 artifact(kind=plan),`payloadHash` 就是计划指纹。`organize.execute` 执行前会重新冻结一次,指纹不一致就返回 conflict。
## 会话 API
| 端点 | 说明 |
|---|---|
| `POST /agent/runs {goal, proposeFirst?, capabilityAllowlist?}` | 建 `agent_run` 和业务任务行(kind=agentRun),并入队持久任务 `runAgent`。**HTTP 请求里不跑模型**,返回时状态为 `queued`。运行模式已取消(AG-2):会话默认在授权范围内执行;`proposeFirst=true` = 先看方案再执行(见「会话交互」)。旧字段 `mode` 已弃用:`plan_only` 按 `proposeFirst=true` 处理,响应带 `Deprecation: true`;响应里不再有 `mode` |
| `POST /agent/runs/{id}/choice {decisionId, optionId \| null, reason?}` | 回答 `agent.propose_options`:选一个方案,或 `optionId=null` 都不要(必须写原因)。方案不存在 422,已处理 409;作答后挂起的会话随即继续 |
| `GET /agent/runs` / `GET /agent/runs/{id}?afterSeq&limit` | 详情包括:`agent_run_event` 按 seq 分页、追加的消息、服务端核查过的目标汇报(`report`) |
| `POST /agent/runs/{id}/messages {text}` | 追加用户消息。运行中时,当前会话在本轮结束后消费;已结束或等待中时,会唤醒一次新的 runAgent 续跑 |
| `POST /agent/runs/{id}/abort {reason?}` | 写取消标志,会话监视器会中止模型并收尾为 cancelled。只停止后续步骤,不撤销已经完成的外部动作 |
| `GET/POST /automation-grants`、`PATCH /automation-grants/{id}` | 持续授权。PATCH 只能撤销或缩短有效期,扩大范围要新建一条 |
| `POST /approvals {planType, planId}`、`PATCH /approvals/{id} {status, payloadHash, reason?}` | 单次审批。**只接受控制台管理员会话**(API Key 一律 403,持 `organize.execute` 的 Key 不能给自己的计划开审批再自批)。批准时要回传 payloadHash;计划 payload hash / revision 变化、过期、撤销后旧审批失效,执行成功一次即 consumed,不可重放(`test/agent/approvals.test.ts`)。会话内审批(见「会话交互」)决定后写关联待处理事项并唤醒挂起的会话 |
| `GET /capabilities`、`POST /capabilities/{id}/invoke` | 能力目录与调用,HTTP scope 按能力描述符再校验一次 |
| `GET /tasks[/{id}]`、`POST /tasks/{id}/cancel`、`POST /tasks/{id}/retry` | 业务任务;`?subjectId=` 按业务对象查(submitDownload 的 subject 是计划,verifyAndImport 的是下载任务) |
| `POST /acquisition-plans {sourceReleaseId, entityId?, downloaderInstanceId?, savePath?, allowUnrecognized?, importMode?, replacementAssetIds?, note?}` | 独立手动下载,不要求订阅或 Slot;未知资源返回 `requiresConfirmation`,确认后允许提交。下载计划复用已有任务提交、审计、幂等与缓存卷检查;自动订阅评估和 Agent `download.plan` 保持自动规则。`importMode` 为 `normal`/`replace`,洗版选择仅本次有效。此批已部署为 v0.3.10,迁移 `0060` 已执行。 |
| `GET /artifacts[/{id}]` | 产物只读:整理 dryRun 计划(kind=plan)、目标汇报(report)、候选评估(evidence)等 |
**增量推送**:事件先写 `agent_run_event`(run_id + seq 唯一),再经 `ctx.events` 推到全局 SSE(`GET /events`)。推送的类型有 `agent.run_status`、`agent.text_delta`、`agent.tool_call`;未授权导致的工具失败,`phase` 为 `denied`。断线后,先用全局 `?after=<seq>` 补读总线事件,再用 `GET /agent/runs/{id}?afterSeq=<最后收到的 runSeq>` 补读单个 run 的完整事件。SSE 只给控制台(cookie 会话);API Key 调用者用 `GET /events?after=<seq>&limit=<n>` 取 JSON 页轮询(见 auth.md「SSE 多进程总线」)。
控制台 Agent 页直接用这些端点(合同取 `@umt/contracts` 的 `AgentRun` / `AgentRunDetail` / `AgentGoalReport`),`?mock=1` 只是演示开关(本地假数据,不连后端,`?mock=0` 退出),只在开发构建或后端默认上游 provider 为 `fake` 时生效;生产构建连真实模型时忽略并清掉记住的开关。页面收到 `agent.*` 事件后按 `afterSeq` 增量补读,`agent.run_status` 到达即刷新 run 快照;SSE 断开时 2.5 秒轮询兜底。
## runAgent 做什么
1. `drive` 步骤:
- 取上游(provider / baseUrl / 模型 / 思考档位来自 `pi_upstream`,凭证由上游的凭证存储在请求时解密注入,不落日志),从设置读上下文预算、压缩和预算。
- 不设每日次数 / 费用上限(2026-10-06 起;`budget.agentMaxRunsPerDay` / `agentMaxCostUsdPerDay` 旧值兼容读取、不生效),花费在设置 → 智能体「用量与限制」与运行状态里可见(`GET /agent/usage`)。
- 打开 `AGENT_SESSION_DIR/<runId>.jsonl`。`pendingOperations>0` 说明上次崩溃:先按幂等键对账,再 `resume()`;否则就 `prompt(goal)`。最后逐条消费追加的消息。
2. `goal-settled` 步骤:只在会话正常结束、且不是后台只读调查(内部 `agent_run.mode=plan_only`)时执行。每隔 `agent.goalPollSec` 检查一次,直到本 run 触达的计划、下载和整理记录全部落定,最长等 `agent.goalWaitHours`。等待期间 run 状态为 `waiting`,汇报写的是"已提交≠已入库"。
3. `finalize` 步骤:从业务表汇总计划、下载任务和整理记录,生成 `AgentGoalReport`(artifact kind=report),并据此决定终态:
- 入库完成 → `succeeded`,汇报带整理记录 ID;
- 仍在等待 → `waiting`;
- 失败 → `failed`;
- 超预算 → `budget_exceeded`,同时开一条待处理事项。同根因只保留一条未关闭事项(`attempts`+1,`evidence.runIds` 累积,`agent_run_id` 指向最新一次):去重键为 `agent_budget:root:<被调查事项的去重键>`(后台调查)、`agent_budget:schedule:<定时任务>`(巡检)或 `agent_budget:goal:<目标模板指纹>`(去掉时间戳 / UUID / 数字后的目标 sha256 前 16 位)。业务任务行记 `failed`,`output.status=budget_exceeded`;任务页按引擎 run 的 `business.outcome`(= `agent_run.status`)显示「超出预算」,不显示「完成」;
- 取消 → `cancelled`。
模型的最终文本只附在汇报后面,标注"模型汇报(未经核查)"。被预算或中止打断时,最后一次工具调用之前的文字只是中间过程,不作为模型汇报(`finalText` 置空,会话页显示服务端的中文汇总)。
**预算提醒**:工具调用只剩 ≤2 次,或 token / 费用 / 时长用到 85% 时,运行时在工具结果末尾追加「【预算提醒】…停止探查,下一条回复直接用中文写小结」,系统提示要求模型见到后直接收尾。
**等待期间的用量**:会话挂起(审批 / 选方案 / 等子会话)时没有 `run_completed`,drive 退出前把运行时累计用量按字段取较大值写回 `agent_run.usage`,会话页和列表在等待期间照样显示已用 token / 费用。
会话页(AG-4):概况条上方恒定一行会话统计「工具调用 N 次 · token 45.2K · 剩 154.8K(77%) · 费用 $0.03」(有 MCP 工具调用时加「MCP N 次」,没设 token 上限不写剩余;运行中随事件流更新);运行中本轮末尾一行「思考中 / 调用工具中 / 回答中 · 已调用 N 次工具、M 次 MCP」,本轮结束后次数并入进度行「调用 N 次工具」。内置智能体目前只调用本工具的能力,MCP 计数只对外部 MCP 工具(`mcp__…` / `域__动作` 形式的工具名)生效。汇报兜底(FIX-7)换掉的 ID / 能力名 / 原因码不再在正文下列映射表,原始汇报只放在结果行「技术信息」的「原文」里。
模型汇报在会话页按 Markdown 安全子集渲染(标题、列表、表格、引用、代码、粗体 / 斜体 / 链接;原文 HTML 按文字显示,链接只放行 http(s) 与站内路径),流式输出中仍按原文显示。
worker 崩溃后,新 worker 在引擎租约到期后(约 30s)接手,`drive` 步骤会从头重跑。已经完成的工具调用不会重放。副作用调用的结果模型会看到"结果未知",模型需要重新核对;同参数再调用一次,会直接拿到账本里的结果。
## 会话交互(AG-2 自动化优先)
不再分 plan_only / execute 模式:每个用户会话默认在授权范围内执行,模型自己规划并可视化,用户在需要时同意、选方案或审批。
`agent_run.mode` 列保留为内部能力上限(后台只读调查、巡检、只读子会话用 `plan_only`),不出现在 API 与界面上;会话选项存在 `agent_run.scope`:
| scope 字段 | 含义 |
|---|---|
| `proposeFirst` | 先看方案再执行(AGUI-3 起是输入框的单次指令 `/先给方案`,不再是开关;缺省关)。用户选定一个方案(`agent.propose_options` 的待处理事项 resolved)之前,本会话及其子会话的 execute 类调用一律被授权器拒绝(`本会话要求先看方案再执行…`);系统提示也要求先调查、再给方案 |
| `interactive` | 会话由控制台管理员会话(cookie)发起。只有这种会话出会话内审批卡;API Key / 开发密钥发起的会话缺少授权时照旧直接拒绝 |
- **步骤清单** `agent.plan`(类别 read):`{steps:[{id,title?,status?,note?}], replace?}`,按 id 合并(新步骤缺省 pending,已有步骤只改传入的字段),最多 30 步。每次调用产生 `plan_updated {steps, revision}` 事件;会话顶部概况条一行「步骤 n/m · 当前:…」,展开看完整清单;`agent.plan` / `agent.propose_options` 的调用本身不在会话流里显示(AGUI-3)。系统提示要求先出计划再执行,执行中逐步更新。只在运行中的 Agent 会话里可用(HTTP / MCP 调用报 precondition)。
- **方案选择** `agent.propose_options`(类别 propose,注册时 replay=safe):`{question, options:[{id,title,summary,risks,estimatedCost?}](2–4 个), recommendedId?}`。只在用户发起的顶层会话里提供(子会话 / 后台 / 巡检没人可问)。处理器登记待处理事项(kind=other,`dedupe_key=agent-options:<run>:<invocation>`,待处理中心也能选),发 `options_proposed`,然后挂起会话(和 `agent.await` 同一机制:`await_wake` 登记后 `waitForFact`,不占 worker 槽位、不调用模型;run 状态 `waiting`、summary「等待选择方案」)。用户作答(`POST /agent/runs/{id}/choice` 或待处理中心)后唤醒,重放这次调用,返回 `{outcome: chosen|rejected|stop, choice, option, reason, by: user|timeout}` 并发 `options_resolved`。超时(`agent.optionsTimeoutSec`,缺省 1800)按 `agent.optionsTimeoutAction`:`recommended` 自动选推荐项(没有推荐项选第一个),`stop` 不选、模型收尾。
- **内联审批**:`download.submit` / `organize.execute` / `policy.apply` 在 interactive 会话里没有持续授权、输入里也没有 `approvalId` 时,授权器在当前步骤建单次审批(绑定 plan + payload hash + revision,有效期 `agent.approvalTimeoutSec`,缺省 86400)与待处理事项(kind=approval_required,`agent-approval:<run>:<invocation>`),发 `approval_requested {approvalId, capabilityId, planType, planId, payloadHash, expectedRevision, riskLevel, summary, stepId, expiresAt}`,挂起会话(summary「等待审批」)。管理员在审批卡 `PATCH /approvals/{id}` 批准 / 拒绝(可带理由)后唤醒:挂起的调用重放一次,批准 → 按审批执行(账本记 approval_id,成功后 consumed);拒绝 / 过期 / 计划已变化 → 调用以原因失败(`管理员拒绝了审批:<理由>`),模型按理由调整;关联待处理事项写 `resolution {approval, reason, suggestion}`。每种结果发一次 `approval_resolved`。
- 重放机制:interactive 会话里 replay=never 的能力对 harness 声明 safe,首次执行写调用级 memo;恢复时只有本次恢复针对的那个调用(`resumedAwaits`)会重新走注册中心,其余中断的调用仍按"执行被中断、外部结果未知"返回,崩溃恢复语义不变。
- **选定即批准**(AGUI-3):方案可带 `action {capabilityId: download.submit|organize.execute|policy.apply, planId}`。提出方案时服务端按计划当前 payload hash + revision 绑定(存 `decision.evidence.bindings`),并用计划生成动作要点发在 `options_proposed.options[].action {label, detail, approves}`(计划不存在 → invalid_input)。**控制台管理员会话**选定这个方案时(`POST /agent/runs/{id}/choice`,计划没变),建一份已批准的单次审批(`approval.scope = {agentRunId, via: "option", decisionId, optionId, capabilityId}`,`decided_by` = 作答人,审计 `approval.approved`),结果里带 `approvedAction`;执行时授权器 `optionApproval` 核对能力、计划类型 + ID、payload hash、revision、有效期(本会话及祖先会话)后放行,成功后 consumed,不再出审批卡。API Key 作答、超时自动选、计划已变化都不算批准,执行时照常出审批卡;`approves=false`(移动或替换文件的整理、发布策略)同样要单独确认。
- **批准并重试**(会话里「没做:…」一行的按钮,只对管理员会话显示):非计划类能力建一条持续授权(`action` = 该能力,`scope.capabilities` 只含它,`budget.maxInvocationsPerDay=1`,1 小时后过期),再追加消息让智能体重试;计划类能力只追加消息,重试时照常出审批卡。
- 停止会话:还在等用户的方案选择 / 审批一并收起(事项 dismissed、审批 revoked),并唤醒挂起的会话按取消收尾。
- 排查:挂起期间 `agent_run.status=waiting`、`await_wake` 非空;`decision` 里按 `dedupe_key like 'agent-options:<run>:%'` / `'agent-approval:<run>:%'` 查;`approval.scope` 带 `{agentRunId, invocationId, capabilityId, stepId, summary}`。
## 后台触发与 resolveDecision
- `decision.created` 路由到 `resolveDecision(trigger=created)`,由 `agent/investigate.ts` 的 `autoInvestigate` 处理。满足以下全部条件时,会发起一次有界 run:`agent.autoInvestigate=true`、kind 为 `identity_ambiguous` / `download_failed` / `download_stalled`、事项仍为 open、事项不是由 Agent 自己引起的。`trigger=event`,工具调用上限为 `agent.investigateMaxToolCalls`,时长上限 `min(600, agent.maxDurationSec)` 秒,`dedupe_key=investigate:<decisionId>`。同一事项只会有一个 run,重复事件或重新打开都不会再建。模式见下一节。调查结束后,没有被系统核实恢复的事项转为 `awaiting_user`,`evidence.agentInvestigation` 里带上结论。
- `decision.updated(status=resolved)` 路由到 `resolveDecision(trigger=updated)`,执行用户选中的处理:
- 选项带 `capabilityId` 时经注册中心调用(照样校验授权);
- `retry` 重试关联任务;
- `reevaluate` / `accept` 按对象重新发起 evaluateAcquisition;
- `dismiss` / `ignore` 不做任何动作。
执行结果写进 `decision.resolution.execution`。身份歧义类由 sources-identity 在处理端点里直接执行。
## 自动调查与恢复
依据 REQ §8.4.2(下载无进展默认不换源)、§10.7(有界任务、同根因去重、有限重试)、AT68 / AT71。
**模式**(`agent.autoInvestigateMode`):
| 模式 | run 参数 | 能做什么 |
|---|---|---|
| `investigate`(缺省) | `mode=plan_only`,白名单 = 只读能力(AG-5 起含 `download.list`、`site.list`、`site.user_data`)+ `identity.resolve` | 查证据、给结论与建议,事项交还人工 |
| `recover` | `mode=execute`,白名单 = 上面 + `task.retry` + `download.recheck`,`created_by=system:auto-recover` | 在持续授权范围内执行恢复动作,系统核实后自动关闭事项 |
**恢复范围**:
- `download.recheck`(execute,replay=never):对已纳管、未移除的下载任务调 qB 强制重新校验(`action=recheck`,起点 missing / failed / stalled / paused),或恢复暂停(`action=resume`)。缓存阈值暂停和人工暂停不自动恢复。只动这一个种子,不换源、不删除、不改路径 / 分类 / 标签。
- `task.retry`:重试失败 / 死信的业务任务,副作用由各处理器的操作账本去重。
- `identity.resolve`:按已有证据等级自动接受;证据不够时仍进待处理。
**明确排除**:换源(`download.plan` / `download.submit`)、删除与缓存清理、`task.cancel`、`organize.execute`、`policy.apply`。这些能力不在恢复白名单里,模型调用会得到 `tool_call_rejected`。
**授权**:每次恢复调用仍经 `authorize` 校验持续授权,模型声明无效。可用的 grant:
- `action=download.recheck`,或发布 kind=automation 的策略时自动生成的 `policy.automation`;
- `action=task.retry` / `task.*`;`identity.resolve` / `policy.identity`;或 `*`。
grant 必须 active、未过期,绑定策略版本时该版本必须仍是 published。`scope.mediaKinds` / `scope.mediaEntityIds` 由 `download.recheck` 处理器按下载任务 → 计划 → Slot → 作品核对。注意:authorize 对非计划类能力拿不到目标作品,所以带 `mediaEntityIds` 的 grant 目前一律不覆盖 `download.recheck`,是保守拒绝。建 run 前先查有没有覆盖该类事项恢复动作的 grant(下载类看 `download.recheck` / `task.retry`,身份类看 `identity.resolve`)。没有就**降级为 investigate**,并在事项上写 `evidence.autoRecover.degraded` 和 `suggestion`,再追加一个可直接点选的"强制重新校验"选项(`capabilityId=download.recheck`,人工点选同样经授权、审计、账本)。
授权示例(把恢复限定在某个已发布策略下):
```sh
curl -X POST localhost:4380/api/v1/automation-grants -H "Authorization: Bearer <admin>" -H 'content-type: application/json' \
-d '{"action":"download.recheck","policyRevisionId":"<published automation 策略版本>","reason":"下载异常自动重新校验"}'
```
**核实与收尾**(`runAgent` 里的 `recover-*` 步骤):
1. 从账本(`capability_invocation`)取出本 run 成功的恢复动作,从审计取出被拒绝的调用。
2. 按业务事实轮询核实,间隔 `min(goalPollSec, 30)` 秒,最长 `agent.recoverVerifySec`:
- `download_task` 进入 downloading / queued / completed / importing / imported 算恢复,removed 算失败;
- 重试的 `task` 为 succeeded 算恢复,failed / dead / cancelled 算失败;
- `source_release` 已 resolved 算恢复。
3. 核实已恢复:事项转 `resolved`,`resolved_by=system:auto-recover`,`resolution.execution={kind:"agent_recover", verified:true, facts}`,发 `decision.updated`(executeResolution 看到 execution 已存在,不会重复执行)。run 汇总以"已恢复(系统核实)"开头,模型原话附在后面,标注"未经核查"。
4. 失败、超时(unverified)、预算超限、没有执行恢复动作:只写 `evidence.autoRecover.outcome` 和建议,事项由 finalize 转 `awaiting_user`,不会自动再试。run 汇总以"未恢复:…"开头。
**有界尝试**:`evidence.autoRecover.attempt` 记录该根因的第几次自动恢复。同一去重键(如 `download_failed:<downloadTaskId>`)下,历史上 `recoverAttempted=true` 的事项数达到 `agent.investigateMaxAttempts`(缺省 2)后,同根因的新事项不再唤醒模型,直接 `awaiting_user`,并写 `skipped=attempts_exhausted`。
## 整理识别不到作品时叫智能体识别(2026-10-06)
下载完整理时认不出作品(接管任务、文件名认不出、`metadata.resolve_organize` 也没认出来),`planImport` 照旧记 `identity_unknown`
并开内部事项 `organize_identity:<下载任务>`。`decision.created → resolveDecision → autoInvestigate` 认出这类事项后不看 `autoInvestigate`,
按 `agent.autoIdentifyOrganize`(缺省开,设置 → 智能体 → 自动调查)派一次识别会话(`identifyOrganize`):
- 会话:`created_by = system:organize-identify`、`dedupe_key = investigate:<事项>`(同一事项只派一次),白名单
`ORGANIZE_IDENTIFY_CAPABILITIES`(查 TMDB / 媒体库 / 网页 + `organize.assign_identity`),工具调用上限 `investigateMaxToolCalls`。
- `organize.assign_identity {decisionId, mediaEntityId | tmdb, reasons}`:回填下载任务的作品 → 冻结手动整理计划 → 入队执行,事项关闭
(与人工「指定作品」同一条路)。授权不走持续授权:只放行 dedupe 指向这条事项的识别会话与用户对话(`authorize.ts`)。
- 结果按恢复动作核实(事项关闭 = 已指定并重新整理);没认出来事项转 `awaiting_user`,记录留在整理记录「需要处理」里。
- 用户在整理记录里点「让智能体识别」(`POST /organization-records/{id}/agent-identify`)不看开关,入队 `resolveDecision {trigger: identify}`,
另起一个会话(`investigate:<事项>#<时间戳>`)。开关关掉时只是不自动派。
## 上游与登录(W7-A)
实现:`apps/server/src/modules/pi/`(上游 CRUD、能力表、测试、服务端订阅登录、刷新任务)、`packages/agent/src/pi/{catalog,upstream,fake-upstream}.ts`、
合同 `packages/contracts/src/pi.ts`、迁移 `0027_pi_upstreams`、`0044_upstream_models`。研究结论见 `docs/design/pi-providers.md`。
**模型**:一个上游(`pi_upstream`)= 一个 pi provider + 一份凭据。provider 是 pi-ai 0.87.1 内置的 41 个之一,或
`openai-compatible`(自定义兼容端点,baseUrl 与模型必填,`extra.api` 选 openai-completions / openai-responses / anthropic-messages / google-generative-ai),
或 `fake`(脚本化假模型,不联网)。同一 provider 可以建多个上游(两个 Codex 账号、两个 OpenAI key):运行时每个上游一个独立的 pi `Models`,
凭据存储只映射到本上游的 secret。认证方式 `authKind`:
| authKind | 凭据 | 说明 |
|---|---|---|
| `api_key` | secret 表存明文 key(`llm_api_key`),`extra.env` 存非密配置(Cloudflare ACCOUNT_ID、Azure 资源名等,见能力表 `envFields`) | 不读宿主环境变量,避免误用宿主的 key |
| `oauth` | secret 表存 pi 的 token 包 JSON(access / refresh / expires / accountId…) | 订阅登录,见下 |
| `none` | 不存密钥 | fake、无鉴权的本地端点、Bedrock 凭据链 / Vertex ADC(只有这一种会读进程环境变量与 `~/.aws`、gcloud 文件) |
默认上游是 `settings.pi.defaultUpstreamId`(只能经 `/pi/upstreams` 的 `makeDefault` 改;第一条上游自动成为默认)。
runAgent 按 `runAgent{upstreamId}` → `agent_run.upstream_id` → 默认上游选上游,都没有时 run 直接失败(「未配置 Agent 上游」)。旧字段 `pi.provider / baseUrl / model / thinkingLevel / apiKeySecretRef` 自 W8-A 起弃用:服务端不读、`PATCH /settings` 丢弃、设置页不展示(`DEPRECATED_SETTINGS_FIELDS`);e2e 直接建一条 `provider=fake` 的上游设为默认。
选中的上游写回 `agent_run.upstream_id / provider / model`。上游停用、未登录(`auth_required`)或登录失效(`expired`)时 run 直接失败并给出原因。
0027 迁移把已保存的旧 `settings.pi`(anthropic / openai-compatible / fake)转成一条上游并设为默认,旧 `apiKeySecretRef` 移交给上游(设置里清空)。
**端点**(全部 admin scope;写、测试、登录只接受控制台会话,API Key 403):
| 端点 | 说明 |
|---|---|
| `GET /pi/providers?models=false` | 能力表:authKinds(第一个是推荐项)、`oauth.modes`(browser / device)、`termsNote`(条款风险)、`baseUrlOverridable`、`envFields`、静态模型表 |
| `GET /pi/upstreams`、`GET /pi/upstreams/{id}` | 不含密钥,只给 `hasCredential`、`credentialMask`(前 4 后 4)、`account`、`oauthExpiresAt`、`isDefault` |
| `POST /pi/upstreams`、`PATCH /pi/upstreams/{id}`、`DELETE …` | `apiKey` 只写(PATCH 传 null 清除);provider 不可改;改 authKind 会删掉旧凭据;删除连同 secret |
| `POST /pi/upstreams/{id}/test {mode?: auth\|completion, model?}` | 缺省 `auth`:解析凭据(OAuth 必要时刷新)+ 校验模型在目录中,**不产生费用**;`completion` 再发一次最多 16 token 的补全,带一次性 `sessionId`(OpenCode Zen / Go 据此加 `x-opencode-session` 头,缺了会被拒) |
| `GET /pi/upstreams/{id}/models` | `{source: remote\|static, fetchedAt, models[], remoteSupported, error, errorKind}`:拉过远程列表 → 远程与静态目录合并去重(同 id 远程优先,远程缺的字段用静态补,远程在前);没拉过 → 静态目录(已配置凭据时按 provider 过滤,如 Copilot) |
| `POST /pi/upstreams/{id}/models/refresh` | 「获取模型列表」:用该上游的凭据调 provider 的模型列表接口,成功存 `pi_upstream.models` + `models_fetched_at`(迁移 0044)。失败不动已存列表,返回当前列表 + 一句话 `error`,`errorKind` = auth(401/403)/ network / unsupported / config / other。改 Base URL 或 `extra.api` 会清掉已存列表 |
| `POST /pi/models/probe {provider, authKind, apiKey?, baseUrl?, extra?}` | 新建上游前试拉(凭据只用这一次,不落库);新建向导创建后会用已存凭据再拉一次并存库 |
| `POST /pi/upstreams/{id}/oauth/start {mode?, enterpriseDomain?}` | 开始订阅登录,见下 |
| `POST /pi/upstreams/{id}/oauth/complete {sessionId, pastedUrlOrCode?, waitMs?}` | 完成 / 轮询登录 |
| `POST /pi/upstreams/{id}/oauth/logout` | 删除本地 token,状态回到 `auth_required`(不会撤销提供方侧授权) |
**模型列表**(PI-2,`apps/server/src/modules/pi/models.ts`):pi-ai 0.87.1 只有 Radius 自带 `refreshModels`(照旧走 pi 的动态刷新),其它按通用规则直连,出站走进程的 `HTTP(S)_PROXY`,超时 15 秒:
| provider | 接口 |
|---|---|
| OpenAI 形状(openai、deepseek、groq、xai、mistral、together、fireworks、nvidia、huggingface、moonshot、qwen/xiaomi token plan、zai、opencode、vercel-ai-gateway、baseten、cerebras、ant-ling、meta;自定义端点 `extra.api` 为 openai-*) | `GET {base}/models`(base 以 `/v1`、`/v4`、`/v1beta` 这类版本段结尾时),否则 `GET {base}/v1/models`;`Authorization: Bearer` |
| anthropic(API Key)、自定义端点 anthropic-messages | `GET /v1/models?limit=1000`,`x-api-key` + `anthropic-version: 2023-06-01` |
| google、自定义端点 google-generative-ai | `GET /v1beta/models?key=…&pageSize=1000`,只留支持 generateContent 的 |
| openrouter(API Key 或登录换到的永久 Key) | `GET /api/v1/models`(带价格、上下文、`input_modalities`、`supported_parameters`) |
| 其它订阅登录、云凭证(Bedrock / Vertex / Azure / Cloudflare)、Copilot、Kimi、MiniMax、fake | 没有公开接口 → `unsupported`,用静态目录(界面不显示获取按钮) |
归一化字段 `{id, displayName, contextWindow, supportsVision, supportsTools, pricing{input, output}(美元 / 百万 token)}`,拿不到为 null;去掉嵌入、语音、审核、重排类 id。
界面:新建向导第 2 步凭据填好后「获取模型列表」(试拉),第 3 步与编辑抽屉「获取 / 更新模型列表」;下拉可搜索,显示 id、上下文、视觉 / 工具标记,也可手填 id;来源行「来源:远程 · 几分钟前更新」或「来源:静态」;失败一句话。设为默认上游时默认模型必填(界面校验)。
**上游状态**(服务端维护):`auth_required`(新建 oauth / 未填 key / 已登出)→ 登录或填 key → `ok`;测试失败 → `error`(凭据被拒 → `auth_required`);
OAuth 刷新失败 → `expired` + 待处理事项「模型上游 X 登录已失效,需要重新登录」(去重键 `pi_upstream_expired:<id>`),重新登录成功后事项自动关闭。
**订阅登录**(8 种:Anthropic Claude Pro/Max、OpenAI Codex、GitHub Copilot、xAI、Kimi Code、Meta Muse、OpenRouter、Radius;OpenCode Go / Zen 只有 API Key):
服务端调用 pi 的 `models.login`,注入挂起式交互 —— 选方式用 start 的 `mode` 作答,Copilot 的企业域名用 `enterpriseDomain` 作答。状态机:
1. `start` → `{sessionId, mode, state: pending, authorizeUrl | userCode + verificationUrl, expiresAt}`(会话 15 分钟)。
2. **browser**(Anthropic、OpenRouter、Codex / Radius 可选):用户在自己的浏览器打开 `authorizeUrl`,授权后会跳到 `localhost:<端口>`,页面打不开是预期的,
把**地址栏完整 URL** 粘回来 → `complete {sessionId, pastedUrlOrCode}`。state 不匹配、code 缺失返回 `failed` 与可读原因。
3. **device**(Copilot、xAI、Kimi、Meta,Codex / Radius 推荐):用户在 `verificationUrl` 输入 `userCode`;服务端轮询。前端反复调
`complete {sessionId}`,每次最多等 `waitMs`(缺省 25s),未完成返回 `pending`。Codex 设备码返回 404 表示账号未开启,改用 browser。
4. 终态:`succeeded`(token 包已加密写入,上游 `ok`)、`failed`、`expired`、`cancelled`(被同一上游的新登录、登出或删除取消)。
约束:同一 provider 的登录串行(另一个上游在登录中时 start 返回 409);pi 的浏览器流程会在服务端监听固定回调端口
(Anthropic 53692、Codex 1455、Radius 1456),端口被占用时返回「服务端回调端口 N 被占用」,容器里不要让别的进程占这些端口。
登录会话只在 api 进程内存里,多实例部署要把 `/pi/upstreams/*/oauth/*` 粘到同一实例。
**条款**:订阅登录借用的是官方 CLI / IDE 的客户端身份(Anthropic 还会伪装成 Claude Code),用于自动化后台服务可能违反提供方条款、有被限流或封号风险;
能力表每个订阅型 provider 都带 `termsNote`,设置页须展示。
**刷新**:定时任务 `pi.token_refresh`(维护组,默认每 10 分钟)入队 `refreshPiTokens`,对 30 分钟内过期的 OAuth 上游调 pi 的 `getAuth(minOAuthValidityMs)`。
pi 在我们的 `CredentialStore.modify` 里双重检查过期时间再刷新;`modify` 在事务里 `select … for update` 锁住上游行,
所以 api(测试端点)与 worker(Agent 运行时的按需刷新、定时任务)同时刷新也只刷新一次,轮换后的 refresh token 原子写回。
OpenRouter 换到的是永久 key,不刷新。
## 运行参数
```jsonc
{
"pi": {
"contextBudget": 128000, "compaction": { "enabled": true, "thresholdRatio": 0.8 },
"subagents": { "enabled": false, "maxConcurrent": 2, "maxDepth": 1 } // subagentsEnabled 是 enabled 的旧别名
},
"agent": {
"maxToolCalls": 120, "maxDurationSec": 1800, // 单次运行的防跑飞限制(只保留这两个)
"autoIdentifyOrganize": true, // 整理识别不到作品时叫智能体识别(见下节)
"autoInvestigate": false, "investigateMaxToolCalls": 20,
"autoInvestigateMode": "investigate", // 或 "recover"(授权内恢复,见上一节)
"investigateMaxAttempts": 2, "recoverVerifySec": 600,
"goalPollSec": 60, "goalWaitHours": 72,
"optionsTimeoutSec": 1800, "optionsTimeoutAction": "recommended", // 方案选择超时:自动选推荐项或 "stop"
"approvalTimeoutSec": 86400 // 会话内审批有效期
}
}
```
**不设硬性预算(2026-10-06)**:每天次数(`budget.agentMaxRunsPerDay`)、每天花费(`budget.agentMaxCostUsdPerDay`)、单次花费(`agent.maxCostUsdPerRun`)、
单次 token(`agent.maxTokensPerRun`)四个上限不再拦截,登记在 `DEPRECATED_SETTINGS_FIELDS`,设置表里的旧值照常读出、不生效。
调用方显式划拨的额度(子会话预算、巡检的 `budget`)仍然生效。花费改为可见:`GET /agent/usage` 按会话类型
(对话 / 巡检 / 后台调查 / 整理识别 / 子会话)给今天与最近 7 天的次数、token 与费用(取 `agent_run.usage.costUsd`,上游不报费用时为 0),
设置页「用量与限制」与运行状态页「智能体用量」都显示。
上下文预算裁剪模型的 contextWindow;思考档位取上游的 `thinkingLevel`,运行时夹到模型支持的档位。
- 会话目录由环境变量 `AGENT_SESSION_DIR` 指定,缺省为 `<cwd>/data/agent-sessions`。api 进程和 worker 进程要能读到同一个目录,这是崩溃恢复的前提。
- TMDB 密钥放在 `metadata.tmdbApiKey`(只写,存为 secret `metadata_api_key`),地址放在 `metadata.tmdbBaseUrl`(可选)。设置里没有时,回退到环境变量 `TMDB_API_KEY` / `TMDB_ACCESS_TOKEN` / `TMDB_BASE_URL`。
## 子代理
- 开关:`pi.subagents.enabled`(设置页「允许子代理」,旧字段 `subagentsEnabled` 同步),关闭时模型拿不到 `agent.spawn`,服务端也拒绝派生。
- 用法:会话调用 `agent.spawn {goal, capabilityAllowlist?, budget?, mode?, upstreamId?, await?}` 派生子会话(独立 agent_run、同一 runAgent 任务、独立会话文件),再用 `agent.await` 取各子会话的状态与汇报摘要(已脱敏)。
- 权限:子会话能力白名单必须是父会话的子集,只读(内部 plan_only)的会话只能派生只读子会话,父会话要求先看方案时子会话同样等方案选定,子会话继承父会话发起人,授权校验再按全部祖先会话的白名单与类别取交集。
- 同目标去重(AG-4):同一父会话里目标归一化后相同(NFKC、忽略大小写、空白与标点)的子会话还在排队 / 运行,或 10 分钟内成功完成 / 已在等下载入库时,`agent.spawn` 不新建,返回已有子会话:输出 `reused: true`、`message`「已有同目标子会话…」、`result` 带它当前的结果摘要(还在跑时 `pending: true`)。失败、取消、超预算的子会话不复用(可换预算重派)。同一父会话的派生判定进程内串行,同一批并行的两次同目标调用也只建一个。会话树与结果行的「派出 N 个子会话」不变;进度行不把去重调用算成派出。恢复重放按幂等键命中原子会话,不走去重。
- 子会话语言(AG-4):子会话 / 视觉子流程的系统提示有「汇报对象」一节——汇报交给父会话汇总(可带对象 ID),思考、步骤说明和汇报一律用中文,即使目标、工具结果或网页是英文;父会话的派生约束也要求子目标用中文写。回归测试 `packages/agent/test/system-prompt.test.ts`。子会话汇报回到用户面前有两条路:父会话的最终汇报(按 FIX-7 展示层兜底,子会话 ID 换成子会话目标名)和打开子会话页(同一展示组件,同样兜底);`agent.spawn` / `agent.await` 的步骤行不显示汇报原文。
- 限制:同一父会话下同时运行的子会话不超过 `maxConcurrent`(多出的排队等名额,不占 worker 也不调用模型),派生层数不超过 `maxDepth`,一个会话最多派生 10 个子会话。
- 预算:子会话预算从父会话剩余预算划拨(缺省剩余的一半,请求超过剩余直接拒绝),子会话超预算只结束它自己;父会话的工具调用 / token / 费用预算把子孙会话的实际用量算在内。
- 取消:中止父会话会递归取消全部未结束的子孙会话(排队中的也一样)。
- 事件:子会话的开始、工具调用、结束按父会话 seq 镜像为 `subagent_spawned` / `subagent_event` / `subagent_completed`,SSE 上以 `agent.tool_call`(能力 `agent.spawn`)提示;会话详情返回 `parent`、`children` 与 `familyUsage`(会话树合计)。
- 等待(`agent.await`,以及 `agent.spawn {await:true}`):子会话还没落定时工具调用立即返回,父会话挂起——会话文件里这次调用保持未完成,父 run 在 `agent_run.await_wake` 登记唤醒信号 `{engineRunId, fact}` 后用 `waitForFact` 等待,**不占 io / agent worker 槽位,也不调用模型**。子会话落定(终态或 waiting)时 `wakeParent` 按登记写 fact 并发引擎信号,父会话立刻重开会话续跑:挂起的调用(两个能力都是 replay=safe)重放一次,子会话汇报作为工具结果交给模型。信号丢失(子会话恰在父会话开始等待前落定)时靠 10 秒兜底轮询;超时(缺省 600 秒)后未结束的子会话标 pending,父会话被取消时立即唤醒按取消收尾。`agent.spawn {await:true}` 挂起那次调用在账本里记为 failed(`会话已挂起…`,不缓存结果),重放时幂等键相同,命中同一个子会话。
- 排查:父会话挂起期间 `agent_run.await_wake` 非空、引擎 run 状态为 waiting(`GET /engine/runs/{id}`);恢复后 `await_wake` 清空,事件流里第二个 `run_started` 的 `resumed=true`,重放调用的 `tool_call_end.recovery=true`。
- 成本:每个子会话都是一次完整的模型会话(独立上下文,有自己的系统提示与工具定义),token 成本大致随子会话数线性增加,也计入每日 Agent 运行次数。
## fake 模式演示
在设置页新建一个 provider = fake、authKind = none 的上游并设为默认(没有任何上游时,旧的 `PATCH /settings {"pi":{"provider":"fake"}}` 仍然生效)。
fake 上游也支持 authKind = oauth,用来联调登录流程:browser 模式粘回任意带 `code` 与正确 `state` 的 URL,device 模式约 3 秒后自动批准。
```sh
curl -X POST localhost:4380/api/v1/agent/runs -H "Authorization: Bearer umt-dev-admin-key" \
-H 'content-type: application/json' -d '{"goal":"看看库里有什么","proposeFirst":true}'
```
没有配置 `agent.fakeScript` 时,使用内置演示脚本:查一次库存,然后如实汇报,不做写操作。自定义脚本用 JSON 写,可以是单个对象或数组:
```json
{
"match": "收进来",
"steps": [
{ "text": "查库存", "toolCalls": [{ "capability": "inventory.query", "input": { "query": "沙丘2" } }] },
{ "toolCalls": [{ "capability": "download.plan",
"input": { "slotId": "{{inventory.query.items.0.slots.0.id}}", "sourceReleaseId": "<id>" } }] },
{ "toolCalls": [{ "capability": "download.submit", "input": { "planId": "{{download.plan.plan.id}}" } }] }
],
"finalText": "已提交"
}
```
`{{能力ID.路径}}` 取该能力最近一次成功调用结果里对应路径的值。测试直接给 `createAgentModule({ fakeScripts })` 注入 `FakeScript`,输入可以写成函数。
## 事件约定
没有单独的 `download.completed` / `organization.completed` 事件,用现有事件表达:
- 下载完成 = `download.status_changed` 且 `to=completed`(只发一次);
- 入库完成 = `organization.changed` 且 `status=succeeded`。
## e2e
`apps/server/test/e2e/`:每个文件使用独立的业务 schema 和引擎 schema,跑真实的四个模块、OpenWorkflow worker 和 outbox 中继,外部依赖用 testkit 的 mock(qB、TMDB、RSS),文件放在临时目录。
| 文件 | 内容 |
|---|---|
| `e1-rss-to-library` | RSS → 识别 → 策略 → 下载 → Copy 入库 → 整理记录增量 → Slot satisfied |
| `e2-agent-delegate` | 委托闭环:无授权时被拒,有授权时成功;汇报以整理记录为准 |
| `e3-background-continues` | 关闭聊天后台照样推进 |
| `e4-crash-recovery` | worker 子进程在 download.submit 时 kill -9,新 worker resume,qB 只收到一次 add |
| `e5-auto-investigate` | 识别歧义 → 有界调查,去重 |
| `e6-auto-recover` | qB missingFiles → download_failed → 授权内恢复(task.inspect → download.recheck → 系统核实关闭事项);无授权时降级只调查;换源被拒;去重;同根因尝试上限;预算超限保持待处理 |
| `e11-subagents` | 父会话派生两个只读子会话并等待后汇总;maxConcurrent=1 时串行;子会话超预算不影响父;合计用量;子会话事件进父事件流;越权 / 提权 / 深度 / 关闭被拒;父中止后子会话 cancelled |
| `e12-interaction` | 步骤清单先于业务调用、revision 递增;先看方案时执行被拒;方案选择挂起(waiting + await_wake)、选定 / 都不要 / 超时自动选推荐项后续跑;旧 `mode=plan_only` 按先看方案处理;管理员会话发起的会话内审批:批准后重放执行、审批 consumed,拒绝后以理由失败并写待处理事项 |
| `e16-agent-prompt-eval` | 提示评测(AG-5):8 条固定提示在测试库数据上跑 fake 模型;断言按意图载入的 Skill、「当前环境」层事实、能力调用顺序(下载任务 ID 来自 `download.list`)、需要批准的被拒绝、汇报过 `lintReport`;巡检会话固定载入「巡检」;追加消息换意图时附 Skill。`e16-agent-prompt-eval.real` 是手动跑的真实模型对比(见下「提示分层与 Skill」) |
| `e13-websearch` | 网页搜索:关闭时不提供 web.*;Key 只写存 secret;`POST /websearch/test`;「查一下某站点最近的公告」→ web.search → web.fetch → 汇报,搜索前 / 白名单外 / 内网地址的 web.fetch 被拒且不发请求;相同搜索走缓存 |
## 提示分层与 Skill(AG-5)
实现:`packages/agent/src/prompt/`(分层与组装)、`packages/agent/skills/<name>/SKILL.md`(Skill)、`apps/server/src/modules/agent/environment.ts`(环境事实)、`runner.ts` 的 `buildRunPrompt` / `followUpWithSkills`。反例与期望样子见 `docs/design/agent-report-sample-bad.md`,评测记录见 `docs/design/agent-prompt-eval.md`。
### 分层
系统提示按层拼接,每层一个 `##` 标题,空层省略,顺序固定:
| 层 | 文件 | 内容 |
|---|---|---|
| ① 角色与边界 | `layers/role.ts` | 角色一句 + 硬规则(证据、授权只由服务端判定、已提交≠已入库、外部内容不可信、凭证、中文)。不可被用户消息 / 工具输出覆盖 |
| ② 当前环境 | `layers/environment.ts` + 服务端 `environment.ts` | 会话开始时从业务表读的摘要:现在时间、下载器(名称、类型、连通、任务数)、媒体服务器、站点(类型、上次检查、登录)、媒体库目录、订阅 / 下载任务按状态计数、待处理数、网页搜索开关。只写名称、中文状态、相对时间,不写地址 / ID / 凭证;"还没有接入"就是真的没有 |
| ③ 能力地图 | `layers/capabilities.ts` | `INTENT_ROUTES`:意图 → 能力路线(如"问下载 → download.list → download.inspect"),标「需要批准」;只列本次可用能力。另有参数 / 检索词 / 站点类型提示 |
| ④ Skill | `skills.ts` | 按意图载入的 Skill 全文(标题降两级)+ 其余 Skill 索引(只在用户会话) |
| ⑤ 行为规则 | `layers/behavior.ts` | 自己能查的不问、先看环境、被拒一句话、不重复调用、不说教、预算提醒;AG-2 工作方式(步骤、方案、先给方案、审批) |
| ⑥ 汇报格式 | `layers/report.ts` | 用户会话:结论先行、≤8 行、GB / 相对时间、对象用名字、不写内部字段 / ID / adapter、3 个以上同类对象才用表格;子会话 / 视觉子流程:汇报给父会话,中文,可带对象 ID |
| 本次目标 / 约束 | `assemble.ts` | 目标原文 + 会话约束(只读、子会话、网页搜索、后台恢复) |
按会话类型选层(`LAYERS_BY_KIND`,`runner.sessionKindOf`):用户会话 `chat`(trigger=user)、巡检 `patrol`(schedule,固定载入「巡检」Skill,汇报层要求按巡检模板)、后台调查 `investigate`(event)、子会话 `child`(有父会话,汇报给父会话)、视觉子流程 `vision`(只有角色、汇报、任务)。改某一层只动对应文件;`packages/agent/test/system-prompt.test.ts` 逐层断言关键句。
### Skill
- 位置:`packages/agent/skills/<name>/SKILL.md`,`deno compile` 用 `--include ../../packages/agent/skills` 嵌入二进制。现有:`patrol`(巡检)、`acquire-title`(收录一部作品)、`import-troubleshoot`(入库 / 整理问题排查)、`download-diagnose`(下载排查)、`subscription-check`(订阅追更检查)、`storage-cleanup`(数据清理与存储)。
- 加载:Pi harness 的 `loadSkills`(agentskills.io 格式,`name` 必须等于目录名、小写连字符,`description` 必填),进程内缓存;扩展字段本项目自己读。Pi 的模型侧用法是把 `<available_skills>` 索引交给模型、由模型用文件读取工具读正文;我们的智能体没有文件读取工具,所以改为**服务端按意图选**:首轮用目标匹配 `triggers`,命中的全文注入系统提示(最多 2 个,巡检会话固定带 `patrol`,Skill 的能力都不在白名单时不选);追加消息命中新的 Skill 时,用 Pi 的 `formatSkillInvocation` 把 `<skill>` 块附在消息前(会话文件里是附了 Skill 的消息,界面显示用户原话)。没有新增 `skill.load` 能力:MoviePilot(`app/agent/middleware/skills.py`)让模型自己调工具加载 Skill,我们首轮由服务端按目标选,省一次工具调用,也不依赖小模型记得去加载。
- frontmatter:
```yaml
---
name: patrol # = 目录名
title: 巡检 # 中文名,索引与标题用
description: 一句话:做什么、用户怎么说时用
triggers: [巡检, 是否正常|正常吗] # 正则源串,不区分大小写;命中数多的优先
capabilities: [downloader.list, site.list] # 正文提到的能力都要列上
sessions: [chat, patrol, investigate, child] # 缺省 chat / patrol / investigate / child
---
```
- 正文五节(测试逐个核对):`## 适用意图`、`## 步骤`(按能力写顺序与取舍)、`## 需要批准`(哪些能力、被拒时一句话怎么写)、`## 汇报模板`(`> ` 开头的示例,≤8 行、人话单位,不含 ID / 字节数 / 能力名)、`## 不要做`。文案按 `docs/design/glossary.md` 与 human-copy 规则写:用界面术语、全角标点、不写客服腔。
- 核对(`packages/agent/test/skills.test.ts`):加载无诊断、≥5 个、五节齐全、正文提到的能力都在 `capabilities` 且是已知能力、汇报模板示例不带内部字段;**每个能力至少被一个 Skill 引用,或列在 `GENERAL_CAPABILITIES`(通用)**,且都出现在能力地图的路线里或是通用;固定提示的意图路由。新增能力时:加进某个 Skill 的 `capabilities` 与步骤、`INTENT_ROUTES` 的路线,或加进 `GENERAL_CAPABILITIES`。
### 评测
- 汇报核对 `lintReport(text)`(`packages/agent/src/prompt/report-lint.ts`):行数 >8、UUID、hash、7 位以上裸数字(原始字节数)、ISO 时间、内部字段(adapter、snake_case、camelCase)、能力名、Markdown 标题、开头不是结论("检查结果""以下…")、向用户要 ID、说教句。反例原文必须不过,期望样子必须过(`report-lint.test.ts`)。
- fake 模型:`apps/server/test/e2e/agent-prompt-eval.test.ts`,夹具 `fixtures/agent-eval.ts`(测试库里的一套用户环境 + 8 条固定提示 + 合格模型的调用路线与汇报),随 `deno task test` 跑。
- 真实模型(手动,不进 CI,每次最多 12 次调用模型会话):
```sh
git show main:packages/agent/src/system-prompt.ts > /tmp/legacy-system-prompt.ts # "前"= main 的提示
cd apps/server && UMT_EVAL_REAL=1 UMT_EVAL_API_KEY=<OpenCode Go key> \
UMT_EVAL_PROVIDER=opencode-go UMT_EVAL_MODEL=deepseek-v4.1-flash \
UMT_EVAL_LEGACY_PROMPT=/tmp/legacy-system-prompt.ts UMT_EVAL_CASES=health,acquire,why \
UMT_EVAL_OUT=/tmp/agent-eval.json DATABASE_URL=postgres://… \
deno run -A npm:vitest run test/e2e/agent-prompt-eval.real.test.ts
```
"前"用 main 的 `buildSystemPrompt` + 旧约束,能力集去掉 `download.list`;"后"用分层提示。结果 JSON 每条含能力调用序列、汇报原文、`lintReport` 结果、行数、用量;对比写进 `docs/design/agent-prompt-eval.md`。站点地址是本机关闭端口、下载器是 mock qB,智能体不会请求真实站点。key 只经环境变量传入,不写进代码与报告。
## 已知限制
- 编译后的二进制(`deno compile`)只授予 `--allow-read=/app`,没有写权限。Agent 会话目录和入库 Copy/Move 在容器里都需要写权限,需要在 W4 统一补 `--allow-write=<数据目录,库根>`,在此之前生产容器里的 runAgent 会失败。
- 41 个 provider 中只有请求形状与凭据装配经过测试(fake、自定义兼容端点真实走 HTTP);各家真实上游与 8 种订阅登录没有联网实测,首次接入请用「测试」按钮的 completion 模式确认。
- 登录会话在 api 进程内存,进程重启后进行中的登录丢失,需要重新开始。
- 追加消息与正在收尾的 run 存在很小的竞态窗口。`finalize` 会检查一次未消费的消息并补唤醒一次 run。