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
+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();