@weifuwujs/pi-agent
v1.3.0
Published
持久化、可审批、可审计的 agent 运行时:pi 内核 + 沙箱 provider + 至少一次语义
Readme
@weifuwujs/pi-agent —— 可恢复、可审批、可审计的 agent 运行时
把 @earendil-works/pi-coding-agent 的 agent 内核包成任务运行时:
一次任务(run)跨进程持久化、崩溃后可续跑、危险工具必须人工批准、全过程留痕可审计、成本与预算显式可查。
设计前提:你已经有一个 Postgres(会话/事件/租约都在库里),外加一个加固容器(工具执行在容器里)。
二者都是可选的——内存驱动(createMemoryStore + createMemorySandbox)足以在测试与本地开发中跑完整链路。
npm i @weifuwujs/pi-agent @earendil-works/pi-coding-agent postgres零运行时依赖:@earendil-works/pi-coding-agent(peer >=1 <2)与 postgres(peer,可选用)都由你提供。
快速上手
import { ModelRuntime } from '@earendil-works/pi-coding-agent'
import { piAgent, createMemorySandbox, piAgentApi } from '@weifuwujs/pi-agent'
// 1. 模型:pi 的 ModelRuntime(密钥由你提供,绝不进容器)
const modelRuntime = await ModelRuntime.create()
const model = modelRuntime.getModel('anthropic', 'claude-sonnet-4-5')
// 2. agent:沙箱决定工具在哪执行;approve 决定哪些工具需要人批准
const agent = piAgent({
sandbox: createMemorySandbox(),
model,
modelRuntime,
approve: ['sandbox_exec'],
})
const run = await agent.start('把 README 里的错别字改掉')
agent.drain() // 推进到工具调用 → 挂起等人批准
console.log(await agent.get(run.id)) // { status: 'waiting_approval', ... }
await agent.approve(run.id, { by: '[email protected]' })
await agent.drain() // 继续执行 → done最小可跑示例(零依赖,node 直接运行;test/batteries/readme-examples.test.ts 会真跑它):
import assert from 'node:assert/strict'
import { piAgentOpenApi } from '@weifuwujs/pi-agent'
const document = piAgentOpenApi() as { openapi: string; paths: Record<string, unknown> }
assert.equal(document.openapi, '3.1.0')
assert.ok(document.paths['/runs'], '运维面必须有 /runs')
assert.ok(document.paths['/runs/{id}'], '运维面必须有 /runs/{id}')示例标注:
ts exec= 由测试真跑;ts fragment= 片段(依赖前后文,不独立运行);自足示例带包标签(如ts tasks),由测试编译。
生产形态(Postgres 持久化 + Docker 沙箱 + worker 进程):
import postgres from 'postgres'
import { piAgent, createPostgresStore, createDockerSandbox } from '@weifuwujs/pi-agent'
const sql = postgres(process.env.DATABASE_URL!)
const agent = piAgent({
store: createPostgresStore({ sql }), // 首次使用自动建表(advisory lock 保护)
sandbox: createDockerSandbox(),
model,
modelRuntime,
workerId: `worker-${process.pid}`,
})
await agent.drain() // 恢复入口:领回**崩溃遗留**的 run 并从最后提交的回合续跑语义契约(先读这一段)
| 语义 | 说明 |
| --- | --- |
| 至少一次(at-least-once) | 进程崩溃后 run 会被重新领取并续跑;同一回合可能被执行第二次。幂等是用户侧义务。 |
| 幂等键 | 每条进沙箱的命令都注入环境变量 PI_AGENT_IDEMPOTENCY_KEY(<runId>:<命令与参数哈希>,同一任务内同命令同参数 → 同键)。你的副作用脚本应当按它去重(写入前 insert ... on conflict do nothing 之类)。 |
| 零重放已提交回合 | 已提交的模型回合不会重发;重放的工具调用记为 history 动作 tool_replayed。 |
| 崩溃自愈 | 租约(lease)+ CAS + FOR UPDATE SKIP LOCKED:worker 崩溃后租约过期,其他 worker 可领取;死 run 不会自动复活(终态永不改)。 |
| 会话丢失 | 会话文件缺失/损坏 → run 显式失败 SESSION_LOST,绝不静默重头跑。 |
人工审批(HITL)
approve: ['sandbox_exec', 'write_file']白名单里的工具每次执行前都要批准;不在名单里的工具直接执行。- 请求批准 → run 变
waiting_approval,不占调度(drain()返回,其他 run 照跑;可以挂到明天)。 agent.approve(id, { by })批准;agent.deny(id, { by, reason })拒绝 → run 显式失败APPROVAL_DENIED: <reason>且零执行。- 一次性:批准只覆盖下一次执行,工具再次运行要再次批准。
- 重复批准 →
APPROVAL_DUPLICATE;没有待批准请求时批准 →APPROVAL_NOT_PENDING。 - 全过程落 history:
approval_requested/approval_granted/approval_denied(带by)。
取消与超时(硬杀)
agent.cancel(id, { by, reason }):先 AbortSignal 硬杀在途命令(进程组 / 容器kill -KILL -1),再通知会话中止;被杀的调用记aborted: true。run 终态cancelled。agent.start(prompt, { timeoutMs }):执行段计时——等人批准的时间不算超时;批准后重新计时。超时 → 终态failed+RUN_TIMEOUT: <ms>。- 两条路径都留痕:
cancel_requested/cancelled/timed_out。
成本与预算
- 每个任务的 token 与美元成本落在 run 上(
tokensIn/tokensOut/costUsd),history 里有usage事件,HTTPGET /runs直接可查。 budget: { maxTokens, maxTurns }在回合边界检查;超限 → run 显式失败BUDGET_EXCEEDED: <detail>+ historybudget_exceeded,绝不静默续跑。- 诚实边界:超限发生在某个回合的工具已开始执行之后时,该工具会跑完(回合边界检查,不是逐 token 熔断)。
会话与事件(分层)
| 层 | 位置 | 用途 |
| --- | --- | --- |
| 会话(文件态) | pi 原生 JSONL 文件(sessionDir,路径落在 DB 的 session_file 列) | 续跑:SessionManager.open() 恢复完整上下文;本机 pi 也能打开同一文件 |
| 会话(DB 态,1.3 起 opt-in) | _pi_sessions(会话行)+ _pi_events(append-only 条目 delta) | 多副本无共享磁盘也能续跑;exportRun()/importSession() 的数据源;exportRun().jsonl 就是上面那种 pi 原生 JSONL |
| 权威事件序列 | Postgres _pi_history(run_id, seq, action, at, detail) | 审计与回放:动作含 start/turn/tool/tool_replayed/usage/budget_exceeded/approval_*/cancel_*/cancelled/timed_out/end/fail |
| 工具幂等记录 | Postgres _pi_tool_calls(key, status, result) | 崩溃后判断某次工具调用是否已提交 |
会话导出/导入(1.3)
默认不落库(sessionPersistence: 'none',行为与 1.2 一致——会话只在磁盘文件或内存)。
要多副本续跑、或要把会话交给本机 pi,开 sessionPersistence: 'store':
const agent = piAgent({ sandbox, model, store, sessionPersistence: 'store' })
// 导出:jsonl 是 pi 原生格式——写文件后本机 `pi` 直接能打开(真 SDK 验收在案)
const snapshot = await agent.exportRun(runId) // { sessionId, cwd, entries, jsonl, exportedAt }
writeFileSync('resume.jsonl', snapshot.jsonl!)
// 导入:逐行校验(坏行报「第 N 行」,绝不静默丢历史)
const imported = await agent.importSession({ jsonl: readFileSync('other.jsonl', 'utf8') })
// 续跑:把导入的会话绑到新任务(模型真的能看到历史——脚本模型断言在案)
await agent.start('接着上次继续', { sessionId: imported.sessionId })weifuwu-pi-agent export <runId> # stdout 纯 JSON(含 jsonl 字段)
weifuwu-pi-agent import --file resume.jsonl # → { sessionId, cwd, entries }
weifuwu-pi-agent start '继续' --session <sessionId>语义边界:导入后开新任务不是「崩溃续跑」——发的是你的新 prompt;只有本 run 已被执行过(崩溃/批准后重试)才发续跑提示词。
未开 sessionPersistence: 'store' 时 importSession/start --session 显式报 SESSION_DISABLED(不静默忽略)。
沙箱(隔离分级——请诚实阅读)
- 双驱动同契约:
createMemorySandbox()(本地/测试)与createDockerSandbox()(生产);exec(command, { timeoutMs, signal, env })。 - 加固基线(docker 驱动默认):
--read-only+--tmpfs /tmp、工作区只写挂载、--cap-drop ALL、no-new-privileges、非 root--user、--pids-limit、--memory、--network none、容器标签pi-agent.managed。 - 没有 Docker 时明确失败:抛
SANDBOX_UNAVAILABLE(绝不回退宿主执行);容器启动失败是SANDBOX_FAILED;命令非零退出不是异常,是{ isError: true }的工具结果。 - 信任边界:沙箱挡住的是事故(误删、误跑、误联网、资源打满),不是对抗性隔离——容器内没有
docker.sock,但恶意代码仍不应与你的宿主机共处。对抗性多租户请上 gVisor / microVM。 - 隔离分级 L0–L4 的判据与实测:判据即源码(
src/isolation.ts: ISOLATION_CRITERIA:可用性 / 性能 p50≤1500ms 且 p95≤基线×3 / 依赖 ≤2 二进制+1 配置+0 代码改动 / 运维单命令且有据可查;样本 <10 不下结论)。 在仓库内一条命令复现全部数字:npm run spike:isolation(脚本不随包发布;门控 e2e:PI_SANDBOX_E2E=1 node --test test/isolation.docker.e2e.test.ts)。 实测(2026-10-05,Arch / 内核 7.2.8 / docker 29.8.2):L2 加固容器成立且逐条可归因——非 rootCapEff=0(注意:非 root 切换本身就会清零能力)、root 对照--user 0:0下 CapEff 也为 0(这才证明--cap-drop ALL本身生效;无 cap-drop 的 root 对照为0xa80405fb)、unshare -U被拒、--network none生效、内存/pid 限额可见、workspace 宿主侧可回读、退出码 0/1/124,启动 p50 ≈ 134 ms(n=15); 但容器内uname -r与宿主一致 ⇒ 共享内核。L3(gVisor)/ L4(microVM)本仓未落地:runsc/kata-runtime/firecracker缺席即判红并留 docker 原话(unknown or invalid runtime name), 不假绿;拿到runsc的宿主跑同一条命令即可得到四准则真数据。长表(L0–L4 分级、原始样本、结论、撤回成本)见仓库spike/isolation.md(不随包发布)。 - 容器清理:
gcSandboxes()扫pi-agent.managed标签清残留(含进程kill -9之后)。 - 镜像:
docker pull alpine:3(默认;可用image选项换)。
升级到强隔离(L3 / L4)
宿主装好 runsc(gVisor)并在 /etc/docker/daemon.json 注册 runtime 后,零代码改动接上(参数插在加固基线之后、image 之前):
const sandbox = createDockerSandbox({
image: 'alpine:3',
extraRunArgs: ['--runtime', 'runsc'], // 强化类参数默认放行;真正生效由 docker inspect 的 HostConfig.Runtime 证明
})- 验证方法:
docker inspect <容器名> --format '{{.HostConfig.Runtime}}';仓库内可一条命令复测四准则:npm run spike:isolation(判据与分级见spike/isolation.md)。 - 逃生舱默认拒绝削弱参数:
extraRunArgs含--privileged/--cap-add/--user/--network/--pid=host/--ipc=host/--userns=host/--device/--security-opt/--mount/ 绑定docker.sock→ 抛SANDBOX_UNSAFE(启动容器之前拒绝),确需时显式allowUnsafeArgs: true(你自己负责)。 - 实测的逃生舱语义(2026-10-05,出处
plan/1.3.md叶子 2.3 Plan):--cap-drop是并集(追加丢得更多,无法撤销ALL);--cap-add能把能力加回来(--cap-drop ALL --cap-add SYS_ADMIN→CapEff=0x200000);重复--user后者生效;--network与none冲突会显式报错(不会静默放开);flag 放在 image 之后会静默失效(所以升级参数必须由extraRunArgs传,位置由本包保证)。 - L3/L4 现状:本仓未预置 gVisor/microVM(2.2 spike 判定红:分发 28.9 KB/s + 无 root),候选与触发条件见
plan/1.3.md的 1.4 候选表。
HTTP 面
import { piAgentApi, piAgentOpenApi } from '@weifuwujs/pi-agent'
const api = piAgentApi(agent, {
authorize: ({ action, runId }) => policy.check(user, action, runId), // 缺省 = 全部 403
})
for (const route of api.routes()) app.route(route.method, route.path, route.handler)
app.get('/openapi.json', () => Response.json(piAgentOpenApi())) // OpenAPI 3.1,可被 openapi-typescript 消费- 默认拒绝:不传
authorize→ 所有接口 403。 - 四包对照:
docs/authorization.md——四包默认拒绝矩阵(本包为严格=== true)与组合边界(可执行契约)。 - 动作级授权:
read/start/approve/deny/cancel(回调收到{ action, runId })。 - 错误映射稳定:400 / 403 / 404(
RUN_NOT_FOUND)/ 409(APPROVAL_*)/ 500。 - 事件流
GET /runs/{id}/events(SSE):用after=<seq>或Last-Event-ID断线续传;纯读,回放不会触发执行。
CLI(weifuwu-pi-agent)
weifuwu-pi-agent runs --status waiting_approval # 每行一个 JSON,可直接 | jq
weifuwu-pi-agent status <runId>
weifuwu-pi-agent history <runId> # 权威事件序列
weifuwu-pi-agent start '修一下线上 500'
weifuwu-pi-agent approve <runId> --by [email protected]
weifuwu-pi-agent deny <runId> --reason '改动面太大'
weifuwu-pi-agent cancel <runId> --reason '换个方案'
weifuwu-pi-agent worker --once # 或 --drain
weifuwu-pi-agent export <runId> # 会话快照(pi 原生 JSONL)
weifuwu-pi-agent import --file resume.jsonl # 导入会话(逐行校验)- 配置文件:
weifuwu-agent.config.{ts,mjs,js}(默认导出 agent 或工厂函数;ESM),或用--config <path>。 - stdout 纯 JSON(结果),诊断走 stderr(
CODE: message);退出码:0成功 /1业务失败(RUN_NOT_FOUND、APPROVAL_*)/2用法或配置错误。 - CLI 与 HTTP 指向同一个 store,两边看到的状态逐字一致(有真库 e2e 守着)。
非目标(诚实边界)
- 不做 会话内容与磁盘文件的双向同步:
'store'模式下 DB 是唯一真源(同一会话两种模式不要混用)。 - 不做 导入时的条目改写/迁移:只接受 pi 原生条目(坏行/不可解析条目显式拒绝),不做静默升级。
- 不提供
gc子命令:GC 是宿主/驱动侧职责(导出gcSandboxes()),HTTP 面也没有/gc路由。 - 不重建 agent 循环:模型循环、工具协议、会话格式全部来自 pi 内核(本包只做任务化、持久化、审批、沙箱与交付面)。
- 不是对抗性隔离:见上文信任边界。
环境
- Node ≥ 20(开发验证于 v26);
type: module的 ESM 工程。 - Postgres:任何 12+ 版本(表由本包自建:
_pi_runs/_pi_history/_pi_tool_calls)。 - Docker:可选;没有则
SANDBOX_UNAVAILABLE(本地用 memory 驱动)。
