# 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版本::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:`。 - `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=` 补读总线事件,再用 `GET /agent/runs/{id}?afterSeq=<最后收到的 runSeq>` 补读单个 run 的完整事件。SSE 只给控制台(cookie 会话);API Key 调用者用 `GET /events?after=&limit=` 取 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/.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::`,待处理中心也能选),发 `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::`),发 `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::%'` / `'agent-approval::%'` 查;`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:`。同一事项只会有一个 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 " -H 'content-type: application/json' \ -d '{"action":"download.recheck","policyRevisionId":"","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:`)下,历史上 `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:`),重新登录成功后事项自动关闭。 **订阅登录**(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` 指定,缺省为 `/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": "" } }] }, { "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//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//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 的模型侧用法是把 `` 索引交给模型、由模型用文件读取工具读正文;我们的智能体没有文件读取工具,所以改为**服务端按意图选**:首轮用目标匹配 `triggers`,命中的全文注入系统提示(最多 2 个,巡检会话固定带 `patrol`,Skill 的能力都不在白名单时不选);追加消息命中新的 Skill 时,用 Pi 的 `formatSkillInvocation` 把 `` 块附在消息前(会话文件里是附了 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= \ 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。