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

+2 -1
View File
@@ -63,12 +63,13 @@ describe("buildSystemPrompt", () => {
role: "媒体库自动化助手",
goal: "把 Dune 1080p 收进来",
constraints: ["只给方案,不执行"],
capabilities: ["download.list", "download.inspect", "inventory.query"],
});
expect(p).toContain("你是媒体库自动化助手");
expect(p).toContain("把 Dune 1080p 收进来");
expect(p).toContain("证据");
expect(p).toContain("不得声称用户已授权");
expect(p).toContain("“已提交”不等于“已入库”");
expect(p).toContain("「已提交」不等于「已入库」");
expect(p).toMatch(/网页、RSS.*不可信数据/);
expect(p).toMatch(/不得输出.*凭证.*passkey/);
// AG-3:探查查询、参数类型、中文思考与汇报、被打断时的中文小结
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { lintReport } from "../src/index.ts";
// 汇报格式核对(AG-5):反例(docs/design/agent-report-sample-bad.md)必须不过,期望样子必须过。
const BAD = `检查结果
结论:下载器与媒体服务器都无法确认为"正常"——本系统里没有它们的登记对象,也没有对应的健康检查能力。以下是有证据的部分。
1. 登记对象盘点(关键)
列出站点 只返回 1 个站点:ID/key/名称/adapter/健康/账号 …「憨憨」 hhanclub nexusphp healthy(2026-09-29T08:12:01Z,延迟 1201ms)active
这是一个 PT 站点(NexusPHP),不是下载器,也不是媒体服务器。
2. tracker 侧间接证据(不构成下载器健康证明)
查站点用户数据 …:upload 17008345370067、download 0、bonus 414799;seeding 24,seedingSize 79407286843(≈79.4 GB),leeching 0
解释:站点侧显示有客户端在做种 24 个任务,但这是 tracker 统计,不能证明下载器本身健康。
3. 未能完成 / 未确认的部分
检测站点 被拒:缺少持续授权或有效的单次审批 … 没有可用的下载任务 ID 或种子 hash,查看下载任务 无法调用(返回 参数不对)。
4. 需要你确认/补的东西
要判断下载器队列健康,请给一个下载任务 ID 或种子 hash。`;
const GOOD =
"下载器 qBittorrent 正常,24 个在做种、没有在下载的;媒体服务器还没接入。站点憨憨上次检查正常(昨天 16:12),实时检测需要你批准。订阅 5 条都已取消,没有待处理事项。";
describe("lintReport", () => {
it("期望样子通过", () => {
expect(lintReport(GOOD)).toEqual([]);
});
it("反例不过:太长、原始字节数、ISO 时间、adapter、camelCase 字段、开头不是结论、向用户要 ID、说教", () => {
const rules = new Set(lintReport(BAD).map((i) => i.rule));
for (const r of [
"too_long",
"raw_bytes",
"iso_time",
"internal_field",
"no_conclusion_first",
"asks_user_for_id",
"lecture",
])
expect(rules, r).toContain(r);
});
it("UUID、hash、能力名、Markdown 标题各自报出", () => {
expect(lintReport("已提交。计划 062e5c51-1a2b-4c3d-8e9f-0123456789ab").map((i) => i.rule)).toContain("uuid");
expect(lintReport(`已提交,hash ${"ab".repeat(20)}`).map((i) => i.rule)).toContain("hash");
expect(lintReport("查了 downloader.list,下载器正常").map((i) => i.rule)).toContain("capability_id");
expect(lintReport("## 巡检\n下载器正常").map((i) => i.rule)).toContain("heading");
});
it("表格分隔行不计行数;年份、百分比、GB 不算原始数字", () => {
const t = [
"3 个下载有问题:",
"| 名称 | 状态 | 原因 |",
"|---|---|---|",
"| 沙丘2 | 失败 | 文件缺失 |",
"| 三体 | 无进展 | 6 小时没有速度 |",
"| 繁花 | 失败 | 站点删种 |",
"其余 12 个正常,共 1024.5 GB,2026 年 9 月下完的占 63%。",
].join("\n");
expect(lintReport(t)).toEqual([]);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { CAPABILITY_IDS } from "@umt/contracts";
import { beforeAll, describe, expect, it } from "vitest";
import {
buildLayeredPrompt,
GENERAL_CAPABILITIES,
INTENT_ROUTES,
loadSkillDocs,
type SkillDoc,
selectSkills,
withSkillInvocation,
} from "../src/index.ts";
// Skill(AG-5):packages/agent/skills/<name>/SKILL.md 的加载、格式、能力覆盖与按意图选择。
let skills: SkillDoc[] = [];
let diagnostics: string[] = [];
beforeAll(async () => {
const r = await loadSkillDocs();
skills = r.skills;
diagnostics = r.diagnostics;
});
const REQUIRED_SECTIONS = ["## 适用意图", "## 步骤", "## 需要批准", "## 汇报模板", "## 不要做"];
describe("加载(Pi harness loadSkills)", () => {
it("至少 5 个 Skill,没有格式诊断", () => {
expect(diagnostics).toEqual([]);
expect(skills.length).toBeGreaterThanOrEqual(5);
expect(skills.map((s) => s.name)).toEqual(
expect.arrayContaining([
"patrol",
"acquire-title",
"import-troubleshoot",
"subscription-check",
"storage-cleanup",
]),
);
});
it("每个 Skill 有中文标题、描述、触发词、能力,正文五节齐全", () => {
for (const s of skills) {
expect(s.title, s.name).toMatch(/[一-龥]/);
expect(s.description.length, s.name).toBeGreaterThan(10);
expect(s.triggers.length, s.name).toBeGreaterThan(0);
expect(s.capabilities.length, s.name).toBeGreaterThan(0);
for (const h of REQUIRED_SECTIONS) expect(s.content, `${s.name} 缺 ${h}`).toContain(h);
}
});
it("正文提到的能力都在 frontmatter 的 capabilities 里,且都是已知能力", () => {
for (const s of skills) {
const mentioned = new Set(s.content.match(/\b[a-z]+\.[a-z_]+\b/g) ?? []);
for (const m of mentioned) {
if (!(CAPABILITY_IDS as readonly string[]).includes(m)) continue;
expect(s.capabilities, `${s.name} 正文用了 ${m}`).toContain(m);
}
}
});
it("汇报模板里的示例本身不带内部字段(UUID、原始字节数、能力名)", () => {
for (const s of skills) {
const tpl = s.content.slice(s.content.indexOf("## 汇报模板"), s.content.indexOf("## 不要做"));
const quotes = tpl
.split("\n")
.filter((l) => l.startsWith("> "))
.join("\n");
expect(quotes, s.name).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}|\d{7,}/);
for (const id of CAPABILITY_IDS) expect(quotes, `${s.name} 模板含 ${id}`).not.toContain(id);
}
});
});
describe("能力覆盖", () => {
it("每个能力至少被一个 Skill 引用,或标注为通用", () => {
const used = new Set(skills.flatMap((s) => s.capabilities));
const general = new Set<string>(GENERAL_CAPABILITIES);
const missing = CAPABILITY_IDS.filter((id) => !used.has(id) && !general.has(id));
expect(missing).toEqual([]);
});
it("每个能力都出现在能力地图的某条路线里,或标注为通用", () => {
const routed = new Set<string>(INTENT_ROUTES.flatMap((r) => r.steps.map((s) => s.cap)));
const missing = CAPABILITY_IDS.filter((id) => !routed.has(id) && !(GENERAL_CAPABILITIES as string[]).includes(id));
expect(missing).toEqual([]);
});
});
/** 固定提示集的意图路由(与 e16 评测同一组提示) */
const ROUTING: [string, string][] = [
["检查站点、下载器和媒体服务器是否正常", "patrol"],
["把《沙丘 2》4K 收进来", "acquire-title"],
["为什么沙丘2没入库", "import-troubleshoot"],
["订阅追更情况", "subscription-check"],
["有没有失败的下载任务", "download-diagnose"],
["空间不够了,看看能清理什么", "storage-cleanup"],
["qB 是不是掉线了", "patrol"],
["我订的三体更新到第几集了", "subscription-check"],
["下载进度怎么样了", "download-diagnose"],
];
describe("按意图选择", () => {
it.each(ROUTING)("%s → %s", (goal, name) => {
const picked = selectSkills(skills, goal, { kind: "chat" });
expect(picked[0]?.name).toBe(name);
});
it("闲聊不载入 Skill;最多载入 2 个", () => {
expect(selectSkills(skills, "你好", { kind: "chat" })).toEqual([]);
expect(selectSkills(skills, "巡检一下下载失败和订阅追更,再清理空间", { kind: "chat" }).length).toBe(2);
});
it("巡检会话固定带「巡检」;可用能力都不在白名单的 Skill 不选", () => {
const p = selectSkills(skills, "巡检待处理(2026-09-30)", { kind: "patrol", force: ["patrol"] });
expect(p[0]?.name).toBe("patrol");
expect(selectSkills(skills, "把《沙丘 2》4K 收进来", { kind: "chat", capabilities: ["site.list"] })).toEqual([]);
// 收录作品只在用户会话和子会话里用
expect(selectSkills(skills, "把《沙丘 2》4K 收进来", { kind: "patrol" })).toEqual([]);
});
it("载入的 Skill 全文进「Skill」层,其余只列索引;追加消息用 Pi 的 skill 块附在消息前", () => {
const loaded = selectSkills(skills, "检查站点、下载器和媒体服务器是否正常", { kind: "chat" });
const p = buildLayeredPrompt({
kind: "chat",
role: "媒体库自动化智能体",
goal: "检查站点、下载器和媒体服务器是否正常",
skills: { loaded, available: skills },
});
expect(p).toContain("### Skill「巡检」");
expect(p).toContain("#### 汇报模板");
expect(p).toContain("- 收录一部作品:");
expect(p.match(/^## /gm)?.length).toBeGreaterThan(2);
const msg = withSkillInvocation(loaded, "再看看订阅");
expect(msg).toMatch(/^<skill name="patrol" location="skills\/patrol\/SKILL.md">/);
expect(msg.endsWith("再看看订阅")).toBe(true);
});
});
+163 -34
View File
@@ -1,7 +1,16 @@
import { describe, expect, it } from "vitest";
import { buildSystemPrompt, interactionRules, REPORT_FORMAT } from "../src/index.ts";
import {
buildLayeredPrompt,
buildSystemPrompt,
type EnvironmentFacts,
interactionRules,
LAYERS_BY_KIND,
promptSections,
REPORT_FORMAT,
renderEnvironment,
} from "../src/index.ts";
// AGUI-3 prompt 回归:用户在会话里看到的汇报就是模型写的文字,格式约束靠这里守住。
// 分层系统提示回归(AG-5,继承 AGUI-3 的汇报约束):用户在会话里看到的汇报就是模型写的文字,格式约束靠这里守住。
// 改提示词时先想清楚是否放宽了这些约束,再同步改断言。
const userPrompt = () =>
@@ -9,6 +18,7 @@ const userPrompt = () =>
role: "媒体库自动化智能体",
goal: "看看有哪些新 4K",
interaction: { plan: true, options: true, approvals: true },
capabilities: ["downloader.list", "site.list", "site.health_check", "download.list", "download.inspect"],
});
/** 取提示词里的一节(## 标题到下一个 ## 之前) */
@@ -20,37 +30,145 @@ function section(p: string, title: string): string {
return next < 0 ? rest : rest.slice(0, next);
}
describe("汇报格式(AGUI-3)", () => {
it("关键约束句:结论先行、用名字不用函数名 / 字段名 / UUID、不超过 10 行、多个资源才用表格", () => {
const f = section(userPrompt(), "汇报格式");
expect(f).toMatchInlineSnapshot(`
"## 汇报格式
- 第一句写结论:做成了什么、没做成什么、为什么。
- 对象用名字:作品名、站点名、资源标题、文件名;不写函数名、工具名、字段名、参数、UUID 或 hash。
- 数量以工具返回为准:派出几个子会话、提交几个下载,只数成功的调用,失败或被拒绝的不算。
- 不超过 10 行;不复述过程,不写下一步打算,除非需要用户做什么。
- 只有列举多个资源(3 个以上的资源、任务或文件)时才用表格,其余用短句或列表。
- 可以用 Markdown 列表、表格和粗体,不写标题,不写 HTML。
"
`);
const NOW = "2026-09-30T09:45:00.000Z";
const ENV: EnvironmentFacts = {
now: NOW,
timeZone: "Asia/Shanghai",
downloaders: [
{
name: "家里的 qB",
kind: "qbittorrent",
enabled: true,
health: "healthy",
lastSeenAt: "2026-09-30T09:42:00.000Z",
tasks: { total: 24, active: 0, failed: 0 },
},
],
mediaServers: [],
sites: [
{
name: "憨憨",
kind: "pt",
enabled: true,
health: "healthy",
lastHealthAt: "2026-09-29T08:12:00.000Z",
account: "active",
},
],
libraryRoots: [{ name: "电影", enabled: true, health: "healthy" }],
subscriptions: { cancelled: 5 },
openDecisions: 0,
downloads: { imported: 20, downloading: 0 },
};
describe("分层组装", () => {
it("用户会话按固定顺序装配:角色与边界 → 当前环境 → 能力地图 → 行为规则 → 汇报格式 → 本次目标", () => {
const p = buildLayeredPrompt({
kind: "chat",
role: "媒体库自动化智能体",
goal: "检查站点、下载器和媒体服务器是否正常",
environment: ENV,
capabilities: ["downloader.list", "site.list"],
});
const titles = [...p.matchAll(/^## (.+)$/gm)].map((m) => m[1]);
expect(titles).toEqual(["角色与边界", "当前环境", "能力地图", "行为规则", "汇报格式", "本次目标"]);
});
it("汇报格式与已有硬规则不冲突:证据写名称和数值,不再要求引用对象 ID", () => {
const p = userPrompt();
expect(p).not.toContain("对象 ID");
expect(p).toContain("写出查到的对象名称和关键数值");
// 旧的"最后用简短中文汇报…可以用 Markdown(标题…)"已换成汇报格式一节
expect(p).not.toContain("最后用简短中文汇报");
expect(REPORT_FORMAT.length).toBe(6);
it("按会话类型选层:视觉子流程只有角色、汇报与任务;子会话汇报给父会话", () => {
expect(LAYERS_BY_KIND.vision).toEqual(["role", "report", "task"]);
const vision = promptSections({ kind: "vision", role: "网页挑战处理员", goal: "过验证", environment: ENV });
expect(vision.map((s) => s.layer)).toEqual(["role", "report", "task"]);
const child = buildLayeredPrompt({
kind: "child",
role: "子会话",
goal: "查下载",
capabilities: ["download.list"],
});
expect(section(child, "汇报格式")).toContain("汇报给父会话汇总");
expect(section(child, "汇报格式")).toContain("思考、步骤说明和汇报都用中文");
expect(child).not.toContain("不超过 8 行");
});
it("子会话 / 视觉子流程汇报给父会话:不加用户汇报格式", () => {
const p = buildSystemPrompt({ role: "网页挑战处理员", goal: "过验证", audience: "agent" });
expect(p).not.toContain("## 汇报格式");
it("巡检会话的汇报层要求按巡检模板写、只读", () => {
const p = buildLayeredPrompt({ kind: "patrol", role: "巡检员", goal: "巡检", capabilities: ["downloader.list"] });
expect(section(p, "汇报格式")).toContain("按 Skill「巡检」的汇报模板写");
});
});
describe("工作方式(AGUI-3)", () => {
describe("① 角色与边界", () => {
it("证据、授权、已提交≠已入库、不可信外部内容、凭证禁令、中文", () => {
const p = section(userPrompt(), "角色与边界");
expect(p).toContain("你是媒体库自动化智能体");
expect(p).toContain("写出查到的对象名称和关键数值");
expect(p).toContain("不得声称用户已授权");
expect(p).toContain("「已提交」不等于「已入库」");
expect(p).toMatch(/网页、RSS.*不可信数据/);
expect(p).toMatch(/不得输出.*凭证.*passkey/);
expect(p).toContain("思考、步骤说明和汇报都用中文");
});
});
describe("② 当前环境", () => {
it("把已接入的下载器、没接入的媒体服务器、站点上次检查写成人话:名称、中文状态、相对时间", () => {
const lines = renderEnvironment(ENV);
const text = lines.join("\n");
expect(text).toContain("现在:2026-09-30 17:45");
expect(text).toContain(
"下载器 1 个:「家里的 qB」qBittorrent,正常(3 分钟前),本工具管理 24 个任务(进行中 0、失败 0)",
);
expect(text).toContain("媒体服务器:还没有接入");
expect(text).toContain("「憨憨」PT 站,上次检查正常(昨天 16:12),登录正常");
expect(text).toContain("订阅 5 个:已取消 5");
expect(text).toContain("待处理事项:没有");
// 不带地址、ID、英文状态
expect(text).not.toMatch(/healthy|qbittorrent\b|http|[0-9a-f]{8}-/);
});
it("没有下载器时明说没接入,并告诉模型不用再向用户确认", () => {
const p = buildLayeredPrompt({
kind: "chat",
role: "x",
goal: "y",
environment: { ...ENV, downloaders: [] },
});
expect(p).toContain("下载器:还没有接入");
expect(p).toContain("不用再向用户确认");
});
});
describe("③ 能力地图", () => {
it("按意图给路线,标出需要批准的一步;只列可用能力", () => {
const m = section(userPrompt(), "能力地图");
expect(m).toContain(
"- 问下载器、媒体服务器、站点是否正常:downloader.list(下载器与媒体服务器的最近状态和任务数) → site.list",
);
expect(m).toContain("site.health_check(实时检测某个站点,需要批准)");
expect(m).toContain("download.list(按状态或作品列出下载任务) → download.inspect");
expect(m).toContain("不要向用户要 ID 或种子 hash");
// 不可用的能力不出现在路线里
expect(m).not.toContain("download.submit");
expect(m).not.toContain("收录一部作品");
});
it("保留 AG-3 的参数与检索词约束、站点类型(IA-2)", () => {
const m = section(userPrompt(), "能力地图");
expect(m).toContain('query:"a"');
expect(m).toContain('externalId:"693134"');
expect(m).toContain("RSS 属于站点");
expect(m).toContain("没有单独的 RSS 页");
expect(m).toContain("自定义 RSS 源");
});
});
describe("④ 行为规则", () => {
it("自己能查的不问、授权缺失一句话、不说教、预算提醒", () => {
const b = section(userPrompt(), "行为规则");
expect(b).toContain("自己能查的不问用户");
expect(b).toContain("只写一句「没做:<动作>,需要你批准」");
expect(b).toContain("不说教");
expect(b).toContain("【预算提醒】");
});
it("只在真有分歧时给方案;查询、盘点不给方案", () => {
const rules = interactionRules({ options: true }).join("\n");
expect(rules).toContain("只有真的存在分歧时");
@@ -65,8 +183,10 @@ describe("工作方式(AGUI-3)", () => {
expect(interactionRules({ options: true }).join("\n")).not.toContain("approvedAction");
});
it("步骤标题写要做的事,不写工具名", () => {
expect(interactionRules({ plan: true }).join("\n")).toContain("不写工具名");
it("步骤标题写要做的事,不写工具名;一两次查询不列步骤", () => {
const r = interactionRules({ plan: true }).join("\n");
expect(r).toContain("不写工具名");
expect(r).toContain("一两次查询就能回答的问题不用列步骤");
});
it("先看方案(/先给方案)只对本会话生效的约束句保留", () => {
@@ -74,11 +194,20 @@ describe("工作方式(AGUI-3)", () => {
});
});
describe("站点类型(IA-2)", () => {
it("RSS 属于站点,没有单独的 RSS 页;按获取方式判断能不能搜索 / 登录", () => {
const p = userPrompt();
expect(p).toContain("RSS 属于站点");
expect(p).toContain("没有单独的 RSS 页");
expect(p).toContain("自定义 RSS 源");
describe("⑤ 汇报格式", () => {
it("结论先行、≤8 行、人话单位、对象用名字、只在列举多个对象时用表格", () => {
const f = section(userPrompt(), "汇报格式");
expect(f).toContain("第一句就是结论");
expect(f).toContain("不超过 8 行");
expect(f).toContain("大小写成 GB / TB");
expect(f).toContain("时间写相对时间");
expect(f).toContain("不写函数名、工具名、字段名、参数、UUID、hash、adapter 或英文状态码");
expect(f).toContain("只有列举 3 个以上同类对象");
expect(f).toContain("不写 HTML");
expect(REPORT_FORMAT.length).toBe(9);
});
it("用户汇报不要求引用对象 ID", () => {
expect(userPrompt()).not.toContain("对象 ID");
});
});