- POST /approvals and PATCH /approvals/{id} require a console admin session
(EndpointDef auth:"session"); API keys incl. admin scope get 403, so an
organize.execute key can no longer approve its own plan (S1)
- tests for single-use approval checks: expired, revision/payload hash
mismatch, consumed, revoked, pending/rejected, plan mismatch; re-approve
after plan change succeeds once and cannot be replayed (S4)
- one redact key table in @umt/contracts shared by server logger and agent
redactForModel: rsskey, torrent_pass, authkey, downhash, c_secure_pass,
passkey variants, x-api-key, cookie headers, nested objects/arrays (S2)
- TMDB network errors: message and cause redacted via redactUrl (S3)
- web Agent ?mock=1 only in DEV builds or when pi.provider is fake (S7)
- EndpointDef auth annotations for /auth/*, asserted against
PUBLIC_AUTH_PATHS (S10); doc drift fixes (S11)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@umt/agent
Pi Agent 接入层:能力注册中心、AgentRuntimePort 及其 Pi 实现、设置映射、敏感信息守卫、系统提示模板,以及测试用的脚本化假模型。
依赖锁定为 @earendil-works/[email protected]、@earendil-works/[email protected]、[email protected](见 ADR-003)。Pi SDK 的类型只出现在 src/pi/* 中,其它包只依赖 src/index.ts 导出的自有接口。
边界
- 模型只能调用本次
capabilities白名单里的能力。白名单有三层保护:- 注册层:只把白名单能力注册成 Pi 工具,其它工具名(例如
bash)由 harness 合成 unavailable 错误; before_tool钩子再校验一次,白名单外的调用一律 block,并产生tool_call_rejected事件;- 每次执行都走
CapabilityRegistry.invoke,依次做能力类别上限(allowedKinds)、schema、授权、幂等键、输出 schema 和审计。
- 注册层:只把白名单能力注册成 Pi 工具,其它工具名(例如
- 不提供 bash/进程执行、任意 SQL、任意文件读写、任意网络/HTTP 代理、读取 Cookie/Token、安装依赖或修改代码的工具。
NodeExecutionEnv只作为会话目录的 FileSystem 使用,它的 exec 不会暴露给模型。 - 授权只在服务端判定:
authorize回调查 automation_grant/approval。模型参数里写的"用户已同意"不起作用。没有注入authorize时,propose/approve/execute 一律拒绝。 - 幂等键由服务端按操作语义生成(默认是
id:v版本:sha256(规范化输入),可以注入自定义生成器),通过ctx.idempotencyKey交给 handler,不采用模型给的值。 - 工具的输入输出在进入模型上下文、事件和审计之前,都要经过
redactForModel(cookie/passkey/token/apiKey/authorization/私有下载 URL/URL 内嵌账号密码)处理。apiKey只在请求时经 providerauth.resolve交给 SDK。 - 子代理:
subagentsEnabled在 v0.1 中只做记录。SDK 本身不提供子代理,以后由agent.delegate能力实现,并继承父任务的授权与预算。 - JSONL 会话(
sessionDir/<runId>.jsonl)只用于转写和恢复点。业务真相放在 PG,压缩摘要不作为唯一状态。
新增一个能力
能力描述只在 @umt/contracts 定义(B3):CapabilitySpec(描述符字段 + 真实 zod schema)、CapabilityKind(read / preview / propose / approve / execute)、RiskLevel(low / medium / high)、CapabilityReplay(safe / never)。本包不再有自己的描述类型。
首批 14 个能力一律用 capabilitySpec(id) 取规格(元数据来自 CAPABILITY_CATALOG,schema 来自 CAPABILITY_IO),server 模块只写处理器:
import { capabilitySpec } from "@umt/contracts";
registry.register(capabilitySpec("download.submit"), async (input, ctx) => {
// input 已按 DownloadSubmitInput 校验;ctx: actorId/taskId/scopeIds/signal + idempotencyKey + authorization
// 失败时抛 CapabilityError("precondition" | "not_found" | "conflict" | "unauthorized" | "budget", message)
const task = await downloads.submit(input.planId, ctx.idempotencyKey!, ctx.signal);
return { taskId: task.id, planId: input.planId, downloadTask: null };
});
目录外的新能力:先在 contracts 的 CAPABILITY_IDS / CAPABILITY_CATALOG / CAPABILITY_IO 各加一项,再按上面注册。测试里可以手写 CapabilitySpec 对象(kind 是能力类别,scope 是 HTTP 调用所需的 ApiScope)。
要点:输出只写业务需要的字段,因为 zod 会剥掉多余字段;不要把凭证放进输出;有副作用的 handler 要按 idempotencyKey 到操作账本去重。propose / approve / execute 三类都必须经过 authorize,并由注册中心生成幂等键。崩溃恢复时,replay:"never" 的工具会得到"结果未知",模型需要用查询类能力核对状态。
在 server 中装配(W2)
import { CapabilityRegistry, PiAgentRuntime, buildSystemPrompt } from "@umt/agent";
const registry = new CapabilityRegistry({
authorize: ({ descriptor, input, ctx }) => grants.check(ctx.actorId, descriptor.id, ctx.scopeIds, input), // 查 automation_grant / approval
audit: (rec) => auditRepo.insert({ type: "capability.invoke", ...rec }), // 写 audit_event
idempotencyKey: (d, input, ctx) => opLedger.keyFor(d.id, input, ctx), // 可选
});
for (const m of modules) m.registerCapabilities?.(registry, ctx); // 各 server 模块用 capabilitySpec(id) 注册处理器
const runtime = new PiAgentRuntime({
registry,
// e 是 contracts 的 AgentRunEvent;同一 run 内按 seq 串行回调。先落库再推 SSE:
onEvent: async (e) => {
await db.insertInto("agent_run_event").values(toDbRow(agentRunEventToRow(e))).execute(); // (run_id, seq) 唯一
const bus = agentRunEventToBusEvent(e); // agent.run_status / agent.text_delta / agent.tool_call,其余返回 null
if (bus) events.publish(bus.type, bus.payload);
},
});
// 在持久任务 agentRun 的处理器里执行(不要在聊天 HTTP 请求里驱动):
const run = await runtime.createRun({
runId: agentRun.id,
goal,
systemPrompt: buildSystemPrompt({ role: "媒体库自动化助手", goal, constraints }),
capabilities: ["catalog.lookup", "inventory.query", "download.plan", "download.submit"],
settings: { ...settingsRow.agent, apiKey: await secrets.decrypt(settingsRow.agent.apiKeyRef) },
sessionDir: join(dataDir, "agent-sessions"),
actor: { actorId, taskId: task.id, scopeIds, allowedKinds }, // "先给方案"模式传 ['read','preview','propose']
firstSeq, // 续跑时 = 该 run 在 agent_run_event 中的 max(seq)+1
});
const outcome = run.pendingOperations > 0 ? await run.resume() : await run.prompt();
await run.close();
// outcome.status === "failed" && reason === "budget" → 标记 attention;任务取消 → run.abort()
测试和 e2e 用 createFakeRuntime({ registry, scripts }):FakeScript 按 goal 匹配一段预定的 tool-call 序列和最终文本,每一步的输入可以根据前面工具的结果计算。它不联网,也不需要 API Key;因为它从 transcript 推导当前步骤,崩溃后在新进程里 resume 也能接着跑。
事件
事件类型只在 contracts 定义(AgentRunEvent / AgentRunEventBody / AgentRunEventType,B9):{ runId, seq, at } & 下列之一:run_started、text_delta、thinking_delta、tool_call_start/tool_call_end(带脱敏截断后的输入/输出摘要和 recovery 标记)、tool_call_rejected、compaction(start/end)、run_completed(AgentUsageTotals + 最终文本)、run_failed(reason: budget | aborted | error)。
- 落库:
agent_run_event(run_id, seq, type, at, payload),主键 (run_id, seq);agentRunEventToRow/agentRunEventFromRow负责转换。 - 推送:
agentRunEventToBusEvent映射到全局 SSE 的agent.run_status/agent.text_delta/agent.tool_call(phase:start→started,end→succeeded/failed,rejected→denied)。 run.events(fromSeq)回放本进程已有事件后继续跟随新事件,close()后结束;跨进程回放读agent_run_event。