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

109 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 草案
```ts
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 用法
```ts
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 事件查看)。