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:
1 parent
dd0f6e2c09
commit
003815591c
24 files changed
+692
-299
No files matched your search
@@ -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`。
|
||||||
|
|||||||
@@ -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]",
|
||||||
|
|||||||
@@ -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]",
|
||||||
|
|||||||
@@ -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 事件查看)。
|
||||||
@@ -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]",
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|
||||||
## 新增一个能力
|
## 新增一个能力
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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("旧版本");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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';`);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user