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
+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.
> **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
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.
+19 -1
View File
@@ -237,7 +237,14 @@ async function shutdownChildSession(session: AgentSession | undefined): Promise<
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 {
private agents = new Map<string, AgentRecord>();
@@ -248,6 +255,9 @@ export class AgentManager {
private onUsage?: OnAgentUsage;
private maxConcurrent: number;
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,
* not just the parent repo (caller-supplied cwd can target other repos). */
private worktreeRepos = new Set<string>();
@@ -281,6 +291,11 @@ export class AgentManager {
this.mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(
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)
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
this.cleanupInterval.unref();
@@ -442,6 +457,9 @@ export class AgentManager {
// rootSessionId; programmatic callers fall back to their current context.
authoritySessionId: record.rootSessionId ?? ctx.sessionManager?.getSessionId?.(),
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 —
// 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
+44 -9
View File
@@ -24,6 +24,7 @@ import { DEFAULT_AGENTS } from "./default-agents.js";
import { detectEnv } from "./env.js";
import {
assertMandatoryExtensionsLoaded,
extensionPathMatches,
isMandatoryExtensionPath,
normalizeMandatoryExtensionPaths,
} from "./mandatory-extensions.js";
@@ -432,6 +433,21 @@ export interface RunOptions {
authoritySessionId?: string;
/** Canonical bundle-owned extension entries that Agent config cannot disable. */
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). */
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;
if (agentConfig) {
systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras);
systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras, basePrompt);
} else {
// Unknown type fallback: spread the canonical general-purpose config (defensive —
// unreachable in practice since index.ts resolves unknown types before calling runAgent).
const fallback = DEFAULT_AGENTS.get("general-purpose");
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,
// which is also where pi-permission-system reads `permission:` frontmatter.
@@ -701,6 +719,11 @@ export async function runAgent(
);
const optionalExtensionsDisabled = extensions === false;
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 extensionsSpec = Array.isArray(extensions)
@@ -718,10 +741,12 @@ export async function runAgent(
if (canons.some((name) => excludeNames.has(name))) return false;
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 = [
...(extensionsSpec?.paths ?? []),
...mandatoryExtensionPaths,
];
].filter((path) => !isExcludedExtension(path));
const additionalExtensionPaths = requestedPaths.length
? [...new Set(requestedPaths)]
: undefined;
@@ -729,16 +754,25 @@ export async function runAgent(
// against discovery, while mandatory identity is checked by exact canonical path.
let discoveredNames: Set<string> | undefined;
const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
noExtensions || (loadAll && !hasExcludes)
(noExtensions || (loadAll && !hasExcludes)) && excludedExtensionPaths.length === 0
? undefined
: (base) => {
discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
return {
...base,
extensions: base.extensions.filter((extension) =>
isMandatoryExtensionPath(extension.path, mandatoryExtensionPaths)
|| normalPolicySelects(extension.path)
),
extensions: base.extensions.filter((extension) => {
if (isExcludedExtension(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,
allowedSubagents: agentConfig.allowedSubagents,
configCwd,
bundledAgentsDir: options.bundledAgentsDir,
})
: [];
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),
* 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
* 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>();
if (!disableDefaults) {
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);
return registry;
@@ -70,12 +76,16 @@ export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<st
/**
* 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.
*/
export function registerAgents(userAgents: Map<string, AgentConfig>): void {
export function registerAgents(
userAgents: Map<string, AgentConfig>,
bundledAgents?: Map<string, AgentConfig>,
): void {
agents.clear();
for (const [name, config] of buildAgentRegistry(userAgents)) {
for (const [name, config] of buildAgentRegistry(userAgents, bundledAgents)) {
agents.set(name, config);
}
}
+114 -56
View File
@@ -79,66 +79,124 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
}
const { frontmatter: fm, body } = parsed;
// 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.
const declared = str(fm.name)?.trim();
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(
`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.",
);
// No `warnSkippedOverride`: this file would have registered under its
// *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
// register the agent under the empty type — unspawnable, and it takes the
// filename-derived one down with it.
const name = declared || filenameType;
// A reserved-name skip carries no `warnSkippedOverride`: this file would
// have registered under its *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.
const name = resolveDeclaredName(path, filenameType, fm);
if (name === undefined) continue;
const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
agents.set(name, {
name,
// 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
// with `name: code-reviewer` still badges as "code-reviewer".
displayName: str(fm.display_name),
color: str(fm.color),
description: str(fm.description) ?? name,
builtinToolNames,
extSelectors,
disallowedTools: csvListOptional(fm.disallowed_tools),
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
excludeExtensions: csvListOptional(fm.exclude_extensions),
skills: inheritField(fm.skills ?? fm.inherit_skills),
model: str(fm.model),
thinking: str(fm.thinking) as ThinkingLevel | undefined,
maxTurns: nonNegativeInt(fm.max_turns),
persistSession: fm.persist_session != null ? fm.persist_session === true : undefined,
outputTranscript: fm.output_transcript != null ? fm.output_transcript !== false : undefined,
sessionDir: str(fm.session_dir),
allowedSubagents: parseAllowedSubagents(fm.allowed_subagents),
systemPrompt: body.trim(),
promptMode: fm.prompt_mode === "append" ? "append" : "replace",
inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
isolated: fm.isolated != null ? fm.isolated === true : undefined,
memory: parseMemory(fm.memory),
isolation: parseIsolation(fm.isolation),
enabled: fm.enabled !== false, // default true; explicitly false disables
source,
sourcePath: path,
});
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();
if (declared?.includes(RESERVED_IN_TYPE)) {
warnIfNew(
`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.",
);
return undefined;
}
return 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);
return {
name,
// 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
// with `name: code-reviewer` still badges as "code-reviewer".
displayName: str(fm.display_name),
color: str(fm.color),
description: str(fm.description) ?? name,
builtinToolNames,
extSelectors,
disallowedTools: csvListOptional(fm.disallowed_tools),
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
excludeExtensions: csvListOptional(fm.exclude_extensions),
skills: inheritField(fm.skills ?? fm.inherit_skills),
model: str(fm.model),
thinking: str(fm.thinking) as ThinkingLevel | undefined,
maxTurns: nonNegativeInt(fm.max_turns),
persistSession: fm.persist_session != null ? fm.persist_session === true : undefined,
outputTranscript: fm.output_transcript != null ? fm.output_transcript !== false : undefined,
sessionDir: str(fm.session_dir),
allowedSubagents: parseAllowedSubagents(fm.allowed_subagents),
systemPrompt: body.trim(),
promptMode: fm.prompt_mode === "append" ? "append" : "replace",
inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
isolated: fm.isolated != null ? fm.isolated === true : undefined,
memory: parseMemory(fm.memory),
isolation: parseIsolation(fm.isolation),
enabled: fm.enabled !== false, // default true; explicitly false disables
isDefault: provenance === undefined ? true : undefined,
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;
}
/**
* Read and parse one agent file, or warn and return undefined for the caller to
* skip. One bad file must not take the whole extension down with it — an
+56 -8
View File
@@ -18,15 +18,15 @@ import { Type } from "@sinclair/typebox";
import { abortable } from "./abortable.js";
import { hasAgentBadge, renderAgentName } from "./agent-color.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 { 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 { 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 { 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 { runMentionClone } from "./mention-clone.js";
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
@@ -281,13 +281,53 @@ export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
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. */
export function createPiSubagentsExtension(
options: PiSubagentsExtensionOptions = {},
): (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();
@@ -371,10 +411,13 @@ function installPiSubagentsExtension(
// the initial load, which happens hundreds of lines before settings are applied.
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 bundledAgents = options.bundledAgentsDir === undefined
? undefined
: loadBundledAgents(options.bundledAgentsDir, 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
@@ -599,7 +642,12 @@ function installPiSubagentsExtension(
// see `PendingUsagePool`. Skipped entirely when the feature is off, so no
// pool grows in a session that will never drain it.
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.
// 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[];
}
function canonicalExistingPath(path: string): string {
export function canonicalExistingPath(path: string): string {
if (!isAbsolute(path)) {
throw new Error(`Mandatory extension path must be absolute: ${path}`);
}
+7 -2
View File
@@ -15,7 +15,7 @@ import {
resolveEnabledTypeIn,
resolveTypeIn,
} 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 { resolveModel } from "./model-resolver.js";
import { checkModelScope } from "./model-scope.js";
@@ -100,6 +100,8 @@ export interface NestedToolContext {
allowedSubagents: "all" | string[];
/** Root used for agent/config discovery; may differ from the agent's working directory. */
configCwd: string;
/** Bundle-shipped agent .md directory — registered as embedded defaults, like the top-level registry. */
bundledAgentsDir?: string;
}
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
// worktree isolation, the copy). Never via registerAgents — that is
// 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 =>
context.allowedSubagents === "all"
? undefined
+33 -4
View File
@@ -32,8 +32,18 @@ export interface PromptExtras {
* 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).
*
* 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 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(
config: AgentConfig,
@@ -41,6 +51,7 @@ export function buildAgentPrompt(
env: EnvInfo,
parentSystemPrompt?: string,
extras?: PromptExtras,
base?: string,
): string {
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 bundleBase = base?.trim();
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 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
</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
// inherited prefix across all subagent invocations. The parent prompt is
// 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
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.
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", () => {
function setupArrayAgent(extensions: string[]) {
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>"));
});
});
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."),
);
}
}
});
});
});