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
+1 -1
View File
@@ -18,7 +18,7 @@
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。 - 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
- 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。 - 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。
- 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。 - 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。
- 本仓库保留维护的 `pi-subagents` 源码及权限集成实现,但当前已从根组合包依赖和默认扩展加载列表中移除,不会随核心包启用。 - 本仓库维护的 `pi-subagents`:从 `@tintinweb/pi-subagents@0.18.0` 源码导入并默认加载,提供隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent;通过 mandatory permission wrapper 接入权限 child lifecycle、根 authority `ask` 转发和根 transcript auto-review 身份桥接,其 3 个编排工具由 Tool Search 的 `subagents` 组按需加载。子 Agent 使用独立提示词画像 `config/prompt/subagent/`(自有 preamble 与内置 Agent 定义),不嵌入父会话提示词。
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。 - 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
- `extensions/system-prompt.ts`:组合包唯一的系统提示词出口(排在最后加载),按 `config/prompt/` 组合开场白、工具简介与跨工具路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。 - `extensions/system-prompt.ts`:组合包唯一的系统提示词出口(排在最后加载),按 `config/prompt/` 组合开场白、工具简介与跨工具路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
+6
View File
@@ -54,6 +54,12 @@
"ctx_doctor": "allow", "ctx_doctor": "allow",
"tool_search": "allow", "tool_search": "allow",
"ask_user_question": "allow", "ask_user_question": "allow",
// Launching, steering or reading a subagent has no side effect itself;
// every child action is still gated by the permission system and any
// unresolved "ask" is forwarded to the root session.
"Agent": "allow",
"get_subagent_result": "allow",
"steer_subagent": "allow",
"web_search": "allow", "web_search": "allow",
"chrome_snapshot": "allow", "chrome_snapshot": "allow",
"chrome_find": "allow", "chrome_find": "allow",
+8
View File
@@ -3,6 +3,8 @@
// <tool_routing> section. A rule is included when every tool in "whenAll" // <tool_routing> section. A rule is included when every tool in "whenAll"
// and at least one tool in "whenAny" is active; a rule with neither is // and at least one tool in "whenAny" is active; a rule with neither is
// always included. Rules render in file order as "- <text>". // always included. Rules render in file order as "- <text>".
// Optional "scope": "main" | "subagent" restricts a rule to that session
// audience (main session vs pi-subagents child); absent means both.
"version": 1, "version": 1,
"rules": [ "rules": [
{ {
@@ -27,8 +29,14 @@
}, },
{ {
"whenAny": ["ssh_connect"], "whenAny": ["ssh_connect"],
"scope": "main",
"text": "Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host." "text": "Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host."
}, },
{
"whenAny": ["ssh_connect"],
"scope": "subagent",
"text": "Use ssh_connect only when the delegated task explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host."
},
{ {
"whenAny": ["ssh_cd"], "whenAny": ["ssh_cd"],
"text": "Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change." "text": "Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change."
+5 -1
View File
@@ -3,6 +3,10 @@
// systemPromptOptions.sections. extensions/system-prompt.ts reorders the // systemPromptOptions.sections. extensions/system-prompt.ts reorders the
// contributed sections to this list; contributed sections not listed here // contributed sections to this list; contributed sections not listed here
// keep their contribution order after the listed ones. // keep their contribution order after the listed ones.
// Optional "subagentExclude": section names removed entirely in subagent
// (customPrompt) sessions — e.g. sections that describe main-session-only
// affordances. Defaults to [].
"version": 1, "version": 1,
"order": ["memory_policy", "remote_ssh", "chrome_profile_bridge", "tool_routing"] "order": ["memory_policy", "remote_ssh", "chrome_profile_bridge", "tool_routing"],
"subagentExclude": ["memory_policy", "chrome_profile_bridge"]
} }
+27
View File
@@ -0,0 +1,27 @@
---
name: Explore
display_name: Explore
description: 'Fast read-only search agent for locating code. Use it to find files by pattern (eg. "src/components/**/*.tsx"), grep for symbols or keywords (eg. "API endpoints"), or answer "where is X defined / which files reference Y." Do NOT use it for code review, design-doc auditing, cross-file consistency checks, or open-ended analysis — it reads excerpts rather than whole files and will miss content past its read window. When calling, specify search breadth: "quick" for a single targeted lookup, "medium" for moderate exploration, or "very thorough" to search across multiple locations and naming conventions.'
tools: read, bash, grep, find, ls
extensions: true
skills: true
prompt_mode: replace
---
# Role: read-only code search
You locate code and answer "where is X / what references Y" questions for the delegating agent. You never modify anything.
## Hard limits
- Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp).
- Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state.
- Never search through bash (grep, rg, find, fd); use the find and grep tools.
## Approach
- Match the requested breadth: "quick" is one targeted lookup, "medium" covers a few related locations, "very thorough" searches multiple locations and naming conventions.
- When codegraph_explore is available, start there for symbols and relationships. Otherwise use find to narrow paths, grep inside the narrowed paths, and read small ranges only to confirm a match.
- Make independent lookups in parallel.
## Report
- Lead with the direct answer.
- Cite every finding with an absolute path and line number.
- State what you searched for and did not find, so the delegating agent knows the coverage.
+31
View File
@@ -0,0 +1,31 @@
---
name: Plan
display_name: Plan
description: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.
tools: read, bash, grep, find, ls
extensions: true
skills: true
prompt_mode: replace
---
# Role: read-only implementation planner
You explore the codebase and return an implementation plan to the delegating agent. You never modify anything.
## Hard limits
- Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp).
- Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state.
- Never search through bash (grep, rg, find, fd); use the find and grep tools.
## Process
1. Restate the goal and the constraints you were given. Record anything ambiguous as an open question instead of guessing.
2. Explore enough to ground every step in real files, existing patterns, and reusable helpers.
3. Design the change: sequencing, interfaces and data shapes, edge cases, and the tests that prove it.
4. Call out trade-offs and risks, and name the rejected alternative when the choice is not obvious.
## Report
- A step-by-step plan where each step names the files it touches.
- Open questions, if any.
- End with:
### Critical files
3–5 absolute paths, each with a one-line reason.
@@ -0,0 +1,8 @@
---
name: general-purpose
display_name: Agent
description: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.
extensions: true
skills: true
prompt_mode: append
---
+8
View File
@@ -0,0 +1,8 @@
You are a subagent operating inside pi, a coding agent harness. Another agent — the delegating agent — assigned you one bounded task, and it is your only audience. The human user does not see your messages and cannot answer questions.
- Do exactly the assigned task. Do not expand its scope, start unrelated work, or delegate further unless the task requires it.
- If the task is ambiguous, make the most reasonable assumption, state it, and continue. If you are blocked, stop and report precisely what is missing instead of guessing.
- Your final message is the deliverable returned to the delegating agent. Make it self-contained: the answer or result first, then the supporting evidence (absolute file paths with line numbers, commands you ran and their outcomes), then assumptions, risks, and anything left unfinished.
- Keep verified facts separate from inferences. Never claim something was changed, run, or tested unless you actually did it in this session.
- Durable memory and skill writes are unavailable here. If you learn something worth remembering, include it in your final report for the delegating agent to decide.
- Be concise but complete. Do not use emojis.
+15
View File
@@ -1,5 +1,9 @@
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createPiSubagentsExtension } from "../pi-subagents/src/index.ts"; 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 // Bundle authority: Agent frontmatter may disable optional extensions, but it
// cannot remove the exact wrapper that deploys my-pi's permission baseline and // cannot remove the exact wrapper that deploys my-pi's permission baseline and
@@ -8,6 +12,17 @@ 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({ 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 { export interface RoutingRule {
whenAll?: string[]; whenAll?: string[];
whenAny?: string[]; whenAny?: string[];
/** Which sessions the rule renders for; absent means both. */
scope?: "main" | "subagent";
text: string; text: string;
} }
@@ -31,6 +33,8 @@ export interface RoutingConfig {
export interface SectionsConfig { export interface SectionsConfig {
version: 1; version: 1;
order: string[]; 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)); 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 * Strip `//` and `/* ... *\/` comments while respecting string literals, so a
* `//` inside a quoted value (for example `"https://…"`) is never removed. * `//` inside a quoted value (for example `"https://…"`) is never removed.
@@ -185,7 +197,7 @@ function validateToolCatalog(raw: unknown): ToolCatalog {
return { version: 1, tools }; 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 { function validateRoutingRule(index: number, raw: unknown): RoutingRule {
if (!isPlainObject(raw)) { if (!isPlainObject(raw)) {
@@ -205,10 +217,13 @@ function validateRoutingRule(index: number, raw: unknown): RoutingRule {
if ("whenAny" in raw && !isStringArray(raw.whenAny)) { if ("whenAny" in raw && !isStringArray(raw.whenAny)) {
fail("routing.jsonc", `rule #${index}.whenAny must be an array of strings`); 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; 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)) { if (!isPlainObject(raw) || raw.version !== 1 || !Array.isArray(raw.rules)) {
fail("routing.jsonc", 'expected { "version": 1, "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_-]*$/; 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)) { if (!isPlainObject(raw) || raw.version !== 1 || !isStringArray(raw.order)) {
fail("sections.jsonc", 'expected { "version": 1, "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`); 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 { export function loadToolCatalog(): ToolCatalog {
@@ -262,6 +289,34 @@ export function readPreamble(): string | undefined {
return trimmed.length > 0 ? trimmed : 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 * Apply the tool catalog to the mutable prompt options: a `snippet` string
* replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces * 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 * `whenAny` tool is active (a rule with neither always applies). An absent
* `selectedTools` means every tool counts as active, matching the previous * `selectedTools` means every tool counts as active, matching the previous
* tool-routing behavior. * 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 isActive = (name: string) => selectedTools === undefined || selectedTools.includes(name);
const lines: string[] = []; const lines: string[] = [];
for (const rule of rules) { for (const rule of rules) {
if (rule.scope !== undefined && rule.scope !== audience) continue;
if (rule.whenAll !== undefined && !rule.whenAll.every(isActive)) continue; if (rule.whenAll !== undefined && !rule.whenAll.every(isActive)) continue;
if (rule.whenAny !== undefined && !rule.whenAny.some(isActive)) continue; if (rule.whenAny !== undefined && !rule.whenAny.some(isActive)) continue;
lines.push(`- ${rule.text}`); lines.push(`- ${rule.text}`);
+24 -5
View File
@@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
import { import {
applyToolCatalog, applyToolCatalog,
isSubagentCustomPrompt,
loadRoutingRules, loadRoutingRules,
loadSectionsConfig, loadSectionsConfig,
loadToolCatalog, loadToolCatalog,
@@ -23,8 +24,11 @@ export function getSystemPromptDumpPath(cwd: string): string {
return resolve(cwd, DUMP_RELATIVE_PATH); return resolve(cwd, DUMP_RELATIVE_PATH);
} }
/** The options object as Pi 1.3 normalizes it; the vendored types predate `sections`/`forceSystemPrompt`. */ /** The options object as Pi 1.3 normalizes it; `MutablePromptOptions` omits `forceSystemPrompt`/`customPrompt`. */
type RuntimePromptOptions = MutablePromptOptions & { forceSystemPrompt?: string }; type RuntimePromptOptions = MutablePromptOptions & {
forceSystemPrompt?: string;
customPrompt?: string;
};
/** /**
* Injectable seams so the composer can be exercised under `node --test` * Injectable seams so the composer can be exercised under `node --test`
@@ -105,6 +109,12 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
return undefined; 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 = ""; let routingText = "";
if (options) { if (options) {
const missing = applyToolCatalog(options, catalog); const missing = applyToolCatalog(options, catalog);
@@ -115,14 +125,19 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
ctx.ui.notify(`以下工具未在 config/prompt/tools.jsonc 登记:${missing.join(", ")}`, "warning"); 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 ??= {}; options.sections ??= {};
if (isSubagent) {
for (const name of sections.subagentExclude) {
delete options.sections[name];
}
}
if (routingText.length > 0) { if (routingText.length > 0) {
options.sections.tool_routing = routingText; options.sections.tool_routing = routingText;
} }
options.sections = orderSections(options.sections, sections.order); options.sections = orderSections(options.sections, sections.order);
} else { } else {
routingText = renderRouting(routing.rules, undefined); routingText = renderRouting(routing.rules, undefined, audience);
} }
let rendered = event.systemPrompt; let rendered = event.systemPrompt;
@@ -132,7 +147,11 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
rendered += `\n\n<tool_routing>\n${routingText}\n</tool_routing>`; 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; lastComposed = final;
return { systemPrompt: final }; return { systemPrompt: final };
} }
+1384 -2299
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -60,6 +60,7 @@
"./pi-minimal-footer/index.ts", "./pi-minimal-footer/index.ts",
"./pi-notify/src/index.ts", "./pi-notify/src/index.ts",
"./extensions/permission-system.ts", "./extensions/permission-system.ts",
"./extensions/subagents.ts",
"./pi-ssh/index.ts", "./pi-ssh/index.ts",
"./pi-ask-user/index.ts", "./pi-ask-user/index.ts",
"./pi-worktree/index.ts", "./pi-worktree/index.ts",
@@ -76,6 +77,7 @@
"@gotgenes/pi-permission-system": "file:./pi-permission-system", "@gotgenes/pi-permission-system": "file:./pi-permission-system",
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer", "@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
"@smoose/pi-notify": "file:./pi-notify", "@smoose/pi-notify": "file:./pi-notify",
"@tintinweb/pi-subagents": "file:./pi-subagents",
"context-mode": "1.0.169", "context-mode": "1.0.169",
"jiti": "2.7.0", "jiti": "2.7.0",
"pi-ask-user": "file:./pi-ask-user", "pi-ask-user": "file:./pi-ask-user",
+2
View File
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- **Subagent (pi-subagents child) sessions are read-only**: sessions whose `systemPromptOptions.customPrompt` carries the `<active_agent name="..."/>` identity tag (detected in `before_agent_start`, tracked per `sessionManager`) get a fixed error result from `memory_add`, `memory_replace`, `memory_remove`, and `skill_manage` — no writes — and skip background review, correction detection, compact/shutdown flush, live and shutdown session indexing, and the write side of initialization (markdown↔SQLite sync, legacy migration, pruning, backfill). `memory_search` and `session_search` stay fully usable. Sessions started from a tag-leading rendered prompt are detected as early as `session_start`; otherwise the authoritative `customPrompt` check at `before_agent_start` applies before any turn-level work.
- **Opt-in chunked subprocess consolidation** (`consolidationChunking`, default `false`): stores above `consolidationChunkChars` (new, default `4000`) consolidate in bounded rounds — a head slice of entries per child run, reloading from disk between rounds, so a killed run resumes from partial progress instead of restarting. The rounds share one overall time budget (`consolidationTimeoutMs`, same worst case as the single call this replaces), the loop stops at the target's capacity goal (failure memory is a 2× tier and is no longer over-merged down to the slice size), a slice that cannot shrink is skipped rather than blocking later slices, A store that fits one prompt runs a single unscoped decisive round with the whole remaining store; a child exiting 0 without shrinking is reported as an honest partial instead of silent success, and an over-chunk store already within its capacity goal is a clean no-op. Partial runs report `partial: true` with the reason and are surfaced by the warning path, the store's add-retry error, and `/memory-consolidate`; disappearances outside a round's slice (rogue child deletions, legitimate cross-slice dedup, or concurrent writers — indistinguishable to the parent) are detected and reported rather than resurrected, because re-adding through the cap-enforcing add path cannot converge and resurrection ping-pongs across triggers. An over-chunk store already within its capacity goal is a clean no-op. Single-shot behavior below the threshold is unchanged. - **Opt-in chunked subprocess consolidation** (`consolidationChunking`, default `false`): stores above `consolidationChunkChars` (new, default `4000`) consolidate in bounded rounds — a head slice of entries per child run, reloading from disk between rounds, so a killed run resumes from partial progress instead of restarting. The rounds share one overall time budget (`consolidationTimeoutMs`, same worst case as the single call this replaces), the loop stops at the target's capacity goal (failure memory is a 2× tier and is no longer over-merged down to the slice size), a slice that cannot shrink is skipped rather than blocking later slices, A store that fits one prompt runs a single unscoped decisive round with the whole remaining store; a child exiting 0 without shrinking is reported as an honest partial instead of silent success, and an over-chunk store already within its capacity goal is a clean no-op. Partial runs report `partial: true` with the reason and are surfaced by the warning path, the store's add-retry error, and `/memory-consolidate`; disappearances outside a round's slice (rogue child deletions, legitimate cross-slice dedup, or concurrent writers — indistinguishable to the parent) are detected and reported rather than resurrected, because re-adding through the cap-enforcing add path cannot converge and resurrection ping-pongs across triggers. An over-chunk store already within its capacity goal is a clean no-op. Single-shot behavior below the threshold is unchanged.
### Added ### Added
@@ -17,6 +17,7 @@ import { MemoryStore } from "../store/memory-store.js";
import { DatabaseManager } from "../store/db.js"; import { DatabaseManager } from "../store/db.js";
import type { MemoryConfig } from "../types.js"; import type { MemoryConfig } from "../types.js";
import type { EnsureMemoryReady } from "../memory-initialization.js"; import type { EnsureMemoryReady } from "../memory-initialization.js";
import { isSubagentSession } from "../subagent-mode.js";
import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js"; import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js";
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js"; import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
import { REVIEW_COMPLETION_TIMEOUT_MS, runDirectMemoryCompletion, usesDirectTransport, type DirectReviewResult } from "./review-memory-ops.js"; import { REVIEW_COMPLETION_TIMEOUT_MS, runDirectMemoryCompletion, usesDirectTransport, type DirectReviewResult } from "./review-memory-ops.js";
@@ -196,6 +197,7 @@ export function setupBackgroundReview(
}); });
pi.on("turn_end", async (event, ctx) => { pi.on("turn_end", async (event, ctx) => {
if (isSubagentSession(ctx)) return;
turnsSinceReview++; turnsSinceReview++;
if (!config.reviewEnabled) return; if (!config.reviewEnabled) return;
@@ -23,6 +23,7 @@ import {
} from "../constants.js"; } from "../constants.js";
import type { MemoryConfig } from "../types.js"; import type { MemoryConfig } from "../types.js";
import type { EnsureMemoryReady } from "../memory-initialization.js"; import type { EnsureMemoryReady } from "../memory-initialization.js";
import { isSubagentSession } from "../subagent-mode.js";
import { getMessageText } from "../types.js"; import { getMessageText } from "../types.js";
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js"; import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
import { resolveProjectName, resolveProjectStore, type ProjectNameRef, type ProjectStoreRef } from "../project-context.js"; import { resolveProjectName, resolveProjectStore, type ProjectNameRef, type ProjectStoreRef } from "../project-context.js";
@@ -151,6 +152,7 @@ export function setupCorrectionDetector(
// Trigger on turn_end (we need full context: user correction + what agent said) // Trigger on turn_end (we need full context: user correction + what agent said)
pi.on("turn_end", async (event, ctx) => { pi.on("turn_end", async (event, ctx) => {
if (isSubagentSession(ctx)) return;
if (!pendingCorrection) { if (!pendingCorrection) {
turnsSinceLastCorrection++; turnsSinceLastCorrection++;
return; return;
@@ -37,6 +37,7 @@ import {
import type { MemoryConfig } from "../types.js"; import type { MemoryConfig } from "../types.js";
import type { EnsureMemoryReady } from "../memory-initialization.js"; import type { EnsureMemoryReady } from "../memory-initialization.js";
import { measureLifecycle } from "../lifecycle-timing.js"; import { measureLifecycle } from "../lifecycle-timing.js";
import { isSubagentSession } from "../subagent-mode.js";
import { collectMessageParts } from "./message-parts.js"; import { collectMessageParts } from "./message-parts.js";
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js"; import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
import { runDirectMemoryCompletion, usesDirectTransport } from "./review-memory-ops.js"; import { runDirectMemoryCompletion, usesDirectTransport } from "./review-memory-ops.js";
@@ -167,6 +168,7 @@ export function setupSessionFlush(
kind: FlushKind, kind: FlushKind,
): Promise<void> { ): Promise<void> {
try { try {
if (isSubagentSession(ctx)) return;
if (userTurnCount < config.flushMinTurns) return; if (userTurnCount < config.flushMinTurns) return;
let entries; let entries;
+17 -3
View File
@@ -61,6 +61,7 @@ import { AGENT_ROOT } from "./paths.js";
import { isDatabaseMigrationPending } from "./extension-root-migration.js"; import { isDatabaseMigrationPending } from "./extension-root-migration.js";
import { measureLifecycle, measureLifecycleSync } from "./lifecycle-timing.js"; import { measureLifecycle, measureLifecycleSync } from "./lifecycle-timing.js";
import { createMemoryInitializer, withMemoryInitialization, type EnsureMemoryReady } from "./memory-initialization.js"; import { createMemoryInitializer, withMemoryInitialization, type EnsureMemoryReady } from "./memory-initialization.js";
import { isSubagentSession, markSubagentSession, markSubagentSessionFromPrompt } from "./subagent-mode.js";
export function resolveProjectSkillDiscovery( export function resolveProjectSkillDiscovery(
skillStore: SkillStore, skillStore: SkillStore,
@@ -195,8 +196,12 @@ export default function (pi: ExtensionAPI) {
const initialization = createMemoryInitializer(async (ctx) => { const initialization = createMemoryInitializer(async (ctx) => {
const timingPrefix = lazy ? "memory-init" : "session-start"; const timingPrefix = lazy ? "memory-init" : "session-start";
if (lazy) migrateLegacyProjectMemoryDirs(agentRoot, config.projectsMemoryDir); // Subagent sessions are read-only: skip the write side of initialization
if (!persistenceInitialized) { // (legacy-dir migration, markdown↔SQLite sync, pruning, backfill) while
// still loading the store so memory_search/session_search keep working.
const subagent = isSubagentSession(ctx);
if (lazy && !subagent) migrateLegacyProjectMemoryDirs(agentRoot, config.projectsMemoryDir);
if (!persistenceInitialized && !subagent) {
try { try {
await measureLifecycle(`${timingPrefix}.persistence-sync`, async () => { await measureLifecycle(`${timingPrefix}.persistence-sync`, async () => {
// Startup reconciles the global files plus the current project only: // Startup reconciles the global files plus the current project only:
@@ -290,18 +295,25 @@ export default function (pi: ExtensionAPI) {
// Skills and pinned instructions must be available even without a lookup. // Skills and pinned instructions must be available even without a lookup.
pi.on("session_start", async (_event, ctx) => { pi.on("session_start", async (_event, ctx) => {
sessionContext = ctx; sessionContext = ctx;
// session_start runs before before_agent_start; a child's rendered prompt
// leads with the <active_agent> tag, so detect it here early enough to
// skip the session_start writes below.
markSubagentSessionFromPrompt(ctx.getSystemPrompt?.() ?? "", ctx);
// Pinned directives must not depend on migration/SQLite being healthy. // Pinned directives must not depend on migration/SQLite being healthy.
if (standingStore) await standingStore.load(); if (standingStore) await standingStore.load();
if (!lazy) await ensureMemoryReady(ctx); if (!lazy) await ensureMemoryReady(ctx);
refreshSkillProjectContext(ctx.cwd); refreshSkillProjectContext(ctx.cwd);
if (!isSubagentSession(ctx)) {
await skillStore.migrateLegacySkills(); await skillStore.migrateLegacySkills();
await skillStore.ensureDiscoveredRoots(); await skillStore.ensureDiscoveredRoots();
}
}); });
registerProjectSkillDiscoveryHandler(pi, skillStore, config.projectsMemoryDir); registerProjectSkillDiscoveryHandler(pi, skillStore, config.projectsMemoryDir);
// ── 2. Inject memory policy by default; legacy mode keeps full frozen memory blocks ── // ── 2. Inject memory policy by default; legacy mode keeps full frozen memory blocks ──
pi.on("before_agent_start", async (event, _ctx) => { pi.on("before_agent_start", async (event, ctx) => {
markSubagentSession(event, ctx);
const promptContext = await buildPromptContext(config, store, projectStoreRef(), projectNameRef(), standingStore); const promptContext = await buildPromptContext(config, store, projectStoreRef(), projectNameRef(), standingStore);
if (!promptContext) return; if (!promptContext) return;
@@ -400,6 +412,7 @@ export default function (pi: ExtensionAPI) {
// ── 10. Live session indexing ── // ── 10. Live session indexing ──
pi.on("message_end", async (_event, ctx) => { pi.on("message_end", async (_event, ctx) => {
if (isSubagentSession(ctx)) return;
// Cold lazy sessions remain in Pi's JSONL files and are backfilled on use. // Cold lazy sessions remain in Pi's JSONL files and are backfilled on use.
if (lazy && !initialization.isReady()) return; if (lazy && !initialization.isReady()) return;
scheduleLiveSessionIndex(dbManager, ctx.sessionManager, { scheduleLiveSessionIndex(dbManager, ctx.sessionManager, {
@@ -432,6 +445,7 @@ export default function (pi: ExtensionAPI) {
return; return;
} }
try { try {
if (isSubagentSession(ctx)) return;
measureLifecycleSync("shutdown.active-index", () => { measureLifecycleSync("shutdown.active-index", () => {
const sessionFile = ctx.sessionManager.getSessionFile(); const sessionFile = ctx.sessionManager.getSessionFile();
if (sessionFile && fs.existsSync(sessionFile)) { if (sessionFile && fs.existsSync(sessionFile)) {
+58
View File
@@ -0,0 +1,58 @@
/**
* subagent-mode.ts — subagent sessions are read-only for memory and skill
* writes. All automatic write-related work (background review, correction
* detection, session flushes, session indexing) is skipped as well.
*
* A child session is identified by the `<active_agent name="..."/>` identity
* tag that pi-subagents stamps into every child prompt
* (permission-system-bridge.ts): the tag arrives via
* `systemPromptOptions.customPrompt`. The tag is only trusted on that field —
* never in the rendered system prompt, which a main session can legitimately
* contain through project docs.
*
* Membership is tracked per session by its sessionManager identity: the same
* process hosts the parent and its children concurrently, so a global boolean
* would let a child's turn flip the parent's session into read-only mid-turn.
*/
import type { BeforeAgentStartEvent, ExtensionContext } from "@earendil-works/pi-coding-agent";
/** pi-subagents `<active_agent name="X"/>` identity tag contract. */
const ACTIVE_AGENT_TAG = /<active_agent\s+name=["'][^"']+["'][^>]*>/i;
const LEADING_ACTIVE_AGENT_TAG = /^\s*<active_agent\s+name=["'][^"']+["'][^>]*>/i;
export const SUBAGENT_WRITE_DISABLED_MESSAGE =
"Memory and skill writes are disabled in subagent sessions; include this in your final report so the delegating agent can decide.";
const subagentSessionManagers = new WeakSet<object>();
/** Same regex as the pi-subagents `<active_agent>` contract, applied only to customPrompt. */
export function isSubagentCustomPrompt(customPrompt: string | undefined | null): boolean {
return typeof customPrompt === "string" && ACTIVE_AGENT_TAG.test(customPrompt);
}
/**
* before_agent_start hook: authoritative detection via the event's
* systemPromptOptions.customPrompt.
*/
export function markSubagentSession(event: BeforeAgentStartEvent, ctx: ExtensionContext): void {
if (isSubagentCustomPrompt(event.systemPromptOptions?.customPrompt)) {
subagentSessionManagers.add(ctx.sessionManager);
}
}
/**
* session_start runs before before_agent_start; detect tag-leading prompts
* early so session_start writes (skill migration, root setup) can be skipped.
* A main prompt cannot lead with the tag — without customPrompt the rendered
* prompt always opens with the base preamble.
*/
export function markSubagentSessionFromPrompt(systemPrompt: string, ctx: ExtensionContext): void {
if (LEADING_ACTIVE_AGENT_TAG.test(systemPrompt)) {
subagentSessionManagers.add(ctx.sessionManager);
}
}
export function isSubagentSession(ctx: unknown): boolean {
const sm = (ctx as { sessionManager?: unknown } | undefined | null)?.sessionManager;
return typeof sm === "object" && sm !== null && subagentSessionManagers.has(sm);
}
+8 -1
View File
@@ -25,6 +25,7 @@ import type { MemoryCategory, MemoryResult } from "../types.js";
import { normalizeMemoryLookupText } from "../store/memory-lookup.js"; import { normalizeMemoryLookupText } from "../store/memory-lookup.js";
import { createSharedToolResultRenderer } from "./shared-output-view.js"; import { createSharedToolResultRenderer } from "./shared-output-view.js";
import { memoryResultView } from "./tool-result-views.js"; import { memoryResultView } from "./tool-result-views.js";
import { isSubagentSession, SUBAGENT_WRITE_DISABLED_MESSAGE } from "../subagent-mode.js";
function appendSyncWarning(result: MemoryResult, warning: string): MemoryResult { function appendSyncWarning(result: MemoryResult, warning: string): MemoryResult {
const warnings = [...(((result as any).warnings ?? []) as string[]), warning]; const warnings = [...(((result as any).warnings ?? []) as string[]), warning];
@@ -425,7 +426,13 @@ This action-specific tool accepts only the parameters listed in its schema.`;
], ],
renderResult: createSharedToolResultRenderer(memoryResultView), renderResult: createSharedToolResultRenderer(memoryResultView),
parameters, parameters,
async execute(_toolCallId, params, signal, _onUpdate, ctx?: { cwd?: string }) { async execute(_toolCallId, params, signal, _onUpdate, ctx?: { cwd?: string; sessionManager?: object }) {
if (isSubagentSession(ctx)) {
return {
content: [{ type: "text" as const, text: SUBAGENT_WRITE_DISABLED_MESSAGE }],
details: { success: false, error: SUBAGENT_WRITE_DISABLED_MESSAGE },
};
}
if (bindProjectFromCwd && ctx?.cwd) { if (bindProjectFromCwd && ctx?.cwd) {
await bindProjectFromCwd(ctx.cwd); await bindProjectFromCwd(ctx.cwd);
} }
+14
View File
@@ -10,6 +10,7 @@ import { SkillStore } from "../store/skill-store.js";
import { SKILL_TOOL_DESCRIPTION } from "../constants.js"; import { SKILL_TOOL_DESCRIPTION } from "../constants.js";
import { createSharedToolResultRenderer } from "./shared-output-view.js"; import { createSharedToolResultRenderer } from "./shared-output-view.js";
import { skillResultView } from "./tool-result-views.js"; import { skillResultView } from "./tool-result-views.js";
import { isSubagentSession, SUBAGENT_WRITE_DISABLED_MESSAGE } from "../subagent-mode.js";
function normalizeTextList(value: unknown): string[] { function normalizeTextList(value: unknown): string[] {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
@@ -90,6 +91,13 @@ const SKILL_TOOL_PARAMETERS = Type.Object({
export const SKILL_MANAGE_TOOL_NAME = "skill_manage"; export const SKILL_MANAGE_TOOL_NAME = "skill_manage";
export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void { export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
if (typeof pi.on === "function") {
pi.on("tool_result", (event) => {
if (event.toolName !== SKILL_MANAGE_TOOL_NAME) return;
const details = event.details as { success?: unknown } | undefined;
if (details?.success === false) return { isError: true };
});
}
pi.registerTool({ pi.registerTool({
name: SKILL_MANAGE_TOOL_NAME, name: SKILL_MANAGE_TOOL_NAME,
label: "Skill Manager", label: "Skill Manager",
@@ -108,6 +116,12 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
renderResult: createSharedToolResultRenderer(skillResultView), renderResult: createSharedToolResultRenderer(skillResultView),
parameters: SKILL_TOOL_PARAMETERS, parameters: SKILL_TOOL_PARAMETERS,
async execute(toolCallId, params, signal, onUpdate, ctx) { async execute(toolCallId, params, signal, onUpdate, ctx) {
if (isSubagentSession(ctx)) {
return {
content: [{ type: "text", text: SUBAGENT_WRITE_DISABLED_MESSAGE }],
details: { success: false, error: SUBAGENT_WRITE_DISABLED_MESSAGE },
};
}
const skillParams = params as { const skillParams = params as {
action: "create" | "view" | "patch" | "update" | "edit" | "delete"; action: "create" | "view" | "patch" | "update" | "edit" | "delete";
name?: string; name?: string;
@@ -0,0 +1,228 @@
/**
* Subagent (pi-subagents child) sessions are read-only: memory/skill write
* tools return a fixed error and all automatic writes are skipped.
*
* Child detection keys on the `<active_agent name="X"/>` tag pi-subagents
* stamps into `systemPromptOptions.customPrompt`; membership is tracked per
* sessionManager so a child in the same process never flips the parent.
*/
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
isSubagentCustomPrompt,
isSubagentSession,
markSubagentSession,
markSubagentSessionFromPrompt,
SUBAGENT_WRITE_DISABLED_MESSAGE,
} from "../src/subagent-mode.js";
import { registerMemoryTool } from "../src/tools/memory-tool.js";
import { registerSkillTool } from "../src/tools/skill-tool.js";
import { setupSessionFlush } from "../src/handlers/session-flush.js";
import { MemoryStore } from "../src/store/memory-store.js";
import { SkillStore } from "../src/store/skill-store.js";
import type { MemoryConfig } from "../src/types.js";
const CHILD_PROMPT = 'You are the Explore agent.\n<active_agent name="Explore"/>';
function beforeAgentStartEvent(customPrompt?: string) {
return { systemPromptOptions: customPrompt === undefined ? {} : { customPrompt } } as any;
}
describe("subagent detection", () => {
it("matches the pi-subagents <active_agent> contract on customPrompt only", () => {
assert.ok(isSubagentCustomPrompt(CHILD_PROMPT));
assert.ok(isSubagentCustomPrompt("intro <active_agent name=\"General Purpose\" extra=\"1\"> tail"));
assert.ok(!isSubagentCustomPrompt("a normal custom prompt"));
assert.ok(!isSubagentCustomPrompt(""));
assert.ok(!isSubagentCustomPrompt(undefined));
assert.ok(!isSubagentCustomPrompt(null));
});
it("marks only sessions whose before_agent_start customPrompt carries the tag", () => {
const childManager = {};
const mainManager = {};
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager: childManager } as any);
markSubagentSession(beforeAgentStartEvent(), { sessionManager: mainManager } as any);
markSubagentSession(beforeAgentStartEvent("no tag here"), { sessionManager: {} } as any);
assert.ok(isSubagentSession({ sessionManager: childManager }));
assert.ok(!isSubagentSession({ sessionManager: mainManager }));
assert.ok(!isSubagentSession(undefined));
assert.ok(!isSubagentSession({}));
});
it("detects tag-leading rendered prompts early (session_start), not mid-prompt tags", () => {
const childManager = {};
const mainManager = {};
markSubagentSessionFromPrompt(
`<active_agent name="Explore"/>\n\nYou are the Explore agent.\n\nrest`,
{ sessionManager: childManager } as any,
);
markSubagentSessionFromPrompt(
`preamble\n\nliteral <active_agent name="X"/> inside project docs`,
{ sessionManager: mainManager } as any,
);
assert.ok(isSubagentSession({ sessionManager: childManager }));
assert.ok(!isSubagentSession({ sessionManager: mainManager }));
});
});
describe("subagent write gating", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subagent-mode-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function makeMemoryStore(): MemoryStore {
return new MemoryStore({
memoryMode: "policy-only",
memoryCharLimit: 5000,
userCharLimit: 5000,
projectCharLimit: 5000,
nudgeInterval: 10,
reviewEnabled: false,
flushOnCompact: false,
flushOnShutdown: false,
flushMinTurns: 6,
autoConsolidate: false,
correctionDetection: false,
failureInjectionEnabled: false,
failureInjectionMaxAgeDays: 7,
failureInjectionMaxEntries: 5,
nudgeToolCalls: 15,
consolidationTimeoutMs: 60000,
memoryDir: tmpDir,
});
}
it("memory_add/replace/remove return the disabled error and perform no writes", async () => {
const tools = new Map<string, any>();
const mockPi = { registerTool: (def: any) => tools.set(def.name, def) } as any;
const store = makeMemoryStore();
await store.loadFromDisk();
let bindCalls = 0;
registerMemoryTool(mockPi, store, null, null, null, () => { bindCalls++; });
const sessionManager = {};
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
const ctx = { cwd: tmpDir, sessionManager };
for (const name of ["memory_add", "memory_replace", "memory_remove"]) {
const result = await tools.get(name).execute("tc", { target: "memory", content: "x", old_text: "x" }, undefined, undefined, ctx);
assert.strictEqual(result.content[0].text, SUBAGENT_WRITE_DISABLED_MESSAGE, name);
assert.strictEqual(result.details.success, false, name);
assert.strictEqual(result.details.error, SUBAGENT_WRITE_DISABLED_MESSAGE, name);
}
assert.strictEqual(bindCalls, 0, "blocked writes must not bind projects");
assert.deepStrictEqual(store.getMemoryEntries(), [], "blocked writes must not mutate the store");
assert.ok(!fs.existsSync(path.join(tmpDir, "MEMORY.md")), "blocked writes must not write files");
});
it("memory tools still write in an unmarked (main) session", async () => {
const tools = new Map<string, any>();
const mockPi = { registerTool: (def: any) => tools.set(def.name, def) } as any;
const store = makeMemoryStore();
await store.loadFromDisk();
registerMemoryTool(mockPi, store, null);
const result = await tools.get("memory_add").execute(
"tc",
{ target: "memory", content: "main session entry" },
undefined,
undefined,
{ cwd: tmpDir, sessionManager: {} },
);
assert.strictEqual(result.details.success, true);
assert.deepStrictEqual(store.getMemoryEntries(), ["main session entry"]);
});
it("skill_manage returns the disabled error in a subagent session", async () => {
let tool: any;
const mockPi = { registerTool: (def: any) => { tool = def; } } as any;
const store = new SkillStore({
globalSkillsDir: path.join(tmpDir, "skills"),
piGlobalSkillsDir: path.join(tmpDir, "pi-skills"),
projectSkillsDir: null,
projectName: null,
legacySkillsDir: path.join(tmpDir, "legacy-skills"),
migrationSentinelPath: path.join(tmpDir, ".migrated"),
});
registerSkillTool(mockPi, store);
const sessionManager = {};
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
const result = await tool.execute(
"tc",
{ action: "create", name: "demo", description: "d", scope: "global", content: "body" },
undefined,
undefined,
{ sessionManager },
);
assert.strictEqual(result.content[0].text, SUBAGENT_WRITE_DISABLED_MESSAGE);
assert.strictEqual(result.details.success, false);
assert.ok(!fs.existsSync(path.join(tmpDir, "skills")), "blocked skill writes must not create the skills dir");
});
it("session flush skips compact and shutdown flushes in a subagent session", async () => {
const handlers: Record<string, Function[]> = {};
const mockPi = {
on(event: string, handler: Function) {
(handlers[event] ??= []).push(handler);
},
async exec() {
throw new Error("subagent flush must not spawn a child pi");
},
registerTool() {},
registerCommand() {},
} as any;
let directCalls = 0;
const config = {
flushOnCompact: true,
flushOnShutdown: true,
flushMinTurns: 1,
flushCompactTimeoutMs: 60000,
reviewTransport: "direct",
} as MemoryConfig;
const sessionManager = { getBranch: () => [] };
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
setupSessionFlush(mockPi, { getMemoryEntries: () => [], getUserEntries: () => [] } as any, null, config, null, null, {
runDirectMemoryCompletion: async () => {
directCalls++;
return { ok: true } as any;
},
});
const ctx = { sessionManager, cwd: tmpDir };
for (const handler of handlers["message_end"] ?? []) {
await handler({ message: { role: "user", content: "hi" } }, ctx);
}
for (const handler of handlers["session_before_compact"] ?? []) {
await handler({ signal: undefined }, ctx);
}
for (const handler of handlers["session_shutdown"] ?? []) {
await handler({ reason: "exit" }, ctx);
}
assert.strictEqual(directCalls, 0, "subagent sessions must not flush");
// Control: the same handlers must still flush an unmarked (main) session.
const mainCtx = { sessionManager: { getBranch: () => [] }, cwd: tmpDir };
for (const handler of handlers["message_end"] ?? []) {
await handler({ message: { role: "user", content: "hi" } }, mainCtx);
}
for (const handler of handlers["session_before_compact"] ?? []) {
await handler({ signal: undefined }, mainCtx);
}
assert.ok(directCalls > 0, "main sessions must still flush");
});
});
+2 -2
View File
@@ -2,13 +2,13 @@
A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-agents** to pi. Spawn specialized agents that run in isolated sessions — each with its own tools, system prompt, model, and thinking level. Run them in the background (the default) or block on them, steer them mid-run, resume completed sessions, and define your own custom agent types. A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-agents** to pi. Spawn specialized agents that run in isolated sessions — each with its own tools, system prompt, model, and thinking level. Run them in the background (the default) or block on them, steer them mid-run, resume completed sessions, and define your own custom agent types.
> **my-pi maintenance note:** This directory was imported from upstream tag `v0.18.0` at commit `3f9d35cd078d18a141eb5a6d8f4fc5010d756280` and is maintained directly from source. Its source, tests, host-owned `extensions/subagents.ts` wrapper, and `pi-permission-system` child lifecycle bridge are retained, but the root bundle currently declares no `pi-subagents` runtime dependency and does not default-load the wrapper. See [`UPSTREAM.md`](UPSTREAM.md). > **my-pi maintenance note:** This directory was imported from upstream tag `v0.18.0` at commit `3f9d35cd078d18a141eb5a6d8f4fc5010d756280` and is maintained directly from source. The root bundle installs it through `file:./pi-subagents`, default-loads the host-owned `extensions/subagents.ts` wrapper, and exposes its orchestration tools through Tool Search's checked-in `subagents` group. The local source implements the `pi-permission-system` child lifecycle bridge described below. See [`UPSTREAM.md`](UPSTREAM.md).
## my-pi permission integration ## my-pi permission integration
Before a child calls `bindExtensions()`, it publishes `subagents:child:session-created` with the child session id and the interactive root session id. This lets `pi-permission-system` classify the headless session as a registered child before its own `session_start`, prevents the child permission service from replacing the root process-global service, and routes unresolved `ask` requests to the root authority. Nested descendants retain the same interactive root instead of forwarding to an intermediate child with no UI. The matching `subagents:child:disposed` event is published once, after the child has actually closed. Before a child calls `bindExtensions()`, it publishes `subagents:child:session-created` with the child session id and the interactive root session id. This lets `pi-permission-system` classify the headless session as a registered child before its own `session_start`, prevents the child permission service from replacing the root process-global service, and routes unresolved `ask` requests to the root authority. Nested descendants retain the same interactive root instead of forwarding to an intermediate child with no UI. The matching `subagents:child:disposed` event is published once, after the child has actually closed.
The retained bundle wrapper configures this extension through `createPiSubagentsExtension({ mandatoryExtensionPaths })`, although that wrapper is currently disabled in the root extension list. If re-enabled, these exact, host-owned absolute paths are canonicalized and merged *after* Agent frontmatter, so `extensions: false`, `isolated: true`, and `exclude_extensions:` cannot remove the permission wrapper. The child aborts before session creation if an exact mandatory entry is unavailable or does not survive loader filtering. Mandatory means lifecycle handlers bind; it does **not** expose that extension's tools when normal Agent extension policy hid them. Agent files cannot add to or alter this list. The bundle loads this extension through `createPiSubagentsExtension({ mandatoryExtensionPaths })`. These exact, host-owned absolute paths are canonicalized and merged *after* Agent frontmatter, so `extensions: false`, `isolated: true`, and `exclude_extensions:` cannot remove the permission wrapper. The child aborts before session creation if an exact mandatory entry is unavailable or does not survive loader filtering. Mandatory means lifecycle handlers bind; it does **not** expose that extension's tools when normal Agent extension policy hid them. Agent files cannot add to or alter this list.
Each child system prompt carries one sanitized `<active_agent name="..."/>` identity. Any inherited parent identity is removed first, so per-agent permission configuration and forwarded-request attribution resolve against the child definition rather than the orchestrator. Each child system prompt carries one sanitized `<active_agent name="..."/>` identity. Any inherited parent identity is removed first, so per-agent permission configuration and forwarded-request attribution resolve against the child definition rather than the orchestrator.
+19 -1
View File
@@ -237,7 +237,14 @@ async function shutdownChildSession(session: AgentSession | undefined): Promise<
finally { unregisterPermissionChildSession(session); } finally { unregisterPermissionChildSession(session); }
} }
export interface AgentManagerOptions extends MandatoryExtensionPolicy {} export interface AgentManagerOptions extends MandatoryExtensionPolicy {
/** Canonical bundle-owned extension entries removed from every child session regardless of Agent frontmatter. */
excludedExtensionPaths?: readonly string[];
/** Per-spawn base prompt supplier — non-blank replaces the inherited parent prompt in buildAgentPrompt. */
basePrompt?: () => string | undefined;
/** Bundle-shipped agent .md directory forwarded to nested-delegation registries. */
bundledAgentsDir?: string;
}
export class AgentManager { export class AgentManager {
private agents = new Map<string, AgentRecord>(); private agents = new Map<string, AgentRecord>();
@@ -248,6 +255,9 @@ export class AgentManager {
private onUsage?: OnAgentUsage; private onUsage?: OnAgentUsage;
private maxConcurrent: number; private maxConcurrent: number;
private readonly mandatoryExtensionPaths: readonly string[]; private readonly mandatoryExtensionPaths: readonly string[];
private readonly excludedExtensionPaths: readonly string[];
private readonly basePrompt?: () => string | undefined;
private readonly bundledAgentsDir?: string;
/** Base repos worktrees were created from — so dispose() can prune them all, /** Base repos worktrees were created from — so dispose() can prune them all,
* not just the parent repo (caller-supplied cwd can target other repos). */ * not just the parent repo (caller-supplied cwd can target other repos). */
private worktreeRepos = new Set<string>(); private worktreeRepos = new Set<string>();
@@ -281,6 +291,11 @@ export class AgentManager {
this.mandatoryExtensionPaths = normalizeMandatoryExtensionPaths( this.mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(
options.mandatoryExtensionPaths, options.mandatoryExtensionPaths,
); );
this.excludedExtensionPaths = normalizeMandatoryExtensionPaths(
options.excludedExtensionPaths,
);
this.basePrompt = options.basePrompt;
this.bundledAgentsDir = options.bundledAgentsDir;
// Cleanup completed agents after 10 minutes (but keep sessions for resume) // Cleanup completed agents after 10 minutes (but keep sessions for resume)
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000); this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
this.cleanupInterval.unref(); this.cleanupInterval.unref();
@@ -442,6 +457,9 @@ export class AgentManager {
// rootSessionId; programmatic callers fall back to their current context. // rootSessionId; programmatic callers fall back to their current context.
authoritySessionId: record.rootSessionId ?? ctx.sessionManager?.getSessionId?.(), authoritySessionId: record.rootSessionId ?? ctx.sessionManager?.getSessionId?.(),
mandatoryExtensionPaths: this.mandatoryExtensionPaths, mandatoryExtensionPaths: this.mandatoryExtensionPaths,
excludedExtensionPaths: this.excludedExtensionPaths,
basePrompt: this.basePrompt,
bundledAgentsDir: this.bundledAgentsDir,
// Worktree wins for the working dir (the agent must run in the copy — // Worktree wins for the working dir (the agent must run in the copy —
// which, with a custom cwd, was created from that target). Config stays // which, with a custom cwd, was created from that target). Config stays
// with the parent project when a caller-supplied cwd is in play; it must // with the parent project when a caller-supplied cwd is in play; it must
+44 -9
View File
@@ -24,6 +24,7 @@ import { DEFAULT_AGENTS } from "./default-agents.js";
import { detectEnv } from "./env.js"; import { detectEnv } from "./env.js";
import { import {
assertMandatoryExtensionsLoaded, assertMandatoryExtensionsLoaded,
extensionPathMatches,
isMandatoryExtensionPath, isMandatoryExtensionPath,
normalizeMandatoryExtensionPaths, normalizeMandatoryExtensionPaths,
} from "./mandatory-extensions.js"; } from "./mandatory-extensions.js";
@@ -432,6 +433,21 @@ export interface RunOptions {
authoritySessionId?: string; authoritySessionId?: string;
/** Canonical bundle-owned extension entries that Agent config cannot disable. */ /** Canonical bundle-owned extension entries that Agent config cannot disable. */
mandatoryExtensionPaths?: readonly string[]; mandatoryExtensionPaths?: readonly string[];
/**
* Canonical bundle-owned extension entries removed from every child session
* regardless of Agent frontmatter — the host-level counterpart of
* `exclude_extensions`. Overlap with `mandatoryExtensionPaths` is rejected
* by `createPiSubagentsExtension`.
*/
excludedExtensionPaths?: readonly string[];
/**
* Per-spawn base prompt supplier. A non-blank return replaces the inherited
* parent prompt and the `<sub_agent_context>` bridge / replace-mode header
* inside `buildAgentPrompt`; undefined or blank keeps upstream composition.
*/
basePrompt?: () => string | undefined;
/** Bundle-shipped agent .md directory forwarded to nested-delegation registries. */
bundledAgentsDir?: string;
/** Override working directory (e.g. for worktree isolation). */ /** Override working directory (e.g. for worktree isolation). */
cwd?: string; cwd?: string;
/** /**
@@ -651,16 +667,18 @@ export async function runAgent(
} }
} }
// Build system prompt from agent config // Build system prompt from agent config. `basePrompt` resolves once per
// spawn — a host-owned base owns the prompt base instead of the parent's.
const basePrompt = options.basePrompt?.();
let systemPrompt: string; let systemPrompt: string;
if (agentConfig) { if (agentConfig) {
systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras); systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras, basePrompt);
} else { } else {
// Unknown type fallback: spread the canonical general-purpose config (defensive — // Unknown type fallback: spread the canonical general-purpose config (defensive —
// unreachable in practice since index.ts resolves unknown types before calling runAgent). // unreachable in practice since index.ts resolves unknown types before calling runAgent).
const fallback = DEFAULT_AGENTS.get("general-purpose"); const fallback = DEFAULT_AGENTS.get("general-purpose");
if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`); if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`);
systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras); systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras, basePrompt);
} }
// Child permission policy is selected by the agent-definition filename, // Child permission policy is selected by the agent-definition filename,
// which is also where pi-permission-system reads `permission:` frontmatter. // which is also where pi-permission-system reads `permission:` frontmatter.
@@ -701,6 +719,11 @@ export async function runAgent(
); );
const optionalExtensionsDisabled = extensions === false; const optionalExtensionsDisabled = extensions === false;
const mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(options.mandatoryExtensionPaths); const mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(options.mandatoryExtensionPaths);
// Bundle-level exclusion: canonicalized like mandatory paths, removed from
// every child's loaded set regardless of the Agent's own extension policy.
const excludedExtensionPaths = normalizeMandatoryExtensionPaths(options.excludedExtensionPaths);
const isExcludedExtension = (extensionPath: string) =>
excludedExtensionPaths.some((path) => extensionPathMatches(extensionPath, path));
const noExtensions = optionalExtensionsDisabled && mandatoryExtensionPaths.length === 0; const noExtensions = optionalExtensionsDisabled && mandatoryExtensionPaths.length === 0;
const extensionsSpec = Array.isArray(extensions) const extensionsSpec = Array.isArray(extensions)
@@ -718,10 +741,12 @@ export async function runAgent(
if (canons.some((name) => excludeNames.has(name))) return false; if (canons.some((name) => excludeNames.has(name))) return false;
return loadAll || canons.some((name) => keepNames.has(name)); return loadAll || canons.some((name) => keepNames.has(name));
}; };
// An excluded path stays out even when an `extensions:` path entry asks for
// it — host policy outranks Agent frontmatter, same as everywhere else.
const requestedPaths = [ const requestedPaths = [
...(extensionsSpec?.paths ?? []), ...(extensionsSpec?.paths ?? []),
...mandatoryExtensionPaths, ...mandatoryExtensionPaths,
]; ].filter((path) => !isExcludedExtension(path));
const additionalExtensionPaths = requestedPaths.length const additionalExtensionPaths = requestedPaths.length
? [...new Set(requestedPaths)] ? [...new Set(requestedPaths)]
: undefined; : undefined;
@@ -729,16 +754,25 @@ export async function runAgent(
// against discovery, while mandatory identity is checked by exact canonical path. // against discovery, while mandatory identity is checked by exact canonical path.
let discoveredNames: Set<string> | undefined; let discoveredNames: Set<string> | undefined;
const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined = const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
noExtensions || (loadAll && !hasExcludes) (noExtensions || (loadAll && !hasExcludes)) && excludedExtensionPaths.length === 0
? undefined ? undefined
: (base) => { : (base) => {
discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path))); discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
return { return {
...base, ...base,
extensions: base.extensions.filter((extension) => extensions: base.extensions.filter((extension) => {
isMandatoryExtensionPath(extension.path, mandatoryExtensionPaths) if (isExcludedExtension(extension.path)) {
|| normalPolicySelects(extension.path) if (normalPolicySelects(extension.path)) {
), options.onToolActivity?.({
type: "end",
toolName: `extension-error:extension "${basename(extension.path)}" is excluded from subagent sessions by bundle policy`,
});
}
return false;
}
return isMandatoryExtensionPath(extension.path, mandatoryExtensionPaths)
|| normalPolicySelects(extension.path);
}),
}; };
}; };
@@ -880,6 +914,7 @@ export async function runAgent(
maxSubagentDepth: effectiveMaxDepth, maxSubagentDepth: effectiveMaxDepth,
allowedSubagents: agentConfig.allowedSubagents, allowedSubagents: agentConfig.allowedSubagents,
configCwd, configCwd,
bundledAgentsDir: options.bundledAgentsDir,
}) })
: []; : [];
const nestedToolNames = new Set(nestedTools.map(tool => tool.name)); const nestedToolNames = new Set(nestedTools.map(tool => tool.name));
+15 -5
View File
@@ -55,14 +55,20 @@ export function setFallbackSubagent(v: string | undefined): void { fallbackSubag
/** /**
* Build a registry map: DEFAULT_AGENTS first (unless disabled via settings), * Build a registry map: DEFAULT_AGENTS first (unless disabled via settings),
* then user agents overlaid on top (same name overrides the default). * then bundle-shipped defaults (bundledAgentsDir — they replace same-name
* embedded defaults and are themselves stamped `isDefault`), then user agents
* overlaid on top (same name overrides everything before it).
* Pure — callers that must not disturb the process-wide registry (nested * Pure — callers that must not disturb the process-wide registry (nested
* delegation resolving agents from its own config root) build their own map. * delegation resolving agents from its own config root) build their own map.
*/ */
export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<string, AgentConfig> { export function buildAgentRegistry(
userAgents: Map<string, AgentConfig>,
bundledAgents?: Map<string, AgentConfig>,
): Map<string, AgentConfig> {
const registry = new Map<string, AgentConfig>(); const registry = new Map<string, AgentConfig>();
if (!disableDefaults) { if (!disableDefaults) {
for (const [name, config] of DEFAULT_AGENTS) registry.set(name, config); for (const [name, config] of DEFAULT_AGENTS) registry.set(name, config);
if (bundledAgents) for (const [name, config] of bundledAgents) registry.set(name, config);
} }
for (const [name, config] of userAgents) registry.set(name, config); for (const [name, config] of userAgents) registry.set(name, config);
return registry; return registry;
@@ -70,12 +76,16 @@ export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<st
/** /**
* Register agents into the unified registry. * Register agents into the unified registry.
* Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name). * Starts with DEFAULT_AGENTS (then bundle-shipped defaults), then overlays
* user agents (overrides defaults with same name).
* Disabled agents (enabled === false) are kept in the registry but excluded from spawning. * Disabled agents (enabled === false) are kept in the registry but excluded from spawning.
*/ */
export function registerAgents(userAgents: Map<string, AgentConfig>): void { export function registerAgents(
userAgents: Map<string, AgentConfig>,
bundledAgents?: Map<string, AgentConfig>,
): void {
agents.clear(); agents.clear();
for (const [name, config] of buildAgentRegistry(userAgents)) { for (const [name, config] of buildAgentRegistry(userAgents, bundledAgents)) {
agents.set(name, config); agents.set(name, config);
} }
} }
+78 -20
View File
@@ -79,32 +79,60 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
} }
const { frontmatter: fm, body } = parsed; const { frontmatter: fm, body } = parsed;
// Claude Code's rule: `name:` IS the agent type, and the filename need not // A reserved-name skip carries no `warnSkippedOverride`: this file would
// match. Absent, the filename stands in — Claude Code requires the field, // have registered under its *declared* name, which nothing else can hold
// but most files here predate it and must keep loading. // (a colon keeps it out of the registry), so it shadowed nothing. Passing
// the filename instead would report a substitution of an unrelated agent
// that never happened.
const name = resolveDeclaredName(path, filenameType, fm);
if (name === undefined) continue;
agents.set(name, configFromFrontmatter(name, fm, body, { source, sourcePath: path }));
}
}
/**
* Claude Code's rule: `name:` IS the agent type, and the filename need not
* match. Absent, the filename stands in — Claude Code requires the field, but
* most files here predate it and must keep loading. `||`, not `??`: a quoted
* empty or all-whitespace `name:` would otherwise register the agent under the
* empty type — unspawnable, and it takes the filename-derived one down with it.
*
* A declared name containing ":" is refused — reserved for plugin-scoped
* identifiers. Refusing beats silently substituting: the file would otherwise
* load under its filename, so `Agent({subagent_type})` would succeed against
* an agent whose declared identity nothing honoured.
*/
function resolveDeclaredName(
path: string,
filenameType: string,
fm: Record<string, unknown>,
): string | undefined {
const declared = str(fm.name)?.trim(); const declared = str(fm.name)?.trim();
if (declared?.includes(RESERVED_IN_TYPE)) { if (declared?.includes(RESERVED_IN_TYPE)) {
// Refusing beats silently substituting: the file would otherwise load
// under its filename, so `Agent({subagent_type})` would succeed against
// an agent whose declared identity nothing honoured.
warnIfNew( warnIfNew(
`Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for ` `Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for `
+ "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.", + "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.",
); );
// No `warnSkippedOverride`: this file would have registered under its return undefined;
// *declared* name, which nothing else can hold (a colon keeps it out of
// the registry), so it shadowed nothing. Passing the filename instead
// would report a substitution of an unrelated agent that never happened.
continue;
} }
// `||`, not `??`: a quoted empty or all-whitespace `name:` would otherwise return declared || filenameType;
// register the agent under the empty type — unspawnable, and it takes the }
// filename-derived one down with it.
const name = declared || filenameType;
/**
* Build an AgentConfig from parsed frontmatter. `provenance` is the
* project/global source record; bundled agents pass none and are stamped as
* embedded defaults instead — `isDefault` with no `source`/`sourcePath`, so
* UIs treat them as built-ins rather than editable/deletable files.
*/
function configFromFrontmatter(
name: string,
fm: Record<string, unknown>,
body: string,
provenance?: { source: "project" | "global"; sourcePath: string },
): AgentConfig {
const { builtinToolNames, extSelectors } = parseToolsField(fm.tools); const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
return {
agents.set(name, {
name, name,
// Only `display_name` now: `name` is the type, and `getConfig` already // Only `display_name` now: `name` is the type, and `getConfig` already
// falls back to the type when no label is set — so a Claude Code file // falls back to the type when no label is set — so a Claude Code file
@@ -133,10 +161,40 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
memory: parseMemory(fm.memory), memory: parseMemory(fm.memory),
isolation: parseIsolation(fm.isolation), isolation: parseIsolation(fm.isolation),
enabled: fm.enabled !== false, // default true; explicitly false disables enabled: fm.enabled !== false, // default true; explicitly false disables
source, isDefault: provenance === undefined ? true : undefined,
sourcePath: path, source: provenance?.source,
}); sourcePath: provenance?.sourcePath,
};
}
/**
* Load bundle-shipped agent .md files as additional embedded defaults
* (the `bundledAgentsDir` extension option). Same frontmatter parser as
* user agent files; entries carry `isDefault` with no `source`/`sourcePath`
* so UIs treat them as built-ins rather than editable files. Files are read
* in sorted order so registration order is deterministic.
*/
export function loadBundledAgents(dir: string, strict = false): Map<string, AgentConfig> {
const agents = new Map<string, AgentConfig>();
if (!existsSync(dir)) return agents;
let files: string[];
try {
files = readdirSync(dir).filter(f => f.endsWith(".md")).sort();
} catch {
return agents;
} }
for (const file of files) {
const filenameType = basename(file, ".md");
const path = join(dir, file);
const parsed = readAgentFile(path, strict);
if (!parsed) continue;
const name = resolveDeclaredName(path, filenameType, parsed.frontmatter);
if (name === undefined) continue;
agents.set(name, configFromFrontmatter(name, parsed.frontmatter, parsed.body));
}
return agents;
} }
/** /**
+56 -8
View File
@@ -18,15 +18,15 @@ import { Type } from "@sinclair/typebox";
import { abortable } from "./abortable.js"; import { abortable } from "./abortable.js";
import { hasAgentBadge, renderAgentName } from "./agent-color.js"; import { hasAgentBadge, renderAgentName } from "./agent-color.js";
import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js"; import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
import { AgentManager } from "./agent-manager.js"; import { AgentManager, type AgentManagerOptions } from "./agent-manager.js";
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js"; import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js"; import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
import { inChildSessionContext } from "./child-context.js"; import { inChildSessionContext } from "./child-context.js";
import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js"; import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
import { loadCustomAgents } from "./custom-agents.js"; import { loadBundledAgents, loadCustomAgents } from "./custom-agents.js";
import { GroupJoinManager } from "./group-join.js"; import { GroupJoinManager } from "./group-join.js";
import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js"; import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
import type { MandatoryExtensionPolicy } from "./mandatory-extensions.js"; import { canonicalExistingPath, isMandatoryExtensionPath, normalizeMandatoryExtensionPaths } from "./mandatory-extensions.js";
import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js"; import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
import { runMentionClone } from "./mention-clone.js"; import { runMentionClone } from "./mention-clone.js";
import { type ModelRegistry, resolveModel } from "./model-resolver.js"; import { type ModelRegistry, resolveModel } from "./model-resolver.js";
@@ -281,13 +281,53 @@ export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
return isFullSet ? "*" : tools.join(", "); return isFullSet ? "*" : tools.join(", ");
} }
export interface PiSubagentsExtensionOptions extends MandatoryExtensionPolicy {} export interface PiSubagentsExtensionOptions extends AgentManagerOptions {
/**
* Per-spawn base prompt supplier. A non-blank return owns the child's prompt
* base: `buildAgentPrompt` drops the inherited parent prompt, the
* `<sub_agent_context>` bridge and the replace-mode header, keeping the
* env/worktree blocks, the `<agent_instructions>` section and extras.
* Undefined or blank keeps upstream composition byte-for-byte.
*/
basePrompt?: () => string | undefined;
/**
* Directory of bundle-shipped agent `.md` files, loaded with the same
* frontmatter parser as user agents and registered as embedded defaults
* (`isDefault`, no `source`/`sourcePath`). Priority: embedded < bundled <
* global < workspace < project.
*/
bundledAgentsDir?: string;
/**
* Bundle-owned extension entry paths removed from every child session
* regardless of Agent frontmatter — the host-level counterpart of
* `exclude_extensions`. Canonicalized like `mandatoryExtensionPaths`;
* overlap with it is rejected at creation.
*/
excludedExtensionPaths?: readonly string[];
}
/** Create a host-configured extension; Agent frontmatter cannot mutate these options. */ /** Create a host-configured extension; Agent frontmatter cannot mutate these options. */
export function createPiSubagentsExtension( export function createPiSubagentsExtension(
options: PiSubagentsExtensionOptions = {}, options: PiSubagentsExtensionOptions = {},
): (pi: ExtensionAPI) => void { ): (pi: ExtensionAPI) => void {
return (pi) => installPiSubagentsExtension(pi, options); // Fail fast at bundle-configuration time: a mandatory entry is the one thing
// an excluded entry can never be, so an overlap is a broken bundle policy.
const mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(options.mandatoryExtensionPaths);
const excludedExtensionPaths = normalizeMandatoryExtensionPaths(options.excludedExtensionPaths);
for (const path of excludedExtensionPaths) {
if (isMandatoryExtensionPath(path, mandatoryExtensionPaths)) {
throw new Error(`Extension path is both mandatory and excluded: ${path}`);
}
}
const bundledAgentsDir = options.bundledAgentsDir === undefined
? undefined
: canonicalExistingPath(options.bundledAgentsDir);
return (pi) => installPiSubagentsExtension(pi, {
...options,
mandatoryExtensionPaths,
excludedExtensionPaths,
bundledAgentsDir,
});
} }
const defaultExtension = createPiSubagentsExtension(); const defaultExtension = createPiSubagentsExtension();
@@ -371,10 +411,13 @@ function installPiSubagentsExtension(
// the initial load, which happens hundreds of lines before settings are applied. // the initial load, which happens hundreds of lines before settings are applied.
let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true; let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true;
/** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */ /** Reload agents from bundled/project/global dirs and merge with defaults (called on init and each Agent invocation). */
const reloadCustomAgents = (strict = false) => { const reloadCustomAgents = (strict = false) => {
const bundledAgents = options.bundledAgentsDir === undefined
? undefined
: loadBundledAgents(options.bundledAgentsDir, strict);
const userAgents = loadCustomAgents(process.cwd(), strict); const userAgents = loadCustomAgents(process.cwd(), strict);
registerAgents(userAgents); registerAgents(userAgents, bundledAgents);
}; };
// Initial load — the only strict one. A bad edit mid-session must not kill the // Initial load — the only strict one. A bad edit mid-session must not kill the
@@ -599,7 +642,12 @@ function installPiSubagentsExtension(
// see `PendingUsagePool`. Skipped entirely when the feature is off, so no // see `PendingUsagePool`. Skipped entirely when the feature is off, so no
// pool grows in a session that will never drain it. // pool grows in a session that will never drain it.
if (reportUsage) pendingUsage.add(usage); if (reportUsage) pendingUsage.add(usage);
}, { mandatoryExtensionPaths: options.mandatoryExtensionPaths }); }, {
mandatoryExtensionPaths: options.mandatoryExtensionPaths,
excludedExtensionPaths: options.excludedExtensionPaths,
basePrompt: options.basePrompt,
bundledAgentsDir: options.bundledAgentsDir,
});
// Expose manager via Symbol.for() global registry for cross-package access. // Expose manager via Symbol.for() global registry for cross-package access.
// Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.). // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
+1 -1
View File
@@ -6,7 +6,7 @@ export interface MandatoryExtensionPolicy {
mandatoryExtensionPaths?: readonly string[]; mandatoryExtensionPaths?: readonly string[];
} }
function canonicalExistingPath(path: string): string { export function canonicalExistingPath(path: string): string {
if (!isAbsolute(path)) { if (!isAbsolute(path)) {
throw new Error(`Mandatory extension path must be absolute: ${path}`); throw new Error(`Mandatory extension path must be absolute: ${path}`);
} }
+7 -2
View File
@@ -15,7 +15,7 @@ import {
resolveEnabledTypeIn, resolveEnabledTypeIn,
resolveTypeIn, resolveTypeIn,
} from "./agent-types.js"; } from "./agent-types.js";
import { loadCustomAgents } from "./custom-agents.js"; import { loadBundledAgents, loadCustomAgents } from "./custom-agents.js";
import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js"; import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
import { resolveModel } from "./model-resolver.js"; import { resolveModel } from "./model-resolver.js";
import { checkModelScope } from "./model-scope.js"; import { checkModelScope } from "./model-scope.js";
@@ -100,6 +100,8 @@ export interface NestedToolContext {
allowedSubagents: "all" | string[]; allowedSubagents: "all" | string[];
/** Root used for agent/config discovery; may differ from the agent's working directory. */ /** Root used for agent/config discovery; may differ from the agent's working directory. */
configCwd: string; configCwd: string;
/** Bundle-shipped agent .md directory — registered as embedded defaults, like the top-level registry. */
bundledAgentsDir?: string;
} }
function textResult(text: string, isError = false) { function textResult(text: string, isError = false) {
@@ -145,7 +147,10 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
// Agents resolve from a registry built for THIS branch's config root (under // Agents resolve from a registry built for THIS branch's config root (under
// worktree isolation, the copy). Never via registerAgents — that is // worktree isolation, the copy). Never via registerAgents — that is
// process-global state shared with the main session and every other agent. // process-global state shared with the main session and every other agent.
const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd)); const loadRegistry = () => buildAgentRegistry(
loadCustomAgents(context.configCwd),
context.bundledAgentsDir === undefined ? undefined : loadBundledAgents(context.bundledAgentsDir),
);
const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined => const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
context.allowedSubagents === "all" context.allowedSubagents === "all"
? undefined ? undefined
+33 -4
View File
@@ -32,8 +32,18 @@ export interface PromptExtras {
* parent prompt forms an identical, cacheable byte prefix with the parent * parent prompt forms an identical, cacheable byte prefix with the parent
* session (the LLM's KV cache can then reuse those tokens across every spawn). * session (the LLM's KV cache can then reuse those tokens across every spawn).
* *
* A non-blank `base` (the `basePrompt` extension option, resolved per spawn)
* owns the child's prompt base instead of the inherited parent prompt: the
* `<sub_agent_context>` bridge and the replace-mode header are omitted, while
* the env/worktree blocks, agent-instructions section and extras keep their
* usual positions. Without an inherited prefix to keep cacheable, the
* active_agent tag leads the prompt in both modes — same as upstream replace
* mode — so session hooks can identify the child before before_agent_start.
* Undefined/blank keeps upstream composition byte-for-byte.
*
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode). * @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
* @param extras Optional extra sections to inject (memory, preloaded skills). * @param extras Optional extra sections to inject (memory, preloaded skills).
* @param base Host-owned base prompt that replaces the inherited parent prompt when non-blank.
*/ */
export function buildAgentPrompt( export function buildAgentPrompt(
config: AgentConfig, config: AgentConfig,
@@ -41,6 +51,7 @@ export function buildAgentPrompt(
env: EnvInfo, env: EnvInfo,
parentSystemPrompt?: string, parentSystemPrompt?: string,
extras?: PromptExtras, extras?: PromptExtras,
base?: string,
): string { ): string {
const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`; const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
@@ -72,7 +83,22 @@ Work only inside it — never in ${extras.worktreeBase}, even if other instructi
} }
const extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : ""; const extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : "";
const bundleBase = base?.trim();
if (config.promptMode === "append") { if (config.promptMode === "append") {
const customSection = config.systemPrompt?.trim()
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
: "";
if (bundleBase) {
// Host-owned base: replaces both the inherited parent prompt and the
// sub_agent_context bridge (the bundle preamble already encodes how a
// sub-agent works). With no parent prompt to keep cacheable, the tag
// leads the prompt — same position as replace mode — so early session
// hooks can spot the child before before_agent_start.
return activeAgentTag + bundleBase + "\n\n" + envBlock + worktreeBlock + customSection + extrasSuffix;
}
const identity = parentSystemPrompt || genericBase; const identity = parentSystemPrompt || genericBase;
const bridge = `<sub_agent_context> const bridge = `<sub_agent_context>
@@ -88,10 +114,6 @@ You are operating as a sub-agent invoked to handle a specific task.
- Be concise but complete - Be concise but complete
</sub_agent_context>`; </sub_agent_context>`;
const customSection = config.systemPrompt?.trim()
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
: "";
// Place shared/stable content first so the LLM's KV cache can reuse the // Place shared/stable content first so the LLM's KV cache can reuse the
// inherited prefix across all subagent invocations. The parent prompt is // inherited prefix across all subagent invocations. The parent prompt is
// placed verbatim (no wrapper tag) so it forms an identical byte prefix // placed verbatim (no wrapper tag) so it forms an identical byte prefix
@@ -101,6 +123,13 @@ You are operating as a sub-agent invoked to handle a specific task.
} }
// "replace" mode — env header + the config's full system prompt // "replace" mode — env header + the config's full system prompt
if (bundleBase) {
// Host-owned base: drops the "You are a pi coding agent sub-agent" header
// (it duplicates the base's own role statement); env/worktree stay.
const configSection = config.systemPrompt?.trim() ? `\n\n${config.systemPrompt}` : "";
return activeAgentTag + bundleBase + "\n\n" + envBlock + worktreeBlock + configSection + extrasSuffix;
}
const replaceHeader = `You are a pi coding agent sub-agent. const replaceHeader = `You are a pi coding agent sub-agent.
You have been invoked to handle a specific task autonomously. You have been invoked to handle a specific task autonomously.
+142
View File
@@ -1763,6 +1763,148 @@ describe("mandatory extension policy", () => {
}); });
}); });
describe("excluded extension paths", () => {
function excludedEntry(name = "pi-ask-user"): { dir: string; path: string } {
const dir = mkdtempSync(join(tmpdir(), "subagents-excluded-runner-"));
const extDir = join(dir, name);
mkdirSync(extDir, { recursive: true });
const path = join(extDir, "index.ts");
writeFileSync(path, "export default () => {};\n");
return { dir, path };
}
function setup(extensions: true | string[] | false, excludeExtensions?: string[]) {
vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions, excludeExtensions }));
vi.mocked(getAgentConfig).mockReturnValueOnce(
makeAgentConfig({ extensions, excludeExtensions }),
);
vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7);
}
it("removes the extension under extensions:true, tools and all", async () => {
const fixture = excludedEntry();
try {
setup(true);
withExtensions({
[fixture.path]: ["ask_user_question"],
"/ext/other.ts": ["other_tool"],
});
const { session } = createSession("OK");
createAgentSession.mockResolvedValue({ session });
const onToolActivity = vi.fn();
await runAgent(ctx, "Explore", "go", {
pi,
excludedExtensionPaths: [fixture.path],
onToolActivity,
});
// Exclusion forces the override on even though extensions:true + no
// frontmatter excludes would otherwise skip it.
expect(lastLoaderOpts().extensionsOverride).toBeDefined();
expect(loaderExtensionsRef.current.extensions.map((e) => e.path))
.not.toContain(fixture.path);
const tools = lastToolsPassed();
expect(tools).not.toContain("ask_user_question");
expect(tools).toContain("other_tool");
expect(onToolActivity).toHaveBeenCalledWith(expect.objectContaining({
toolName: expect.stringContaining("excluded from subagent sessions"),
}));
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
it("removes the extension even when an `extensions:` name entry selects it", async () => {
const fixture = excludedEntry();
try {
setup(["pi-ask-user"]);
withExtensions({ [fixture.path]: ["ask_user_question"] });
const { session } = createSession("OK");
createAgentSession.mockResolvedValue({ session });
const onToolActivity = vi.fn();
await runAgent(ctx, "Explore", "go", {
pi,
excludedExtensionPaths: [fixture.path],
onToolActivity,
});
expect(loaderExtensionsRef.current.extensions.map((e) => e.path))
.not.toContain(fixture.path);
expect(lastToolsPassed()).not.toContain("ask_user_question");
expect(onToolActivity).toHaveBeenCalledWith(expect.objectContaining({
toolName: expect.stringContaining("excluded from subagent sessions"),
}));
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
it("removes the extension even when an `extensions:` path entry requests it", async () => {
const fixture = excludedEntry();
try {
setup([fixture.path]);
withExtensions({ [fixture.path]: ["ask_user_question"] });
const { session } = createSession("OK");
createAgentSession.mockResolvedValue({ session });
await runAgent(ctx, "Explore", "go", {
pi,
excludedExtensionPaths: [fixture.path],
});
expect(lastLoaderOpts().additionalExtensionPaths).toBeUndefined();
expect(loaderExtensionsRef.current.extensions.map((e) => e.path))
.not.toContain(fixture.path);
expect(lastToolsPassed()).not.toContain("ask_user_question");
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
it("is a no-op when empty — no override installed for wildcard agents", async () => {
setup(["*"]);
withExtensions({ "/ext/a.ts": ["tool_a"] });
const { session } = createSession("OK");
createAgentSession.mockResolvedValue({ session });
await runAgent(ctx, "Explore", "go", { pi, excludedExtensionPaths: [] });
expect(lastLoaderOpts().extensionsOverride).toBeUndefined();
expect(lastToolsPassed()).toContain("tool_a");
});
it("keeps mandatory entries while removing excluded ones", async () => {
const mandatoryDir = mkdtempSync(join(tmpdir(), "subagents-mandatory-runner-"));
const mandatoryPath = join(mandatoryDir, "permission-system.ts");
writeFileSync(mandatoryPath, "export default () => {};\n");
const fixture = excludedEntry();
try {
setup(true);
withExtensions({
[mandatoryPath]: ["permission_admin"],
[fixture.path]: ["ask_user_question"],
});
const { session } = createSession("OK");
createAgentSession.mockResolvedValue({ session });
await runAgent(ctx, "Explore", "go", {
pi,
mandatoryExtensionPaths: [mandatoryPath],
excludedExtensionPaths: [fixture.path],
});
const loaded = loaderExtensionsRef.current.extensions.map((e) => e.path);
expect(loaded).toContain(mandatoryPath);
expect(loaded).not.toContain(fixture.path);
} finally {
rmSync(mandatoryDir, { recursive: true, force: true });
rmSync(fixture.dir, { recursive: true, force: true });
}
});
});
describe("agent-runner extension allowlist", () => { describe("agent-runner extension allowlist", () => {
function setupArrayAgent(extensions: string[]) { function setupArrayAgent(extensions: string[]) {
vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions })); vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions }));
+147
View File
@@ -0,0 +1,147 @@
/**
* bundled-agents.test.ts — `bundledAgentsDir` extension option: bundle-shipped
* agent .md files register as embedded defaults (isDefault, no source/path),
* sit between embedded defaults and user agents in precedence, and stay out of
* the registry when the option is unset.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildAgentRegistry, getAgentConfig, getDefaultAgentNames, registerAgents } from "../src/agent-types.js";
import { loadBundledAgents } from "../src/custom-agents.js";
import { createPiSubagentsExtension } from "../src/index.js";
let tmpDir: string;
let bundledDir: string;
let originalHome: string | undefined;
let originalAgentDir: string | undefined;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "pi-bundled-agents-"));
bundledDir = join(tmpDir, "bundled");
mkdirSync(bundledDir, { recursive: true });
originalHome = process.env.HOME;
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.HOME = join(tmpDir, "home");
delete process.env.PI_CODING_AGENT_DIR;
});
afterEach(() => {
if (originalHome == null) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = originalAgentDir;
rmSync(tmpDir, { recursive: true, force: true });
registerAgents(new Map());
});
function writeBundled(name: string, content: string) {
writeFileSync(join(bundledDir, `${name}.md`), content);
}
describe("loadBundledAgents", () => {
it("parses agent files with the custom-agent parser and stamps them as built-ins", () => {
writeBundled("scout", `---
name: scout
description: Bundle scout
prompt_mode: append
tools: read, grep
---
Bundle prompt body.`);
const agents = loadBundledAgents(bundledDir);
const scout = agents.get("scout");
expect(scout).toBeDefined();
expect(scout?.description).toBe("Bundle scout");
expect(scout?.systemPrompt).toBe("Bundle prompt body.");
expect(scout?.promptMode).toBe("append");
expect(scout?.builtinToolNames).toEqual(["read", "grep"]);
expect(scout?.isDefault).toBe(true);
expect(scout?.source).toBeUndefined();
expect(scout?.sourcePath).toBeUndefined();
});
it("returns an empty map for a missing or empty directory", () => {
expect(loadBundledAgents(join(tmpDir, "missing")).size).toBe(0);
expect(loadBundledAgents(bundledDir).size).toBe(0);
});
it("skips non-.md files and reserved ':' names", () => {
writeBundled("fine", "---\ndescription: ok\n---\nbody");
writeFileSync(join(bundledDir, "notes.txt"), "not an agent");
writeBundled("bad", "---\nname: 'plugin:scout'\ndescription: bad\n---\nbody");
const agents = loadBundledAgents(bundledDir);
expect([...agents.keys()]).toEqual(["fine"]);
});
});
describe("bundled agent precedence", () => {
it("replaces a same-name embedded default and reports as a default", () => {
writeBundled("Explore", `---
name: Explore
description: Bundled Explore override
prompt_mode: replace
---
Bundled Explore body.`);
const bundled = loadBundledAgents(bundledDir);
const registry = buildAgentRegistry(new Map(), bundled);
expect(registry.get("Explore")?.systemPrompt).toBe("Bundled Explore body.");
expect(registry.get("Explore")?.isDefault).toBe(true);
// Unrelated embedded defaults are untouched.
expect(registry.get("general-purpose")?.systemPrompt).not.toBe("Bundled Explore body.");
});
it("new bundled names join the default set", () => {
writeBundled("bundle-only", "---\ndescription: only in bundle\n---\nbody");
const registry = buildAgentRegistry(new Map(), loadBundledAgents(bundledDir));
expect(registry.get("bundle-only")?.isDefault).toBe(true);
registerAgents(new Map(), loadBundledAgents(bundledDir));
expect(getDefaultAgentNames()).toContain("bundle-only");
expect(getAgentConfig("bundle-only")?.description).toBe("only in bundle");
});
it("user agents still override bundled definitions", () => {
writeBundled("scout", "---\ndescription: bundled\n---\nbundled body");
const user = new Map([
["scout", {
name: "scout",
description: "project override",
systemPrompt: "project body",
promptMode: "replace" as const,
extensions: true as const,
skills: true as const,
source: "project" as const,
sourcePath: "/proj/.pi/agents/scout.md",
}],
]);
const registry = buildAgentRegistry(user, loadBundledAgents(bundledDir));
expect(registry.get("scout")?.description).toBe("project override");
expect(registry.get("scout")?.isDefault).not.toBe(true);
});
});
describe("extension option validation", () => {
it("rejects a path that is both mandatory and excluded", () => {
const dir = mkdtempSync(join(tmpdir(), "pi-subagents-overlap-"));
const path = join(dir, "permission-system.ts");
writeFileSync(path, "export default () => {};\n");
expect(() =>
createPiSubagentsExtension({
mandatoryExtensionPaths: [path],
excludedExtensionPaths: [path],
}),
).toThrow("both mandatory and excluded");
rmSync(dir, { recursive: true, force: true });
});
it("canonicalizes bundledAgentsDir and rejects missing directories", () => {
expect(() =>
createPiSubagentsExtension({ bundledAgentsDir: join(tmpDir, "missing") }),
).toThrow("is unavailable");
expect(() => createPiSubagentsExtension({ bundledAgentsDir: bundledDir })).not.toThrow();
});
});
+112
View File
@@ -433,4 +433,116 @@ describe("buildAgentPrompt", () => {
expect(prompt.indexOf("<worktree_isolation>")).toBeGreaterThan(prompt.indexOf("<sub_agent_context>")); expect(prompt.indexOf("<worktree_isolation>")).toBeGreaterThan(prompt.indexOf("<sub_agent_context>"));
}); });
}); });
describe("host-owned base prompt", () => {
const BASE = "You are a subagent operating inside pi. The human user does not see your messages.";
function baseConfig(promptMode: "append" | "replace", systemPrompt = "Custom instructions."): AgentConfig {
return {
name: "bundled-agent",
description: "Bundled",
builtinToolNames: [],
extensions: true,
skills: true,
systemPrompt,
promptMode,
inheritContext: false,
runInBackground: false,
isolated: false,
};
}
it("append mode: replaces parent prompt and drops the bridge, keeps env/instructions/tag", () => {
const prompt = buildAgentPrompt(
baseConfig("append"),
"/workspace",
env,
"SECRET parent prompt content",
undefined,
BASE,
);
// No inherited parent prefix to keep cacheable, so the tag leads the
// prompt — same position as replace mode — and lets early session hooks
// identify the child before before_agent_start.
expect(prompt.startsWith('<active_agent name="bundled-agent"/>')).toBe(true);
expect(prompt).not.toContain("SECRET parent prompt content");
expect(prompt).not.toContain("<sub_agent_context>");
expect(prompt).not.toContain("You are a pi coding agent sub-agent");
expect(prompt).toContain("Working directory: /workspace");
expect(prompt).toContain("<agent_instructions>");
expect(prompt).toContain("Custom instructions.");
expect(prompt.match(/<active_agent name="bundled-agent"\/>/g)).toHaveLength(1);
const order = [
'<active_agent name="bundled-agent"/>',
BASE,
"# Environment",
"<agent_instructions>",
].map((s) => prompt.indexOf(s));
expect(order.every((i) => i >= 0)).toBe(true);
expect(order).toEqual([...order].sort((a, b) => a - b));
});
it("replace mode: drops the sub-agent header, keeps env + config prompt + tag", () => {
const prompt = buildAgentPrompt(
baseConfig("replace", "You are a read-only search agent."),
"/workspace",
env,
"SECRET parent prompt content",
undefined,
BASE,
);
expect(prompt).not.toContain("You are a pi coding agent sub-agent");
expect(prompt).not.toContain("SECRET parent prompt content");
expect(prompt).not.toContain("<sub_agent_context>");
expect(prompt).toContain("Working directory: /workspace");
expect(prompt).toContain("You are a read-only search agent.");
expect(prompt.match(/<active_agent name="bundled-agent"\/>/g)).toHaveLength(1);
// The tag leads, then base, then env, then the config's system prompt.
const order = [
'<active_agent name="bundled-agent"/>',
BASE,
"# Environment",
"You are a read-only search agent.",
].map((s) => prompt.indexOf(s));
expect(order.every((i) => i >= 0)).toBe(true);
expect(order).toEqual([...order].sort((a, b) => a - b));
});
it("keeps worktree and extras blocks", () => {
const prompt = buildAgentPrompt(
baseConfig("append"),
"/wt/copy",
env,
undefined,
{ worktreeBase: "/repo", memoryBlock: "# Agent Memory\nRemember this." },
BASE,
);
expect(prompt).toContain("isolated git worktree copy of /repo");
expect(prompt).toContain("# Agent Memory");
expect(prompt).not.toContain("<sub_agent_context>");
});
it("empty agent systemPrompt yields no agent_instructions in append mode", () => {
const prompt = buildAgentPrompt(
baseConfig("append", ""),
"/workspace",
env,
"Parent.",
undefined,
BASE,
);
expect(prompt).not.toContain("<agent_instructions>");
});
it("blank base keeps upstream composition byte-for-byte", () => {
for (const promptMode of ["append", "replace"] as const) {
const config = baseConfig(promptMode);
for (const blank of [undefined, "", " \n "]) {
expect(buildAgentPrompt(config, "/workspace", env, "Parent.", undefined, blank)).toBe(
buildAgentPrompt(config, "/workspace", env, "Parent."),
);
}
}
});
});
}); });
+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 repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const hasPi = spawnSync("pi", ["--version"], { encoding: "utf8" }).status === 0; 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 { const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as {
dependencies: Record<string, string>; dependencies: Record<string, string>;
files: string[]; files: string[];
pi: { extensions: string[] }; pi: { extensions: string[] };
}; };
assert.ok(packageJson.files.includes("pi-subagents"), "the bundle must retain the maintained subagents source"); assert.ok(packageJson.files.includes("pi-subagents"));
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], undefined); assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents");
assert.equal( assert.ok(
packageJson.pi.extensions.includes("./extensions/subagents.ts"), packageJson.pi.extensions.includes("./extensions/subagents.ts"),
false, "the bundle must default-load the locally maintained subagents wrapper",
"the bundle must not default-load the disabled 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 () => { 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_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.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.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.path, undefined, "sensitive paths must not be hard-denied by default");
assert.equal(hardGuardConfig.permission.bash["rm -rf /"], "deny"); assert.equal(hardGuardConfig.permission.bash["rm -rf /"], "deny");
assert.equal( 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", "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["@ogulcancelik/pi-minimal-footer"], "file:./pi-minimal-footer");
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], undefined); assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents");
assert.equal(packageJson.pi.extensions.includes("./extensions/subagents.ts"), false); 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( assert.ok(
packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"), packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"),
"the bundle must load the locally maintained Hermes memory extension", "the bundle must load the locally maintained Hermes memory extension",
+158 -1
View File
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
import { import {
applyToolCatalog, applyToolCatalog,
isSubagentCustomPrompt,
loadRoutingRules, loadRoutingRules,
loadSectionsConfig, loadSectionsConfig,
loadToolCatalog, loadToolCatalog,
@@ -18,6 +19,8 @@ import {
resolvePreamblePath, resolvePreamblePath,
spliceCover, spliceCover,
stripJsoncComments, stripJsoncComments,
validateRoutingConfig,
validateSectionsConfig,
type MutablePromptOptions, type MutablePromptOptions,
type RoutingRule, type RoutingRule,
} from "../extensions/system-prompt-config.ts"; } from "../extensions/system-prompt-config.ts";
@@ -74,6 +77,40 @@ test("config/prompt files load with the expected shapes", () => {
"chrome_profile_bridge", "chrome_profile_bridge",
"tool_routing", "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/", () => { 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"]), ""); 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", () => { test("bundled routing rules select the same guidance the retired tool-routing extension emitted", () => {
const routing = loadRoutingRules(); const routing = loadRoutingRules();
@@ -275,12 +346,18 @@ test("spliceCover degenerates to the preamble when the prompt has no sections",
interface FakePromptOptions extends MutablePromptOptions { interface FakePromptOptions extends MutablePromptOptions {
forceSystemPrompt?: string; forceSystemPrompt?: string;
customPrompt?: string;
} }
/** Simplified re-render of Pi's prompt from the mutable options object. */ /** Simplified re-render of Pi's prompt from the mutable options object. */
function renderPiPrompt(options: FakePromptOptions): string { function renderPiPrompt(options: FakePromptOptions): string {
if (options.forceSystemPrompt !== undefined) return options.forceSystemPrompt; 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 ?? {}); const snippets = Object.entries(options.toolSnippets ?? {});
if (snippets.length > 0) { if (snippets.length > 0) {
parts.push(`<tools>\n${snippets.map(([n, s]) => `- ${n}: ${s}`).join("\n")}\n</tools>`); 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", () => { test("composer falls back to text appends when the host exposes no prompt options", () => {
const composer = createSystemPromptComposer(); const composer = createSystemPromptComposer();
const { ctx } = makeUiCtx(); const { ctx } = makeUiCtx();