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
+17 -2
View File
@@ -1,13 +1,28 @@
import { fileURLToPath } from "node:url";
import { createPiSubagentsExtension } from "../pi-subagents/src/index.ts";
import {
readSubagentPreamble,
resolveSubagentAgentsDir,
} from "./system-prompt-config.ts";
// Bundle authority: Agent frontmatter may disable optional extensions, but it
// cannot remove the exact wrapper that deploys my-pi's permission baseline and
// binds the maintained permission-system source.
const BUNDLED_PERMISSION_EXTENSION = fileURLToPath(
new URL("./permission-system.ts", import.meta.url),
new URL("./permission-system.ts", import.meta.url),
);
// Subagents get their own prompt profile: config/prompt/subagent/preamble.md
// replaces the parent-prompt embedding upstream performs, and
// config/prompt/subagent/agents/*.md are loaded as built-in agent types.
// ask_user_question is main-session-only — a child has no user to ask.
const ASK_USER_EXTENSION = fileURLToPath(
new URL("../pi-ask-user/index.ts", import.meta.url),
);
export default createPiSubagentsExtension({
mandatoryExtensionPaths: [BUNDLED_PERMISSION_EXTENSION],
mandatoryExtensionPaths: [BUNDLED_PERMISSION_EXTENSION],
basePrompt: readSubagentPreamble,
bundledAgentsDir: resolveSubagentAgentsDir(),
excludedExtensionPaths: [ASK_USER_EXTENSION],
});
+68 -5
View File
@@ -20,6 +20,8 @@ export interface ToolCatalog {
export interface RoutingRule {
whenAll?: string[];
whenAny?: string[];
/** Which sessions the rule renders for; absent means both. */
scope?: "main" | "subagent";
text: string;
}
@@ -31,6 +33,8 @@ export interface RoutingConfig {
export interface SectionsConfig {
version: 1;
order: string[];
/** Contributed sections removed from subagent (customPrompt) sessions. */
subagentExclude: string[];
}
/**
@@ -64,6 +68,14 @@ export function resolveSectionsPath(): string {
return fileURLToPath(new URL("sections.jsonc", PROMPT_CONFIG_DIR));
}
export function resolveSubagentPreamblePath(): string {
return fileURLToPath(new URL("subagent/preamble.md", PROMPT_CONFIG_DIR));
}
export function resolveSubagentAgentsDir(): string {
return fileURLToPath(new URL("subagent/agents/", PROMPT_CONFIG_DIR));
}
/**
* Strip `//` and `/* ... *\/` comments while respecting string literals, so a
* `//` inside a quoted value (for example `"https://…"`) is never removed.
@@ -185,7 +197,7 @@ function validateToolCatalog(raw: unknown): ToolCatalog {
return { version: 1, tools };
}
const ROUTING_RULE_KEYS = new Set(["whenAll", "whenAny", "text"]);
const ROUTING_RULE_KEYS = new Set(["whenAll", "whenAny", "scope", "text"]);
function validateRoutingRule(index: number, raw: unknown): RoutingRule {
if (!isPlainObject(raw)) {
@@ -205,10 +217,13 @@ function validateRoutingRule(index: number, raw: unknown): RoutingRule {
if ("whenAny" in raw && !isStringArray(raw.whenAny)) {
fail("routing.jsonc", `rule #${index}.whenAny must be an array of strings`);
}
if ("scope" in raw && raw.scope !== "main" && raw.scope !== "subagent") {
fail("routing.jsonc", `rule #${index}.scope must be "main" or "subagent"`);
}
return raw as RoutingRule;
}
function validateRoutingConfig(raw: unknown): RoutingConfig {
export function validateRoutingConfig(raw: unknown): RoutingConfig {
if (!isPlainObject(raw) || raw.version !== 1 || !Array.isArray(raw.rules)) {
fail("routing.jsonc", 'expected { "version": 1, "rules": [ ... ] }');
}
@@ -220,7 +235,7 @@ function validateRoutingConfig(raw: unknown): RoutingConfig {
const SECTION_NAME_PATTERN = /^[a-z][a-z0-9_-]*$/;
function validateSectionsConfig(raw: unknown): SectionsConfig {
export function validateSectionsConfig(raw: unknown): SectionsConfig {
if (!isPlainObject(raw) || raw.version !== 1 || !isStringArray(raw.order)) {
fail("sections.jsonc", 'expected { "version": 1, "order": [ ... ] }');
}
@@ -229,7 +244,19 @@ function validateSectionsConfig(raw: unknown): SectionsConfig {
fail("sections.jsonc", `"${name}" is not a valid section name`);
}
}
return { version: 1, order: raw.order };
if ("subagentExclude" in raw && !isStringArray(raw.subagentExclude)) {
fail("sections.jsonc", `"subagentExclude" must be an array of section names`);
}
for (const name of raw.subagentExclude ?? []) {
if (!SECTION_NAME_PATTERN.test(name) || name === "preamble") {
fail("sections.jsonc", `"subagentExclude" entry "${name}" is not a valid section name`);
}
}
return {
version: 1,
order: raw.order,
subagentExclude: isStringArray(raw.subagentExclude) ? raw.subagentExclude : [],
};
}
export function loadToolCatalog(): ToolCatalog {
@@ -262,6 +289,34 @@ export function readPreamble(): string | undefined {
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* Read the bundle's subagent base preamble (`config/prompt/subagent/preamble.md`),
* re-read on each call so the file is never cached between spawns.
* Blank or missing → `undefined`, which keeps pi-subagents' upstream composition.
*/
export function readSubagentPreamble(): string | undefined {
let raw: string;
try {
raw = readFileSync(resolveSubagentPreamblePath(), "utf8");
} catch {
return undefined;
}
const trimmed = raw.replace(/\s+$/, "");
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* The `<active_agent name="..."/>` tag pi-subagents stamps into every child
* prompt — tested ONLY against `systemPromptOptions.customPrompt`, never the
* rendered prompt: a main session may contain the literal tag via project
* docs and must still be treated as main.
*/
const ACTIVE_AGENT_TAG = /<active_agent\s+name=["'][^"']+["'][^>]*>/i;
export function isSubagentCustomPrompt(customPrompt: unknown): boolean {
return typeof customPrompt === "string" && ACTIVE_AGENT_TAG.test(customPrompt);
}
/**
* Apply the tool catalog to the mutable prompt options: a `snippet` string
* replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces
@@ -302,11 +357,19 @@ export function applyToolCatalog(options: MutablePromptOptions, catalog: ToolCat
* `whenAny` tool is active (a rule with neither always applies). An absent
* `selectedTools` means every tool counts as active, matching the previous
* tool-routing behavior.
*
* `audience` filters `scope`: rules without a scope render for both "main"
* and "subagent" sessions, scoped rules only for their own audience.
*/
export function renderRouting(rules: RoutingRule[], selectedTools: string[] | undefined): string {
export function renderRouting(
rules: RoutingRule[],
selectedTools: string[] | undefined,
audience: "main" | "subagent" = "main",
): string {
const isActive = (name: string) => selectedTools === undefined || selectedTools.includes(name);
const lines: string[] = [];
for (const rule of rules) {
if (rule.scope !== undefined && rule.scope !== audience) continue;
if (rule.whenAll !== undefined && !rule.whenAll.every(isActive)) continue;
if (rule.whenAny !== undefined && !rule.whenAny.some(isActive)) continue;
lines.push(`- ${rule.text}`);
+24 -5
View File
@@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
import {
applyToolCatalog,
isSubagentCustomPrompt,
loadRoutingRules,
loadSectionsConfig,
loadToolCatalog,
@@ -23,8 +24,11 @@ export function getSystemPromptDumpPath(cwd: string): string {
return resolve(cwd, DUMP_RELATIVE_PATH);
}
/** The options object as Pi 1.3 normalizes it; the vendored types predate `sections`/`forceSystemPrompt`. */
type RuntimePromptOptions = MutablePromptOptions & { forceSystemPrompt?: string };
/** The options object as Pi 1.3 normalizes it; `MutablePromptOptions` omits `forceSystemPrompt`/`customPrompt`. */
type RuntimePromptOptions = MutablePromptOptions & {
forceSystemPrompt?: string;
customPrompt?: string;
};
/**
* Injectable seams so the composer can be exercised under `node --test`
@@ -105,6 +109,12 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
return undefined;
}
// A subagent session is identified by its customPrompt carrying the
// pi-subagents <active_agent/> tag — never by the rendered prompt, which
// a main session may legitimately contain the literal tag inside.
const isSubagent = isSubagentCustomPrompt(options?.customPrompt);
const audience = isSubagent ? "subagent" : "main";
let routingText = "";
if (options) {
const missing = applyToolCatalog(options, catalog);
@@ -115,14 +125,19 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
ctx.ui.notify(`以下工具未在 config/prompt/tools.jsonc 登记:${missing.join(", ")}`, "warning");
}
}
routingText = renderRouting(routing.rules, options.selectedTools);
routingText = renderRouting(routing.rules, options.selectedTools, audience);
options.sections ??= {};
if (isSubagent) {
for (const name of sections.subagentExclude) {
delete options.sections[name];
}
}
if (routingText.length > 0) {
options.sections.tool_routing = routingText;
}
options.sections = orderSections(options.sections, sections.order);
} else {
routingText = renderRouting(routing.rules, undefined);
routingText = renderRouting(routing.rules, undefined, audience);
}
let rendered = event.systemPrompt;
@@ -132,7 +147,11 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
rendered += `\n\n<tool_routing>\n${routingText}\n</tool_routing>`;
}
const final = preamble ? spliceCover(rendered, preamble) : rendered;
// A non-empty customPrompt (pi-subagents agent prompt, SYSTEM.md) owns
// the whole preamble — including identities like <active_agent/> that
// the permission system reads — so the bundle cover must not replace it.
const ownsPreamble = typeof options?.customPrompt === "string" && options.customPrompt.length > 0;
const final = preamble && !ownsPreamble ? spliceCover(rendered, preamble) : rendered;
lastComposed = final;
return { systemPrompt: final };
}