Subagent / vision subflow prompts (audience=agent) get a dedicated section: report goes to the parent session, and thinking, step notes and reports stay in Chinese even when the goal, tool results or web pages are in English. Prompt regression test covers both the shared hard rule and the new section. Child reports reaching the user are already humanized: the child session page uses the same FIX-7 report-humanize path, and the parent's final report resolves child run IDs to their goal titles; agent.spawn/await tool rows show no raw summary. 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。