feat: 系统提示词改为单一入口统一管理

多个扩展各自整段改写系统提示词并按加载顺序接力,任一扩展定死提示词后
其余只能做字符串手术,且各上游的工具说明与组合包实际工具格局冲突。

- 新增 extensions/system-prompt.ts 作为唯一出口(加载列表最后),按
  config/prompt/(preamble.md、tools.jsonc、routing.jsonc、sections.jsonc)
  组合开场白、工具简介/规则与 <tool_routing> 分区;替代原封面与
  tool-routing 扩展,/dump-system-prompt 输出最终提示词。
- pi-chrome、pi-ssh、pi-hermes-memory 改为写命名分区,pi-rtk-optimizer
  改写 promptGuidelines,pi-permission-system 直接过滤结构化 skills,
  均不再返回 systemPrompt;pi-chrome 未授权时不再每轮冻结提示词。
- 新增 prompt-guard 代理加载第三方扩展(Hashline、FFF、LSP、
  context-mode):拦截直接改写提示词并按目录覆盖工具说明;context-mode
  去掉与组合包路由冲突的锚点段,保留记忆与恢复快照。
- 消除冲突:bash 简介不再建议 grep/find;Hashline edit 不再称可用 grep
  锚点、不再介绍已关闭的 replace_text;context-mode 读写/网页路由提示。
- 守门测试:工具目录覆盖率、禁用用法检查、context-mode 上游原文锁定;
  AGENTS.md 新增「系统提示词单一所有权」。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-20 17:53:04 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent dc4fe92e27
commit ba164896d2
39 changed files with 1863 additions and 742 deletions
+6 -3
View File
@@ -19,7 +19,7 @@ import {
} from "../pi-rtk-optimizer/src/constants.ts";
import { CONFIG_PATH as COMPACTION_CONFIG_PATH } from "../pi-compaction/src/config.ts";
import { WORKTREE_CONFIG_PATH } from "../pi-worktree/src/settings.ts";
import { resolveCoverPath } from "../extensions/system-prompt-cover-config.ts";
import { resolvePreamblePath } from "../extensions/system-prompt-config.ts";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bundleConfigDir = join(repositoryRoot, "config");
@@ -42,7 +42,10 @@ const BUNDLE_CONFIG_FILES = [
"pi-compaction.json",
"pi-worktree.json",
"pi-hermes-memory.json",
"system-prompt-cover.md",
"prompt/preamble.md",
"prompt/tools.jsonc",
"prompt/routing.jsonc",
"prompt/sections.jsonc",
"pi-hashline-edit.json",
"lsp.json",
"p10k.zsh",
@@ -92,7 +95,7 @@ test("each extension resolves its config path inside the bundle config/ dir", ()
inBundleConfig(COMPACTION_CONFIG_PATH, "pi-compaction.json");
inBundleConfig(WORKTREE_CONFIG_PATH, "pi-worktree.json");
inBundleConfig(HERMES_CONFIG_PATH, "pi-hermes-memory.json");
inBundleConfig(resolveCoverPath(), "system-prompt-cover.md");
inBundleConfig(resolvePreamblePath(), "prompt/preamble.md");
} finally {
if (savedToolSearch === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH;
else process.env.PI_TOOL_SEARCH_CONFIG_PATH = savedToolSearch;
+35 -7
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
@@ -154,17 +155,44 @@ test("all package extensions load together without global registration conflicts
packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"),
"the bundle must load the locally maintained Hermes memory extension",
);
// The cover must rewrite the prompt before tool-routing flattens it into a forced prompt;
// once flattened, later handlers cannot restore the section structure.
// extensions/system-prompt.ts is the bundle's single system-prompt owner and
// must run after every tool-registration extension (including Tool Search)
// so it sees the complete tool set and any contributed sections.
assert.equal(
packageJson.pi.extensions.at(-1),
"./extensions/system-prompt.ts",
"the single system-prompt owner must be the last loaded extension",
);
assert.ok(
packageJson.pi.extensions.includes("./extensions/context-mode.ts"),
"Context Mode must load through the bundle's prompt-guarded wrapper",
);
assert.equal(
packageJson.pi.extensions.some((entry) => entry.includes("context-mode/build/")),
false,
"the raw Context Mode adapter must not be loaded alongside the wrapper",
);
assert.equal(
packageJson.pi.extensions.includes("./extensions/system-prompt-cover.ts"),
"the bundle must load the system prompt cover extension",
false,
"the retired system-prompt cover must not be loaded",
);
assert.ok(
packageJson.pi.extensions.indexOf("./extensions/system-prompt-cover.ts") <
packageJson.pi.extensions.indexOf("./extensions/tool-routing.ts"),
"the system prompt cover must run before tool-routing forces the prompt",
assert.equal(
packageJson.pi.extensions.includes("./extensions/tool-routing.ts"),
false,
"the retired tool-routing extension must not be loaded",
);
for (const retired of [
"extensions/system-prompt-cover.ts",
"extensions/system-prompt-cover-config.ts",
"extensions/tool-routing.ts",
]) {
assert.equal(
existsSync(join(repositoryRoot, retired)),
false,
`${retired} was superseded by extensions/system-prompt.ts and must be deleted`,
);
}
assert.equal(packageJson.dependencies["pi-hermes-memory"], "file:./pi-hermes-memory");
assert.ok(
packageJson.files.includes("pi-hermes-memory"),
-160
View File
@@ -1,160 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
readCoverFile,
resolveCoverPath,
spliceCover,
} from "../extensions/system-prompt-cover-config.ts";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bundledCoverPath = join(repositoryRoot, "config", "system-prompt-cover.md");
const DEFAULT_PREAMBLE =
"You are an expert coding assistant operating inside pi, a coding agent harness. " +
"You help users by reading files, executing commands, editing code, and writing new files.";
/** A rendered prompt shaped like Pi's: preamble, then tag-wrapped top-level sections. */
function renderedPrompt(preamble = DEFAULT_PREAMBLE) {
return [
preamble,
"<tools>",
"- read: reads files",
"</tools>",
"<rules>",
"- Be concise",
"</rules>",
"<skills>",
"<available_skills>",
" <skill>",
" <name>s</name>",
" </skill>",
"</available_skills>",
"</skills>",
"<project_context>",
'<project_instructions path="/work/AGENTS.md">',
"Nested content with a </project_context>-looking line is not a tag.",
"</project_instructions>",
"</project_context>",
"<cwd>",
"/work",
"</cwd>",
].join("\n");
}
function tagsOf(text: string): string[] {
return [...text.matchAll(/^<([a-z][a-z0-9_-]*)>$/gm)].map((match) => match[1]);
}
async function makeTempDir() {
return mkdtemp(join(tmpdir(), "my-pi-cover-"));
}
test("bundled cover holds Pi's default preamble text", async () => {
const content = await readFile(bundledCoverPath, "utf8");
assert.equal(content.replace(/\s+$/, ""), DEFAULT_PREAMBLE);
});
test("spliceCover replaces only the preamble and preserves the remaining bytes", () => {
const rendered = renderedPrompt();
const spliced = spliceCover(rendered, "MY CUSTOM COVER");
assert.ok(spliced.startsWith("MY CUSTOM COVER\n"));
// Everything after the preamble's terminating newline is untouched.
const originalTail = rendered.slice(rendered.indexOf("\n<tools>"));
assert.ok(spliced.endsWith(originalTail));
// Section order — including nested tags — is identical to the unmodified prompt.
assert.deepEqual(tagsOf(spliced), tagsOf(rendered));
assert.deepEqual(tagsOf(spliced), [
"tools",
"rules",
"skills",
"available_skills",
"project_context",
"cwd",
]);
});
test("spliceCover keeps section order rather than appending recovered sections", () => {
const rendered = renderedPrompt("ORIGINAL COVER");
const spliced = spliceCover(rendered, "NEW COVER");
const order = tagsOf(spliced).join(",");
assert.equal(order, "tools,rules,skills,available_skills,project_context,cwd");
assert.ok(
order.indexOf("tools") < order.indexOf("cwd"),
"tools must still precede cwd; a re-appended section list would invert this",
);
assert.ok(
order.indexOf("project_context") < order.indexOf("cwd"),
"project_context must keep its original position",
);
});
test("spliceCover tolerates a trailing newline from the editor and a blank cover", () => {
const rendered = renderedPrompt();
assert.equal(spliceCover(rendered, "COVER\n\n"), spliceCover(rendered, "COVER"));
// A whitespace-only cover leaves an empty preamble but still keeps every section.
const blank = spliceCover(rendered, " \n");
assert.equal(blank, rendered.slice(rendered.indexOf("\n<tools>")));
assert.deepEqual(tagsOf(blank), tagsOf(rendered));
});
test("spliceCover degenerates to the cover when the prompt has no sections", () => {
assert.equal(spliceCover("just a preamble", "NEW"), "NEW");
assert.equal(spliceCover("", "NEW"), "NEW");
});
test("spliceCover keeps an attribute-bearing nested tag inside its parent section", () => {
// Pi nests <project_instructions path="..."> inside the bare <project_context> tag, so the
// boundary must be the first *bare* tag line and the nested markup must survive verbatim.
const rendered = [
"ORIGINAL COVER",
"<tools>",
"- read: reads files",
"</tools>",
"<project_context>",
'<project_instructions path="/work/AGENTS.md">',
"nested instructions",
"</project_instructions>",
"</project_context>",
"<cwd>",
"/work",
"</cwd>",
].join("\n");
const spliced = spliceCover(rendered, "NEW");
assert.equal(tagsOf(spliced).join(","), "tools,project_context,cwd");
assert.ok(spliced.startsWith("NEW\n<tools>"));
assert.ok(
spliced.includes('<project_instructions path="/work/AGENTS.md">\nnested instructions\n</project_instructions>'),
"nested project instructions must be preserved byte-for-byte",
);
});
test("resolveCoverPath points at the bundle config file", () => {
assert.equal(resolveCoverPath(), bundledCoverPath);
});
test("readCoverFile returns undefined when the file is absent or blank", async () => {
const dir = await makeTempDir();
try {
const coverPath = join(dir, "system-prompt-cover.md");
assert.equal(readCoverFile(coverPath), undefined);
await writeFile(coverPath, " \n\n", "utf8");
assert.equal(readCoverFile(coverPath), undefined);
await writeFile(coverPath, " REAL COVER \n", "utf8");
assert.equal(readCoverFile(coverPath), " REAL COVER");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+605
View File
@@ -0,0 +1,605 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
applyToolCatalog,
loadRoutingRules,
loadSectionsConfig,
loadToolCatalog,
orderSections,
readPreamble,
renderRouting,
resolvePreamblePath,
spliceCover,
stripJsoncComments,
type MutablePromptOptions,
type RoutingRule,
} from "../extensions/system-prompt-config.ts";
import systemPromptExtension, {
createSystemPromptComposer,
getSystemPromptDumpPath,
} from "../extensions/system-prompt.ts";
import { guardExtensionApi } from "../extensions/prompt-guard.ts";
import {
CONTEXT_MODE_ANCHOR_PREFIX,
filterContextModeMessages,
} from "../extensions/context-mode-filter.ts";
import { BUNDLE_GROUP_DEFINITIONS } from "../pi-tool-search/extensions/bundle-groups.ts";
import { BUNDLE_TOOL_SEARCH_DEFAULTS } from "../pi-tool-search/extensions/config.ts";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const promptConfigDir = join(repositoryRoot, "config", "prompt");
const BUNDLE_PREAMBLE =
"You are an expert coding assistant operating inside pi, a coding agent harness. " +
"You help users by reading files, executing commands, editing code, and writing new files.";
// ── JSONC parsing ───────────────────────────────────────────────────────────
test("stripJsoncComments removes comments but keeps // inside strings", () => {
const source = `{
// line comment
"url": "https://example.com//path", /* block comment */
"escaped": "a\\"//not a comment"
}`;
assert.deepEqual(JSON.parse(stripJsoncComments(source)), {
url: "https://example.com//path",
escaped: 'a"//not a comment',
});
});
// ── Config files load and validate ──────────────────────────────────────────
test("config/prompt files load with the expected shapes", () => {
const catalog = loadToolCatalog();
assert.equal(catalog.version, 1);
assert.ok(Object.keys(catalog.tools).length > 0);
const routing = loadRoutingRules();
assert.equal(routing.version, 1);
assert.ok(routing.rules.length > 0);
for (const rule of routing.rules) assert.equal(typeof rule.text, "string");
const sections = loadSectionsConfig();
assert.equal(sections.version, 1);
assert.deepEqual(sections.order, [
"memory_policy",
"remote_ssh",
"chrome_profile_bridge",
"tool_routing",
]);
});
test("readPreamble reads the bundle preamble from config/prompt/", () => {
assert.equal(
resolvePreamblePath(),
join(promptConfigDir, "preamble.md"),
);
assert.equal(readPreamble(), BUNDLE_PREAMBLE);
});
// ── Catalog completeness ────────────────────────────────────────────────────
const CORE_TOOL_NAMES = [
"read",
"bash",
"edit",
"write",
"grep",
"find",
"ls",
"powershell",
"tool_search",
];
function requiredToolNames(): Set<string> {
const required = new Set(CORE_TOOL_NAMES);
for (const group of BUNDLE_GROUP_DEFINITIONS) {
for (const name of group.tools) required.add(name);
}
for (const name of BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled) required.add(name);
return required;
}
test("tools.jsonc covers every tool the bundle can expose, and nothing else", () => {
const required = requiredToolNames();
const catalog = loadToolCatalog();
for (const name of required) {
assert.ok(name in catalog.tools, `tool "${name}" is missing from config/prompt/tools.jsonc`);
}
for (const name of Object.keys(catalog.tools)) {
assert.ok(required.has(name), `tools.jsonc entry "${name}" is not a bundle-exposed tool`);
}
});
// ── Forbidden phrases ───────────────────────────────────────────────────────
test("prompt config carries no retired guidance phrasing", () => {
const forbidden = [
"ls, grep, find",
"read, grep, or edit",
"read/grep",
"replace_text",
"Read/edit files",
"ctx_fetch_and_index then ctx_search",
];
for (const file of ["preamble.md", "tools.jsonc", "routing.jsonc"]) {
const source = readFileSync(join(promptConfigDir, file), "utf8");
for (const phrase of forbidden) {
assert.ok(!source.includes(phrase), `config/prompt/${file} must not contain "${phrase}"`);
}
}
});
// ── applyToolCatalog ────────────────────────────────────────────────────────
test("applyToolCatalog applies snippet/guideline overrides and reports unregistered tools", () => {
const catalog = {
version: 1 as const,
tools: {
bash: { snippet: "custom bash line" },
edit: { guidelines: ["rule one", "rule two"] },
read: { inherit: true as const },
write: { snippet: null },
},
};
const options: MutablePromptOptions = {
selectedTools: ["bash", "edit", "read", "write", "mystery_tool"],
toolSnippets: { bash: "orig bash", write: "orig write", read: "orig read" },
toolGuidelines: { edit: ["old rule"] },
};
const missing = applyToolCatalog(options, catalog);
assert.deepEqual(missing, ["mystery_tool"]);
assert.equal(options.toolSnippets?.bash, "custom bash line");
assert.equal(options.toolSnippets?.read, "orig read", "inherit entries keep upstream text");
assert.ok(!("write" in (options.toolSnippets ?? {})), "snippet: null hides the tools entry");
assert.deepEqual(options.toolGuidelines?.edit, ["rule one", "rule two"]);
});
// ── renderRouting ───────────────────────────────────────────────────────────
test("renderRouting honours whenAll, whenAny and unconditional rules", () => {
const rules: RoutingRule[] = [
{ whenAll: ["a", "b"], text: "all" },
{ whenAny: ["x", "y"], text: "any" },
{ text: "always" },
{ whenAll: ["a"], whenAny: ["b", "z"], text: "both" },
];
assert.equal(renderRouting(rules, ["a", "b", "x"]), "- all\n- any\n- always\n- both");
assert.equal(renderRouting(rules, ["a"]), "- always", "whenAny must match at least one");
assert.equal(renderRouting(rules, []), "- always");
assert.equal(
renderRouting(rules, undefined),
"- all\n- any\n- always\n- both",
"absent selectedTools means every tool is active",
);
assert.equal(renderRouting([], ["a"]), "");
});
test("bundled routing rules select the same guidance the retired tool-routing extension emitted", () => {
const routing = loadRoutingRules();
const literal = renderRouting(routing.rules, ["find", "grep", "read"]);
assert.match(literal, /Use find to identify likely files or directories/);
assert.match(literal, /narrow the path, glob, or pattern instead of increasing the limit/);
const ssh = renderRouting(routing.rules, ["ssh_connect", "ssh_cd", "ssh_bash"]);
assert.match(ssh, /only when the user explicitly names an imported host/);
assert.match(ssh, /command-local directory change/);
const chrome = renderRouting(routing.rules, ["chrome_snapshot", "ctx_execute_file"]);
assert.match(chrome, /immediately analyze the returned workspace-relative capture path/);
const minimal = renderRouting(routing.rules, ["read"]);
assert.doesNotMatch(minimal, /codegraph_explore first/);
assert.doesNotMatch(minimal, /For literal search/);
assert.doesNotMatch(minimal, /ssh_connect only when/);
assert.match(minimal, /Stop searching once the current evidence is sufficient/);
});
// ── orderSections ───────────────────────────────────────────────────────────
test("orderSections applies the configured order and keeps unlisted sections last", () => {
const sections = {
late_unlisted: "L",
tool_routing: "R",
memory_policy: "M",
early_unlisted: "E",
};
assert.deepEqual(
Object.keys(orderSections(sections, ["memory_policy", "tool_routing"])),
["memory_policy", "tool_routing", "late_unlisted", "early_unlisted"],
);
assert.deepEqual(orderSections({}, ["memory_policy"]), {});
});
// ── spliceCover (moved from the retired cover extension) ────────────────────
function renderedPrompt(preamble = "PI DEFAULT PREAMBLE") {
return [
preamble,
"<tools>",
"- read: reads files",
"</tools>",
"<rules>",
"- Be concise",
"</rules>",
"<project_context>",
'<project_instructions path="/work/AGENTS.md">',
"Nested content with a </project_context>-looking line is not a tag.",
"</project_instructions>",
"</project_context>",
"<cwd>",
"/work",
"</cwd>",
].join("\n");
}
function tagsOf(text: string): string[] {
return [...text.matchAll(/^<([a-z][a-z0-9_-]*)>$/gm)].map((match) => match[1]);
}
test("spliceCover replaces only the preamble and preserves the remaining bytes", () => {
const rendered = renderedPrompt();
const spliced = spliceCover(rendered, "MY CUSTOM COVER");
assert.ok(spliced.startsWith("MY CUSTOM COVER\n"));
const originalTail = rendered.slice(rendered.indexOf("\n<tools>"));
assert.ok(spliced.endsWith(originalTail));
assert.deepEqual(tagsOf(spliced), tagsOf(rendered));
assert.deepEqual(tagsOf(spliced), ["tools", "rules", "project_context", "cwd"]);
});
test("spliceCover tolerates a trailing newline and a whitespace-only preamble", () => {
const rendered = renderedPrompt();
assert.equal(spliceCover(rendered, "COVER\n\n"), spliceCover(rendered, "COVER"));
const blank = spliceCover(rendered, " \n");
assert.equal(blank, rendered.slice(rendered.indexOf("\n<tools>")));
});
test("spliceCover degenerates to the preamble when the prompt has no sections", () => {
assert.equal(spliceCover("just a preamble", "NEW"), "NEW");
assert.equal(spliceCover("", "NEW"), "NEW");
});
// ── Composer: modern structured path ────────────────────────────────────────
interface FakePromptOptions extends MutablePromptOptions {
forceSystemPrompt?: string;
}
/** Simplified re-render of Pi's prompt from the mutable options object. */
function renderPiPrompt(options: FakePromptOptions): string {
if (options.forceSystemPrompt !== undefined) return options.forceSystemPrompt;
const parts = ["PI DEFAULT PREAMBLE"];
const snippets = Object.entries(options.toolSnippets ?? {});
if (snippets.length > 0) {
parts.push(`<tools>\n${snippets.map(([n, s]) => `- ${n}: ${s}`).join("\n")}\n</tools>`);
}
const rules = [
...(options.promptGuidelines ?? []),
...Object.entries(options.toolGuidelines ?? {}).flatMap(([name, lines]) =>
lines.map((line) => `${name}: ${line}`),
),
];
if (rules.length > 0) parts.push(`<rules>\n${rules.join("\n")}\n</rules>`);
parts.push("<cwd>\n/work\n</cwd>");
for (const [name, text] of Object.entries(options.sections ?? {})) {
parts.push(`<${name}>\n${text}\n</${name}>`);
}
return parts.join("\n");
}
function makeEvent(options: FakePromptOptions | undefined) {
return {
systemPromptOptions: options,
get systemPrompt() {
return options ? renderPiPrompt(options) : "PI DEFAULT PREAMBLE\n<tools>\n- x\n</tools>";
},
};
}
function makeUiCtx() {
const notices: string[] = [];
const ctx = {
hasUI: true,
ui: { notify: (message: string) => notices.push(message) },
};
return { notices, ctx: ctx as never };
}
test("composer splices the preamble, applies the catalog, and orders sections", () => {
const composer = createSystemPromptComposer();
const options: FakePromptOptions = {
selectedTools: ["read", "bash", "edit", "codegraph_explore", "tool_search"],
toolSnippets: {
read: "reads files",
bash: "runs commands",
edit: "edits",
codegraph_explore: "explores",
tool_search: "searches",
},
toolGuidelines: { edit: ["upstream edit rule"] },
sections: { memory_policy: "policy text" },
};
const { notices, ctx } = makeUiCtx();
const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx);
const output = result?.systemPrompt;
assert.ok(output);
assert.ok(
output.startsWith(BUNDLE_PREAMBLE),
"the bundle preamble replaces Pi's default preamble",
);
assert.match(output, /<tool_routing>\n- Use codegraph_explore first/);
assert.match(output, /search files with the find\/grep tools, not bash/);
assert.match(output, /LINE#HASH anchors/);
assert.ok(
!output.includes("upstream edit rule"),
"the edit guidelines entry replaces upstream rules",
);
assert.ok(
output.indexOf("<memory_policy>") < output.indexOf("<tool_routing>"),
"sections.jsonc orders memory_policy before tool_routing",
);
assert.deepEqual(notices, []);
assert.equal(composer.getLastComposed(), output);
});
test("composer degrades to text splicing when an earlier extension froze the prompt", () => {
const composer = createSystemPromptComposer();
const options: FakePromptOptions = {
selectedTools: ["read", "bash"],
forceSystemPrompt: "FROZEN PROMPT\n<frozen>\nkept\n</frozen>",
};
const { notices, ctx } = makeUiCtx();
const event = makeEvent(options);
const first = composer.handleBeforeAgentStart(event as never, ctx);
composer.handleBeforeAgentStart(event as never, ctx);
assert.match(notices[0] ?? "", /单一入口规则/);
assert.equal(notices.length, 1, "the violation warning is reported once per process");
assert.match(first?.systemPrompt ?? "", /<tool_routing>/);
assert.match(first?.systemPrompt ?? "", /<frozen>\nkept\n<\/frozen>/);
assert.ok(
(first?.systemPrompt ?? "").startsWith(BUNDLE_PREAMBLE),
"the preamble is still spliced onto the frozen prompt",
);
});
test("composer falls back to text appends when the host exposes no prompt options", () => {
const composer = createSystemPromptComposer();
const { ctx } = makeUiCtx();
const event = { systemPrompt: "PI DEFAULT PREAMBLE\n<tools>\n- x\n</tools>" };
const result = composer.handleBeforeAgentStart(event as never, ctx);
const output = result?.systemPrompt ?? "";
assert.ok(output.startsWith(BUNDLE_PREAMBLE));
assert.match(output, /<tool_routing>/);
assert.match(output, /<tools>\n- x\n<\/tools>/);
});
test("composer reports unregistered tools once per distinct name set", () => {
const composer = createSystemPromptComposer();
const { notices, ctx } = makeUiCtx();
const options = (): FakePromptOptions => ({
selectedTools: ["read", "ghost_tool"],
toolSnippets: { read: "r" },
});
composer.handleBeforeAgentStart(makeEvent(options()) as never, ctx);
composer.handleBeforeAgentStart(makeEvent(options()) as never, ctx);
assert.equal(notices.length, 1);
assert.match(notices[0], /未在 config\/prompt\/tools\.jsonc 登记:.*ghost_tool/);
composer.handleBeforeAgentStart(
makeEvent({ selectedTools: ["other_tool"], toolSnippets: {} }) as never,
ctx,
);
assert.equal(notices.length, 2, "a different missing-tool set notifies again");
});
test("composer warns once and returns nothing when the prompt config fails to load", () => {
const composer = createSystemPromptComposer({
loadToolCatalog: () => {
throw new Error("invalid jsonc");
},
});
const { notices, ctx } = makeUiCtx();
const options: FakePromptOptions = { selectedTools: ["read"], toolSnippets: { read: "r" } };
assert.equal(composer.handleBeforeAgentStart(makeEvent(options) as never, ctx), undefined);
assert.equal(composer.handleBeforeAgentStart(makeEvent(options) as never, ctx), undefined);
assert.equal(notices.length, 1);
assert.match(notices[0], /invalid jsonc/);
});
// ── prompt guard ────────────────────────────────────────────────────────────
function makeRecordingPi() {
const tools: Array<{ name: string; description?: string }> = [];
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
const pi = {
registerTool(def: { name: string; description?: string }) {
tools.push(def);
},
on(name: string, handler: (event: unknown, ctx: unknown) => unknown) {
handlers.set(name, handler);
},
someOtherApi: () => "passthrough",
};
return { pi, tools, handlers };
}
test("prompt guard applies catalog description overrides at registration", () => {
const { pi, tools } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
guarded.registerTool({ name: "edit", description: "upstream edit" } as never);
guarded.registerTool({ name: "grep", description: "upstream grep" } as never);
const override = loadToolCatalog().tools.edit;
assert.ok(override && !("inherit" in override) && typeof override.description === "string");
assert.equal(tools[0].description, override.description);
assert.equal(tools[1].description, "upstream grep", "inherit entries keep the upstream description");
});
test("prompt guard strips a returned systemPrompt but keeps other fields", async () => {
const { pi, handlers } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
guarded.on("before_agent_start", (async () => ({
systemPrompt: "forced by third party",
message: "kept",
})) as never);
const result = (await handlers.get("before_agent_start")?.({}, {})) as Record<string, unknown>;
assert.deepEqual(result, { message: "kept" });
});
test("prompt guard runs context results through the provided contextFilter", async () => {
const { pi, handlers } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI, {
contextFilter: (before, after) => after.slice(0, before.length),
});
guarded.on("context", (async (event: { messages: Array<{ role: string; content: string }> }) => {
event.messages.push({ role: "user", content: "injected" });
return { messages: event.messages };
}) as never);
const event = { messages: [{ role: "user", content: "real" }] };
const result = (await handlers.get("context")?.(event, {})) as {
messages: Array<{ content: string }>;
};
assert.deepEqual(result.messages.map((m) => m.content), ["real"]);
});
test("prompt guard returns the handler result untouched when the filter changes nothing", async () => {
const { pi, handlers } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI, {
contextFilter: filterContextModeMessages,
});
const expected = { messages: [{ role: "user", content: "plain" }] };
guarded.on("context", (async () => expected) as never);
const result = await handlers.get("context")?.({ messages: expected.messages.slice() }, {});
assert.equal(result, expected, "a no-op filter must preserve result identity");
});
test("prompt guard passes unrelated API members through to the real pi object", () => {
const { pi } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
assert.equal((guarded as unknown as { someOtherApi: () => string }).someOtherApi(), "passthrough");
});
// ── context-mode filter ─────────────────────────────────────────────────────
test("context-mode filter drops only the routing-anchor part", () => {
const before = [{ role: "user", content: "real" }];
const anchor = `${CONTEXT_MODE_ANCHOR_PREFIX} ctx_batch_execute > ctx_execute`;
const after = [
...before,
{ role: "user", content: `${anchor}\n\n<active_memory>\nx\n</active_memory>` },
];
const filtered = filterContextModeMessages(before, after);
assert.equal(filtered.length, 2);
assert.equal(filtered[1].content, "<active_memory>\nx\n</active_memory>");
});
test("context-mode filter removes the appended message when only the anchor remains", () => {
const before = [{ role: "user", content: "real" }];
const after = [
...before,
{ role: "user", content: `${CONTEXT_MODE_ANCHOR_PREFIX} only anchor` },
];
assert.deepEqual(filterContextModeMessages(before, after), before);
});
test("context-mode filter returns the same reference when nothing changed", () => {
const before = [{ role: "user", content: "real" }];
const after = [...before, { role: "user", content: "no anchor here" }];
assert.equal(filterContextModeMessages(before, after), after);
assert.equal(filterContextModeMessages(after, after), after);
});
test("context-mode filter ignores appended non-user or non-string messages", () => {
const before = [{ role: "user", content: "real" }];
const after = [
...before,
{ role: "assistant", content: `${CONTEXT_MODE_ANCHOR_PREFIX} not ours` },
{ role: "user", content: [{ type: "text", text: "structured" }] },
];
assert.equal(filterContextModeMessages(before, after), after);
});
// ── Pinned upstream text ────────────────────────────────────────────────────
test("context-mode upstream still appends the single anchor-bearing user message", async () => {
const packageJson = JSON.parse(
await readFile(join(repositoryRoot, "package.json"), "utf8"),
) as { dependencies: Record<string, string> };
assert.equal(
packageJson.dependencies["context-mode"],
"1.0.169",
"the context-filter pin requires the exact upstream version",
);
const upstream = await readFile(
join(repositoryRoot, "node_modules/context-mode/build/adapters/pi/extension.js"),
"utf8",
);
assert.ok(upstream.includes('parts.push("context-mode active. Hierarchy:'));
assert.ok(upstream.includes('_pendingContext = extraParts.join("\\n\\n")'));
assert.ok(upstream.includes("event.messages.push({"));
assert.ok(upstream.includes('role: "user"'));
});
// ── /dump-system-prompt ─────────────────────────────────────────────────────
test("dump-system-prompt writes the last composed prompt under .pi-debug", async () => {
let beforeAgentStart: ((event: unknown, ctx: unknown) => unknown) | undefined;
let command: { handler: (args: string, ctx: unknown) => Promise<void> } | undefined;
const pi = {
on(name: string, handler: typeof beforeAgentStart) {
if (name === "before_agent_start") beforeAgentStart = handler;
},
registerCommand(name: string, definition: typeof command) {
if (name === "dump-system-prompt") command = definition;
},
} as unknown as ExtensionAPI;
systemPromptExtension(pi);
assert.ok(beforeAgentStart);
assert.ok(command);
const { ctx } = makeUiCtx();
beforeAgentStart(
makeEvent({ selectedTools: ["read"], toolSnippets: { read: "r" } }) as never,
ctx,
);
const cwd = await mkdtemp(join(tmpdir(), "my-pi-system-prompt-"));
try {
await command.handler("", {
cwd,
getSystemPrompt: () => "fallback prompt",
ui: { notify() {} },
});
const dumped = await readFile(getSystemPromptDumpPath(cwd), "utf8");
assert.ok(dumped.startsWith(BUNDLE_PREAMBLE));
assert.match(dumped, /<tool_routing>/);
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
-155
View File
@@ -1,155 +0,0 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import toolRoutingExtension, {
appendToolRouting,
buildToolRoutingSection,
getSystemPromptDumpPath,
} from "../extensions/tool-routing.ts";
test("search routing narrows files before requesting matching line numbers", () => {
const section = buildToolRoutingSection(["find", "grep", "read"]);
assert.match(section, /Use find to identify likely files or directories/);
assert.match(section, /Use grep or multi_grep only inside the narrowed path or file/);
assert.match(section, /obtain matching line numbers/);
assert.match(section, /narrow the path, glob, or pattern instead of increasing the limit/);
assert.match(section, /Use read with offset\/limit only for the exact matching region/);
});
test("SSH connection routing requires an explicit named host and concrete task", () => {
const section = buildToolRoutingSection(["ssh_connect"]);
assert.match(section, /only when the user explicitly names an imported host/);
assert.match(section, /separate step and wait for success before calling other ssh_\* tools/);
assert.match(section, /never infer or substitute a different host/);
});
test("remote cwd routing separates persistent workspace changes from shell commands", () => {
const section = buildToolRoutingSection(["ssh_cd", "ssh_bash"]);
assert.match(section, /call it as a separate step and wait for success/);
assert.match(section, /relative ssh_read\/ssh_write\/ssh_edit\/ssh_find\/ssh_grep/);
assert.match(section, /intentionally temporary, command-local directory change/);
});
test("remote search routing uses structured bounded SSH tools", () => {
const section = buildToolRoutingSection(["ssh_find", "ssh_grep"]);
assert.match(section, /use ssh_find to narrow remote file paths before ssh_grep/);
assert.match(section, /Do not run find, fd, grep, or rg through ssh_bash/);
assert.match(section, /when a result says truncated, narrow the path or pattern/);
});
test("context routing analyzes large files without a full read", () => {
const section = buildToolRoutingSection(["ctx_execute", "ctx_execute_file"]);
assert.match(section, /Prefer ctx_execute_file over read when deriving an answer from a large file/);
assert.match(section, /ctx_execute_file is confined to the current project root/);
assert.match(section, /host permission approval does not bypass this Context Mode boundary/);
});
test("Chrome snapshot routing keeps raw page data out of model context", () => {
const section = buildToolRoutingSection(["chrome_snapshot", "ctx_execute_file"]);
assert.match(section, /immediately analyze the returned workspace-relative capture path with ctx_execute_file/);
assert.match(section, /never use read or cat for the raw JSON/);
assert.match(section, /take a fresh snapshot after any interaction/);
});
test("hashline routing uses small reads and fresh anchors for edits", () => {
const section = buildToolRoutingSection(["read", "edit"]);
assert.match(section, /smallest useful offset\/limit range/);
assert.match(section, /obtain fresh LINE#HASH anchors with a small read/);
assert.match(section, /Reuse fresh anchors returned by successful edits/);
});
test("LSP routing covers navigation and post-edit diagnostics", () => {
const section = buildToolRoutingSection(["lsp_definition", "lsp_references", "lsp_diagnostics"]);
assert.match(section, /Use LSP for symbol definitions, references/);
assert.match(section, /post-edit diagnostics/);
});
test("online search routing uses the unified web tools without naming providers", () => {
const section = buildToolRoutingSection(["web_search", "web_fetch"]);
assert.match(section, /Use web_search for current, real-world, or citable web information/);
assert.match(section, /provider selection is handled internally/);
assert.match(section, /read selected pages with web_fetch/);
assert.doesNotMatch(section, /tavily|exa|keenable/iu);
const searchOnly = buildToolRoutingSection(["web_search"]);
assert.match(searchOnly, /Use web_search/);
});
test("routing includes only guidance for active optional tools", () => {
const section = buildToolRoutingSection(["read"]);
assert.doesNotMatch(section, /codegraph_explore first/);
assert.doesNotMatch(section, /For literal search/);
assert.doesNotMatch(section, /Use Context Mode/);
assert.doesNotMatch(section, /ctx_execute_file is confined/);
assert.doesNotMatch(section, /fresh LINE#HASH anchors/);
assert.doesNotMatch(section, /Use web_search for current/);
assert.match(section, /Use read only when exact source is needed/);
});
test("routing is appended once", () => {
const once = appendToolRouting("base prompt", ["find", "grep"]);
const twice = appendToolRouting(once, ["find", "grep"]);
assert.equal(twice, once);
assert.equal((once.match(/<!-- my-pi-tool-routing -->/g) ?? []).length, 1);
});
test("dump path stays under the current project", () => {
assert.equal(
getSystemPromptDumpPath("/workspace/project"),
"/workspace/project/.pi-debug/effective-system-prompt.md",
);
});
test("dump-system-prompt writes the last routed prompt seen by the extension", async () => {
let beforeAgentStart: ((event: any) => { systemPrompt: string }) | undefined;
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
const pi = {
on(name: string, handler: typeof beforeAgentStart) {
if (name === "before_agent_start") beforeAgentStart = handler;
},
registerCommand(_name: string, definition: typeof command) {
command = definition;
},
} as unknown as ExtensionAPI;
toolRoutingExtension(pi);
assert.ok(beforeAgentStart);
assert.ok(command);
beforeAgentStart({
systemPrompt: "prompt already modified by an earlier extension",
systemPromptOptions: { selectedTools: ["find", "grep"] },
});
const cwd = await mkdtemp(join(tmpdir(), "my-pi-tool-routing-"));
try {
let notification = "";
await command.handler("", {
cwd,
getSystemPrompt: () => "base prompt",
getSystemPromptOptions: () => ({ selectedTools: ["find", "grep"] }),
ui: {
notify(message: string) {
notification = message;
},
},
});
const outputPath = getSystemPromptDumpPath(cwd);
const dumped = await readFile(outputPath, "utf8");
assert.match(dumped, /^prompt already modified by an earlier extension/);
assert.match(dumped, /<!-- my-pi-tool-routing -->/);
assert.match(notification, new RegExp(outputPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
} finally {
await rm(cwd, { recursive: true, force: true });
}
});