feat(agent): 运行时从 pi-agent-core 0.99.2 迁到 pi-durable 1.1.0 + pi-ai 1.1.0

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-08 13:42:36 +08:00
1 parent dd0f6e2c09
commit 003815591c
24 files changed
+756 -363

No files matched your search

+85 -5
View File
@@ -1,19 +1,29 @@
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 { AgentRunEvent } from "@umt/contracts";
import { afterEach, describe, expect, it } from "vitest";
import { z } from "zod";
import {
type AgentRunHandle,
type AgentSettings,
checkToolCall,
createFakeRuntime,
type FakeScript,
LegacySessionError,
} from "../src/index.ts";
import { crashScript, RUN_ID } from "./fixtures/crash-script.ts";
import { type Fixture, makeFixture, settings, tempDir, types } from "./helpers.ts";
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 () => {
await Promise.all(opened.splice(0).map((r) => r.close()));
});
@@ -72,7 +82,7 @@ async function start(
}
describe("PiAgentRuntime + FakeModelProvider", () => {
it("完整闭环:事件顺序、usage、最终文本、JSONL 位于 sessionDir/<runId>.jsonl", async () => {
it("完整闭环:事件顺序、usage、最终文本、会话存储位于 sessionDir/<runId>/", async () => {
const fx = makeFixture();
fx.grants.set("user:1", new Set(["download.submit"]));
const { run, sessionDir } = await start(fx, [dune]);
@@ -104,9 +114,11 @@ describe("PiAgentRuntime + FakeModelProvider", () => {
const last = events.at(-1)!;
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(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
expect(fx.audits.find((a) => a.capabilityId === "download.submit")).toMatchObject({
source: "agent",
@@ -233,7 +245,7 @@ describe("PiAgentRuntime + FakeModelProvider", () => {
for (const secret of ["abcdef123456", "deadbeef", "sk-live-XXXX"]) {
expect(transcript).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(run.history()[0]).toMatchObject({ type: "run_started", resumed: true });
}, 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("旧版本");
});
});