Files
UHD_Media_Tool/docs/adr/ADR-003-pi-integration.md
T
rootandClaude Opus 5.5 003815591c feat(agent): 运行时从 pi-agent-core 0.99.2 迁到 pi-durable 1.1.0 + pi-ai 1.1.0
- PiAgentRuntime 改用 pi-durable Harness:每个 run 一个 JSONL 存储目录 sessionDir/<runId>/ + root 对话;
  系统提示 / 能力工具 / 白名单与预算钩子装在每次运行自己的扩展里;watchEvents 翻译成 AgentRunEvent
- 挂起(工具内 close)立即封口,调用保持未完成,重开后 resume 重放;replayGate 用调用级 memo
- 旧格式 <runId>.jsonl 打开时抛 LegacySessionError;skills 加载与 <skill> 块格式内置(源自 pi-agent-core,MIT)
- tool_call_end 新增可选 durationMs;用量取 pi.usage 合计(整段会话总量)
- Azure provider 改名 azure,迁移 0093 改存量;移除 pi-agent-core,锁 pi-durable/pi-ai/chord 1.1.0
- 文档:ADR-003、pi-providers、runbook、agent README、CHANGELOG

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 13:42:36 +08:00

11 KiB
Raw Blame History

ADR-003:Pi 接入采用 pi-agent-core AgentHarness,封装在 AgentRuntimePort 之后

  • 状态:已采纳(2026-09-27,W0-B spike 后);2026-10-08 运行时迁到 @earendil-works/pi-durable,见文末「2026-10-08 迁到 pi-durable」,下文的 AgentHarness 细节保留为历史
  • 关联:REQ §10、§11.4;plan §1、§3.1-9;验证记录 spike w0b-pi(已删除,见 git ceb66f8);代码 spikes/pi/(已删除,见 git ceb66f8)

决定

  1. 依赖锁精确版本:@earendil-works/[email protected]、@earendil-works/[email protected],另加它们要求的 [email protected]。不引入 @earendil-works/pi-coding-agent,因为它的 ResourceLoader 会做隐式发现、自带 bash/read 工具,并依赖 TUI 和 jiti 扩展加载。
  2. 使用 core 的 AgentHarness / AgentLane。它就是 0.87.1 里 REQ §10.1 所说的 AgentSession 层:持久会话、事件流、压缩、abort/resume、工具重放策略都在这一层;而且只使用显式传入的 tools/resources/systemPrompt,不读取任何目录。
  3. 所有 SDK 类型只出现在 packages/agent/src/pi/* 里,对外只暴露 AgentRuntimePort。
  4. 测试统一使用 pi-ai 自带的 fauxProvider,配合 FauxResponseFactory 按 transcript 决策;生产中的上游用 createProvider 显式构造,不依赖环境变量。

AgentRuntimePort 草案

export interface AgentRunSpec {
  agentRunId: string;                 // = agent_run.id,同时作为 pi session id
  goal: string;                       // 用户目标或事件派生的有界任务
  scope: { actorId: string; scopeIds: string[]; grantIds: string[] };
  capabilities: CapabilityId[];       // 本次允许的能力白名单(能力注册中心解析)
  model: { upstreamId: string; modelId: string; thinkingLevel: ThinkingLevel };
  budget: { maxTurns: number; maxTokens: number; maxCostUsd?: number; deadlineAt: Date };
  compaction: { enabled: boolean; reserveTokens: number; keepRecentTokens: number };
  evidenceRefs: string[];
}

export type AgentRunEvent =
  | { type: "text_delta"; seq: number; delta: string }
  | { type: "tool_started"; seq: number; capabilityId: string; invocationId: string; args: unknown }
  | { type: "tool_finished"; seq: number; capabilityId: string; invocationId: string; isError: boolean; summary: string; recovery?: true }
  | { type: "usage"; seq: number; input: number; output: number; costUsd: number }
  | { type: "compaction"; seq: number; reason: "manual" | "threshold" | "overflow"; status: string }
  | { type: "run_finished"; seq: number; status: "completed" | "aborted" | "failed" | "budget_exceeded"; error?: string };

export interface AgentRuntimePort {
  createRun(spec: AgentRunSpec): Promise<{ agentRunId: string }>;            // 建会话 + accept prompt,不驱动
  stream(agentRunId: string, fromSeq?: number): AsyncIterable<AgentRunEvent>; // SSE 用,事件先落库(带 seq)再推送
  drive(agentRunId: string, signal?: AbortSignal): Promise<AgentRunOutcome>;  // 由持久任务调用;内部是 lane.resume / drive
  abort(agentRunId: string, reason: string): Promise<void>;                   // lane.abort → 工具收到 context.abortSignal
  resume(agentRunId: string): Promise<AgentRunOutcome>;                       // 进程重启后:AgentHarness.create 返回 open → lane.resume
  followUp(agentRunId: string, message: string): Promise<void>;               // 用户纠正
}

实现要点:

  • drive/resume 由 packages/tasks 的持久任务(例如 agentRun 任务)调用。聊天 HTTP 只做 createRun + stream,断线不影响执行(REQ §10.7)。
  • 事件监听器(harness.events.on)把事件写入 agent_run_event(seq 单调递增),SSE 按 seq 补读。
  • 预算:usage 事件累计,before_request hook 里发现超预算就 abort,状态记为 budget_exceeded,然后进入 attention。
  • 会话存储:v0.1 用 JsonlSessionRepo,目录固定为应用数据目录 data/agent-sessions/(不放在媒体目录或用户目录),agent_run 行记录 session id 和路径。NodeExecutionEnv 只当作这个目录的 FileSystem 用,不把它的 exec/shell 暴露成工具。PG 存储要等上游或自研 Storage 实现(接口约 10 个方法);格式仍是 WIP,所以不在 v0.1 做。
  • 业务真相放在 PG(agent_run、操作账本、能力调用记录),JSONL 只是会话转写和恢复点。REQ §10.5/§11.4 说"不能把压缩摘要当唯一状态",这里同理。

工具白名单机制(三层)

  1. 注册层:AgentHarness.create({ tools }) 只传本次 capabilities 对应的工具;也可以用 activeToolNames / lane.setActiveTools 缩小范围。模型调用未注册的工具时,harness 合成 Tool "x" is unavailable 错误结果,不会执行(spike 中已验证 bash)。
  2. 授权层:工具 execute 调能力服务。能力服务在服务端校验 toolContext(actorId、grants、scope)和持续/单次授权(REQ §10.3),不信任模型参数里的"已同意"。before_tool hook 用来记审计和做快速拒绝({ block: { reason } })。
  3. 命名映射:能力 ID download.submit ↔ 工具名 download__submit,因为上游工具名只允许 [a-zA-Z0-9_-]。映射表在 packages/agent 里唯一定义。

每个能力还要声明 replay:只读能力(catalog.lookup、inventory.query、task.inspect 等)用 "safe";有副作用的能力(download.submit、organize.execute、policy.apply 等)用 "never"。崩溃恢复时,副作用工具会得到"结果未知"。业务侧按服务端生成的幂等键到操作账本对账,模型重新调用时会命中去重,不会产生第二次副作用。

fake provider 用法

const faux = fauxProvider({ provider: "umt-fake", models: [{ id: "fake", contextWindow: 32000 }] });
models.setProvider(faux.provider);
faux.setResponses([
  () => fauxAssistantMessage([fauxText("先查库存"), fauxToolCall("inventory__query", { title: "Dune" })], { stopReason: "toolUse" }),
  (ctx) => /* 读 ctx.messages 里的 toolResult 决定下一步 */ fauxAssistantMessage("完成"),
]);
// faux.state.callCount、getPendingResponseCount();队列耗尽时返回 errorMessage "No more faux responses queued"

packages/agent 提供 createScriptedModel(steps),供 testkit 和 e2e 使用(plan §3.1-13,"把 X 电影 1080p 收进来")。

设置页 → SDK 映射

设置项 SDK
上游接入(Anthropic / OpenAI 兼容) createProvider({ id, auth: { apiKey: { resolve } }, models: [Model], api: anthropicMessagesApi() / openAICompletionsApi() });Model.baseUrl(Anthropic 不带 /v1,OpenAI 兼容带 /v1)、Model.id、Model.compat
模型选择 lane.setModel({ provider, modelId })
思考档位 thinkingLevel / lane.setThinkingLevel;可选值来自 getSupportedThinkingLevels(model)
上下文预算 自定义 Model.contextWindow / maxTokens
压缩策略 compaction: { enabled, reserveTokens, keepRecentTokens } + before_compaction hook / customInstructions(保留目标、约束、证据引用、已完成动作、剩余事项)
重试 retry: { enabled, maxRetries, baseDelayMs }、streamOptions.timeoutMs
子代理开关 SDK 不提供。由我们实现一个 agent.delegate 能力:开新 lane 或新会话,继承父任务的授权上限、预算和取消状态;默认关闭

已知限制

  • 0.x 版本,存储格式 4 仍是 WIP,可能原地改变。锁精确版本,升级后跑 agent 与 server 的 Pi 相关测试(deno task test:all)。
  • 没有子代理,没有 PG 会话存储,watchSession 未实现。
  • Pi 不提供权限系统(REQ §10.2)。进程、容器、网络白名单要在部署层落实。
  • 模型流中途崩溃后按"合成 error + 重试"恢复,只读了规范,未实测;v0.1 的 e2e 要补上。
  • 真实上游只验证了请求形状(本地抓包),没有联网调用。

2026-10-08 迁到 pi-durable

pi-agent-core 1.0 删掉了 harness(AgentHarness、会话存储、压缩、skills、系统提示等),只剩 Agent 与循环;harness 搬到 @earendil-works/pi-durable(实验性,版本之间 API 会变)。我们改为:

  • 依赖锁精确版本:@earendil-works/[email protected]、@earendil-works/[email protected]、@earendil-works/[email protected](pi-durable 的 Context / 文档状态),移除 pi-agent-core。Pi 的类型仍只出现在 packages/agent/src/pi/*(另有 prompt/skills.ts 的格式约定),对外只有 AgentRuntimePort,端口与 AgentRunEvent 合同不变(tool_call_end 只新增可选 durationMs)。
  • 一个 run = 一个 pi-durable 存储:sessionDir/<runId>/ 下的 JSONL 存储(openNodeJsonlStorage),里面用 root 对话。每次 createRun 新建一个 Harness + 一个只给本次用的 registry,装一个 umt 扩展:系统提示是一个不加标签的 section,能力是 tools,白名单 / 工具次数预算在 ToolTask.beforeTool,时长 / 费用预算在 GenerationTask.beforeRequest。
  • 单写者:pi-durable 不做跨进程加锁,同一存储同一时刻只能由一个进程打开。runAgent 每次运行在一个持久步骤里打开一次会话、结束时关闭;同一 run 同一时刻只由持有任务引擎租约的那个 worker 驱动(租约过期才会被别的 worker 接手),挂起后由下一次 runAgent 重新打开。旧版 JSONL 会话也是同样的前提。
  • 事件:用 watchEvents 订阅 root 对话的提交事件,翻译成我们的 AgentRunEvent(文本 / 思考增量、工具开始 / 结束 / 拒绝、用量、压缩)。增量按提交粒度(默认 100ms)到达。用量取 pi.usage 文档的合计,是整段会话的总量(换进程续跑不再从 0 计)。
  • prompt = root.submit({type:"input"}) + wait();resume = harness.resume() 后等未完成的输入;abort = root.abort();手动压缩 = root.compact() + 等任务;自动压缩用 compaction: { enabled, reserveTokens, keepRecentTokens, backgroundTokens: 0 }(不做后台提前压缩,与旧版阈值压缩一致)。
  • 重放:能力 replay=safe → 工具 replay:"safe",never → "unsafe"(崩溃后得到 interrupted 错误结果);带 replayGate 的审批类能力对 harness 声明 safe,用调用级 memo 区分首次与重放。调用 ID 用 <工具任务 ID>:<toolCallId>,重放时不变。
  • 挂起(agent.await / 审批 / 选方案):工具内部调用 handle.close() 时立即封口 Harness(之后的提交一律被拒),该调用在存储里保持未完成;重新打开后 pendingOperations>0,resume() 重放这次调用。
  • Skills:pi-durable 不再提供 skills 加载器,prompt/skills.ts 保留最小实现(<name>/SKILL.md 的发现、frontmatter 解析与校验、Pi 的 <skill> 调用块格式),来源 pi-agent-core 0.99.2(MIT)。
  • 工具输出上限:pi-durable 默认把工具文本截到 50KB / 2000 行,我们放宽到 8MB,沿用旧版不截断的行为。
  • 旧会话:0.99.2 的 sessionDir/<runId>.jsonl 不能续跑;只有旧文件、没有新目录时 createRun 抛 LegacySessionError,runAgent 以清楚的错误结束该 run(已结束的会话照常从 PG 事件查看)。