Files
UHD_Media_Tool/packages/agent/src/prompt/skills.ts
T

169 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`;
}