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

+73 -30
View File
@@ -1,36 +1,57 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { 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 { parse as parseYaml } from "yaml";
import type { PromptSection, SessionKind, SkillDoc } from "./types.ts";
// Skill(AG-5):packages/agent/skills/<name>/SKILL.md。发现、解析与校验用 Pi harness 的 loadSkills
// (agentskills.io 格式:frontmatter name / description + 正文;name 必须等于目录名)。
// Skill(AG-5):packages/agent/skills/<name>/SKILL.md(agentskills.io 格式:frontmatter name / description + 正文;
// name 必须等于目录名)。
//
// Pi 的模型侧用法是把 <available_skills> 索引放进系统提示,由模型用文件读取工具自己读 SKILL.md;
// 我们的智能体没有(也不该有)文件读取工具,所以改成服务端按意图选:frontmatter 的 triggers 命中目标或追加消息时,
// 首轮把正文注入系统提示的「Skill」层,后续消息用 Pi 的 formatSkillInvocation 附在用户消息前(见 runner)。
// 首轮把正文注入系统提示的「Skill」层,后续消息用 Pi 的 skill 块格式附在用户消息前(见 runner)。
// 本项目的 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 任务) */
export const SKILLS_DIR = fileURLToPath(new URL("../../skills", import.meta.url));
const DEFAULT_SESSIONS: SessionKind[] = ["chat", "patrol", "investigate", "child"];
const SESSION_KINDS = new Set<SessionKind>(["chat", "patrol", "investigate", "child", "vision"]);
const MAX_NAME_LENGTH = 64;
const MAX_DESCRIPTION_LENGTH = 1024;
export interface SkillLoadResult {
skills: SkillDoc[];
/** 格式问题(Pi 的诊断 + 扩展字段校验);测试要求为空 */
/** 格式问题(Pi 同款校验 + 扩展字段校验);测试要求为空 */
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");
if (!text.startsWith("---")) return {};
if (!text.startsWith("---")) return { frontmatter: {}, body: text };
const end = text.indexOf("\n---", 3);
if (end < 0) return {};
return (parseYaml(text.slice(4, end)) ?? {}) as Record<string, unknown>;
if (end < 0) return { frontmatter: {}, body: text };
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[] =>
@@ -38,25 +59,46 @@ const strings = (v: unknown): string[] =>
/** 读目录下全部 Skill;失败的项进 diagnostics,不抛错(运行时缺 Skill 只少一层提示,不影响会话) */
export async function loadSkillDocs(dir: string = SKILLS_DIR): Promise<SkillLoadResult> {
const env = new NodeExecutionEnv({ cwd: dir });
const diagnostics: string[] = [];
let skills: Skill[] = [];
let entries: { name: string; isDirectory(): boolean }[];
try {
const r = await loadSkills(env, dir, BACKGROUND_CONTEXT);
skills = r.skills;
for (const d of r.diagnostics) diagnostics.push(`${d.path}: ${d.message}`);
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
return { skills: [], diagnostics: [`${dir}: ${(err as Error).message}`] };
}
const out: SkillDoc[] = [];
for (const s of skills) {
const raw = await env.readTextFile(s.filePath, BACKGROUND_CONTEXT);
const fm = raw.ok ? frontmatter(raw.value) : {};
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
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 capabilities = strings(fm.capabilities);
const sessions = strings(fm.sessions) as SessionKind[];
const title = typeof fm.title === "string" ? fm.title : s.name;
const where = `${s.name}/SKILL.md`;
const title = typeof fm.title === "string" ? fm.title : name;
if (triggers.length === 0) diagnostics.push(`${where}: 缺少 triggers`);
for (const t of triggers) {
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 k of sessions) if (!SESSION_KINDS.has(k)) diagnostics.push(`${where}: 未知会话类型 ${k}`);
out.push({
name: s.name,
name,
title,
description: s.description,
description,
triggers,
capabilities,
sessions: sessions.length ? sessions : DEFAULT_SESSIONS,
content: s.content,
filePath: s.filePath,
content: body,
filePath,
});
}
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 };
}
/** 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 附在消息前(模型当作本条消息的做事说明) */
export function withSkillInvocation(skills: readonly SkillDoc[], text: string): string {
if (skills.length === 0) return text;
const blocks = skills.map((s) =>
formatSkillInvocation({
name: s.name,
description: s.description,
content: s.content,
filePath: `skills/${s.name}/SKILL.md`,
}),
formatSkillInvocation({ name: s.name, content: s.content, filePath: `skills/${s.name}/SKILL.md` }),
);
return `${blocks.join("\n\n")}\n\n${text}`;
}
+1 -1
View File
@@ -92,7 +92,7 @@ export interface LayeredPromptInput {
constraints?: readonly string[];
}
/** 一个 Skill(Pi harness Skill + 本项目的 frontmatter 扩展) */
/** 一个 Skill(Pi 的 agentskills.io 格式 + 本项目的 frontmatter 扩展) */
export interface SkillDoc {
/** 目录名,小写字母 / 数字 / 连字符 */
name: string;