feat(agent): layered system prompt, environment facts and intent-routed skills

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 18:47:15 +08:00
1 parent 0f20c19b7c
commit 21b7e8ff23
25 files changed
+1783 -149

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
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 必须等于目录名)。
//
// Pi 的模型侧用法是把 <available_skills> 索引放进系统提示,由模型用文件读取工具自己读 SKILL.md;
// 我们的智能体没有(也不该有)文件读取工具,所以改成服务端按意图选:frontmatter 的 triggers 命中目标或追加消息时,
// 首轮把正文注入系统提示的「Skill」层,后续消息用 Pi 的 formatSkillInvocation 附在用户消息前(见 runner)。
// 本项目的 frontmatter 扩展:title(中文名)、triggers(正则)、capabilities(用到的能力)、sessions(适用会话类型)。
/** 仓库内的 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"]);
export interface SkillLoadResult {
skills: SkillDoc[];
/** 格式问题(Pi 的诊断 + 扩展字段校验);测试要求为空 */
diagnostics: string[];
}
function frontmatter(raw: string): Record<string, unknown> {
const text = raw.replace(/\r\n?/g, "\n");
if (!text.startsWith("---")) return {};
const end = text.indexOf("\n---", 3);
if (end < 0) return {};
return (parseYaml(text.slice(4, end)) ?? {}) as Record<string, unknown>;
}
const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.trim() !== "") : [];
/** 读目录下全部 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[] = [];
try {
const r = await loadSkills(env, dir, BACKGROUND_CONTEXT);
skills = r.skills;
for (const d of r.diagnostics) diagnostics.push(`${d.path}: ${d.message}`);
} 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) : {};
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`;
if (triggers.length === 0) diagnostics.push(`${where}: 缺少 triggers`);
for (const t of triggers) {
try {
new RegExp(t, "i");
} catch {
diagnostics.push(`${where}: trigger 不是合法正则:${t}`);
}
}
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,
title,
description: s.description,
triggers,
capabilities,
sessions: sessions.length ? sessions : DEFAULT_SESSIONS,
content: s.content,
filePath: s.filePath,
});
}
out.sort((a, b) => a.name.localeCompare(b.name));
return { skills: out, diagnostics };
}
let cached: Promise<SkillLoadResult> | null = null;
/** 进程内缓存的内置 Skill(首次调用时读盘) */
export function builtinSkills(): Promise<SkillLoadResult> {
cached ??= loadSkillDocs();
return cached;
}
/** 意图命中数:每个触发词命中记 1 */
export function skillScore(skill: SkillDoc, text: string): number {
let n = 0;
for (const t of skill.triggers) {
try {
if (new RegExp(t, "i").test(text)) n += 1;
} catch {
// 非法正则在加载时已进 diagnostics
}
}
return n;
}
export interface SelectSkillsOptions {
kind: SessionKind;
/** 本次可用的能力;给了时 Skill 至少要有一个能力可用才选 */
capabilities?: readonly string[];
/** 最多选几个(缺省 2) */
max?: number;
/** 不管命中与否都选上的 Skill(巡检会话固定带 patrol) */
force?: readonly string[];
}
/** 按意图选 Skill:命中多的在前,同分按名称;适用会话类型与可用能力不符的不选 */
export function selectSkills(skills: readonly SkillDoc[], text: string, opts: SelectSkillsOptions): SkillDoc[] {
const avail = opts.capabilities ? new Set(opts.capabilities) : null;
const usable = skills.filter(
(s) => s.sessions.includes(opts.kind) && (!avail || s.capabilities.some((c) => avail.has(c))),
);
const forced = usable.filter((s) => opts.force?.includes(s.name));
const scored = usable
.filter((s) => !forced.includes(s))
.map((s) => ({ s, n: skillScore(s, text) }))
.filter((x) => x.n > 0)
.sort((a, b) => b.n - a.n || a.s.name.localeCompare(b.s.name))
.map((x) => x.s);
return [...forced, ...scored].slice(0, Math.max(opts.max ?? 2, forced.length));
}
/** 正文标题降两级(SKILL.md 里的 ## 在系统提示里变成 ####,不和层标题混) */
function demote(content: string): string {
return content.replace(/^(#{1,4}) /gm, (_m, h: string) => `${h}## `);
}
export function renderSkill(s: SkillDoc): string {
return [`### Skill「${s.title}」`, "", demote(s.content.trim())].join("\n");
}
export function skillsLayer(loaded: readonly SkillDoc[], available: readonly SkillDoc[] = []): PromptSection {
const rest = available.filter((a) => !loaded.some((l) => l.name === a.name));
if (loaded.length === 0 && rest.length === 0) return { layer: "skills", title: "Skill", lines: [] };
const lines: string[] = [];
if (loaded.length > 0) {
lines.push("按本次目标载入了下面的 Skill:照它的步骤做、按它的汇报模板写;和「角色与边界」冲突时以边界为准。");
for (const s of loaded) lines.push("", renderSkill(s));
}
if (rest.length > 0) {
lines.push("", "其余 Skill(用户后续消息换了意图时,系统会把对应 Skill 附在消息前面):");
for (const s of rest) lines.push(`- ${s.title}:${s.description}`);
}
return { layer: "skills", title: "Skill", lines };
}
/** 追加消息命中新 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`,
}),
);
return `${blocks.join("\n\n")}\n\n${text}`;
}