mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat(pi-subagents): 重新启用子 Agent 并使用独立的子 Agent 提示词
- 根包恢复 file:./pi-subagents 依赖与 extensions/subagents.ts 加载 (排在 permission-system 之后、Tool Search 之前),并刷新根锁文件; Agent / get_subagent_result / steer_subagent 权限设为 allow, 子 Agent 内的实际操作仍逐个经权限系统并转发主会话。 - 新增 config/prompt/subagent/:执行者角色 preamble 与组合包自带的 general-purpose / Explore / Plan 定义(替换上游内置,去掉 bash find 冲突规则与写死的 Haiku 模型)。 - pi-subagents 新增 basePrompt / bundledAgentsDir / excludedExtensionPaths 选项:子 Agent 不再嵌入父会话提示词,身份标记置于开头;子会话排除 pi-ask-user。未传选项时保持上游行为。 - system-prompt.ts:customPrompt 自带开场白时不再覆盖(修复子 Agent 角色 与 <active_agent> 身份被抹掉);仅凭 customPrompt 上的 <active_agent> 识别子会话,routing 规则支持 scope,sections 支持 subagentExclude (子会话去掉 memory_policy 与 chrome_profile_bridge)。 - pi-hermes-memory:子会话只读,记忆/skill 写入工具返回错误,后台整理、 纠错检测、会话 flush 与索引写入全部跳过。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
b817d66a11
commit
360d8cffc8
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.).
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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."),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user