mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
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:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
b817d66a11
commit
360d8cffc8
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user