Files
UHD_Media_Tool/docs/runbook/agent.md
T
rootandClaude Opus 5.5 86af723b2a feat(agent): W4-E2 in-grant auto recovery for decisions with verified outcome
- settings.agent.autoInvestigateMode investigate|recover, investigateMaxAttempts, recoverVerifySec
- new capability download.recheck (execute, replay never): qB force recheck / resume, no source swap/delete
- autoInvestigate moves to agent/investigate.ts: recover mode runs execute with a recovery allowlist
  only when an active grant (published policy if bound) covers the recovery actions; otherwise degrades
  to read-only investigation and leaves suggestion + recheck option on the decision
- runAgent verifies recovery against business facts (download_task/task/source_release) before finalize;
  verified -> decision resolved by system:auto-recover, run summary '已恢复(系统核实)'; otherwise stays
  awaiting_user; per-root-cause attempt cap
- e2e E6: missingFiles -> download_failed -> recheck -> verified; no grant degrades; swap rejected;
  dedupe; attempts exhausted; budget exceeded keeps attention

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 22:35:03 +08:00

17 KiB

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。

能力

启动时装配 15 个能力(catalog.lookup、release.search、release.inspect、identity.resolve、download.plan、download.submit、download.recheck、organize.plan、organize.execute、task.inspect、task.retry、task.cancel、policy.preview、policy.apply、inventory.query)。注册数不等于 CAPABILITY_IDS.length 时进程启动失败。Web(POST /capabilities/{id}/invoke)、Pi 工具和后台任务都走 ctx.capabilities.invoke 这一条路径。

  • 授权(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,不计入预算,也不会被后台巡检补提交。download.submit 在授权通过后先把 draft 转成 reserved(Slot revision 必须与生成计划时一致),再入队 submitDownload,和自动路径共用幂等键 plan:<planId>。

  • organize.plan 把冻结的整理计划存成 artifact(kind=plan),payloadHash 就是计划指纹。organize.execute 执行前会重新冻结一次,指纹不一致就返回 conflict。

会话 API

端点 说明
POST /agent/runs {goal, mode: execute|plan_only, capabilityAllowlist?} 建 agent_run 和业务任务行(kind=agentRun),并入队持久任务 runAgent。HTTP 请求里不跑模型,返回时状态为 queued。plan_only 只允许 read / preview / propose 类能力,白名单里放了 execute 类会返回 422
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} 单次审批。只接受控制台管理员会话(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, slotId? | entityId?, note?} 手动提交下载(工作台下载入口):依次调 download.plan → download.submit,和 Agent 同一条能力路径(授权 / 审计 / 幂等键 / 账本)。没给 slotId 时按作品的 Slot 创建顺序逐个尝试,第一个生成计划的胜出;都没生成时 submitted=false 并逐个 Slot 给出原因。控制台管理员算人工授权;API Key 需要持续授权或单次审批
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 退出),只在开发构建或后端 pi.provider = "fake" 时生效;生产构建连真实模型时忽略并清掉记住的开关。页面收到 agent.* 事件后按 afterSeq 增量补读,agent.run_status 到达即刷新 run 快照;SSE 断开时 2.5 秒轮询兜底。

runAgent 做什么

  1. drive 步骤:

    • 从设置读 Pi 参数(provider / baseUrl / model / 思考档位 / 上下文预算 / 压缩)和预算。pi.apiKeySecretRef 现场解密后传给 SDK,不落日志。
    • 检查今日次数和费用预算(budget.agentMaxRunsPerDay / agentMaxCostUsdPerDay)。
    • 打开 AGENT_SESSION_DIR/<runId>.jsonl。pendingOperations>0 说明上次崩溃:先按幂等键对账,再 resume();否则就 prompt(goal)。最后逐条消费追加的消息。
  2. goal-settled 步骤:只在 execute 模式且会话正常结束时执行。每隔 agent.goalPollSec 检查一次,直到本 run 触达的计划、下载和整理记录全部落定,最长等 agent.goalWaitHours。等待期间 run 状态为 waiting,汇报写的是"已提交≠已入库"。

  3. finalize 步骤:从业务表汇总计划、下载任务和整理记录,生成 AgentGoalReport(artifact kind=report),并据此决定终态:

    • 入库完成 → succeeded,汇报带整理记录 ID;
    • 仍在等待 → waiting;
    • 失败 → failed;
    • 超预算 → budget_exceeded,同时开一条待处理事项 agent_budget:<runId>;
    • 取消 → cancelled。

    模型的最终文本只附在汇报后面,标注"模型汇报(未经核查)"。

worker 崩溃后,新 worker 在引擎租约到期后(约 30s)接手,drive 步骤会从头重跑。已经完成的工具调用不会重放。副作用调用的结果模型会看到"结果未知",模型需要重新核对;同参数再调用一次,会直接拿到账本里的结果。

后台触发与 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,白名单 = 只读能力 + 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,人工点选同样经授权、审计、账本)。

授权示例(把恢复限定在某个已发布策略下):

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。

配置上游

在设置页或 PATCH /settings 里配置:

{
  "pi": {
    "provider": "anthropic",            // 或 "openai-compatible"(baseUrl 要带 /v1);"fake" = 演示假模型
    "baseUrl": null,                    // Anthropic 写根地址,不带 /v1
    "model": "<模型 ID>",
    "thinkingLevel": "medium",
    "contextBudget": 128000,
    "compaction": { "enabled": true, "thresholdRatio": 0.8 },
    "apiKey": "<只写,存为 secret>"
  },
  "agent": {
    "maxToolCalls": 40, "maxDurationSec": 900,          // 单次运行预算
    "maxTokensPerRun": null, "maxCostUsdPerRun": null,
    "autoInvestigate": false, "investigateMaxToolCalls": 8,
    "autoInvestigateMode": "investigate",               // 或 "recover"(授权内恢复,见上一节)
    "investigateMaxAttempts": 2, "recoverVerifySec": 600,
    "goalPollSec": 60, "goalWaitHours": 72
  },
  "budget": { "agentMaxRunsPerDay": 50, "agentMaxCostUsdPerDay": 5 }
}
  • 会话目录由环境变量 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。

fake 模式演示

curl -X PATCH localhost:4380/api/v1/settings -H "Authorization: Bearer umt-dev-admin-key" \
  -H 'content-type: application/json' -d '{"pi":{"provider":"fake","model":"fake-model"}}'
curl -X POST localhost:4380/api/v1/agent/runs -H "Authorization: Bearer umt-dev-admin-key" \
  -H 'content-type: application/json' -d '{"goal":"看看库里有什么","mode":"plan_only"}'

没有配置 agent.fakeScript 时,使用内置演示脚本:查一次库存,然后如实汇报,不做写操作。自定义脚本用 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 → 系统核实关闭事项);无授权时降级只调查;换源被拒;去重;同根因尝试上限;预算超限保持待处理

已知限制

  • 编译后的二进制(deno compile)只授予 --allow-read=/app,没有写权限。Agent 会话目录和入库 Copy/Move 在容器里都需要写权限,需要在 W4 统一补 --allow-write=<数据目录,库根>,在此之前生产容器里的 runAgent 会失败。
  • 子代理(subagentsEnabled)只做记录,没有实现。
  • 追加消息与正在收尾的 run 存在很小的竞态窗口。finalize 会检查一次未消费的消息并补唤醒一次 run。