Files
UHD_Media_Tool/docs/runbook/agent.md
T
rootandClaude Opus 5.5 f4666a4671 feat(identity): web metadata enrichment for titles missing on TMDB (META-1)
- metadata.enrich (propose, low, replay safe): normalized-title cache (kv_cache
  region enrich, 7 days) -> daily budget -> web.search (title + 短剧 / 电视剧 平台,
  second query limited to whitelist via site:) -> web.fetch of up to 3 relevant
  whitelisted pages (registered site domains never read, redirects out of the
  whitelist dropped) -> upstream model extraction with rule fallback -> server-side
  evidence check (rule_confirmed / multi_evidence / needs_review). Suggestion only.
- POST /identity-pool/{id}/enrich (suggest) and /enrich/apply (console session):
  provisional entity or same-title merge, season/episode units, field-level
  metadata with source web/agent + evidence, aliases, release assignment.
  Poster proxy GET /img/enrich/{id} (og:image from whitelisted pages only).
- Auto-enrich once per pool entry when subscription/rule lookups find nothing on
  TMDB (identity.autoEnrich, off by default); identity.enrichDailyBudget,
  identity.enrichDomains settings; web.fetch now returns og:image.
- Pool sheet "元数据补全" section; entity detail shows evidence links.
- Migration 0049_enrichment (metadata_enrichment, metadata_field.evidence, source
  web). Requires image rebuild / api + worker restart to take effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 15:57:48 +08:00

39 KiB
Raw Blame History

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。

能力

启动时装配 39 个能力,注册数不等于 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.inspect、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)
会话 agent.plan、agent.propose_options、agent.spawn、agent.await
网页搜索 web.search、web.fetch(见 websearch.md)
元数据补全 metadata.enrich(propose,只出建议;网页搜索开启才提供,见 identity.md)

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。
  • 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, 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 退出),只在开发构建或后端默认上游 provider 为 fake 时生效;生产构建连真实模型时忽略并清掉记住的开关。页面收到 agent.* 事件后按 afterSeq 增量补读,agent.run_status 到达即刷新 run 快照;SSE 断开时 2.5 秒轮询兜底。

runAgent 做什么

  1. drive 步骤:

    • 取上游(provider / baseUrl / 模型 / 思考档位来自 pi_upstream,凭证由上游的凭证存储在请求时解密注入,不落日志),从设置读上下文预算、压缩和预算。
    • 检查今日次数和费用预算(budget.agentMaxRunsPerDay / agentMaxCostUsdPerDay)。
    • 打开 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 / 费用。

    模型汇报在会话页按 Markdown 安全子集渲染(标题、列表、表格、引用、代码、粗体 / 斜体 / 链接;原文 HTML 按文字显示,链接只放行 http(s) 与站内路径),流式输出中仍按原文显示。

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

会话交互(AG-2 自动化优先)

不再分 plan_only / execute 模式:每个用户会话默认在授权范围内执行,模型自己规划并可视化,用户在需要时同意、选方案或审批。 agent_run.mode 列保留为内部能力上限(后台只读调查、巡检、只读子会话用 plan_only),不出现在 API 与界面上;会话选项存在 agent_run.scope:

scope 字段 含义
proposeFirst 先看方案再执行(输入区开关,缺省关)。用户选定一个方案(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} 事件;会话页顶部是 TodoList 实时勾选,抽屉里压缩为「步骤 n/m · 当前:…」一行进度。系统提示要求先出计划再执行,执行中逐步更新。只在运行中的 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)会重新走注册中心,其余中断的调用仍按"执行被中断、外部结果未知"返回,崩溃恢复语义不变。
  • 停止会话:还在等用户的方案选择 / 审批一并收起(事项 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,白名单 = 只读能力 + 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。

上游与登录(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,不刷新。

运行参数

{
  "pi": {
    "contextBudget": 128000, "compaction": { "enabled": true, "thresholdRatio": 0.8 },
    "subagents": { "enabled": false, "maxConcurrent": 2, "maxDepth": 1 }   // subagentsEnabled 是 enabled 的旧别名
  },
  "agent": {
    "maxToolCalls": 40, "maxDurationSec": 900,          // 单次运行预算
    "maxTokensPerRun": null, "maxCostUsdPerRun": null,
    "autoInvestigate": false, "investigateMaxToolCalls": 8,
    "autoInvestigateMode": "investigate",               // 或 "recover"(授权内恢复,见上一节)
    "investigateMaxAttempts": 2, "recoverVerifySec": 600,
    "goalPollSec": 60, "goalWaitHours": 72,
    "optionsTimeoutSec": 1800, "optionsTimeoutAction": "recommended",   // 方案选择超时:自动选推荐项或 "stop"
    "approvalTimeoutSec": 86400                           // 会话内审批有效期
  },
  "budget": { "agentMaxRunsPerDay": 50, "agentMaxCostUsdPerDay": 5 }
}

上下文预算裁剪模型的 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)的会话只能派生只读子会话,父会话要求先看方案时子会话同样等方案选定,子会话继承父会话发起人,授权校验再按全部祖先会话的白名单与类别取交集。
  • 限制:同一父会话下同时运行的子会话不超过 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 秒后自动批准。

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 写,可以是单个对象或数组:

{
  "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,拒绝后以理由失败并写待处理事项
e13-websearch 网页搜索:关闭时不提供 web.*;Key 只写存 secret;POST /websearch/test;「查一下某站点最近的公告」→ web.search → web.fetch → 汇报,搜索前 / 白名单外 / 内网地址的 web.fetch 被拒且不发请求;相同搜索走缓存

已知限制

  • 编译后的二进制(deno compile)只授予 --allow-read=/app,没有写权限。Agent 会话目录和入库 Copy/Move 在容器里都需要写权限,需要在 W4 统一补 --allow-write=<数据目录,库根>,在此之前生产容器里的 runAgent 会失败。
  • 41 个 provider 中只有请求形状与凭据装配经过测试(fake、自定义兼容端点真实走 HTTP);各家真实上游与 8 种订阅登录没有联网实测,首次接入请用「测试」按钮的 completion 模式确认。
  • 登录会话在 api 进程内存,进程重启后进行中的登录丢失,需要重新开始。
  • 追加消息与正在收尾的 run 存在很小的竞态窗口。finalize 会检查一次未消费的消息并补唤醒一次 run。