- CapabilityRegistry: descriptor(id/version/schema/scope/risk/idempotent/preview/replay), invoke = scope limit -> input schema -> server-side authorize -> idempotency key -> handler -> output schema -> audit; capability id <-> tool name mapping (download.submit <-> download__submit) - PiAgentRuntime (pi-agent-core AgentHarness 0.87.1): whitelist tools + before_tool re-check, redacted event stream, JSONL session at sessionDir/<runId>.jsonl, resume/abort/compact, tool-call/duration/token/cost budgets -> abort + run_failed(budget) - AgentSettings -> pi-ai createProvider (anthropic / openai-compatible), compaction mapping - FakeModelProvider + createFakeRuntime (scripted, transcript-derived, survives crash/resume) - buildSystemPrompt, redactForModel; 27 tests incl. SIGKILL + resume Co-Authored-By: Claude Fable 5.1 <[email protected]>
83 lines
5.7 KiB
Markdown
83 lines
5.7 KiB
Markdown
# @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` 白名单里的能力。白名单有三层保护:
|
|
1. 注册层:只把白名单能力注册成 Pi 工具,其它工具名(例如 `bash`)由 harness 合成 unavailable 错误;
|
|
2. `before_tool` 钩子再校验一次,白名单外的调用一律 block,并产生 `tool_call_rejected` 事件;
|
|
3. 每次执行都走 `CapabilityRegistry.invoke`,依次做作用域、schema、授权、幂等键、输出 schema 和审计。
|
|
- **不提供** bash/进程执行、任意 SQL、任意文件读写、任意网络/HTTP 代理、读取 Cookie/Token、安装依赖或修改代码的工具。`NodeExecutionEnv` 只作为会话目录的 FileSystem 使用,它的 exec 不会暴露给模型。
|
|
- 授权只在服务端判定:`authorize` 回调查 automation_grant/approval。模型参数里写的"用户已同意"不起作用。没有注入 `authorize` 时,propose/execute 一律拒绝。
|
|
- 幂等键由服务端按操作语义生成(默认是 `id:v版本:sha256(规范化输入)`,可以注入自定义生成器),通过 `ctx.idempotencyKey` 交给 handler,不采用模型给的值。
|
|
- 工具的输入输出在进入模型上下文、事件和审计之前,都要经过 `redactForModel`(cookie/passkey/token/apiKey/authorization/私有下载 URL/URL 内嵌账号密码)处理。`apiKey` 只在请求时经 provider `auth.resolve` 交给 SDK。
|
|
- 子代理:`subagentsEnabled` 在 v0.1 中只做记录。SDK 本身不提供子代理,以后由 `agent.delegate` 能力实现,并继承父任务的授权与预算。
|
|
- JSONL 会话(`sessionDir/<runId>.jsonl`)只用于转写和恢复点。业务真相放在 PG,压缩摘要不作为唯一状态。
|
|
|
|
## 新增一个能力
|
|
|
|
```ts
|
|
registry.register(
|
|
{
|
|
id: "download.submit", // 域.动作;对 Pi 暴露为 download__submit
|
|
version: 1,
|
|
title: "提交下载",
|
|
description: "提交下载计划到下载器。成功只代表已提交,不代表已入库。", // 给模型看的中文说明
|
|
inputSchema: z.object({ planId: z.string() }),
|
|
outputSchema: z.object({ taskId: z.string(), status: z.literal("submitted") }),
|
|
scope: "execute", // read | preview | propose | execute
|
|
riskLevel: "medium", // low | medium | high | critical
|
|
idempotent: true,
|
|
supportsPreview: true,
|
|
replay: "never", // 有副作用的能力必须是 never;只读能力用 safe
|
|
},
|
|
async (input, ctx) => {
|
|
// ctx: actorId/taskId/scopeIds/signal + idempotencyKey + authorization
|
|
// 失败时抛 CapabilityError("precondition" | "not_found" | "conflict" | "unauthorized" | "budget", message)
|
|
return { taskId: await downloads.submit(input.planId, ctx.idempotencyKey!, ctx.signal), status: "submitted" };
|
|
},
|
|
);
|
|
```
|
|
|
|
要点:输出只写业务需要的字段,因为 zod 会剥掉多余字段;不要把凭证放进输出;有副作用的 handler 要按 `idempotencyKey` 到操作账本去重。崩溃恢复时,`replay:"never"` 的工具会得到"结果未知",模型需要用查询类能力核对状态。
|
|
|
|
## 在 server 中装配(W2)
|
|
|
|
```ts
|
|
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), // 可选
|
|
});
|
|
registerAllCapabilities(registry); // catalog.lookup / release.search / … / inventory.query
|
|
|
|
const runtime = new PiAgentRuntime({
|
|
registry,
|
|
onEvent: (e) => agentRunEvents.append(e).then(() => sse.publish(`agent_run:${e.runId}`, e)), // 先落库(带 seq)再推 SSE
|
|
});
|
|
|
|
// 在持久任务 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, allowedScopes }, // "先给方案"模式传 ['read','preview','propose']
|
|
});
|
|
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 也能接着跑。
|
|
|
|
## 事件
|
|
|
|
`AgentEvent` = `{ runId, seq, at } &` 下列之一:`run_started`、`text_delta`、`thinking_delta`、`tool_call_start`/`tool_call_end`(带脱敏截断后的输入/输出摘要和 `recovery` 标记)、`tool_call_rejected`、`compaction`(start/end)、`run_completed`(usage + 最终文本)、`run_failed`(reason: budget | aborted | error)。`run.events(fromSeq)` 回放已有事件后继续跟随新事件,`close()` 后结束。
|