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
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 13:42:36 +08:00
1 parent dd0f6e2c09
commit 003815591c
24 files changed
+692 -299

No files matched your search

+12
View File
@@ -2,6 +2,18 @@
版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。所有成员包(`apps/*`、`packages/*`)与根 `deno.json` 使用同一个版本号;`/api/v1/health` 与备份清单里的版本取自 `apps/server/deno.json`。 版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。所有成员包(`apps/*`、`packages/*`)与根 `deno.json` 使用同一个版本号;`/api/v1/health` 与备份清单里的版本取自 `apps/server/deno.json`。
## [Unreleased]
迁移 `0093`。
### 调整
- 智能体运行时升级到 Pi 1.1(pi-durable + pi-ai 1.1.0):会话的每一步(模型回复、工具调用、用量)都先写盘再显示,进程中途退出后续跑更稳;工具调用结束事件多了执行耗时(毫秒)。
- 多轮对话和续跑后的 token / 费用累计改为整段会话的真实总量(之前换进程续跑后从 0 重新计,界面上的用量偏低)。
- 模型服务的 Azure 提供方随 Pi 改名为 `azure`(原 `azure-openai-responses`),迁移 `0093` 自动改已保存的模型服务,配置项不变。
### 注意
- 会话存储格式变了:会话文件从 `agent-sessions/<会话 ID>.jsonl` 改为 `agent-sessions/<会话 ID>/` 目录。升级前已经结束的会话照常查看;升级时还在运行或挂起等待(审批、选方案、等子会话)的旧会话不能继续,会以「这个会话由旧版本创建……」失败,需要的话重新发起。
## [0.3.40] - 2026-10-08 ## [0.3.40] - 2026-10-08
迁移 `0091`。 迁移 `0091`。
+6 -4
View File
@@ -15,10 +15,12 @@
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"imports": { "imports": {
"@biomejs/biome": "npm:@biomejs/[email protected]", "@biomejs/biome": "npm:@biomejs/[email protected]",
"@earendil-works/pi-agent-core": "npm:@earendil-works/[email protected]", "@earendil-works/chord": "npm:@earendil-works/[email protected]",
"@earendil-works/pi-agent-core/": "npm:/@earendil-works/[email protected]/", "@earendil-works/chord/": "npm:/@earendil-works/[email protected]/",
"@earendil-works/pi-ai": "npm:@earendil-works/pi-ai@0.99.2", "@earendil-works/pi-ai": "npm:@earendil-works/pi-ai@1.1.0",
"@earendil-works/pi-ai/": "npm:/@earendil-works/pi-ai@0.99.2/", "@earendil-works/pi-ai/": "npm:/@earendil-works/pi-ai@1.1.0/",
"@earendil-works/pi-durable": "npm:@earendil-works/[email protected]",
"@earendil-works/pi-durable/": "npm:/@earendil-works/[email protected]/",
"@hono/swagger-ui": "npm:@hono/[email protected]", "@hono/swagger-ui": "npm:@hono/[email protected]",
"@hono/zod-openapi": "npm:@hono/[email protected]", "@hono/zod-openapi": "npm:@hono/[email protected]",
"@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/[email protected]", "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/[email protected]",
Generated
+29 -37
View File
@@ -7,8 +7,9 @@
"npm:@babel/[email protected]": "7.29.9", "npm:@babel/[email protected]": "7.29.9",
"npm:@biomejs/biome@*": "2.5.15", "npm:@biomejs/biome@*": "2.5.15",
"npm:@biomejs/[email protected]": "2.5.15", "npm:@biomejs/[email protected]": "2.5.15",
"npm:@earendil-works/[email protected]": "0.99.2_@modelcontextprotocol+sdk@1.31.0[email protected]", "npm:@earendil-works/[email protected]": "1.1.0",
"npm:@earendil-works/pi-ai@0.99.2": "0.99.2_@[email protected][email protected]", "npm:@earendil-works/pi-ai@1.1.0": "1.1.0_@[email protected][email protected]",
"npm:@earendil-works/[email protected]": "1.1.0_@[email protected][email protected]",
"npm:@fontsource-variable/inter-tight@^5.3.0": "5.3.0", "npm:@fontsource-variable/inter-tight@^5.3.0": "5.3.0",
"npm:@fontsource-variable/space-grotesk@^5.3.0": "5.3.0", "npm:@fontsource-variable/space-grotesk@^5.3.0": "5.3.0",
"npm:@hono/[email protected]": "[email protected]", "npm:@hono/[email protected]": "[email protected]",
@@ -91,8 +92,8 @@
], ],
"bin": true "bin": true
}, },
"@anthropic-ai/[email protected]4[email protected]": { "@anthropic-ai/[email protected]9[email protected]": {
"integrity": "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==", "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==",
"dependencies": [ "dependencies": [
"json-schema-to-ts", "json-schema-to-ts",
"standardwebhooks", "standardwebhooks",
@@ -424,8 +425,8 @@
], ],
"bin": true "bin": true
}, },
"@babel/[email protected].7": { "@babel/[email protected].10": {
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==" "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA=="
}, },
"@babel/[email protected]": { "@babel/[email protected]": {
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
@@ -511,26 +512,14 @@
"@date-fns/[email protected]": { "@date-fns/[email protected]": {
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==" "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="
}, },
"@earendil-works/chord@0.99.2": { "@earendil-works/chord@1.1.0": {
"integrity": "sha512-2dJWiNsOJm/ioBl1fhpJmw0hIjFUhM0CAteEnVkiVG0Kg4LimR7Asq8PfjclEvrbCPsDntzy7rGKhTZObI9TZw==", "integrity": "sha512-gsHzKfyQ3t0ZIQ3cLBjJ5Vqy1TYnFyruHIV3g3BwmulCDYe5BW4yT9vaJk9QzN6YANILIO75uzuq+Gdxtzz1og==",
"dependencies": [ "dependencies": [
"esbuild" "esbuild"
] ]
}, },
"@earendil-works/pi-a[email protected]_@[email protected][email protected]": { "@earendil-works/pi-a[email protected]_@[email protected][email protected]": {
"integrity": "sha512-VX0QMcg8HKBsv1bYP2NwmutBYa44OrwmtqR6Dq7QC3Ln8X/IlpHhUxB5L1JgnXNUUkTm8Wg/HZhzS797NBDMCQ==", "integrity": "sha512-1T7LAkc/5Bvc0v6w4vAGVdCrli0o/E0pEmYKTnixu95vSFArBjvbhS/G4ZwI0RUePgf0Imcu0VyqlM4EcXxqfw==",
"dependencies": [
"@earendil-works/chord",
"@earendil-works/pi-ai",
"@earendil-works/pi-telemetry",
"diff",
"ignore",
"[email protected]",
"[email protected]"
]
},
"@earendil-works/[email protected]_@[email protected][email protected]": {
"integrity": "sha512-9RFOEdY+ZTJ1AI+UuAFs4RM0tF2Tje/R/CEv9gWTJHt0iTu8XHZs64U/EIZxNSllFmec/4HfwsOxYj1qQek9bg==",
"dependencies": [ "dependencies": [
"@anthropic-ai/sdk", "@anthropic-ai/sdk",
"@aws-sdk/client-bedrock-runtime", "@aws-sdk/client-bedrock-runtime",
@@ -545,8 +534,17 @@
], ],
"bin": true "bin": true
}, },
"@earendil-works/pi-[email protected]": { "@earendil-works/pi-[email protected]_@[email protected][email protected]": {
"integrity": "sha512-WThYU4XM6jjjzH4FzLdreN7QAPS9SDdokL0W0Ldheg1ssCIJkfpK7PgebFm7/PoQub7OiXx+SRF6B/oF7z7lXA==" "integrity": "sha512-NtuB3RY24vQ+0/rWYpeHupyFWBfXuKq17to3VMYUYqfD/uuGnz6F6UTPBEm51VSxxA6LZwRcDwLX2LtRehnIgQ==",
"dependencies": [
"@earendil-works/chord",
"@earendil-works/pi-ai",
"diff",
"[email protected]"
]
},
"@earendil-works/[email protected]": {
"integrity": "sha512-8gAK05/2pPozZZz6hCLOi8x2vsqzvsmO9gG92kbvuvTPm0qMHaFlaXX98ndXsrOi5pmzha7vNLguuG4+0Rgxjw=="
}, },
"@esbuild/[email protected]": { "@esbuild/[email protected]": {
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
@@ -708,7 +706,7 @@
"@fontsource-variable/[email protected]": { "@fontsource-variable/[email protected]": {
"integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==" "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw=="
}, },
"@google/[email protected]_@[email protected][email protected]": { "@google/[email protected]_@[email protected]": {
"integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==",
"dependencies": [ "dependencies": [
"@modelcontextprotocol/sdk", "@modelcontextprotocol/sdk",
@@ -3298,9 +3296,6 @@
"safer-buffer" "safer-buffer"
] ]
}, },
"[email protected]": {
"integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==" "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ=="
}, },
@@ -3681,7 +3676,7 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA==", "integrity": "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA==",
"dependencies": [ "dependencies": [
"yaml@2.9.1" "yaml"
] ]
}, },
"[email protected][email protected]": { "[email protected][email protected]": {
@@ -4555,14 +4550,14 @@
"postcss", "postcss",
"rolldown", "rolldown",
"tinyglobby", "tinyglobby",
"yaml@2.9.1" "yaml"
], ],
"optionalDependencies": [ "optionalDependencies": [
"fsevents" "fsevents"
], ],
"optionalPeers": [ "optionalPeers": [
"@types/node", "@types/node",
"yaml@2.9.1" "yaml"
], ],
"bin": true "bin": true
}, },
@@ -4632,10 +4627,6 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
}, },
"[email protected]": {
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"bin": true
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"bin": true "bin": true
@@ -4654,8 +4645,9 @@
"dependencies": [ "dependencies": [
"npm:[email protected]", "npm:[email protected]",
"npm:@biomejs/[email protected]", "npm:@biomejs/[email protected]",
"npm:@earendil-works/[email protected]", "npm:@earendil-works/[email protected]",
"npm:@earendil-works/pi-ai@0.99.2", "npm:@earendil-works/pi-ai@1.1.0",
"npm:@earendil-works/[email protected]",
"npm:@hono/[email protected]", "npm:@hono/[email protected]",
"npm:@hono/[email protected]", "npm:@hono/[email protected]",
"npm:@modelcontextprotocol/[email protected]", "npm:@modelcontextprotocol/[email protected]",
+16 -1
View File
@@ -1,6 +1,6 @@
# ADR-003:Pi 接入采用 pi-agent-core `AgentHarness`,封装在 `AgentRuntimePort` 之后 # ADR-003:Pi 接入采用 pi-agent-core `AgentHarness`,封装在 `AgentRuntimePort` 之后
- 状态:已采纳(2026-09-27,W0-B spike 后) - 状态:已采纳(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`) - 关联:REQ §10、§11.4;plan §1、§3.1-9;验证记录 spike `w0b-pi`(已删除,见 git `ceb66f8`);代码 `spikes/pi/`(已删除,见 git `ceb66f8`)
## 决定 ## 决定
@@ -91,3 +91,18 @@ faux.setResponses([
- Pi 不提供权限系统(REQ §10.2)。进程、容器、网络白名单要在部署层落实。 - Pi 不提供权限系统(REQ §10.2)。进程、容器、网络白名单要在部署层落实。
- 模型流中途崩溃后按"合成 error + 重试"恢复,只读了规范,未实测;v0.1 的 e2e 要补上。 - 模型流中途崩溃后按"合成 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 事件查看)。
+1
View File
@@ -45,6 +45,7 @@
"kysely": "npm:[email protected]", "kysely/": "npm:/[email protected]/", "kysely": "npm:[email protected]", "kysely/": "npm:/[email protected]/",
"pg": "npm:[email protected]", "@types/pg": "npm:@types/[email protected]", "postgres": "npm:[email protected]", "pg": "npm:[email protected]", "@types/pg": "npm:@types/[email protected]", "postgres": "npm:[email protected]",
"openworkflow": "npm:[email protected]", "openworkflow/": "npm:/[email protected]/", "openworkflow": "npm:[email protected]", "openworkflow/": "npm:/[email protected]/",
// 2026-10-08 起 pi-agent-core 换成 @earendil-works/[email protected] + @earendil-works/[email protected](见 ADR-003)
"@earendil-works/pi-agent-core": "npm:@earendil-works/[email protected]", "@earendil-works/pi-agent-core": "npm:@earendil-works/[email protected]",
"@earendil-works/pi-agent-core/": "npm:/@earendil-works/[email protected]/", "@earendil-works/pi-agent-core/": "npm:/@earendil-works/[email protected]/",
"@earendil-works/pi-ai": "npm:@earendil-works/[email protected]", "typebox": "npm:[email protected]", "@earendil-works/pi-ai": "npm:@earendil-works/[email protected]", "typebox": "npm:[email protected]",
+2
View File
@@ -1,5 +1,7 @@
# Pi 模型上游与订阅登录(pi-ai / pi-coding-agent 0.87.1) # Pi 模型上游与订阅登录(pi-ai / pi-coding-agent 0.87.1)
> 2026-10-08 升级补充:Agent 运行时换成 `@earendil-works/pi-durable` 1.1.0 + `pi-ai` 1.1.0(+ `@earendil-works/chord` 1.1.0),不再依赖 pi-agent-core(1.0 起它只剩 `Agent` 与循环,harness 移到 pi-durable)。provider 目录仍是 42 个内置;pi-ai 1.0.3 把 `azure-openai-responses` 改名为 `azure`(api id 与 `AZURE_OPENAI_*` 配置名不变),合同枚举随之改名,迁移 `0093` 改已存的模型服务。登录流程、能力表与设置页映射不变。运行时的变化见 ADR-003「2026-10-08 迁到 pi-durable」。
>
> 2026-10-01 升级补充:当前接入目标已更新为 pi-agent-core/pi-ai 0.99.2,执行结果见 全面升级记录(已删除,见 git `ceb66f8`)。下文保留 0.87.1 的研究基线,不用历史计数验收新版。 > 2026-10-01 升级补充:当前接入目标已更新为 pi-agent-core/pi-ai 0.99.2,执行结果见 全面升级记录(已删除,见 git `ceb66f8`)。下文保留 0.87.1 的研究基线,不用历史计数验收新版。
> >
> 新版实际目录为 42 个内置 provider(新增 TypeSafe 分类服务),合同和目录回归已同步;TypeSafe 没有 Agent 聊天模型,目录说明已标明。OAuth 共 9 个 provider:新增 `openai` 原生 Sign in with ChatGPT,浏览器流程与回调端口 1455;现有 `openai-codex` 仍保留 device/browser 两种模式。新版 `openai` 是动态注册的公开客户端流程,没有照搬旧 Codex 客户端身份提示。来源:[0.99.2 OpenAI provider](https://github.com/earendil-works/pi/blob/v0.99.2/packages/ai/src/providers/openai.ts)、[原生 ChatGPT OAuth](https://github.com/earendil-works/pi/blob/v0.99.2/packages/ai/src/auth/oauth/openai-chatgpt.ts)。 > 新版实际目录为 42 个内置 provider(新增 TypeSafe 分类服务),合同和目录回归已同步;TypeSafe 没有 Agent 聊天模型,目录说明已标明。OAuth 共 9 个 provider:新增 `openai` 原生 Sign in with ChatGPT,浏览器流程与回调端口 1455;现有 `openai-codex` 仍保留 device/browser 两种模式。新版 `openai` 是动态注册的公开客户端流程,没有照搬旧 Codex 客户端身份提示。来源:[0.99.2 OpenAI provider](https://github.com/earendil-works/pi/blob/v0.99.2/packages/ai/src/providers/openai.ts)、[原生 ChatGPT OAuth](https://github.com/earendil-works/pi/blob/v0.99.2/packages/ai/src/auth/oauth/openai-chatgpt.ts)。
+1 -1
View File
@@ -227,7 +227,7 @@ ENV CAMOUFOX_EXECUTABLE_PATH=/opt/camoufox/camoufox-bin CAMOUFOX_INSTALL_DIR=/op
另追加一条带 `image_url` 的 user 消息;Google Gemini 3+ 嵌在 `functionResponse.parts`,更早版本另起 user 轮;Bedrock 转 image 块; 另追加一条带 `image_url` 的 user 消息;Google Gemini 3+ 嵌在 `functionResponse.parts`,更早版本另起 user 轮;Bedrock 转 image 块;
Mistral 视模型而定。全部以 `model.input.includes("image")` 为门,不支持视觉的模型得到占位文本。 Mistral 视模型而定。全部以 `model.input.includes("image")` 为门,不支持视觉的模型得到占位文本。
- **我们的 runtime 目前把能力输出 `JSON.stringify` 成单个 text 块**(`packages/agent/src/pi/runtime.ts` L193–196),要加图片通道。 - **我们的 runtime 目前把能力输出 `JSON.stringify` 成单个 text 块**(`packages/agent/src/pi/runtime.ts` L193–196),要加图片通道。
pi-agent-core 有 `transformContext`,可在每轮请求前把较早的截图替换为占位文本,控制 token 与缓存前缀。 pi-durable 的 `GenerationTask.beforeRequest` 钩子(原 pi-agent-core `transformContext`)可在每轮请求前把较早的截图替换为占位文本,控制 token 与缓存前缀。
- **camofox 1.17.0 没有坐标鼠标端点**;适配器现在的坐标点击是 `elementFromPoint` → 选择器点击,对 canvas 滑块、 - **camofox 1.17.0 没有坐标鼠标端点**;适配器现在的坐标点击是 `elementFromPoint` → 选择器点击,对 canvas 滑块、
跨源 iframe(Turnstile 复选框)与拖拽无效。截图是 `page.screenshot({type:'png'})`,像素 = 视口 CSS 像素 × DPR,runbook 实测 DPR=1。 跨源 iframe(Turnstile 复选框)与拖拽无效。截图是 `page.screenshot({type:'png'})`,像素 = 视口 CSS 像素 × DPR,runbook 实测 DPR=1。
+2 -1
View File
@@ -64,7 +64,8 @@ AG-3 补的只读摘要(类别 read,scope `read`,都在后台调查 / 巡检白
1. `drive` 步骤: 1. `drive` 步骤:
- 取上游(provider / baseUrl / 模型 / 思考档位来自 `pi_upstream`,凭证由上游的凭证存储在请求时解密注入,不落日志),从设置读上下文预算、压缩和预算。 - 取上游(provider / baseUrl / 模型 / 思考档位来自 `pi_upstream`,凭证由上游的凭证存储在请求时解密注入,不落日志),从设置读上下文预算、压缩和预算。
- 不设每日次数 / 费用上限(2026-10-06 起;`budget.agentMaxRunsPerDay` / `agentMaxCostUsdPerDay` 旧值兼容读取、不生效),花费在设置 → 智能体「用量与限制」与运行状态里可见(`GET /agent/usage`)。 - 不设每日次数 / 费用上限(2026-10-06 起;`budget.agentMaxRunsPerDay` / `agentMaxCostUsdPerDay` 旧值兼容读取、不生效),花费在设置 → 智能体「用量与限制」与运行状态里可见(`GET /agent/usage`)。
- 打开 `AGENT_SESSION_DIR/<runId>.jsonl`。`pendingOperations>0` 说明上次崩溃:先按幂等键对账,再 `resume()`;否则就 `prompt(goal)`。最后逐条消费追加的消息。 - 打开 `AGENT_SESSION_DIR/<runId>/`(pi-durable JSONL 存储;同一时刻只由一个进程打开)。`pendingOperations>0` 说明上次崩溃或挂起:先按幂等键对账,再 `resume()`;否则就 `prompt(goal)`。最后逐条消费追加的消息。
- 0.3.40 及以前留下的 `<runId>.jsonl`(旧格式)不能续跑:这类 run 被唤醒时直接失败,错误写明「这个会话由旧版本创建」;已结束的照常查看。
2. `goal-settled` 步骤:只在会话正常结束、且不是后台只读调查(内部 `agent_run.mode=plan_only`)时执行。每隔 `agent.goalPollSec` 检查一次,直到本 run 触达的计划、下载和整理记录全部落定,最长等 `agent.goalWaitHours`。等待期间 run 状态为 `waiting`,汇报写的是"已提交≠已入库"。 2. `goal-settled` 步骤:只在会话正常结束、且不是后台只读调查(内部 `agent_run.mode=plan_only`)时执行。每隔 `agent.goalPollSec` 检查一次,直到本 run 触达的计划、下载和整理记录全部落定,最长等 `agent.goalWaitHours`。等待期间 run 状态为 `waiting`,汇报写的是"已提交≠已入库"。
3. `finalize` 步骤:从业务表汇总计划、下载任务和整理记录,生成 `AgentGoalReport`(artifact kind=report),并据此决定终态: 3. `finalize` 步骤:从业务表汇总计划、下载任务和整理记录,生成 `AgentGoalReport`(artifact kind=report),并据此决定终态:
- 入库完成 → `succeeded`,汇报带整理记录 ID; - 入库完成 → `succeeded`,汇报带整理记录 ID;
+7 -5
View File
@@ -1,20 +1,22 @@
# @umt/agent # @umt/agent
Pi Agent 接入层:能力注册中心、`AgentRuntimePort` 及其 Pi 实现、设置映射、敏感信息守卫、系统提示模板,以及测试用的脚本化假模型。 Pi Agent 接入层:能力注册中心、`AgentRuntimePort` 及其 Pi 实现、设置映射、敏感信息守卫、系统提示模板,以及测试用的脚本化假模型。
依赖锁定为 `@earendil-works/pi-[email protected]`、`@earendil-works/pi-ai@0.87.1`、`[email protected]`(见 ADR-003)。Pi SDK 的类型只出现在 `src/pi/*` 中,其它包只依赖 `src/index.ts` 导出的自有接口。 依赖锁定为 `@earendil-works/pi-[email protected]`、`@earendil-works/pi-ai@1.1.0`、`@earendil-works/[email protected]`(见 ADR-003「2026-10-08 迁到 pi-durable」)。Pi SDK 的类型只出现在 `src/pi/*` 中,其它包只依赖 `src/index.ts` 导出的自有接口。
## 边界 ## 边界
- 模型只能调用本次 `capabilities` 白名单里的能力。白名单有三层保护: - 模型只能调用本次 `capabilities` 白名单里的能力。白名单有三层保护:
1. 注册层:只把白名单能力注册成 Pi 工具,其它工具名(例如 `bash`)由 harness 合成 unavailable 错误; 1. 注册层:只把白名单能力注册成 Pi 工具,其它工具名(例如 `bash`)由 harness 合成 tool_unavailable 错误,并产生 `tool_call_rejected` 事件;
2. `before_tool` 钩子再校验一次,白名单外的调用一律 block,并产生 `tool_call_rejected` 事件; 2. `ToolTask.beforeTool` 钩子再校验一次,白名单外的调用一律 block,并产生 `tool_call_rejected` 事件;
3. 每次执行都走 `CapabilityRegistry.invoke`,依次做能力类别上限(allowedKinds)、schema、授权、幂等键、输出 schema 和审计。 3. 每次执行都走 `CapabilityRegistry.invoke`,依次做能力类别上限(allowedKinds)、schema、授权、幂等键、输出 schema 和审计。
- **不提供** bash/进程执行、任意 SQL、任意文件读写、任意网络/HTTP 代理、读取 Cookie/Token、安装依赖或修改代码的工具。`NodeExecutionEnv` 只作为会话目录的 FileSystem 使用,它的 exec 不会暴露给模型。 - **不提供** bash/进程执行、任意 SQL、任意文件读写、任意网络/HTTP 代理、读取 Cookie/Token、安装依赖或修改代码的工具。Harness 不配执行环境(`env`),pi-durable 自带的 read/write/edit/bash 工具不安装。
- 授权只在服务端判定:`authorize` 回调查 automation_grant/approval。模型参数里写的"用户已同意"不起作用。没有注入 `authorize` 时,propose/approve/execute 一律拒绝。 - 授权只在服务端判定:`authorize` 回调查 automation_grant/approval。模型参数里写的"用户已同意"不起作用。没有注入 `authorize` 时,propose/approve/execute 一律拒绝。
- 幂等键由服务端按操作语义生成(默认是 `id:v版本:sha256(规范化输入)`,可以注入自定义生成器),通过 `ctx.idempotencyKey` 交给 handler,不采用模型给的值。 - 幂等键由服务端按操作语义生成(默认是 `id:v版本:sha256(规范化输入)`,可以注入自定义生成器),通过 `ctx.idempotencyKey` 交给 handler,不采用模型给的值。
- 工具的输入输出在进入模型上下文、事件和审计之前,都要经过 `redactForModel`(cookie/passkey/token/apiKey/authorization/私有下载 URL/URL 内嵌账号密码)处理。`apiKey` 只在请求时经 provider `auth.resolve` 交给 SDK。 - 工具的输入输出在进入模型上下文、事件和审计之前,都要经过 `redactForModel`(cookie/passkey/token/apiKey/authorization/私有下载 URL/URL 内嵌账号密码)处理。`apiKey` 只在请求时经 provider `auth.resolve` 交给 SDK。
- 子代理:`subagentsEnabled` 在 v0.1 中只做记录。SDK 本身不提供子代理,以后由 `agent.delegate` 能力实现,并继承父任务的授权与预算。 - 子代理:`subagentsEnabled` 在 v0.1 中只做记录。SDK 本身不提供子代理,以后由 `agent.delegate` 能力实现,并继承父任务的授权与预算。
- JSONL 会话(`sessionDir/<runId>.jsonl`)只用于转写和恢复点。业务真相放在 PG,压缩摘要不作为唯一状态。 - 会话存储(`sessionDir/<runId>/`,pi-durable JSONL)只用于转写和恢复点。业务真相放在 PG,压缩摘要不作为唯一状态。
- 单写者:同一会话存储同一时刻只能由一个进程打开(pi-durable 不做跨进程加锁);runAgent 每次运行只打开一次,结束或挂起时关闭。
- 0.3.40 及以前的 `sessionDir/<runId>.jsonl`(pi-agent-core 0.99 格式)不能续跑,打开时抛 `LegacySessionError`。
## 新增一个能力 ## 新增一个能力
+1 -1
View File
@@ -38,7 +38,7 @@ export {
type PiAgentRuntimeOptions, type PiAgentRuntimeOptions,
toolContent, toolContent,
} from "./pi/runtime.ts"; } from "./pi/runtime.ts";
export { sessionPathFor } from "./pi/session.ts"; export { LegacySessionError, sessionPathFor } from "./pi/session.ts";
export { export {
classifyUpstreamError, classifyUpstreamError,
PiUpstreamClient, PiUpstreamClient,
+5 -11
View File
@@ -12,10 +12,10 @@ import type {
} from "@umt/contracts"; } from "@umt/contracts";
// pi-ai 内置 provider → 能力表。认证方式、模型表直接读 builtinProviders(); // pi-ai 内置 provider → 能力表。认证方式、模型表直接读 builtinProviders();
// 登录方式、env 字段、条款提示是研究文档(docs/design/pi-providers.md §1.3/§2/§5)及 0.99.2 升级核对的静态表, // 登录方式、env 字段、条款提示是研究文档(docs/design/pi-providers.md §1.3/§2/§5)及 0.99.2 / 1.1.0 升级核对的静态表,
// 升级 pi-ai 时随文档一起复核。 // 升级 pi-ai 时随文档一起复核。
export const PI_AI_VERSION = "0.99.2"; export const PI_AI_VERSION = "1.1.0";
let cached: readonly Provider[] | null = null; let cached: readonly Provider[] | null = null;
/** 内置 provider 实例(无状态的共享;Radius 这类动态 provider 按上游另建新实例) */ /** 内置 provider 实例(无状态的共享;Radius 这类动态 provider 按上游另建新实例) */
@@ -71,7 +71,7 @@ const env = (name: string, label: string, required: boolean, hint: string | null
/** provider 需要的非密配置(存 extra.env;请求时经 pi 的 AuthResult.env 下发) */ /** provider 需要的非密配置(存 extra.env;请求时经 pi 的 AuthResult.env 下发) */
export const ENV_FIELDS: Record<string, PiEnvField[]> = { export const ENV_FIELDS: Record<string, PiEnvField[]> = {
"azure-openai-responses": [ azure: [
env("AZURE_OPENAI_BASE_URL", "Azure 端点 URL", false, "与资源名二选一"), env("AZURE_OPENAI_BASE_URL", "Azure 端点 URL", false, "与资源名二选一"),
env("AZURE_OPENAI_RESOURCE_NAME", "Azure 资源名", false, "与端点 URL 二选一"), env("AZURE_OPENAI_RESOURCE_NAME", "Azure 资源名", false, "与端点 URL 二选一"),
env("AZURE_OPENAI_API_VERSION", "API 版本", false, null), env("AZURE_OPENAI_API_VERSION", "API 版本", false, null),
@@ -95,13 +95,7 @@ export const ENV_FIELDS: Record<string, PiEnvField[]> = {
/** 用服务端环境凭据(不存密钥)的 provider:authKind none */ /** 用服务端环境凭据(不存密钥)的 provider:authKind none */
const AMBIENT = new Set(["amazon-bedrock", "google-vertex"]); const AMBIENT = new Set(["amazon-bedrock", "google-vertex"]);
const CLOUD = new Set([ const CLOUD = new Set(["amazon-bedrock", "google-vertex", "azure", "cloudflare-ai-gateway", "cloudflare-workers-ai"]);
"amazon-bedrock",
"google-vertex",
"azure-openai-responses",
"cloudflare-ai-gateway",
"cloudflare-workers-ai",
]);
/** baseUrl 由 token / env 决定,不允许覆盖 */ /** baseUrl 由 token / env 决定,不允许覆盖 */
const NO_BASE_OVERRIDE = new Set([ const NO_BASE_OVERRIDE = new Set([
"openai-codex", "openai-codex",
@@ -109,7 +103,7 @@ const NO_BASE_OVERRIDE = new Set([
"radius", "radius",
"amazon-bedrock", "amazon-bedrock",
"google-vertex", "google-vertex",
"azure-openai-responses", "azure",
"cloudflare-ai-gateway", "cloudflare-ai-gateway",
"cloudflare-workers-ai", "cloudflare-workers-ai",
]); ]);
+7 -3
View File
@@ -1,7 +1,7 @@
import type { CompactionSettings } from "@earendil-works/pi-agent-core";
import { type Api, createModels, createProvider, type Model, type MutableModels } from "@earendil-works/pi-ai"; import { type Api, createModels, createProvider, type Model, type MutableModels } from "@earendil-works/pi-ai";
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy"; import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
import type { CompactionPolicy } from "@earendil-works/pi-durable";
import type { ResolvedAgentSettings } from "../settings.ts"; import type { ResolvedAgentSettings } from "../settings.ts";
/** Pi 侧需要的模型装配结果(仅在 src/pi/* 内部流转) */ /** Pi 侧需要的模型装配结果(仅在 src/pi/* 内部流转) */
@@ -62,12 +62,16 @@ export function resolveModelFromSettings(settings: ResolvedAgentSettings): Resol
return { models, model }; return { models, model };
} }
/** 压缩设置:threshold 模式下占用达到 threshold 比例时触发;manual 只允许手动 compact() */ /**
export function compactionFromSettings(settings: ResolvedAgentSettings, contextWindow: number): CompactionSettings { * 压缩设置:threshold 模式下占用达到 threshold 比例时阻塞压缩;manual 只允许手动 compact()。
* backgroundTokens=0:不做 pi-durable 的后台提前压缩,行为与 pi-agent-core 0.99 的阈值压缩一致。
*/
export function compactionFromSettings(settings: ResolvedAgentSettings, contextWindow: number): CompactionPolicy {
const reserveTokens = Math.max(256, Math.round(contextWindow * (1 - settings.compaction.threshold))); const reserveTokens = Math.max(256, Math.round(contextWindow * (1 - settings.compaction.threshold)));
return { return {
enabled: settings.compaction.mode === "threshold", enabled: settings.compaction.mode === "threshold",
reserveTokens, reserveTokens,
keepRecentTokens: Math.max(256, Math.round(contextWindow * 0.25)), keepRecentTokens: Math.max(256, Math.round(contextWindow * 0.25)),
backgroundTokens: 0,
}; };
} }
+372 -147
View File
@@ -1,15 +1,24 @@
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";
import { import {
AgentHarness, type AgentEvent,
type AgentHarness as AgentHarnessInstance, type AgentEventStream,
type AgentHarnessTool, type Conversation,
type AgentLane, createRegistry,
type AgentMessage, defineExtension,
BACKGROUND_CONTEXT, type Extension,
type Context, GenerationTask,
type JsonlSessionRepo, Harness,
type RunResult, type HarnessSettings,
} from "@earendil-works/pi-agent-core"; hook,
import type { Usage } from "@earendil-works/pi-ai"; type SettledSubmissionRecord,
section,
type ToolExecutionResult,
type ToolRegistration,
ToolTask,
type UsageState,
watchEvents,
} from "@earendil-works/pi-durable";
import { import {
type AgentBudgetKind, type AgentBudgetKind,
type AgentRunEvent, type AgentRunEvent,
@@ -33,7 +42,7 @@ import {
} from "../runtime/port.ts"; } from "../runtime/port.ts";
import { type ResolvedAgentSettings, resolveAgentSettings } from "../settings.ts"; import { type ResolvedAgentSettings, resolveAgentSettings } from "../settings.ts";
import { compactionFromSettings, type PiModelResolver, resolveModelFromSettings } from "./model.ts"; import { compactionFromSettings, type PiModelResolver, resolveModelFromSettings } from "./model.ts";
import { openRunSession } from "./session.ts"; import { openRunStorage } from "./session.ts";
import { import {
PiUpstreamClient, PiUpstreamClient,
type UpstreamClientOptions, type UpstreamClientOptions,
@@ -63,12 +72,18 @@ export interface PiAgentRuntimeOptions {
export const DEFAULT_COMPACTION_INSTRUCTIONS = export const DEFAULT_COMPACTION_INSTRUCTIONS =
"保留:本次目标、约束与授权范围、证据引用(对象 ID/记录)、已完成的动作及其结果(已提交≠已入库)、剩余事项。不要保留任何凭证。"; "保留:本次目标、约束与授权范围、证据引用(对象 ID/记录)、已完成的动作及其结果(已提交≠已入库)、剩余事项。不要保留任何凭证。";
const LANE = "main";
/** 可挂起重放的能力:首次执行时写入的调用级 memo(重放时据此区分) */ /** 可挂起重放的能力:首次执行时写入的调用级 memo(重放时据此区分) */
const STARTED_MEMO = "umt.started"; const STARTED_MEMO = "umt.started";
const SUMMARY_LIMIT = 600; const SUMMARY_LIMIT = 600;
/** pi-durable 默认把工具文本截到 50KB / 2000 行;能力输出已自带分页与脱敏,沿用 0.99 时代不截断的行为 */
const TOOL_OUTPUT_LIMITS = { maxBytes: 8 * 1024 * 1024, maxLines: 1_000_000 } as const;
/** Pi(pi-agent-core AgentHarness)实现的 AgentRuntimePort */ /**
* Pi(pi-durable Harness)实现的 AgentRuntimePort。
*
* 一个 run = 一个 pi-durable 存储(sessionDir/<runId>/ 下的 JSONL)+ 其中的 root 对话。
* Harness 只在本进程打开一次(单写者),close() 后其他进程才能接手续跑。
*/
export class PiAgentRuntime implements AgentRuntimePort { export class PiAgentRuntime implements AgentRuntimePort {
constructor(private readonly options: PiAgentRuntimeOptions) {} constructor(private readonly options: PiAgentRuntimeOptions) {}
@@ -84,32 +99,29 @@ export class PiAgentRuntime implements AgentRuntimePort {
: resolveModelFromSettings(settings); : resolveModelFromSettings(settings);
const { models, model } = resolved; const { models, model } = resolved;
const thinkingLevel = resolved.thinkingLevel ?? settings.thinkingLevel; const thinkingLevel = resolved.thinkingLevel ?? settings.thinkingLevel;
const opened = await openRunSession(spec.sessionDir, spec.runId, ctx); const opened = await openRunStorage(spec.sessionDir, spec.runId, ctx);
const run = new PiAgentRun(spec, settings, capabilities, this.options, opened.path, opened.repo); const run = new PiAgentRun(spec, settings, capabilities, this.options, opened.path);
run.supportsImages = Array.isArray(model.input) && model.input.includes("image"); run.supportsImages = Array.isArray(model.input) && model.input.includes("image");
try { const registry = createRegistry();
const { harness, open } = await AgentHarness.create( registry.install(run.extension());
{ const harnessSettings: HarnessSettings = {
session: opened.session, stream: { timeoutMs: settings.requestTimeoutMs, maxRetries: settings.maxRetries },
models,
model,
systemPrompt: spec.systemPrompt,
tools: capabilities.map((c) => run.toTool(c)),
toolContext: {},
thinkingLevel,
compaction: compactionFromSettings(settings, model.contextWindow),
retry: { enabled: settings.maxRetries > 0, maxRetries: settings.maxRetries, baseDelayMs: 1000 }, retry: { enabled: settings.maxRetries > 0, maxRetries: settings.maxRetries, baseDelayMs: 1000 },
streamOptions: { timeoutMs: settings.requestTimeoutMs, maxRetries: settings.maxRetries }, compaction: compactionFromSettings(settings, model.contextWindow),
toolExecution: "sequential", toolExecution: "sequential",
}, };
ctx, let harness: Harness | undefined;
); try {
const lane = await harness.lane(LANE, ctx); harness = await Harness.open(opened.storage, { models, registry, settings: harnessSettings }, ctx);
run.attach(harness, lane, open.filter((o) => o.lane === LANE).length); const agent = { model: { provider: model.provider, modelId: model.id }, thinkingLevel };
const root = await harness.root(ctx, { agent });
// 已有会话:模型 / 思考档位跟随本次设置(换了上游或模型后继续)
await root.configure(agent, ctx);
await run.attach(harness, root);
} catch (error) { } catch (error) {
await opened.session.close(ctx).catch(() => undefined); if (harness) await harness.close(ctx).catch(() => undefined);
await opened.repo.close(ctx).catch(() => undefined); else await opened.storage.close(ctx).catch(() => undefined);
throw error; throw error;
} }
return run; return run;
@@ -117,6 +129,7 @@ export class PiAgentRuntime implements AgentRuntimePort {
} }
type BudgetState = { kind: AgentBudgetKind; message: string }; type BudgetState = { kind: AgentBudgetKind; message: string };
type ContentBlock = AssistantMessage["content"][number];
class PiAgentRun implements AgentRunHandle { class PiAgentRun implements AgentRunHandle {
readonly runId: string; readonly runId: string;
@@ -128,9 +141,16 @@ class PiAgentRun implements AgentRunHandle {
private readonly channel = new EventChannel<AgentRunEvent>(); private readonly channel = new EventChannel<AgentRunEvent>();
private readonly allowed = new Map<string, string>(); private readonly allowed = new Map<string, string>();
private readonly rejected = new Set<string>(); private readonly rejected = new Set<string>();
/** 已发出 tool_call_start 的调用 */
private readonly started = new Set<string>();
/** 打开会话时仍在执行中的调用(崩溃 / 挂起前未完成):恢复后的事件带 recovery */
private readonly recovering = new Map<string, { toolName: string; taskId: unknown }>();
/** 模型给出的调用参数(未经过 intent 的调用,结束时补发 start 用) */
private readonly callArgs = new Map<string, unknown>();
private readonly abortController = new AbortController(); private readonly abortController = new AbortController();
private harness!: AgentHarnessInstance<object>; private harness!: Harness;
private lane!: AgentLane; private root!: Conversation;
private stream: AgentEventStream | undefined;
private readonly firstSeq: number; private readonly firstSeq: number;
private seq: number; private seq: number;
private delivery: Promise<void> = Promise.resolve(); private delivery: Promise<void> = Promise.resolve();
@@ -144,14 +164,21 @@ class PiAgentRun implements AgentRunHandle {
private startedAt: number | undefined; private startedAt: number | undefined;
private deadlineTimer: NodeJS.Timeout | undefined; private deadlineTimer: NodeJS.Timeout | undefined;
private closed = false; private closed = false;
private closing: Promise<void> | undefined;
/** 正在执行的工具数:工具内部触发 close()(挂起等待)时不能等 Harness 关闭,它要等这个工具结束 */
private activeTools = 0;
/** 流式中的 assistant 内容(由 message_start / message_update 维护)与各块已发出的字数 */
private partial: ContentBlock[] | undefined;
private shown: number[] = [];
private batches = 0;
private consuming = false;
constructor( constructor(
private readonly spec: CreateRunSpec, private readonly spec: CreateRunSpec,
private readonly settings: ResolvedAgentSettings, private readonly settings: ResolvedAgentSettings,
capabilities: RegisteredCapability[], private readonly capabilities: RegisteredCapability[],
private readonly options: PiAgentRuntimeOptions, private readonly options: PiAgentRuntimeOptions,
sessionPath: string, sessionPath: string,
private readonly repo: JsonlSessionRepo,
) { ) {
this.runId = spec.runId; this.runId = spec.runId;
this.sessionPath = sessionPath; this.sessionPath = sessionPath;
@@ -160,8 +187,29 @@ class PiAgentRun implements AgentRunHandle {
for (const c of capabilities) this.allowed.set(c.toolName, c.descriptor.id); for (const c of capabilities) this.allowed.set(c.toolName, c.descriptor.id);
} }
/** 本次运行的 pi-durable 扩展:系统提示 + 能力工具 + 白名单 / 预算钩子 */
extension(): Extension {
const systemPrompt = this.spec.systemPrompt;
return defineExtension({
name: "umt",
sections: [section("umt", () => systemPrompt, { tag: false })],
tools: this.capabilities.map((c) => this.toTool(c)),
hooks: [
hook(ToolTask, { beforeTool: (call) => this.beforeTool(call.id, call.name) }),
hook(GenerationTask, {
beforeRequest: () => {
this.checkDeadline();
this.checkCost();
if (this.budget) this.scheduleAbort();
return undefined;
},
}),
],
});
}
/** 能力 → Pi 工具。执行时统一走注册中心 invoke(schema/作用域/授权/幂等/审计) */ /** 能力 → Pi 工具。执行时统一走注册中心 invoke(schema/作用域/授权/幂等/审计) */
toTool(entry: RegisteredCapability): AgentHarnessTool<object> { toTool(entry: RegisteredCapability): ToolRegistration {
const { descriptor } = entry; const { descriptor } = entry;
const registry = this.options.registry; const registry = this.options.registry;
const actor = this.spec.actor; const actor = this.spec.actor;
@@ -170,19 +218,23 @@ class PiAgentRun implements AgentRunHandle {
const gated = descriptor.replay === "never" && !!gate; const gated = descriptor.replay === "never" && !!gate;
return { return {
name: entry.toolName, name: entry.toolName,
label: descriptor.title,
description: `${descriptor.description}\n[能力 ${descriptor.id} v${descriptor.version};类别 ${descriptor.kind};风险 ${descriptor.riskLevel}]`, description: `${descriptor.description}\n[能力 ${descriptor.id} v${descriptor.version};类别 ${descriptor.kind};风险 ${descriptor.riskLevel}]`,
parameters: registry.inputJsonSchema(descriptor.id) as unknown as TSchema, parameters: registry.inputJsonSchema(descriptor.id) as unknown as TSchema,
replay: gated ? "safe" : descriptor.replay, replay: gated || descriptor.replay === "safe" ? "safe" : "unsafe",
outputLimits: TOOL_OUTPUT_LIMITS,
...(descriptor.kind === "execute" ? { executionMode: "sequential" as const } : {}), ...(descriptor.kind === "execute" ? { executionMode: "sequential" as const } : {}),
execute: async (toolCallId, params, _onUpdate, _toolCtx, invocation, context) => { execute: async (params, api, context): Promise<ToolExecutionResult> => {
const signal = context.abortSignal ?? this.abortController.signal; const signals = [this.abortController.signal, ...(context.abortSignal ? [context.abortSignal] : [])];
const invocationId = invocation.invocationId ?? toolCallId; const signal = AbortSignal.any(signals);
// 稳定的调用 ID:同一存储内唯一,挂起 / 崩溃后重放时不变(审批、子会话等待按它对应)
const invocationId = `${api.taskId}:${api.callId}`;
this.activeTools += 1;
try {
if (gated) { if (gated) {
const replayed = (await invocation.getMemo(STARTED_MEMO)) === true; const replayed = (await api.memo<boolean>(STARTED_MEMO, context)) === true;
if (!replayed) await invocation.setMemo(STARTED_MEMO, true); if (!replayed) await api.memo(STARTED_MEMO, true, context);
else if (!gate(descriptor.id, invocationId)) else if (!gate(descriptor.id, invocationId))
throw new Error( return errorResult(
JSON.stringify({ JSON.stringify({
error: { error: {
code: "interrupted", code: "interrupted",
@@ -203,36 +255,64 @@ class PiAgentRun implements AgentRunHandle {
agentRunId: this.runId, agentRunId: this.runId,
modelImages: this.supportsImages, modelImages: this.supportsImages,
}); });
// 会话已在本调用内关闭(挂起等待):结果不落盘,恢复后重放本调用
if (this.closed) throw new Error("会话已挂起");
const content = toolContent(output, this.supportsImages); const content = toolContent(output, this.supportsImages);
const notice = this.budgetNotice(); const notice = this.budgetNotice();
if (notice) content.push({ type: "text", text: notice }); if (notice) content.push({ type: "text", text: notice });
return { content, details: undefined }; return { content };
} catch (error) { } catch (error) {
// 中止 / 挂起:交给 harness(中止写 aborted 结果;挂起时调用保持未完成)
if (this.closed || context.abortSignal?.aborted) throw error;
const notice = this.budgetNotice(); const notice = this.budgetNotice();
const tail = notice ? `\n${notice}` : ""; const tail = notice ? `\n${notice}` : "";
if (isCapabilityError(error)) { if (isCapabilityError(error))
throw new Error( return errorResult(
JSON.stringify({ error: { code: error.code, message: redactString(error.message) } }) + tail, JSON.stringify({ error: { code: error.code, message: redactString(error.message) } }) + tail,
); );
return errorResult(redactString(error instanceof Error ? error.message : String(error)) + tail);
} }
throw new Error(redactString(error instanceof Error ? error.message : String(error)) + tail); } finally {
this.activeTools -= 1;
} }
}, },
}; };
} }
attach(harness: AgentHarnessInstance<object>, lane: AgentLane, pendingOperations: number): void { async attach(harness: Harness, root: Conversation): Promise<void> {
const ctx = BACKGROUND_CONTEXT;
this.harness = harness; this.harness = harness;
this.lane = lane; this.root = root;
this.pendingOperations = pendingOperations; const inspection = await harness.inspect(ctx);
const { events, hooks } = harness; const inputs = inspection.submissions.filter((s) => s.conversationId === root.id && s.type === "input");
this.pendingOperations = inputs.length > 0 ? inputs.length : inspection.tasks.length > 0 ? 1 : 0;
const stream = await watchEvents(harness, root.id, ctx);
for (const slot of stream.snapshot.tools)
if (slot.status === "running") this.recovering.set(slot.callId, { toolName: slot.name, taskId: slot.taskId });
this.usage = usageTotals(stream.snapshot.usage);
this.stream = stream;
stream.start(async (events) => {
this.batches += 1;
this.consuming = true;
try {
for (const event of events) await this.onAgentEvent(event);
} finally {
this.consuming = false;
}
});
}
// 白名单二次校验 + 工具调用次数预算 /** 白名单二次校验 + 工具调用次数预算(ToolTask.beforeTool) */
hooks.on("before_tool", (event) => { private beforeTool(toolCallId: string, toolName: string): { block: string } | undefined {
const decision = checkToolCall(event.toolName, this.allowed); const decision = checkToolCall(toolName, this.allowed);
if (!decision.allowed) { if (!decision.allowed) {
this.reject(event.toolCallId, event.toolName, decision.reason); this.reject(toolCallId, toolName, decision.reason);
return { block: { reason: decision.reason } }; return { block: decision.reason };
}
if (this.budget) {
// 已超预算、中止尚未落地:不再执行任何工具
this.reject(toolCallId, toolName, this.budget.message);
return { block: this.budget.message };
} }
this.toolCalls += 1; this.toolCalls += 1;
const extCalls = this.spec.externalUsage?.().toolCalls ?? 0; const extCalls = this.spec.externalUsage?.().toolCalls ?? 0;
@@ -242,77 +322,163 @@ class PiAgentRun implements AgentRunHandle {
? `工具调用次数超出预算(${this.settings.maxToolCalls},含子会话 ${extCalls} 次)` ? `工具调用次数超出预算(${this.settings.maxToolCalls},含子会话 ${extCalls} 次)`
: `工具调用次数超出预算(${this.settings.maxToolCalls})`; : `工具调用次数超出预算(${this.settings.maxToolCalls})`;
this.exceed("tool_calls", reason); this.exceed("tool_calls", reason);
this.rejected.add(event.toolCallId); this.rejected.add(toolCallId);
this.emit({ this.emit({
type: "tool_call_rejected", type: "tool_call_rejected",
toolCallId: event.toolCallId, toolCallId,
toolName: event.toolName, toolName,
capabilityId: decision.capabilityId, capabilityId: decision.capabilityId,
reason, reason,
}); });
return { block: { reason, terminate: true } }; return { block: reason };
} }
return undefined; return undefined;
}); }
hooks.on("before_request", () => {
this.checkDeadline();
this.checkCost();
if (this.budget) this.scheduleAbort();
return undefined;
});
events.on("message_update", (e) => { /** pi-durable 事件(每次提交一批)→ AgentRunEvent */
if (e.event.type === "text_delta") this.emit({ type: "text_delta", delta: e.event.delta }); private async onAgentEvent(e: AgentEvent): Promise<void> {
else if (e.event.type === "thinking_delta") this.emit({ type: "thinking_delta", delta: e.event.delta }); switch (e.type) {
}); case "message_start": {
events.on("message_end", (e) => { if (e.message.role !== "assistant") return;
const text = assistantText(e.message); this.partial = structuredClone(e.message.content);
if (text?.trim()) { this.shown = [];
this.finalText = text; this.flushDeltas(this.partial);
this.finalIsTail = true; return;
} }
}); case "message_update": {
events.on("tool_start", (e) => { for (const c of e.changes) {
this.finalIsTail = false; if (c.type === "message") {
this.partial = structuredClone(c.message.content);
continue;
}
if (!this.partial) continue;
if (
c.type === "text_start" ||
c.type === "thinking_start" ||
c.type === "toolcall_start" ||
c.type === "block"
)
this.partial[c.contentIndex] = structuredClone(c.block);
else if (c.type === "text_delta" || c.type === "thinking_delta") {
const block = this.partial[c.contentIndex];
if (block?.type === "text") block.text += c.delta;
else if (block?.type === "thinking") block.thinking += c.delta;
}
}
if (this.partial) this.flushDeltas(this.partial);
return;
}
case "message_end": {
const message = e.entry.model?.[0] as Message | undefined;
if (message?.role !== "assistant") return;
this.flushDeltas(message.content);
this.partial = undefined;
this.shown = [];
const text = assistantText(message);
const calls = message.content.filter((c) => c.type === "toolCall");
for (const c of calls) this.callArgs.set(c.id, c.arguments);
if (text.trim()) {
this.finalText = text;
this.finalIsTail = calls.length === 0;
} else if (calls.length > 0) this.finalIsTail = false;
return;
}
case "tool_execution_start": {
if (this.rejected.has(e.toolCallId) || this.started.has(e.toolCallId)) return;
const capabilityId = this.allowed.get(e.toolName);
if (!capabilityId) return;
this.emitToolStart(e.toolCallId, capabilityId, e.args);
return;
}
case "tool_execution_end": {
if (this.rejected.has(e.toolCallId)) return; if (this.rejected.has(e.toolCallId)) return;
const capabilityId = this.allowed.get(e.toolName); const capabilityId = this.allowed.get(e.toolName);
if (!capabilityId) { if (!capabilityId) {
// 模型调用了未注册的工具(例如 bash):harness 已合成 unavailable 错误,这里只产生拒绝事件 // 模型调用了本次没提供的工具(例如 bash):harness 已合成 tool_unavailable 错误,这里只产生拒绝事件
this.reject(e.toolCallId, e.toolName, `工具 ${e.toolName} 不在本次白名单内`); this.reject(e.toolCallId, e.toolName, `工具 ${e.toolName} 不在本次白名单内`);
return; return;
} }
this.emit({ // 参数校验失败、恢复后才结束的调用没有 start:先补一条
type: "tool_call_start", if (!this.started.has(e.toolCallId))
toolCallId: e.toolCallId, this.emitToolStart(e.toolCallId, capabilityId, this.callArgs.get(e.toolCallId) ?? {});
capabilityId, const result = e.entry?.model?.[0] as Message | undefined;
inputSummary: summarizeForLog(e.args, SUMMARY_LIMIT), const tool = result?.role === "toolResult" ? (result as ToolResultMessage) : undefined;
recovery: e.recovery === true, const text = tool
}); ? tool.content
});
events.on("tool_end", (e) => {
if (this.rejected.has(e.toolCallId)) return;
const capabilityId = this.allowed.get(e.toolName) ?? e.toolName;
const text = e.result.content
.map((c) => (c.type === "text" ? c.text : `[${c.type}]`)) .map((c) => (c.type === "text" ? c.text : `[${c.type}]`))
.join("") .join("")
.trim(); .trim()
: "工具调用没有结果(执行被中断)";
this.emit({ this.emit({
type: "tool_call_end", type: "tool_call_end",
toolCallId: e.toolCallId, toolCallId: e.toolCallId,
capabilityId, capabilityId,
isError: e.isError, isError: tool?.isError ?? true,
outputSummary: summarizeForLog(text, SUMMARY_LIMIT), outputSummary: summarizeForLog(text, SUMMARY_LIMIT),
recovery: e.recovery === true, recovery: this.recovering.has(e.toolCallId),
...(tool?.durationMs !== undefined ? { durationMs: tool.durationMs } : {}),
}); });
}); return;
events.on("usage", (e) => { }
this.usage = toAgentUsage(e.totals); case "usage_changed":
this.usage = usageTotals(e.usage);
this.checkCost(); this.checkCost();
return;
case "compaction_start":
this.emit({ type: "compaction", phase: "start", reason: e.reason });
return;
case "compaction_end": {
const task = await this.harness.getTask(e.taskId, BACKGROUND_CONTEXT).catch(() => undefined);
const state = task?.state;
const outcome = state && state.status === "terminal" ? state.outcome.status : undefined;
const status =
outcome === "completed" ? "completed" : outcome === "aborted" ? "aborted" : outcome ? "failed" : undefined;
this.emit({ type: "compaction", phase: "end", reason: e.reason, ...(status ? { status } : {}) });
return;
}
default:
return;
}
}
/** 流式文本:按块补发尚未发出的部分 */
private flushDeltas(content: readonly ContentBlock[]): void {
content.forEach((block, i) => {
if (block.type !== "text" && block.type !== "thinking") return;
const text = block.type === "text" ? block.text : block.thinking;
const done = this.shown[i] ?? 0;
if (text.length <= done) return;
this.shown[i] = text.length;
const delta = text.slice(done);
this.emit(block.type === "text" ? { type: "text_delta", delta } : { type: "thinking_delta", delta });
}); });
events.on("compaction_start", (e) => this.emit({ type: "compaction", phase: "start", reason: e.reason })); }
events.on("compaction_end", (e) =>
this.emit({ type: "compaction", phase: "end", reason: e.reason, status: e.status }), private emitToolStart(toolCallId: string, capabilityId: string, args: unknown): void {
); this.started.add(toolCallId);
this.finalIsTail = false;
this.emit({
type: "tool_call_start",
toolCallId,
capabilityId,
inputSummary: summarizeForLog(args, SUMMARY_LIMIT),
recovery: this.recovering.has(toolCallId),
});
}
/** 恢复时为打开会话时仍在执行的调用补发 start(带 recovery),参数取自工具任务的 intent */
private async announceRecovery(): Promise<void> {
for (const [callId, slot] of this.recovering) {
if (this.started.has(callId) || this.rejected.has(callId)) continue;
const capabilityId = this.allowed.get(slot.toolName) ?? toolNameToCapabilityId(slot.toolName) ?? slot.toolName;
let args: unknown = this.callArgs.get(callId) ?? {};
if (slot.taskId !== undefined) {
const task = await this.harness.getTask(slot.taskId as never, BACKGROUND_CONTEXT).catch(() => undefined);
const checkpoint = task?.state.checkpoint as { arguments?: unknown } | undefined;
if (checkpoint?.arguments) args = checkpoint.arguments;
}
this.emitToolStart(callId, capabilityId, args);
}
} }
events(fromSeq = this.firstSeq): AsyncIterable<AgentRunEvent> { events(fromSeq = this.firstSeq): AsyncIterable<AgentRunEvent> {
@@ -325,21 +491,34 @@ class PiAgentRun implements AgentRunHandle {
async prompt(text?: string): Promise<RunOutcome> { async prompt(text?: string): Promise<RunOutcome> {
const message = text ?? this.spec.goal; const message = text ?? this.spec.goal;
return this.drive(false, () => this.lane.prompt(message, undefined, BACKGROUND_CONTEXT)); return this.drive(false, async () => {
const submission = await this.root.submit({ type: "input", content: message }, BACKGROUND_CONTEXT);
return submission.wait(BACKGROUND_CONTEXT);
});
} }
async resume(): Promise<RunOutcome> { async resume(): Promise<RunOutcome> {
return this.drive(true, async () => { return this.drive(true, async () => {
const r = await this.lane.resume(BACKGROUND_CONTEXT); const ctx = BACKGROUND_CONTEXT;
if (!r.ok && r.error._tag === "NothingToResume") return undefined; const inspection = await this.harness.inspect(ctx);
return r as RunResult; const inputs = inspection.submissions.filter((s) => s.conversationId === this.root.id && s.type === "input");
if (inspection.tasks.length === 0 && inputs.length === 0) return undefined;
await this.announceRecovery();
this.harness.resume();
let last: SettledSubmissionRecord | undefined;
for (const s of inputs) {
const submission = await this.harness.submission(s.id, ctx);
if (submission) last = await submission.wait(ctx);
}
if (!last) await this.root.waitForIdle(ctx);
return last;
}); });
} }
async abort(reason = "用户取消"): Promise<void> { async abort(reason = "用户取消"): Promise<void> {
this.userAbort = reason; this.userAbort = reason;
this.abortController.abort(reason); this.abortController.abort(reason);
await this.lane.abort(BACKGROUND_CONTEXT).catch(() => undefined); await this.root?.abort(BACKGROUND_CONTEXT).catch(() => undefined);
} }
appendEvent(body: InjectedEventBody): boolean { appendEvent(body: InjectedEventBody): boolean {
@@ -353,54 +532,80 @@ class PiAgentRun implements AgentRunHandle {
} }
async compact(customInstructions = DEFAULT_COMPACTION_INSTRUCTIONS): Promise<void> { async compact(customInstructions = DEFAULT_COMPACTION_INSTRUCTIONS): Promise<void> {
const r = await this.lane.compact({ customInstructions }, BACKGROUND_CONTEXT); const ctx = BACKGROUND_CONTEXT;
if (!r.ok && r.error._tag !== "NothingToCompact") throw new Error(`压缩失败: ${r.error._tag}`); const id = await this.root.compact(customInstructions, ctx);
const settled = await this.harness.waitForTask(id, ctx);
const outcome = settled.state.outcome;
if (outcome.status === "failed") throw new Error(`压缩失败: ${outcome.error.message}`);
if (outcome.status === "completed" && outcome.result.submissionId !== undefined) {
const placed = await this.harness.submission(outcome.result.submissionId, ctx);
await placed?.wait(ctx);
}
await this.settleEvents();
} }
/**
* 关闭会话(Harness + 存储)。挂起等待时由工具内部调用:此时 Harness 关闭要等这个工具结束,
* 所以只封口(之后的提交一律被拒,本调用在存储里保持未完成)就返回,由外层再次 close() 等待真正关完。
*/
async close(): Promise<void> { async close(): Promise<void> {
if (this.closed) return; if (!this.closing) {
this.closed = true; this.closed = true;
this.clearDeadline(); this.clearDeadline();
const ctx: Context = BACKGROUND_CONTEXT; // 同步调用:立即封口,工具随后的结果提交会被拒绝
await this.harness?.close(ctx).catch(() => undefined); const harnessClosed = this.harness ? this.harness.close(BACKGROUND_CONTEXT) : Promise.resolve();
await this.repo.close(ctx).catch(() => undefined); this.closing = (async () => {
await harnessClosed.catch(() => undefined);
await this.stream?.stop().catch(() => undefined);
this.channel.close(); this.channel.close();
await this.delivery; await this.delivery;
})();
}
if (this.activeTools > 0) return;
await this.closing;
} }
// ---- internals ---- // ---- internals ----
private async drive(resumed: boolean, start: () => Promise<RunResult | undefined>): Promise<RunOutcome> { private async drive(
resumed: boolean,
start: () => Promise<SettledSubmissionRecord | undefined>,
): Promise<RunOutcome> {
if (this.closed) throw new Error(`run ${this.runId} 已关闭`); if (this.closed) throw new Error(`run ${this.runId} 已关闭`);
this.startedAt ??= Date.now(); this.startedAt ??= Date.now();
this.emit({ type: "run_started", goal: this.spec.goal, resumed }); this.emit({ type: "run_started", goal: this.spec.goal, resumed });
this.checkDeadline(); this.checkDeadline();
if (this.budget) return this.finish(undefined); if (this.budget) return this.finish(undefined);
this.armDeadline(); this.armDeadline();
let result: RunResult | undefined; let record: SettledSubmissionRecord | undefined;
try { try {
result = await start(); record = await start();
} finally { } finally {
this.clearDeadline(); this.clearDeadline();
} }
if (result && !result.ok) throw new Error(`Pi 拒绝了本次操作: ${result.error._tag} ${result.error.message}`); await this.settleEvents();
this.pendingOperations = 0; this.pendingOperations = 0;
return this.finish(result); return this.finish(record);
} }
private finish(result: RunResult | undefined): RunOutcome { /** 等事件流把已提交的批次处理完(批次在提交时同步生成,经微任务投递) */
const record = result?.ok ? result.value : undefined; private async settleEvents(): Promise<void> {
const status = record && "status" in record ? record.status : undefined; for (let i = 0; i < 1000; i++) {
const before = this.batches;
await new Promise((r) => setTimeout(r, 0));
while (this.consuming) await new Promise((r) => setTimeout(r, 1));
if (this.batches === before) return;
}
}
private finish(record: SettledSubmissionRecord | undefined): RunOutcome {
let outcome: RunOutcome; let outcome: RunOutcome;
if (this.budget) { if (this.budget) {
outcome = this.failed("budget", this.budget.message, this.budget.kind); outcome = this.failed("budget", this.budget.message, this.budget.kind);
} else if (status === "aborted" || this.userAbort) { } else if (this.userAbort || (record?.status === "unanswered" && record.reason === "aborted")) {
outcome = this.failed("aborted", this.userAbort ?? "已中止"); outcome = this.failed("aborted", this.userAbort ?? "已中止");
} else if (status === "failed") { } else if (record?.status === "unanswered") {
const err = record && "error" in record ? record.error : undefined; outcome = this.failed("error", redactString(unansweredMessage(record)));
outcome = this.failed("error", redactString(err ? `${err.code}: ${err.message}` : "运行失败"));
} else if (status === "suspended") {
outcome = this.failed("error", "模型服务进入延迟模式(deferred),v0.1 不支持");
} else { } else {
outcome = { status: "completed", finalText: this.finalText, usage: this.usage }; outcome = { status: "completed", finalText: this.finalText, usage: this.usage };
this.emit({ type: "run_completed", finalText: outcome.finalText, usage: this.usage }); this.emit({ type: "run_completed", finalText: outcome.finalText, usage: this.usage });
@@ -445,7 +650,7 @@ class PiAgentRun implements AgentRunHandle {
private scheduleAbort(): void { private scheduleAbort(): void {
setTimeout(() => { setTimeout(() => {
this.abortController.abort("budget"); this.abortController.abort("budget");
void this.lane.abort(BACKGROUND_CONTEXT).catch(() => undefined); void this.root.abort(BACKGROUND_CONTEXT).catch(() => undefined);
}, 0); }, 0);
} }
@@ -563,22 +768,42 @@ export function checkToolCall(
}; };
} }
function assistantText(message: AgentMessage): string | undefined { function errorResult(text: string): ToolExecutionResult {
const m = message as { role?: string; content?: unknown }; return { content: [{ type: "text", text }], isError: true };
if (m.role !== "assistant" || !Array.isArray(m.content)) return undefined; }
return (m.content as Array<{ type: string; text?: string }>)
/** 没有得到回答的输入 → 失败原因 */
function unansweredMessage(record: SettledSubmissionRecord): string {
const detail = typeof record.detail === "string" ? record.detail : record.detail ? JSON.stringify(record.detail) : "";
switch (record.reason) {
case "model_error":
return `模型调用失败: ${detail || "未知错误"}`;
case "no_model":
return "模型不可用:检查模型服务与模型设置";
case "faulted":
return `运行出错: ${detail || "未知错误"}`;
default:
return `本轮没有完成(${record.reason}${detail ? `: ${detail}` : ""})`;
}
}
function assistantText(message: AssistantMessage): string {
return message.content
.filter((c) => c.type === "text") .filter((c) => c.type === "text")
.map((c) => c.text ?? "") .map((c) => c.text)
.join(""); .join("");
} }
function toAgentUsage(u: Usage): AgentUsageTotals { /** pi.usage(按 provider/model 与工具分桶)→ 合计 */
return { function usageTotals(u: UsageState): AgentUsageTotals {
inputTokens: u.input, const t = emptyUsage();
outputTokens: u.output, for (const x of [...Object.values(u.models), ...Object.values(u.tools)]) {
cacheReadTokens: u.cacheRead, t.inputTokens += x.input;
cacheWriteTokens: u.cacheWrite, t.outputTokens += x.output;
totalTokens: u.totalTokens, t.cacheReadTokens += x.cacheRead;
costUsd: u.cost.total, t.cacheWriteTokens += x.cacheWrite;
}; t.totalTokens += x.totalTokens;
t.costUsd += x.cost.total;
}
return t;
} }
+42 -45
View File
@@ -1,58 +1,55 @@
import { mkdir, readFile, rename, rmdir, stat } from "node:fs/promises"; import { stat } from "node:fs/promises";
import { dirname, join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { type Context, type JsonlSessionMetadata, JsonlSessionRepo, type Session } from "@earendil-works/pi-agent-core"; import type { Context } from "@earendil-works/chord";
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/harness/env/nodejs"; import { type JsonlStorage, openNodeJsonlStorage } from "@earendil-works/pi-durable/storage/jsonl/node";
export interface OpenedSession { export interface OpenedStorage {
session: Session<JsonlSessionMetadata>; storage: JsonlStorage;
repo: JsonlSessionRepo; /** 会话存储目录 sessionDir/<runId>/ */
path: string; path: string;
created: boolean;
} }
const SAFE_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const SAFE_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
export function sessionPathFor(sessionDir: string, runId: string): string { function checkRunId(runId: string): void {
if (!SAFE_RUN_ID.test(runId) || runId.includes("..")) throw new Error(`runId 不能用作文件名: ${runId}`); if (!SAFE_RUN_ID.test(runId) || runId.includes("..")) throw new Error(`runId 不能用作文件名: ${runId}`);
}
/** pi-durable JSONL 存储目录:sessionDir/<runId>/(一个 run 一个目录,目录里是 pi-durable 自己的追加文件) */
export function sessionPathFor(sessionDir: string, runId: string): string {
checkRunId(runId);
return join(resolve(sessionDir), runId);
}
/** 0.3.40 及以前(pi-agent-core 0.99.2)的会话文件 sessionDir/<runId>.jsonl,格式与 pi-durable 不兼容 */
export function legacySessionPathFor(sessionDir: string, runId: string): string {
checkRunId(runId);
return join(resolve(sessionDir), `${runId}.jsonl`); return join(resolve(sessionDir), `${runId}.jsonl`);
} }
/** /** 旧版本留下的会话无法续跑:给出明确原因(已结束的会话照常从数据库事件查看) */
* 打开/创建 sessionDir/<runId>.jsonl。 export class LegacySessionError extends Error {
* JsonlSessionRepo 默认会把文件放到 `<root>/--cwd--/<时间戳>_<id>.jsonl`;这里创建后把空会话文件 constructor(readonly legacyPath: string) {
* (只有 header)移到固定路径,再按元数据打开,之后所有写入都追加到固定路径。 super("这个会话由旧版本创建(会话格式已升级),不能继续运行;已有的记录照常可看,需要继续请新开一个会话");
* NodeExecutionEnv 只当作会话目录的 FileSystem 使用,它的 exec/shell 从不暴露给模型。 this.name = "LegacySessionError";
*/
export async function openRunSession(sessionDir: string, runId: string, ctx: Context): Promise<OpenedSession> {
const root = resolve(sessionDir);
await mkdir(root, { recursive: true });
const target = sessionPathFor(root, runId);
const fileSystem = new NodeExecutionEnv({ cwd: root });
const repo = new JsonlSessionRepo({ fileSystem, sessionsRoot: root });
const existing = await stat(target).catch(() => undefined);
if (existing) {
const firstLine = (await readFile(target, "utf8")).split("\n", 1)[0] ?? "";
const header = JSON.parse(firstLine) as { id: string; createdAt: number; storageVersion: number; cwd: string };
if (header.id !== runId) throw new Error(`会话文件 ${target} 属于 ${header.id},不是 ${runId}`);
const metadata: JsonlSessionMetadata = {
id: header.id,
createdAt: header.createdAt,
storageVersion: header.storageVersion,
cwd: header.cwd,
path: target,
modifiedAt: existing.mtimeMs,
};
const session = await repo.open(metadata, ctx);
return { session, repo, path: target, created: false };
} }
}
const draft = await repo.create({ id: runId, cwd: root }, ctx); /**
const draftPath = draft.metadata.path; * 打开/创建 sessionDir/<runId>/ 下的 pi-durable JSONL 存储。
await draft.close(ctx); *
await rename(draftPath, target); * 单写者:pi-durable 不做跨进程加锁,同一存储同一时刻只能由一个进程打开。runAgent 每次运行只打开一次会话
await rmdir(dirname(draftPath)).catch(() => undefined); * (同一 run 同一时刻只由持有任务引擎租约的 worker 驱动,见 docs/adr/ADR-003-pi-integration.md),关闭后另一个进程才可以接手。
const info = await stat(target); *
const session = await repo.open({ ...draft.metadata, path: target, modifiedAt: info.mtimeMs }, ctx); * 只有旧格式文件 <runId>.jsonl、没有新目录时抛 LegacySessionError(不尝试解析旧格式)。
return { session, repo, path: target, created: true }; */
export async function openRunStorage(sessionDir: string, runId: string, ctx: Context): Promise<OpenedStorage> {
const path = sessionPathFor(sessionDir, runId);
const current = await stat(path).catch(() => undefined);
if (!current) {
const legacy = legacySessionPathFor(sessionDir, runId);
if (await stat(legacy).catch(() => undefined)) throw new LegacySessionError(legacy);
}
const storage = await openNodeJsonlStorage(path, ctx);
return { storage, path };
} }
+73 -30
View File
@@ -1,36 +1,57 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { BACKGROUND_CONTEXT, formatSkillInvocation, loadSkills, type Skill } from "@earendil-works/pi-agent-core";
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/harness/env/nodejs";
import { CAPABILITY_CATALOG } from "@umt/contracts"; import { CAPABILITY_CATALOG } from "@umt/contracts";
import { parse as parseYaml } from "yaml"; import { parse as parseYaml } from "yaml";
import type { PromptSection, SessionKind, SkillDoc } from "./types.ts"; import type { PromptSection, SessionKind, SkillDoc } from "./types.ts";
// Skill(AG-5):packages/agent/skills/<name>/SKILL.md。发现、解析与校验用 Pi harness 的 loadSkills // Skill(AG-5):packages/agent/skills/<name>/SKILL.md(agentskills.io 格式:frontmatter name / description + 正文;
// (agentskills.io 格式:frontmatter name / description + 正文;name 必须等于目录名)。 // name 必须等于目录名)。
// //
// Pi 的模型侧用法是把 <available_skills> 索引放进系统提示,由模型用文件读取工具自己读 SKILL.md; // Pi 的模型侧用法是把 <available_skills> 索引放进系统提示,由模型用文件读取工具自己读 SKILL.md;
// 我们的智能体没有(也不该有)文件读取工具,所以改成服务端按意图选:frontmatter 的 triggers 命中目标或追加消息时, // 我们的智能体没有(也不该有)文件读取工具,所以改成服务端按意图选:frontmatter 的 triggers 命中目标或追加消息时,
// 首轮把正文注入系统提示的「Skill」层,后续消息用 Pi 的 formatSkillInvocation 附在用户消息前(见 runner)。 // 首轮把正文注入系统提示的「Skill」层,后续消息用 Pi 的 skill 块格式附在用户消息前(见 runner)。
// 本项目的 frontmatter 扩展:title(中文名)、triggers(正则)、capabilities(用到的能力)、sessions(适用会话类型)。 // 本项目的 frontmatter 扩展:title(中文名)、triggers(正则)、capabilities(用到的能力)、sessions(适用会话类型)。
//
// 发现 / 解析 / 校验与 skill 块格式取自 pi-agent-core 0.99.2 的 harness/skills.ts(MIT,© Earendil Works);
// pi 1.x 把 harness 移到 pi-durable 后不再提供 skills,这里只保留本项目用到的部分:
// 一层目录下的 <name>/SKILL.md,不读 .gitignore、不收根目录的散 .md、不跟随符号链接。
/** 仓库内的 Skill 目录;deno compile 用 --include 嵌入(apps/server/deno.json 的 compile 任务) */ /** 仓库内的 Skill 目录;deno compile 用 --include 嵌入(apps/server/deno.json 的 compile 任务) */
export const SKILLS_DIR = fileURLToPath(new URL("../../skills", import.meta.url)); export const SKILLS_DIR = fileURLToPath(new URL("../../skills", import.meta.url));
const DEFAULT_SESSIONS: SessionKind[] = ["chat", "patrol", "investigate", "child"]; const DEFAULT_SESSIONS: SessionKind[] = ["chat", "patrol", "investigate", "child"];
const SESSION_KINDS = new Set<SessionKind>(["chat", "patrol", "investigate", "child", "vision"]); const SESSION_KINDS = new Set<SessionKind>(["chat", "patrol", "investigate", "child", "vision"]);
const MAX_NAME_LENGTH = 64;
const MAX_DESCRIPTION_LENGTH = 1024;
export interface SkillLoadResult { export interface SkillLoadResult {
skills: SkillDoc[]; skills: SkillDoc[];
/** 格式问题(Pi 的诊断 + 扩展字段校验);测试要求为空 */ /** 格式问题(Pi 同款校验 + 扩展字段校验);测试要求为空 */
diagnostics: string[]; diagnostics: string[];
} }
function frontmatter(raw: string): Record<string, unknown> { /** frontmatter + 正文(与 Pi 的 parseFrontmatter 一致:没有 frontmatter 时整篇是正文) */
function parseSkillFile(raw: string): { frontmatter: Record<string, unknown>; body: string } {
const text = raw.replace(/\r\n?/g, "\n"); const text = raw.replace(/\r\n?/g, "\n");
if (!text.startsWith("---")) return {}; if (!text.startsWith("---")) return { frontmatter: {}, body: text };
const end = text.indexOf("\n---", 3); const end = text.indexOf("\n---", 3);
if (end < 0) return {}; if (end < 0) return { frontmatter: {}, body: text };
return (parseYaml(text.slice(4, end)) ?? {}) as Record<string, unknown>; return {
frontmatter: (parseYaml(text.slice(4, end)) ?? {}) as Record<string, unknown>,
body: text.slice(end + 4).trim(),
};
}
/** Pi 的 name 规则:等于目录名、≤64、只含小写字母数字与连字符、不以连字符开头结尾、不连续 */
function validateName(name: string, dirName: string): string[] {
const errors: string[] = [];
if (name !== dirName) errors.push(`name "${name}" 与目录名 "${dirName}" 不一致`);
if (name.length > MAX_NAME_LENGTH) errors.push(`name 超过 ${MAX_NAME_LENGTH} 个字符`);
if (!/^[a-z0-9-]+$/.test(name)) errors.push("name 只能用小写字母、数字和连字符");
if (name.startsWith("-") || name.endsWith("-")) errors.push("name 不能以连字符开头或结尾");
if (name.includes("--")) errors.push("name 不能有连续连字符");
return errors;
} }
const strings = (v: unknown): string[] => const strings = (v: unknown): string[] =>
@@ -38,25 +59,46 @@ const strings = (v: unknown): string[] =>
/** 读目录下全部 Skill;失败的项进 diagnostics,不抛错(运行时缺 Skill 只少一层提示,不影响会话) */ /** 读目录下全部 Skill;失败的项进 diagnostics,不抛错(运行时缺 Skill 只少一层提示,不影响会话) */
export async function loadSkillDocs(dir: string = SKILLS_DIR): Promise<SkillLoadResult> { export async function loadSkillDocs(dir: string = SKILLS_DIR): Promise<SkillLoadResult> {
const env = new NodeExecutionEnv({ cwd: dir });
const diagnostics: string[] = []; const diagnostics: string[] = [];
let skills: Skill[] = []; let entries: { name: string; isDirectory(): boolean }[];
try { try {
const r = await loadSkills(env, dir, BACKGROUND_CONTEXT); entries = await readdir(dir, { withFileTypes: true });
skills = r.skills;
for (const d of r.diagnostics) diagnostics.push(`${d.path}: ${d.message}`);
} catch (err) { } catch (err) {
return { skills: [], diagnostics: [`${dir}: ${(err as Error).message}`] }; return { skills: [], diagnostics: [`${dir}: ${(err as Error).message}`] };
} }
const out: SkillDoc[] = []; const out: SkillDoc[] = [];
for (const s of skills) { for (const entry of entries) {
const raw = await env.readTextFile(s.filePath, BACKGROUND_CONTEXT); if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fm = raw.ok ? frontmatter(raw.value) : {}; const filePath = join(dir, entry.name, "SKILL.md");
const where = `${entry.name}/SKILL.md`;
let raw: string;
try {
raw = await readFile(filePath, "utf8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") diagnostics.push(`${where}: ${(err as Error).message}`);
continue;
}
let fm: Record<string, unknown>;
let body: string;
try {
({ frontmatter: fm, body } = parseSkillFile(raw));
} catch (err) {
diagnostics.push(`${where}: frontmatter 解析失败:${(err as Error).message}`);
continue;
}
const description = typeof fm.description === "string" ? fm.description : "";
if (!description.trim()) {
diagnostics.push(`${where}: 缺少 description`);
continue;
}
if (description.length > MAX_DESCRIPTION_LENGTH)
diagnostics.push(`${where}: description 超过 ${MAX_DESCRIPTION_LENGTH} 个字符`);
const name = typeof fm.name === "string" && fm.name ? fm.name : entry.name;
for (const e of validateName(name, entry.name)) diagnostics.push(`${where}: ${e}`);
const triggers = strings(fm.triggers); const triggers = strings(fm.triggers);
const capabilities = strings(fm.capabilities); const capabilities = strings(fm.capabilities);
const sessions = strings(fm.sessions) as SessionKind[]; const sessions = strings(fm.sessions) as SessionKind[];
const title = typeof fm.title === "string" ? fm.title : s.name; const title = typeof fm.title === "string" ? fm.title : name;
const where = `${s.name}/SKILL.md`;
if (triggers.length === 0) diagnostics.push(`${where}: 缺少 triggers`); if (triggers.length === 0) diagnostics.push(`${where}: 缺少 triggers`);
for (const t of triggers) { for (const t of triggers) {
try { try {
@@ -68,14 +110,14 @@ export async function loadSkillDocs(dir: string = SKILLS_DIR): Promise<SkillLoad
for (const c of capabilities) if (!(c in CAPABILITY_CATALOG)) diagnostics.push(`${where}: 未知能力 ${c}`); for (const c of capabilities) if (!(c in CAPABILITY_CATALOG)) diagnostics.push(`${where}: 未知能力 ${c}`);
for (const k of sessions) if (!SESSION_KINDS.has(k)) diagnostics.push(`${where}: 未知会话类型 ${k}`); for (const k of sessions) if (!SESSION_KINDS.has(k)) diagnostics.push(`${where}: 未知会话类型 ${k}`);
out.push({ out.push({
name: s.name, name,
title, title,
description: s.description, description,
triggers, triggers,
capabilities, capabilities,
sessions: sessions.length ? sessions : DEFAULT_SESSIONS, sessions: sessions.length ? sessions : DEFAULT_SESSIONS,
content: s.content, content: body,
filePath: s.filePath, filePath,
}); });
} }
out.sort((a, b) => a.name.localeCompare(b.name)); out.sort((a, b) => a.name.localeCompare(b.name));
@@ -153,16 +195,17 @@ export function skillsLayer(loaded: readonly SkillDoc[], available: readonly Ski
return { layer: "skills", title: "Skill", lines }; return { layer: "skills", title: "Skill", lines };
} }
/** Pi 的 skill 调用块格式(pi-agent-core 0.99.2 formatSkillInvocation) */
function formatSkillInvocation(skill: { name: string; filePath: string; content: string }): string {
const dir = skill.filePath.replace(/[\\/]+$/, "").replace(/[\\/][^\\/]*$/, "") || "/";
return `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${dir}.\n\n${skill.content}\n</skill>`;
}
/** 追加消息命中新 Skill 时,用 Pi 的格式把 Skill 附在消息前(模型当作本条消息的做事说明) */ /** 追加消息命中新 Skill 时,用 Pi 的格式把 Skill 附在消息前(模型当作本条消息的做事说明) */
export function withSkillInvocation(skills: readonly SkillDoc[], text: string): string { export function withSkillInvocation(skills: readonly SkillDoc[], text: string): string {
if (skills.length === 0) return text; if (skills.length === 0) return text;
const blocks = skills.map((s) => const blocks = skills.map((s) =>
formatSkillInvocation({ formatSkillInvocation({ name: s.name, content: s.content, filePath: `skills/${s.name}/SKILL.md` }),
name: s.name,
description: s.description,
content: s.content,
filePath: `skills/${s.name}/SKILL.md`,
}),
); );
return `${blocks.join("\n\n")}\n\n${text}`; return `${blocks.join("\n\n")}\n\n${text}`;
} }
+1 -1
View File
@@ -92,7 +92,7 @@ export interface LayeredPromptInput {
constraints?: readonly string[]; constraints?: readonly string[];
} }
/** 一个 Skill(Pi harness Skill + 本项目的 frontmatter 扩展) */ /** 一个 Skill(Pi 的 agentskills.io 格式 + 本项目的 frontmatter 扩展) */
export interface SkillDoc { export interface SkillDoc {
/** 目录名,小写字母 / 数字 / 连字符 */ /** 目录名,小写字母 / 数字 / 连字符 */
name: string; name: string;
+2 -2
View File
@@ -46,14 +46,14 @@ export interface AgentActor {
} }
export interface CreateRunSpec { export interface CreateRunSpec {
/** = agent_run.id,同时作为 Pi 会话 ID 与 JSONL 文件名 */ /** = agent_run.id,同时作为会话存储目录名 */
runId: string; runId: string;
goal: string; goal: string;
systemPrompt: string; systemPrompt: string;
/** 本次允许的能力 ID 白名单 */ /** 本次允许的能力 ID 白名单 */
capabilities: string[]; capabilities: string[];
settings: AgentSettings; settings: AgentSettings;
/** 应用数据目录下的会话目录;会话文件为 sessionDir/<runId>.jsonl */ /** 应用数据目录下的会话目录;本次会话存储在 sessionDir/<runId>/(pi-durable JSONL) */
sessionDir: string; sessionDir: string;
actor: AgentActor; actor: AgentActor;
/** /**
+1
View File
@@ -106,6 +106,7 @@ export function makeFixture(opts: { slowMs?: number } = {}): Fixture {
export function sleep(ms: number, signal?: AbortSignal): Promise<void> { export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new Error("aborted by signal"));
const t = setTimeout(resolve, ms); const t = setTimeout(resolve, ms);
signal?.addEventListener( signal?.addEventListener(
"abort", "abort",
+85 -5
View File
@@ -1,19 +1,29 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { AgentRunEvent } from "@umt/contracts"; import { AgentRunEvent } from "@umt/contracts";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { z } from "zod";
import { import {
type AgentRunHandle, type AgentRunHandle,
type AgentSettings, type AgentSettings,
checkToolCall, checkToolCall,
createFakeRuntime, createFakeRuntime,
type FakeScript, type FakeScript,
LegacySessionError,
} from "../src/index.ts"; } from "../src/index.ts";
import { crashScript, RUN_ID } from "./fixtures/crash-script.ts"; import { crashScript, RUN_ID } from "./fixtures/crash-script.ts";
import { type Fixture, makeFixture, settings, tempDir, types } from "./helpers.ts"; import { type Fixture, makeFixture, settings, tempDir, types } from "./helpers.ts";
const opened: AgentRunHandle[] = []; const opened: AgentRunHandle[] = [];
/** 会话存储目录下全部文件的文本(pi-durable JSONL 主文件 + 附属文件) */
function sessionText(dir: string): string {
return readdirSync(dir, { recursive: true, withFileTypes: true })
.filter((f) => f.isFile())
.map((f) => readFileSync(join(f.parentPath, f.name), "utf8"))
.join("\n");
}
afterEach(async () => { afterEach(async () => {
await Promise.all(opened.splice(0).map((r) => r.close())); await Promise.all(opened.splice(0).map((r) => r.close()));
}); });
@@ -72,7 +82,7 @@ async function start(
} }
describe("PiAgentRuntime + FakeModelProvider", () => { describe("PiAgentRuntime + FakeModelProvider", () => {
it("完整闭环:事件顺序、usage、最终文本、JSONL 位于 sessionDir/<runId>.jsonl", async () => { it("完整闭环:事件顺序、usage、最终文本、会话存储位于 sessionDir/<runId>/", async () => {
const fx = makeFixture(); const fx = makeFixture();
fx.grants.set("user:1", new Set(["download.submit"])); fx.grants.set("user:1", new Set(["download.submit"]));
const { run, sessionDir } = await start(fx, [dune]); const { run, sessionDir } = await start(fx, [dune]);
@@ -104,9 +114,11 @@ describe("PiAgentRuntime + FakeModelProvider", () => {
const last = events.at(-1)!; const last = events.at(-1)!;
expect(last).toMatchObject({ type: "run_completed", finalText: outcome.finalText }); expect(last).toMatchObject({ type: "run_completed", finalText: outcome.finalText });
expect(run.sessionPath).toBe(join(sessionDir, "run-1.jsonl")); expect(run.sessionPath).toBe(join(sessionDir, "run-1"));
expect(existsSync(run.sessionPath)).toBe(true); expect(existsSync(run.sessionPath)).toBe(true);
expect(readFileSync(run.sessionPath, "utf8")).toContain("download__submit"); expect(sessionText(run.sessionPath)).toContain("download__submit");
const end = events.find((e) => e.type === "tool_call_end");
expect(end).toMatchObject({ durationMs: expect.any(Number) });
// 审计来自 agent,带 invocationId 与 runId // 审计来自 agent,带 invocationId 与 runId
expect(fx.audits.find((a) => a.capabilityId === "download.submit")).toMatchObject({ expect(fx.audits.find((a) => a.capabilityId === "download.submit")).toMatchObject({
source: "agent", source: "agent",
@@ -233,7 +245,7 @@ describe("PiAgentRuntime + FakeModelProvider", () => {
for (const secret of ["abcdef123456", "deadbeef", "sk-live-XXXX"]) { for (const secret of ["abcdef123456", "deadbeef", "sk-live-XXXX"]) {
expect(transcript).not.toContain(secret); expect(transcript).not.toContain(secret);
expect(JSON.stringify(run.history())).not.toContain(secret); expect(JSON.stringify(run.history())).not.toContain(secret);
expect(readFileSync(run.sessionPath, "utf8")).not.toContain(secret); expect(sessionText(run.sessionPath)).not.toContain(secret);
} }
}); });
@@ -382,4 +394,72 @@ describe("PiAgentRuntime + FakeModelProvider", () => {
expect(recovered).toMatchObject({ capabilityId: "download.submit", isError: true, recovery: true }); expect(recovered).toMatchObject({ capabilityId: "download.submit", isError: true, recovery: true });
expect(run.history()[0]).toMatchObject({ type: "run_started", resumed: true }); expect(run.history()[0]).toMatchObject({ type: "run_started", resumed: true });
}, 30_000); }, 30_000);
it("挂起:工具执行中关闭会话(结果不落盘),换进程重开后 resume 重放该调用并完成", async () => {
const sessionDir = await tempDir();
const fx = makeFixture();
let handle: AgentRunHandle | undefined;
const calls: Array<{ inv: string; resumed: boolean }> = [];
let suspended = true;
fx.registry.register(
{
id: "agent.await",
version: 1,
title: "等待子会话",
description: "等子会话落定。",
inputSchema: z.object({ runIds: z.array(z.string()) }),
outputSchema: z.object({ allDone: z.boolean() }),
kind: "read",
scope: "read",
riskLevel: "low",
idempotent: true,
supportsPreview: false,
replay: "safe",
},
async (_input, c) => {
calls.push({ inv: c.invocationId ?? "", resumed: !suspended });
if (suspended) {
// 与 server 的 suspendRun 一样:在工具内部关闭会话,返回值不会进入模型上下文
await new Promise((r) => setTimeout(r, 0));
await handle!.close();
return { allDone: false };
}
return { allDone: true };
},
);
const script: FakeScript = {
match: "Dune",
steps: [{ toolCalls: [{ capability: "agent.await", input: { runIds: ["child-1"] } }] }],
finalText: (r) => `子会话:${r.at(-1)?.text}`,
};
const first = await start(fx, [script], { sessionDir, runId: "run-sus", capabilities: ["agent.await"] });
handle = first.run;
await expect(first.run.prompt()).rejects.toThrow();
await first.run.close();
expect(first.run.history().some((e) => e.type === "tool_call_end")).toBe(false);
suspended = false;
const second = await start(fx, [script], {
sessionDir,
runId: "run-sus",
capabilities: ["agent.await"],
firstSeq: first.run.history().length,
});
expect(second.run.pendingOperations).toBe(1);
const out = await second.run.resume();
expect(out).toMatchObject({ status: "completed", finalText: '子会话:{"allDone":true}' });
expect(calls).toHaveLength(2);
expect(calls[1]).toMatchObject({ inv: calls[0]!.inv, resumed: true });
const ends = second.run.history().filter((e) => e.type === "tool_call_end");
expect(ends).toEqual([expect.objectContaining({ capabilityId: "agent.await", isError: false, recovery: true })]);
});
it("旧版(pi-agent-core 0.99)会话文件:打开时给出明确错误,不崩溃", async () => {
const sessionDir = await tempDir();
writeFileSync(join(sessionDir, "run-old.jsonl"), '{"type":"header","id":"run-old","storageVersion":4}\n');
const fx = makeFixture();
const err = await start(fx, [dune], { sessionDir, runId: "run-old" }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(LegacySessionError);
expect((err as Error).message).toContain("旧版本");
});
}); });
+6 -1
View File
@@ -38,7 +38,12 @@ describe("AgentSettings → pi-ai", () => {
}); });
const { model } = resolveModelFromSettings(s); const { model } = resolveModelFromSettings(s);
expect(model).toMatchObject({ api: "openai-completions", baseUrl: "http://llm.local/v1", reasoning: false }); expect(model).toMatchObject({ api: "openai-completions", baseUrl: "http://llm.local/v1", reasoning: false });
expect(compactionFromSettings(s, 32_000)).toEqual({ enabled: true, reserveTokens: 8000, keepRecentTokens: 8000 }); expect(compactionFromSettings(s, 32_000)).toEqual({
enabled: true,
reserveTokens: 8000,
keepRecentTokens: 8000,
backgroundTokens: 0,
});
const manual = resolveAgentSettings({ ...s, compaction: { mode: "manual", threshold: 0.8 } }); const manual = resolveAgentSettings({ ...s, compaction: { mode: "manual", threshold: 0.8 } });
expect(compactionFromSettings(manual, 32_000).enabled).toBe(false); expect(compactionFromSettings(manual, 32_000).enabled).toBe(false);
expect(() => resolveAgentSettings({ ...base, model: "" })).toThrow(); expect(() => resolveAgentSettings({ ...base, model: "" })).toThrow();
+1 -1
View File
@@ -23,7 +23,7 @@ beforeAll(async () => {
const REQUIRED_SECTIONS = ["## 适用意图", "## 步骤", "## 需要批准", "## 汇报模板", "## 不要做"]; const REQUIRED_SECTIONS = ["## 适用意图", "## 步骤", "## 需要批准", "## 汇报模板", "## 不要做"];
describe("加载(Pi harness loadSkills)", () => { describe("加载(skills/<name>/SKILL.md)", () => {
it("至少 5 个 Skill,没有格式诊断", () => { it("至少 5 个 Skill,没有格式诊断", () => {
expect(diagnostics).toEqual([]); expect(diagnostics).toEqual([]);
expect(skills.length).toBeGreaterThanOrEqual(5); expect(skills.length).toBeGreaterThanOrEqual(5);
+2
View File
@@ -63,6 +63,8 @@ const ToolCallEnd = z.object({
/** 已脱敏、截断的输出摘要 */ /** 已脱敏、截断的输出摘要 */
outputSummary: z.string(), outputSummary: z.string(),
recovery: z.boolean(), recovery: z.boolean(),
/** 工具执行耗时(毫秒,单调时钟;没真正执行的调用没有) */
durationMs: z.number().int().nonnegative().optional(),
}); });
const ToolCallRejected = z.object({ const ToolCallRejected = z.object({
type: z.literal("tool_call_rejected"), type: z.literal("tool_call_rejected"),
+3 -3
View File
@@ -3,14 +3,14 @@ import { Id, Timestamp } from "./common.ts";
// Pi 模型上游(W7-A):一个上游 = 一个 pi-ai provider 的一份凭据(API Key / 订阅登录 token / 环境凭据)。 // Pi 模型上游(W7-A):一个上游 = 一个 pi-ai provider 的一份凭据(API Key / 订阅登录 token / 环境凭据)。
// 同一 provider 可以建多个上游(例如两个 Codex 账号);Agent 运行按上游 id 取凭据。 // 同一 provider 可以建多个上游(例如两个 Codex 账号);Agent 运行按上游 id 取凭据。
// 研究结论与映射见 docs/design/pi-providers.md;provider 列表锁定 pi-ai 0.99.2 的 builtinProviders()。 // 研究结论与映射见 docs/design/pi-providers.md;provider 列表锁定 pi-ai 1.1.0 的 builtinProviders()(1.0.3 起 azure-openai-responses 改名 azure,迁移 0093 已改存量)。
/** pi-ai 0.99.2 内置的 42 个 provider id(含 TypeSafe 分类服务) */ /** pi-ai 1.1.0 内置的 42 个 provider id(含 TypeSafe 分类服务) */
export const PI_BUILTIN_PROVIDER_IDS = [ export const PI_BUILTIN_PROVIDER_IDS = [
"amazon-bedrock", "amazon-bedrock",
"ant-ling", "ant-ling",
"anthropic", "anthropic",
"azure-openai-responses", "azure",
"baseten", "baseten",
"cerebras", "cerebras",
"cloudflare-ai-gateway", "cloudflare-ai-gateway",
@@ -0,0 +1,15 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// pi-ai 1.0.3 把 Azure provider 从 azure-openai-responses 改名为 azure(api id 与 AZURE_OPENAI_* 配置名不变)。
// 已存的模型服务跟着改名,否则按 provider 查不到 pi-ai 的内置 provider。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(db, `update pi_upstream set provider = 'azure' where provider = 'azure-openai-responses';`);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, `update pi_upstream set provider = 'azure-openai-responses' where provider = 'azure';`);
}