# W0-B spike:Pi SDK(pi-agent-core / pi-ai 0.87.1)验证记录 - 日期:2026-09-27;Node v24.15.0;`@earendil-works/pi-agent-core@0.87.1`、`@earendil-works/pi-ai@0.87.1`(依赖 `@earendil-works/chord`、`pi-telemetry`、`typebox@1.3.27`) - 代码:`spikes/pi/`(`common.ts` 工具与脚本化模型、`fake-run.ts`、`abort-resume.ts`、`compaction.ts`、`provider-config.ts`) - 参考:包内 README / .d.ts;GitHub `earendil-works/pi` tag `v0.87.1` 的 `packages/agent/docs/harness.md`(AgentHarness 规范)、`packages/agent/test/harness/runtime/drive-public.test.ts`、`packages/coding-agent/examples/sdk/12-full-control.ts` - 结论见 `docs/adr/ADR-003-pi-integration.md` ## 0. 该用哪一层 | 层 | 包 | 说明 | 结论 | | --- | --- | --- | --- | | `Agent` / `agentLoop` | pi-agent-core | 进程内状态,无持久化 | 不用 | | **`AgentHarness` + `AgentLane`** | pi-agent-core | 持久会话(entry 树 + 操作状态机),有 prompt/resume/abort/compact、事件、hooks、工具重放策略;**不做任何隐式发现** | **采用** | | `createAgentSession` / `AgentSession` | pi-coding-agent | 编码助手层:ResourceLoader 会发现扩展、skills、AGENTS.md,内置 read/bash 等工具,依赖 pi-tui、jiti(运行时加载 TS 扩展)、photon | 不用。REQ §10.1 说的"AgentSession 层"在 0.87.1 里的对应物,就是 core 的 AgentHarness | `grep` 过 pi-agent-core dist:只有 `harness/skills.js`(显式调用的 loader)、`env/nodejs.js`(显式构造的执行环境)、`pico3/jsonl.js` 会接触文件系统。`AgentHarness.create()` 只使用传入的 `tools` / `resources` / `systemPrompt`,**不读取 cwd、home、`.pi/`、AGENTS.md**。 ## 1. 关键 API 形状 ```ts import { createModels, createProvider, fauxProvider, fauxAssistantMessage, fauxToolCall, fauxText } from "@earendil-works/pi-ai"; import { AgentHarness, BACKGROUND_CONTEXT, MemorySessionRepo, JsonlSessionRepo, type AgentHarnessTool } from "@earendil-works/pi-agent-core"; const models = createModels(); models.setProvider(provider); const session = await new MemorySessionRepo().create({ id }, ctx); // 或 JsonlSessionRepo({ fileSystem, sessionsRoot }) const { harness, open } = await AgentHarness.create({ session, models, model, systemPrompt, tools: [/* AgentHarnessTool */], toolContext: { actorId, grants }, // toolContext 可为 (ctx)=>T activeToolNames?, thinkingLevel: "off"|"minimal"|"low"|"medium"|"high"|"xhigh"|"max", compaction: { enabled, reserveTokens, keepRecentTokens }, retry: { enabled, maxRetries, baseDelayMs, maxAgentDelayMs? }, streamOptions: { timeoutMs, maxRetries, headers, metadata, cacheRetention, deferred }, toolExecution: "sequential" | "parallel", resources?: { skills, promptTemplates }, }, ctx); // open = 附着时发现的未完成操作 const lane = await harness.lane("main", ctx); await lane.prompt(text, undefined, ctx) / lane.resume(ctx) / lane.abort(ctx) / lane.compact({ customInstructions }, ctx) lane.accept({kind:"prompt",operationId,prompt}) + lane.drive({operationId}) + lane.requestAbort(operationId) // 低层原语,可由外部调度器驱动 harness.events.on("tool_start" | "tool_end" | "message_update" | "run_end" | "usage" | "compaction_end" | …, listener) harness.hooks.on("before_tool" | "after_tool" | "before_request" | "before_payload" | "transform_context" | "before_compaction" | …, handler) lane.setModel({provider, modelId}) / setThinkingLevel / setActiveTools;harness.setTools / setCompactionSettings / setRetryPolicy ``` 工具: ```ts const t: AgentHarnessTool = { name, label, description, parameters: Type.Object({...}), // typebox 1.x replay: "safe" | "never", // 崩溃后:safe = 用持久化的参数重跑;never = 合成"执行被中断、结果未知"的错误结果 executionMode?: "sequential", async execute(toolCallId, params, onUpdate, toolContext, invocation /* invocationId, getMemo/setMemo */, context /* context.abortSignal */) { return { content: [{ type: "text", text }], details }; // 失败直接 throw → isError:true }, }; ``` ## 2. 验证结果 ### 2.1 fake 模型驱动两个工具 + 流式事件 —— 通过(`npx tsx pi/fake-run.ts`) fake 模型用 `fauxProvider` + `FauxResponseFactory`(函数收到 transcript,按上一个工具结果决定下一步)实现,不需要 API Key。 ``` [run_start] lane=main [turn_start] lane=main [text_delta] "我先查一下库里有没有《D" [text_delta] "une》。" [toolcall_end] inventory__query [usage] in=145 totals.input=145 output=18 [hook before_tool] inventory__query args={"title":"Dune","year":2021} [tool_start] inventory__query {"title":"Dune","year":2021} [tool_update] lane=main [tool_end] inventory__query isError=false -> "{\"title\":\"Dune\",\"inLibrary\":false,\"versions\":[]}" [turn_end] lane=main [turn_start] lane=main [text_delta] "库里没有,提交 1080" … [toolcall_end] download__submit [toolcall_end] bash [hook before_tool] download__submit args={"title":"Dune","quality":"1080p"} [tool_start] download__submit {"title":"Dune","quality":"1080p"} [tool_end] download__submit isError=false -> "{\"accepted\":true,\"idempotencyKey\":\"dl:Dune:1080p\",\"status\":\"submitted\"}" [tool_start] bash {"command":"rm -rf /"} [tool_end] bash isError=true -> "Tool \"bash\" is unavailable" ← 不在白名单的工具被合成错误结果,不执行 [turn_start] … [text_delta] "下载已提交(仅代表已提交,不代表已入库)…" [run_end] status=completed RESULT {"ok":true,"value":{"operationId":"01a0e235-…","kind":"run","status":"completed",…}} SUBMITTED [{"key":"dl:Dune:1080p","title":"Dune"}] fake calls: 3 ``` ### 2.2 AbortSignal 取消 —— 通过(`SUBMIT_DELAY_MS=5000 npx tsx pi/abort-resume.ts abort`) ``` [tool_start] download__submit >> lane.abort() [operation_abort] [tool_end] download__submit "aborted by signal" ← execute 里的 context.abortSignal 触发了 [tool_end] bash "Tool execution was cancelled before completion." ← 同批次尚未执行的调用被合成为取消结果 [run_end] aborted PROMPT RESULT {…"status":"aborted"…} ``` ### 2.3 会话持久化与崩溃恢复 —— 通过(JSONL) 进程 1(`crash`)在 `download.submit` 执行中 `SIGKILL` 自己;进程 2(`resume`)打开同一个 JSONL 会话: ``` found session crash-demo …/.sessions/--opt-uhd_media_tool-spikes-pi-.sessions--/2026-09-27T09-33-19-828Z_crash-demo.jsonl open operations at attach: [{"lane":"main","operationId":"01a0e236-3a62-…","kind":"run","startedAt":1790501599842}] [tool_end] (recovery) download__submit "[Tool execution was interrupted. … the external outcome is unknown.]" ← replay:"never" 不重跑 [tool_start] (recovery) bash / [tool_end] (recovery) bash "Tool \"bash\" is unavailable" [run_end] completed fake calls: 1 ← 只多请求一次模型;inventory.query 没有重跑 ``` 恢复时 `download.submit` 得到的是"结果未知"。业务侧要按 `invocationId` 或服务端幂等键去操作账本对账,不能让模型重新提交一次。 ### 2.4 压缩 —— 手动通过,阈值触发可见(`compaction.ts`) ``` [compaction_start] reason=manual / [compaction_end] reason=manual status=completed REPEAT=400:[compaction_start] reason=threshold → [compaction_end] status=completed 单条输入本身超过 contextWindow 时 run 失败:{"code":"assistant_error","message":"Assistant request exceeded the context window"} entry types: compaction,message,… ``` 摘要本身也是一次模型调用(fake 模型按脚本返回)。压缩提示词可以通过 `customInstructions` 或 `before_compaction` hook 定制。 ### 2.5 真实 provider 类型的配置(不出外网)—— 通过(`provider-config.ts`) 用 `createProvider` 分别套 `anthropic-messages` 和 `openai-completions` 两种 API 实现,baseUrl 指向脚本自带的本地抓包服务器(返回 400): ``` builtin anthropic models (first 5): claude-fable-5, claude-fable-5-1, claude-haiku-4-5, … builtin anthropic auth configured without env? undefined supported thinking levels, custom anthropic model: off,minimal,low,medium,high [before_request] upstream-anthropic/claude-sonnet-4-5 step=assistant attempt=1 [upstream-anthropic] run: {"status":"failed","error":{"code":"assistant_error","message":"400 {…capture server: refusing on purpose…}"}} CAPTURED POST /v1/messages?beta=true auth=sk-test-anthropic model=claude-sonnet-4-5 max_tokens=8192 thinking={"type":"enabled","budget_tokens":7168,"display":"summarized"} tools=["inventory__query"] stream=true CAPTURED POST /v1/chat/completions auth=Bearer sk-test-openai model=qwen3-235b max_tokens=8192 thinking=true tools=["inventory__query"] stream=true ``` 配置字段: - `Model`:`id`(上游模型名)、`api`(`anthropic-messages` | `openai-completions` | `openai-responses` | …)、`provider`、`baseUrl`、`reasoning`、`thinkingLevelMap`、`contextWindow`、`maxTokens`、`cost`、`headers`、`compat`(OpenAI 兼容上游的差异开关,如 `supportsDeveloperRole`、`thinkingFormat`、`maxTokensField`)。 - baseUrl 的拼接方式:Anthropic 写 **不带 `/v1`** 的根地址(SDK 自己拼 `/v1/messages`);OpenAI 兼容写 **带 `/v1`** 的地址(SDK 拼 `/chat/completions`)。 - apiKey:`createProvider({ auth: { apiKey: { name, resolve: async () => ({ auth: { apiKey, baseUrl?, headers? } }) } } })`,每次请求时解析,可以接我们自己的 secret store。内置 provider 用 `envApiKeyAuth` 读环境变量(如 `ANTHROPIC_API_KEY`),也可以用 `createModels({ credentials })` 注入。 - 思考档位:thinkingLevel 映射到上游参数(Anthropic high → `budget_tokens 7168`),可用 `getSupportedThinkingLevels(model)` / `clampThinkingLevel` 查询。 - 上下文预算:`model.contextWindow`(可以自定义成比上游小的值)配合 `compaction.reserveTokens/keepRecentTokens`。 ## 3. 不支持 / 未验证 - **子代理**:pi-agent-core 和 pi-ai 里都没有子代理概念(dist 里 grep 不到)。设置页的"子代理开关"要由我们自己实现,例如在工具里开一个新 lane 或新会话,并继承预算和授权。 - **工具名不能带点**:`inventory.query` 这种名字上游(Anthropic/OpenAI 要求 `^[a-zA-Z0-9_-]{1,64}$`)不接受,本 spike 统一映射成 `inventory__query`。pi 自身是否校验名字**未验证**。 - **PostgreSQL 会话存储**:0.87.1 只提供 Memory / JSONL / SQLite(独立包 `pi-session-backend-sqlite-node`,**未验证**)。Postgres 分区存储在规范 Part 6 里标注为 future。存储格式 4 仍是 WIP,不保证迁移。 - `watchSession` 未实现(规范 R12);遥测只有部分实现。 - 模型流式输出中途崩溃的恢复(合成 error 后按 retry 重试)只读了规范,**没有实测**。 - pi-coding-agent 的 `createAgentSession` / ResourceLoader **没有安装,也没有运行**(按上文结论不采用)。 - 与真实上游联网调用:没有(不需要 key)。