feat(pi-subagents): 重新启用子 Agent 并使用独立的子 Agent 提示词

- 根包恢复 file:./pi-subagents 依赖与 extensions/subagents.ts 加载
  (排在 permission-system 之后、Tool Search 之前),并刷新根锁文件;
  Agent / get_subagent_result / steer_subagent 权限设为 allow,
  子 Agent 内的实际操作仍逐个经权限系统并转发主会话。
- 新增 config/prompt/subagent/:执行者角色 preamble 与组合包自带的
  general-purpose / Explore / Plan 定义(替换上游内置,去掉 bash find
  冲突规则与写死的 Haiku 模型)。
- pi-subagents 新增 basePrompt / bundledAgentsDir / excludedExtensionPaths
  选项:子 Agent 不再嵌入父会话提示词,身份标记置于开头;子会话排除
  pi-ask-user。未传选项时保持上游行为。
- system-prompt.ts:customPrompt 自带开场白时不再覆盖(修复子 Agent 角色
  与 <active_agent> 身份被抹掉);仅凭 customPrompt 上的 <active_agent>
  识别子会话,routing 规则支持 scope,sections 支持 subagentExclude
  (子会话去掉 memory_policy 与 chrome_profile_bridge)。
- pi-hermes-memory:子会话只读,记忆/skill 写入工具返回错误,后台整理、
  纠错检测、会话 flush 与索引写入全部跳过。

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 19:43:22 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent b817d66a11
commit 360d8cffc8
37 changed files with 2837 additions and 2416 deletions
+23
View File
@@ -136,3 +136,26 @@ test("every package-local @earendil-works/* devDependency is pinned to latest",
}
}
});
test("subagent prompt profile: preamble non-blank, agent names match filename stems", async () => {
const subagentDir = join(bundleConfigDir, "prompt", "subagent");
const preamble = await readFile(join(subagentDir, "preamble.md"), "utf8");
assert.ok(
preamble.trim().length > 0,
"config/prompt/subagent/preamble.md must not be blank",
);
const agentsDir = join(subagentDir, "agents");
const agentFiles = (await readdir(agentsDir)).filter((name) => name.endsWith(".md"));
assert.ok(agentFiles.length > 0, "config/prompt/subagent/agents/ must ship agent files");
for (const file of agentFiles) {
const raw = await readFile(join(agentsDir, file), "utf8");
const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
assert.ok(frontmatter, `${file} must open with a frontmatter block`);
const name = frontmatter[1].match(/^name:[ \t]*["']?([^"'\r\n]+?)["']?[ \t]*$/m)?.[1];
assert.equal(
name,
file.slice(0, -".md".length),
`config/prompt/subagent/agents/${file} frontmatter name must match its filename stem`,
);
}
});
+40 -8
View File
@@ -10,19 +10,38 @@ import test from "node:test";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const hasPi = spawnSync("pi", ["--version"], { encoding: "utf8" }).status === 0;
test("subagents source is retained but disabled in the root bundle", async () => {
test("enabled subagents wrapper loads with its bundle-owned mandatory permission entry", { skip: !hasPi }, async () => {
const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as {
dependencies: Record<string, string>;
files: string[];
pi: { extensions: string[] };
};
assert.ok(packageJson.files.includes("pi-subagents"), "the bundle must retain the maintained subagents source");
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], undefined);
assert.equal(
assert.ok(packageJson.files.includes("pi-subagents"));
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents");
assert.ok(
packageJson.pi.extensions.includes("./extensions/subagents.ts"),
false,
"the bundle must not default-load the disabled subagents wrapper",
"the bundle must default-load the locally maintained subagents wrapper",
);
const home = await mkdtemp(join(tmpdir(), "my-pi-subagents-wrapper-load-"));
const result = spawnSync(
"pi",
["--no-extensions", "-e", join(repositoryRoot, "extensions", "subagents.ts"), "--mode", "rpc"],
{
encoding: "utf8",
timeout: 60_000,
env: {
...process.env,
HOME: home,
XDG_CONFIG_HOME: join(home, ".config"),
PI_CODING_AGENT_DIR: join(home, ".pi-agent"),
PI_OFFLINE: "1",
},
},
);
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension/u);
});
test("all package extensions load together without global registration conflicts", { skip: !hasPi }, async () => {
@@ -117,6 +136,9 @@ test("all package extensions load together without global registration conflicts
assert.equal(permissionConfig.permission.ssh_connect, "ask", "SSH connection must enter the permission gate");
assert.equal(permissionConfig.permission.ssh_cd, "ask", "remote workspace changes must enter the permission gate");
assert.deepEqual(permissionConfig.authorizerChain, ["auto-review"], "SSH connection asks must reach AutoReview");
assert.equal(permissionConfig.permission.Agent, "allow", "launching a subagent is side-effect free; child actions stay gated");
assert.equal(permissionConfig.permission.get_subagent_result, "allow");
assert.equal(permissionConfig.permission.steer_subagent, "allow");
assert.equal(hardGuardConfig.permission.path, undefined, "sensitive paths must not be hard-denied by default");
assert.equal(hardGuardConfig.permission.bash["rm -rf /"], "deny");
assert.equal(
@@ -149,8 +171,18 @@ test("all package extensions load together without global registration conflicts
"the packed bundle must include the locally maintained notification source",
);
assert.equal(packageJson.dependencies["@ogulcancelik/pi-minimal-footer"], "file:./pi-minimal-footer");
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], undefined);
assert.equal(packageJson.pi.extensions.includes("./extensions/subagents.ts"), false);
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents");
assert.ok(packageJson.pi.extensions.includes("./extensions/subagents.ts"));
assert.ok(
packageJson.pi.extensions.indexOf("./extensions/permission-system.ts") <
packageJson.pi.extensions.indexOf("./extensions/subagents.ts"),
"the permission service must load before the subagents wrapper injects it as a mandatory child extension",
);
assert.ok(
packageJson.pi.extensions.indexOf("./extensions/subagents.ts") <
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
"the subagent orchestration tools must register before Tool Search builds its catalog",
);
assert.ok(
packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"),
"the bundle must load the locally maintained Hermes memory extension",
+158 -1
View File
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
import {
applyToolCatalog,
isSubagentCustomPrompt,
loadRoutingRules,
loadSectionsConfig,
loadToolCatalog,
@@ -18,6 +19,8 @@ import {
resolvePreamblePath,
spliceCover,
stripJsoncComments,
validateRoutingConfig,
validateSectionsConfig,
type MutablePromptOptions,
type RoutingRule,
} from "../extensions/system-prompt-config.ts";
@@ -74,6 +77,40 @@ test("config/prompt files load with the expected shapes", () => {
"chrome_profile_bridge",
"tool_routing",
]);
assert.deepEqual(sections.subagentExclude, ["memory_policy", "chrome_profile_bridge"]);
});
test("routing scope validation: main/subagent accepted, other values rejected", () => {
assert.doesNotThrow(() =>
validateRoutingConfig({
version: 1,
rules: [
{ text: "both" },
{ scope: "main", text: "m" },
{ scope: "subagent", text: "s" },
],
}),
);
assert.throws(
() => validateRoutingConfig({ version: 1, rules: [{ scope: "everyone", text: "x" }] }),
/scope must be "main" or "subagent"/,
);
});
test("sections.jsonc validation: subagentExclude defaults to [] and rejects bad shapes", () => {
assert.deepEqual(validateSectionsConfig({ version: 1, order: ["tool_routing"] }), {
version: 1,
order: ["tool_routing"],
subagentExclude: [],
});
assert.throws(
() => validateSectionsConfig({ version: 1, order: [], subagentExclude: "memory_policy" }),
/subagentExclude.*array of section names/,
);
assert.throws(
() => validateSectionsConfig({ version: 1, order: [], subagentExclude: ["Bad Name"] }),
/not a valid section name/,
);
});
test("readPreamble reads the bundle preamble from config/prompt/", () => {
@@ -185,6 +222,40 @@ test("renderRouting honours whenAll, whenAny and unconditional rules", () => {
assert.equal(renderRouting([], ["a"]), "");
});
test("renderRouting filters scoped rules by audience, unscoped rules render for both", () => {
const rules: RoutingRule[] = [
{ text: "both audiences" },
{ scope: "main", text: "main only" },
{ scope: "subagent", text: "subagent only" },
];
assert.equal(renderRouting(rules, undefined, "main"), "- both audiences\n- main only");
assert.equal(renderRouting(rules, undefined, "subagent"), "- both audiences\n- subagent only");
// The omitted-audience default stays "main" for callers that predate scopes.
assert.equal(renderRouting(rules, undefined), "- both audiences\n- main only");
});
test("isSubagentCustomPrompt matches the <active_agent> contract only on customPrompt", () => {
assert.ok(isSubagentCustomPrompt('a\n<active_agent name="Explore"/>'));
assert.ok(isSubagentCustomPrompt("<active_agent name='x'/>"));
assert.ok(!isSubagentCustomPrompt("plain main-session prompt"));
assert.ok(!isSubagentCustomPrompt(undefined));
assert.ok(!isSubagentCustomPrompt(42));
});
test("bundled ssh_connect rules differ per audience", () => {
const routing = loadRoutingRules();
const tools = ["ssh_connect", "ssh_bash"];
const main = renderRouting(routing.rules, tools, "main");
assert.match(main, /the user explicitly names an imported host/);
assert.doesNotMatch(main, /the delegated task explicitly names/);
const subagent = renderRouting(routing.rules, tools, "subagent");
assert.match(subagent, /the delegated task explicitly names an imported host/);
assert.doesNotMatch(subagent, /the user explicitly names an imported host/);
});
test("bundled routing rules select the same guidance the retired tool-routing extension emitted", () => {
const routing = loadRoutingRules();
@@ -275,12 +346,18 @@ test("spliceCover degenerates to the preamble when the prompt has no sections",
interface FakePromptOptions extends MutablePromptOptions {
forceSystemPrompt?: string;
customPrompt?: 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"];
// Like the real host: a non-empty customPrompt owns the preamble section.
const preamble =
typeof options.customPrompt === "string" && options.customPrompt.length > 0
? options.customPrompt
: "PI DEFAULT PREAMBLE";
const parts = [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>`);
@@ -378,6 +455,86 @@ test("composer degrades to text splicing when an earlier extension froze the pro
);
});
test("composer keeps a customPrompt-owned preamble (subagent / SYSTEM.md) intact", () => {
const composer = createSystemPromptComposer();
const customPrompt = 'You are the Explore agent.\n<active_agent name="Explore"/>';
const options: FakePromptOptions = {
customPrompt,
selectedTools: ["read"],
toolSnippets: { read: "reads files" },
};
const { ctx } = makeUiCtx();
const event = {
systemPromptOptions: options,
systemPrompt: `${customPrompt}\n\n<tools>\n- read: reads files\n</tools>`,
};
const result = composer.handleBeforeAgentStart(event as never, ctx);
const output = result?.systemPrompt ?? "";
assert.ok(output.startsWith(customPrompt), "the agent's own preamble is kept verbatim");
assert.ok(
output.includes('<active_agent name="Explore"/>'),
"the <active_agent> identity the permission system reads survives",
);
assert.ok(
!output.includes(BUNDLE_PREAMBLE),
"the bundle preamble must not be spliced over a customPrompt-owned preamble",
);
assert.match(output, /<tool_routing>/, "routing still degrades to an appended section");
});
test("composer treats an <active_agent> customPrompt as a subagent session", () => {
const composer = createSystemPromptComposer();
const customPrompt =
'You are a subagent.\n<active_agent name="Explore"/>\n# Environment\nWorking directory: /work';
const options: FakePromptOptions = {
customPrompt,
selectedTools: ["read", "ssh_connect"],
toolSnippets: { read: "reads files", ssh_connect: "connects" },
sections: {
memory_policy: "memory guidance for main sessions",
chrome_profile_bridge: "chrome bridge preamble",
remote_ssh: "remote ssh preamble",
},
};
const { ctx } = makeUiCtx();
const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx);
const output = result?.systemPrompt ?? "";
assert.ok(output.startsWith(customPrompt), "the child preamble stays untouched");
assert.doesNotMatch(output, /<memory_policy>/, "subagentExclude removes memory_policy");
assert.doesNotMatch(output, /<chrome_profile_bridge>/, "subagentExclude removes chrome_profile_bridge");
assert.match(output, /<remote_ssh>/, "unexcluded sections are kept");
assert.match(output, /the delegated task explicitly names an imported host/, "subagent-scope routing renders");
assert.doesNotMatch(output, /the user explicitly names an imported host/, "main-scope routing stays out");
});
test("a main session keeps excluded sections and main-scope routing even with a literal <active_agent> tag", () => {
const composer = createSystemPromptComposer();
// The tag appears inside a contributed section (project docs can carry the
// literal text) but there is no customPrompt — this is a main session.
const options: FakePromptOptions = {
selectedTools: ["read", "ssh_connect"],
toolSnippets: { read: "reads files", ssh_connect: "connects" },
sections: {
memory_policy: 'docs mention <active_agent name="Guide"/>',
chrome_profile_bridge: "chrome bridge preamble",
},
};
const { ctx } = makeUiCtx();
const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx);
const output = result?.systemPrompt ?? "";
assert.ok(output.startsWith(BUNDLE_PREAMBLE), "the bundle preamble still splices for a main session");
assert.match(output, /<memory_policy>/, "main sessions are unaffected by subagentExclude");
assert.match(output, /<chrome_profile_bridge>/);
assert.match(output, /the user explicitly names an imported host/, "main-scope routing renders");
assert.doesNotMatch(output, /the delegated task explicitly names/, "subagent-scope routing stays out");
});
test("composer falls back to text appends when the host exposes no prompt options", () => {
const composer = createSystemPromptComposer();
const { ctx } = makeUiCtx();