Files
my-pi/pi-subagents/src/agent-runner.ts
T

1226 lines
53 KiB
TypeScript

/**
* agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
*/
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import type { Model } from "@earendil-works/pi-ai";
import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
import {
type AgentSession,
type AgentSessionEvent,
createAgentSession,
DefaultResourceLoader,
type ExtensionAPI,
getAgentDir,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
import { runInChildSessionContext } from "./child-context.js";
import { buildParentContext, extractText } from "./context.js";
import { DEFAULT_AGENTS } from "./default-agents.js";
import { detectEnv } from "./env.js";
import {
assertMandatoryExtensionsLoaded,
isMandatoryExtensionPath,
normalizeMandatoryExtensionPaths,
} from "./mandatory-extensions.js";
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
import {
registerPermissionChildSession,
unregisterPermissionChildSession,
withActiveAgentIdentity,
} from "./permission-system-bridge.js";
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
import { preloadSkills } from "./skill-loader.js";
import type { SubagentType, ThinkingLevel } from "./types.js";
import type { LifetimeUsage } from "./usage.js";
/**
* Tool names registered by THIS extension. Single source of truth so the
* registration sites (index.ts) and the subagent exclusion list below can't
* drift apart. These are our own tools, not pi built-ins, so they can't be
* derived from pi — but they only need defining once.
*/
export const SUBAGENT_TOOL_NAMES = {
AGENT: "Agent",
GET_RESULT: "get_subagent_result",
STEER: "steer_subagent",
} as const;
/** Names of tools registered by this extension that subagents must NOT inherit. */
const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES);
/**
* Canonical name of an extension for `extensions: [...]` allowlist matching.
* Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
* resolves the same as `[mcp]`. Tool names within `ext:foo/bar` are not affected.
* Directory extensions (`foo/index.ts`) resolve to the parent directory name;
* single-file extensions to the basename minus `.ts`/`.js`.
*/
export function extensionCanonicalName(extPath: string): string {
const base = basename(extPath);
const name = base === "index.ts" || base === "index.js"
? basename(dirname(extPath))
: base.replace(/\.(ts|js)$/, "");
return name.toLowerCase();
}
/**
* The unscoped, lowercased npm short name of the pi package that DECLARES
* `extPath` as an extension entry — or undefined if the entry doesn't belong to
* such a package.
*
* Climbs from the entry's directory looking for the package that owns it, and
* stays strictly within that package's tree by stopping at two structural
* boundaries — no hardcoded depth:
* - the FIRST `package.json` found (the package root); the entry's own
* manifest always sits at the root, above the entry, below any node_modules.
* - a `node_modules` directory: a package never spans one (it's where OTHER
* packages live), so reaching it means we've climbed out of the package —
* stop before reading a consumer's or parent package's manifest.
* The name is then taken only when that root's `pi.extensions` manifest actually
* lists this entry. That "declares this entry" check is deliberate: our own test
* fixtures live under this repo, whose root manifest declares `./src/index.ts`
* as `@tintinweb/pi-subagents`, so a looser rule would misattribute every
* co-located file to `pi-subagents`.
*/
function extensionPackageName(extPath: string): string | undefined {
const entry = resolve(extPath);
let dir = dirname(extPath);
for (;;) {
// Climbing into node_modules means we've left the owning package's tree.
if (basename(dir) === "node_modules") return undefined;
let pkg: { name?: unknown; pi?: { extensions?: unknown } };
try {
pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
} catch {
const parent = dirname(dir);
if (parent === dir) return undefined; // walked to the filesystem root
dir = parent;
continue;
}
// First package.json wins — it's the package root; decide here.
const entries = pkg.pi?.extensions;
if (
typeof pkg.name === "string" &&
Array.isArray(entries) &&
entries.some((e) => typeof e === "string" && resolve(dir, e) === entry)
) {
const short = pkg.name.startsWith("@") ? pkg.name.slice(pkg.name.indexOf("/") + 1) : pkg.name;
return short.toLowerCase();
}
return undefined;
}
}
/**
* All names an extension answers to for allowlist matching (lowercased): its
* path-derived {@link extensionCanonicalName} plus, when a pi package manifest
* declares this entry, that package's unscoped short name (`@scope/foo` → `foo`).
* #143: an extension installed via `pi.extensions: ["./src/index.ts"]` would
* otherwise only ever match as `src` (the source directory), never by its
* package name. The path-derived name is preserved, so it keeps matching too.
*/
export function extensionCanonicalNames(extPath: string): string[] {
const canonical = extensionCanonicalName(extPath);
const pkg = extensionPackageName(extPath);
return pkg && pkg !== canonical ? [canonical, pkg] : [canonical];
}
/**
* Classify `extensions: string[]` frontmatter entries for the loader-level filter.
*
* An entry is a PATH iff it contains a path separator or starts with `~`; otherwise
* it is a NAME. `"*"` sets the wildcard flag (keep all default-discovered extensions).
*
* Path entries are resolved (`~` expanded, made absolute against `cwd`) into `paths`
* — and their canonical name is also added to `names`. The loader override matches
* everything by canonical name, so path-loaded extensions are matched via their name
* rather than their post-staging `Extension.path`.
*/
export function parseExtensionsSpec(
entries: string[],
cwd: string,
): { names: Set<string>; paths: string[]; wildcard: boolean } {
const names = new Set<string>();
const paths: string[] = [];
let wildcard = false;
for (const entry of entries) {
if (!entry) continue;
if (entry === "*") {
wildcard = true;
continue;
}
const isPathEntry = entry.includes("/") || entry.includes("\\") || entry.startsWith("~");
if (!isPathEntry) {
names.add(entry.toLowerCase());
continue;
}
let p = entry;
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
p = homedir() + p.slice(1);
}
const abs = isAbsolute(p) ? p : resolve(cwd, p);
paths.push(abs);
names.add(extensionCanonicalName(abs));
}
return { names, paths, wildcard };
}
/**
* Parse raw `ext:` selector strings (from the `tools:` CSV) into the set of
* extension names to keep loaded and a per-extension tool-narrowing map.
*
* `ext:foo` → `extNames` has `foo`, no narrowing entry (all of foo's tools).
* `ext:foo/bar` → `extNames` has `foo`, `narrowing.foo` has `bar` (only `bar`).
* A name lands in `narrowing` only when a `/tool` form is seen, so a bare
* `ext:foo` alongside `ext:foo/bar` leaves narrowing in effect (narrowing wins).
* The split is on the first `/`; extension canonical names never contain `/`.
*/
export function parseExtSelectors(entries: string[]): {
extNames: Set<string>;
narrowing: Map<string, Set<string>>;
} {
const extNames = new Set<string>();
const narrowing = new Map<string, Set<string>>();
for (const raw of entries) {
if (!raw) continue;
const body = raw.slice("ext:".length);
const slash = body.indexOf("/");
// Extension name matches case-insensitively (matches the loader-side canonical
// name). Tool names are case-preserved — they're matched against pi-mono's
// registered identifiers, which are case-sensitive.
const name = (slash === -1 ? body : body.slice(0, slash)).trim().toLowerCase();
if (!name) continue;
extNames.add(name);
if (slash === -1) continue;
const tool = body.slice(slash + 1).trim();
if (!tool) continue;
let set = narrowing.get(name);
if (!set) {
set = new Set();
narrowing.set(name, set);
}
set.add(tool);
}
return { extNames, narrowing };
}
/**
* Keep a subagent's tool scope correct as extensions register tools over time.
*
* Extensions may call `registerTool` long after load — pi-mcp from `session_start`,
* context-mode from `before_agent_start` — so scope has to be re-derived rather than
* snapshotted. `registerTool` writes into the very `extension.tools` maps this reads,
* so `inScope()` sees late arrivals on the next call.
*
* Two enforcement points, because neither covers the whole picture:
*
* - `turn_end` re-narrows the ACTIVE set. pi emits `turn_end` immediately before
* `prepareNextTurn` re-snapshots `agent.state.tools`, and session listeners run
* synchronously, so the narrow lands in time for turns 2..N.
* - `beforeToolCall` blocks out-of-scope calls. Turn 1 cannot be narrowed at all:
* `before_agent_start` fires INSIDE `prompt()` and may widen the tool set, but
* `createContextSnapshot()` freezes that turn's tools immediately after — there
* is no hook in between. A call-time check is the only correct guard there.
*
* Both are installed on the session and deliberately NOT unsubscribed: they must
* outlive the `runAgent` call so resumed/steered turns stay scoped. pi's `dispose()`
* clears `_eventListeners`, so they die with the session rather than leaking.
*
* Only meaningful when extensions are loaded — under `noExtensions`/`isolated` the
* static `allowedToolNames` allowlist already gates the registry itself.
*/
export function installExtensionToolScope(
session: AgentSession,
ctx: {
loader: DefaultResourceLoader;
toolNames: string[];
disallowedSet: Set<string> | undefined;
extNames: Set<string>;
narrowing: Map<string, Set<string>>;
/** Opt-in nested-delegation tool names to keep active despite the EXCLUDED strip. */
/** Loaded extensions whose tools Agent policy permits the LLM to use. */
toolEligibleExtensionPaths: ReadonlySet<string>;
nestedToolNames: Set<string>;
},
): void {
const { loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames, toolEligibleExtensionPaths } = ctx;
// The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
// selector is present, extension tools become an explicit allowlist — a loaded
// extension not named by a selector contributes nothing (its handlers still ran),
// and `ext:foo/bar` narrows `foo` to just `bar`.
const inScope = (): Set<string> => {
const keep = new Set(toolNames.filter((t) => !disallowedSet?.has(t)));
const optInActive = extNames.size > 0;
for (const extension of loader.getExtensions().extensions) {
// Mandatory means its handlers must bind; it does not implicitly expose
// tools that `extensions: false` or normal include/exclude policy hid.
if (!toolEligibleExtensionPaths.has(extension.path)) continue;
const canons = extensionCanonicalNames(extension.path);
if (optInActive && !canons.some((c) => extNames.has(c))) continue;
// First alias that carries a narrowing set — a user won't narrow one
// extension under two different names, so first-match is correct.
const narrowed = canons.map((c) => narrowing.get(c)).find(Boolean);
for (const name of extension.tools.keys()) {
if (narrowed && !narrowed.has(name)) continue;
if (disallowedSet?.has(name)) continue;
keep.add(name);
}
}
for (const name of EXCLUDED_TOOL_NAMES) keep.delete(name);
// Opt-in nested delegation tools share EXCLUDED_TOOL_NAMES' names but are
// legitimately active for this agent — re-admit them so the renarrow keeps
// them in the active set and beforeToolCall doesn't block them.
for (const name of nestedToolNames) {
if (!disallowedSet?.has(name)) keep.add(name);
}
return keep;
};
const renarrow = () => {
const allowed = inScope();
const next = session.getAllTools().map((t) => t.name).filter((n) => allowed.has(n));
const current = session.getActiveToolNames();
// setActiveToolsByName unconditionally rebuilds the system prompt, so skip
// the no-op that steady-state turns would otherwise pay for every turn.
if (next.length !== current.length || next.some((n, i) => n !== current[i])) {
session.setActiveToolsByName(next);
}
};
// Activate what registered during session_start (eager MCP servers); pi would
// otherwise leave only its four default built-ins active at turn 1.
renarrow();
session.subscribe((event: AgentSessionEvent) => {
if (event.type === "turn_end") renarrow();
});
const priorBeforeToolCall = session.agent.beforeToolCall;
session.agent.beforeToolCall = async (context, signal) => {
if (!inScope().has(context.toolCall.name)) {
return {
block: true,
reason: `Tool "${context.toolCall.name}" is not available to this subagent.`,
};
}
return priorBeforeToolCall?.(context, signal);
};
}
/** Default max turns. undefined = unlimited (no turn limit). */
let defaultMaxTurns: number | undefined;
/** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
export function normalizeMaxTurns(n: number | undefined): number | undefined {
if (n == null || n === 0) return undefined;
return Math.max(1, n);
}
/** Get the default max turns value. undefined = unlimited. */
export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; }
/** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = normalizeMaxTurns(n); }
/**
* The turn limit a run of `type` will actually enforce: an explicit value if the
* caller supplied one, else the agent's own `max_turns`, else the project
* default. `undefined` = unlimited.
*
* Exported because the widget's turn counter (`↻3≤20`) has to predict this
* before the run starts, and a second copy of the expression would drift from
* the one below that enforces it.
*/
export function resolveEffectiveMaxTurns(type: string, explicit?: number): number | undefined {
return normalizeMaxTurns(explicit ?? getAgentConfig(type)?.maxTurns ?? defaultMaxTurns);
}
/**
* Project default for `persist_session`, from the `rememberAgents` setting.
* On by default: a persisted session is what lets `@handle` reopen an agent's
* conversation after its record has been evicted, which is the whole point of
* addressing an agent by a name that outlives one run. Per-agent frontmatter
* still overrides it in both directions.
*/
let rememberAgents = true;
/** Whether subagent sessions are persisted by default. */
export function getRememberAgents(): boolean { return rememberAgents; }
/** Set whether subagent sessions are persisted by default. */
export function setRememberAgents(b: boolean): void { rememberAgents = b; }
/** Additional turns allowed after the soft limit steer message. */
let graceTurns = 5;
/** Get the grace turns value. */
export function getGraceTurns(): number { return graceTurns; }
/** Set the grace turns value (minimum 1). */
export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); }
/**
* Try to find the right model for an agent type.
* Priority: explicit option > config.model > parent model.
*/
export function resolveDefaultModel(
parentModel: Model<any> | undefined,
registry: { find(provider: string, modelId: string): Model<any> | undefined; getAvailable?(): Model<any>[] },
configModel?: string,
): Model<any> | undefined {
if (configModel) {
const slashIdx = configModel.indexOf("/");
if (slashIdx !== -1) {
const provider = configModel.slice(0, slashIdx);
const modelId = configModel.slice(slashIdx + 1);
// Build a set of available model keys for fast lookup
const available = registry.getAvailable?.();
const availableKeys = available
? new Set(available.map((m: any) => `${m.provider}/${m.id}`))
: undefined;
const isAvailable = (p: string, id: string) =>
!availableKeys || availableKeys.has(`${p}/${id}`);
const found = registry.find(provider, modelId);
if (found && isAvailable(provider, modelId)) return found;
}
}
return parentModel;
}
/** Info about a tool event in the subagent. */
export interface ToolActivity {
type: "start" | "end";
toolName: string;
}
export interface RunOptions {
/** ExtensionAPI instance — used for pi.exec() instead of execSync. */
pi: ExtensionAPI;
/** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
agentId?: string;
model?: Model<any>;
maxTurns?: number;
signal?: AbortSignal;
isolated?: boolean;
inheritContext?: boolean;
thinkingLevel?: ThinkingLevel;
/**
* Reopen this pi session file rather than starting an empty conversation.
* `createAgentSession` seeds itself from whatever its SessionManager holds,
* so pointing it at an existing file rehydrates that agent's history and the
* prompt continues it. Everything else — tools, model, system prompt, turn
* caps — is still resolved from the agent type, so the continuation runs
* under the type's *current* definition, not the one the original run used.
*/
resumeSessionFile?: string;
/**
* True when another agent spawned this one. Only top-level agents get a
* handle, so only they can be reopened by name — which is the whole reason
* `rememberAgents` persists a session at all. A nested run's transcript would
* be unreachable by anything, so it stays in memory unless its own
* frontmatter asks otherwise.
*/
nested?: boolean;
/** Interactive root session that serves permission requests for this branch. */
authoritySessionId?: string;
/** Canonical bundle-owned extension entries that Agent config cannot disable. */
mandatoryExtensionPaths?: readonly string[];
/** Override working directory (e.g. for worktree isolation). */
cwd?: string;
/**
* Directory the worktree copy was created from. Set only when `cwd` points
* into a worktree — the prompt then tells the agent to stay in the copy
* instead of following the inherited parent prompt back to the main tree.
*/
worktreeBase?: string;
/**
* Where .pi config is discovered (project extensions, skills, pi settings,
* agent memory). Default: same as the working directory. The manager sets
* this to the parent session's cwd when `SpawnOptions.cwd` points the
* working directory elsewhere — the agent works *there* but carries the
* parent project's config (the target's `.pi` extensions never execute).
*
* WARNING for future callers: if you pass `cwd` pointing at a directory the
* user didn't open, you almost certainly must pass `configCwd` too —
* omitting it makes the target's `.pi` extensions execute in this process.
* (Worktree isolation is the one intentional exception: its copy IS the
* parent's repo, so config resolving inside it is correct.)
*/
configCwd?: string;
/** Called on tool start/end with activity info. */
onToolActivity?: (activity: ToolActivity) => void;
/** Called on streaming text deltas from the assistant response. */
onTextDelta?: (delta: string, fullText: string) => void;
onSessionCreated?: (session: AgentSession) => void;
/** Called at the end of each agentic turn with the cumulative count. */
onTurnEnd?: (turnCount: number) => void;
/**
* Called once per assistant message_end with that message's usage delta.
* Lets callers maintain a lifetime accumulator that survives compaction
* (which replaces session.state.messages and resets stats-derived sums).
*
* `cost` is pi's own `usage.cost.total` for that message — priced from the
* model's rates, so it is 0 (not missing) for a model pi has no pricing for.
* We never price anything ourselves; every dollar figure this extension shows
* or reports traces back to this field.
*/
onAssistantUsage?: (usage: LifetimeUsage) => void;
/**
* Called when the session successfully compacts. `tokensBefore` is upstream's
* pre-compaction context size estimate. Aborted compactions don't fire.
*/
onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
/** Runtime bridge for opt-in child-safe nested delegation. */
nestedRuntime?: {
manager: NestedAgentManager;
parentAgentId: string;
depth: number;
maxSubagentDepth?: number;
};
}
export interface RunResult {
responseText: string;
session: AgentSession;
/** True if the agent was hard-aborted (max_turns + grace exceeded). */
aborted: boolean;
/** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
steered: boolean;
/**
* A failure message for the run's FINAL assistant turn, when that turn failed:
* a provider error (stopReason "error"), or a "length" stop that produced no
* text (a silent max-token death). pi resolves an exhausted-retries failure
* normally instead of rejecting, so without this the manager would report such
* a run as completed — with an empty result, or worse, an earlier turn's text
* presented as the answer (#144). Undefined for a clean stop, or a "length"
* stop that produced text (a legitimate truncated answer).
*/
failure?: string;
}
/**
* Subscribe to a session and collect the last assistant message text.
* Returns an object with a `getText()` getter and an `unsubscribe` function.
*/
function collectResponseText(session: AgentSession) {
let text = "";
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
// message_start also fires for user and toolResult messages — resetting on
// those would wipe assistant text already collected. Reset only when a new
// ASSISTANT message begins, so getText() is the last assistant message's text.
if (event.type === "message_start" && event.message.role === "assistant") {
text = "";
}
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
text += event.assistantMessageEvent.delta;
}
});
return { getText: () => text, unsubscribe };
}
/**
* Get the last non-empty assistant text produced during THIS invocation.
* `startIndex` is the message count captured before the prompt, so the walk-back
* never crosses into a previous turn: on a resume whose new turn failed empty,
* this returns "" instead of the prior turn's answer (#144). Defaults to 0 (a
* fresh spawn, where the whole history belongs to this run).
*/
function getLastAssistantText(session: AgentSession, startIndex = 0): string {
for (let i = session.messages.length - 1; i >= startIndex; i--) {
const msg = session.messages[i];
if (msg.role !== "assistant") continue;
const text = extractText(msg.content).trim();
if (text) return text;
}
return "";
}
/**
* Error message of THIS invocation's final assistant message, when that turn
* failed. Two failure shapes, both keyed off how the final turn STOPPED:
* - stopReason "error": a provider failure pi resolved instead of rejecting
* (any text; partial output is surfaced separately).
* - stopReason "length" with NO text: a silent max-token death — the run hit
* the output-token ceiling before writing anything, which would otherwise
* land as a "completed" run with an empty result (the #144 symptom).
* Everything else completes: a clean "stop"/"toolUse" final, and — crucially — a
* "length" stop that DID produce text (a legitimate truncated-but-useful answer).
* "aborted" is handled by the manager's abort flag / "stopped" guard, not here.
* Bounded by `startIndex` (like the text fallback) so a resume that produced no
* assistant message of its own never inherits a PRIOR turn's stop reason.
*/
function finalTurnError(session: AgentSession, startIndex = 0): string | undefined {
for (let i = session.messages.length - 1; i >= startIndex; i--) {
const msg = session.messages[i];
if (msg.role !== "assistant") continue;
if (msg.stopReason === "error") {
return (msg as { errorMessage?: string }).errorMessage?.trim() || "provider error with no output";
}
if (msg.stopReason === "length" && !extractText(msg.content).trim()) {
return "run hit the output token limit before producing any text";
}
return undefined;
}
return undefined;
}
/**
* Wire an AbortSignal to abort a session.
* Returns a cleanup function to remove the listener.
*/
function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => void {
if (!signal) return () => {};
const onAbort = () => session.abort();
signal.addEventListener("abort", onAbort, { once: true });
return () => signal.removeEventListener("abort", onAbort);
}
function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string): string | undefined {
if (!sessionDir) return undefined;
if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homedir(), sessionDir.slice(2));
if (isAbsolute(sessionDir)) return sessionDir;
return resolve(cwd, sessionDir);
}
export async function runAgent(
ctx: ExtensionContext,
type: SubagentType,
prompt: string,
options: RunOptions,
): Promise<RunResult> {
const config = getConfig(type);
const agentConfig = getAgentConfig(type);
// Resolve working directory: worktree override > parent cwd
const effectiveCwd = options.cwd ?? ctx.cwd;
// Filesystem work happens in effectiveCwd; config discovery in configCwd.
// They differ only for SpawnOptions.cwd spawns (config stays with the parent).
const configCwd = options.configCwd ?? effectiveCwd;
const env = await detectEnv(options.pi, effectiveCwd);
// Get parent system prompt for append-mode agents
const parentSystemPrompt = ctx.getSystemPrompt();
// Build prompt extras (memory, skill preloading)
const extras: PromptExtras = {};
if (options.worktreeBase) extras.worktreeBase = options.worktreeBase;
// Resolve extensions/skills: isolated overrides to false
const extensions = options.isolated ? false : config.extensions;
// Nulling excludes under isolated also suppresses the orphaned-exclude warning —
// isolation is an intentional override, not a misconfiguration.
const excludeExtensions = options.isolated ? undefined : config.excludeExtensions;
const skills = options.isolated ? false : config.skills;
// Skill preloading: when skills is string[], preload their content into prompt
if (Array.isArray(skills)) {
const loaded = preloadSkills(skills, configCwd);
if (loaded.length > 0) {
extras.skillBlocks = loaded;
}
}
let toolNames = getToolNamesForType(type);
// Persistent memory: detect write capability and branch accordingly.
// Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
if (agentConfig?.memory) {
const existingNames = new Set(toolNames);
const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined;
const effectivelyHas = (name: string) => existingNames.has(name) && !denied?.has(name);
const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
if (hasWriteTools) {
// Read-write memory: add any missing memory tool names (read/write/edit)
const extraNames = getMemoryToolNames(existingNames);
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
} else {
// Read-only memory: only add read tool name, use read-only prompt
const extraNames = getReadOnlyMemoryToolNames(existingNames);
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
}
}
// Build system prompt from agent config
let systemPrompt: string;
if (agentConfig) {
systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras);
} 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);
}
// Child permission policy is selected by the agent-definition filename,
// which is also where pi-permission-system reads `permission:` frontmatter.
// A declared `name:` may differ from that filename, so sourcePath wins when
// available. Strip inherited parent identity before installing this one.
const permissionAgentName = agentConfig?.sourcePath
? basename(agentConfig.sourcePath).replace(/\.md$/i, "")
: agentConfig?.name ?? type;
systemPrompt = withActiveAgentIdentity(systemPrompt, permissionAgentName);
// When skills is string[], we've already preloaded them into the prompt.
// Still pass noSkills: true since we don't need the skill loader to load them again.
const noSkills = skills === false || Array.isArray(skills);
const agentDir = getAgentDir();
// Extension loading:
// - true → all default-discovered extensions
// - false → no optional extensions
// - string[] → loader-level allowlist. Bare names keep the matching
// default-discovered extension; path entries load that extension fresh;
// "*" keeps all default-discovered extensions.
// Bundle-owned mandatory entries are merged after this Agent-controlled policy:
// neither extensions:false, isolated mode, nor exclude_extensions can prevent
// their handlers from binding. Their tools remain governed by normal Agent policy.
//
// Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
// buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
// would defeat prompt_mode: replace and isolated: true. Parent context, if
// wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
// is embedded in systemPromptOverride) or inherit_context (conversation).
// `ext:` selectors from the `tools:` CSV narrow which extension tools surface to
// the LLM. They do NOT control loading — `extensions:` is the sole Agent-level
// authority for optional extensions. Mandatory extension tools are not surfaced
// unless that same Agent-level policy would normally select the extension.
const { extNames, narrowing } = parseExtSelectors(
options.isolated ? [] : (agentConfig?.extSelectors ?? []),
);
const optionalExtensionsDisabled = extensions === false;
const mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(options.mandatoryExtensionPaths);
const noExtensions = optionalExtensionsDisabled && mandatoryExtensionPaths.length === 0;
const extensionsSpec = Array.isArray(extensions)
? parseExtensionsSpec(extensions, configCwd)
: undefined;
const keepNames = extensionsSpec?.names ?? new Set<string>();
// Agent excludes still hide optional extensions and extension tools, but they
// cannot remove a mandatory extension's lifecycle handlers.
const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
const hasExcludes = excludeNames.size > 0;
const loadAll = extensions === true || extensionsSpec?.wildcard === true;
const normalPolicySelects = (extensionPath: string): boolean => {
if (optionalExtensionsDisabled) return false;
const canons = extensionCanonicalNames(extensionPath);
if (canons.some((name) => excludeNames.has(name))) return false;
return loadAll || canons.some((name) => keepNames.has(name));
};
const requestedPaths = [
...(extensionsSpec?.paths ?? []),
...mandatoryExtensionPaths,
];
const additionalExtensionPaths = requestedPaths.length
? [...new Set(requestedPaths)]
: undefined;
// Pre-filter discovered set, captured by the override — typo warnings compare
// 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)
? 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)
),
};
};
const loader = new DefaultResourceLoader({
cwd: configCwd,
agentDir,
noExtensions,
additionalExtensionPaths,
extensionsOverride,
noSkills,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
systemPromptOverride: () => systemPrompt,
appendSystemPromptOverride: () => [],
});
await runInChildSessionContext(() => loader.reload());
const loadedExtensions = loader.getExtensions().extensions;
assertMandatoryExtensionsLoaded(
loadedExtensions.map((extension) => extension.path),
mandatoryExtensionPaths,
);
const toolEligibleExtensionPaths = new Set(
loadedExtensions
.filter((extension) => normalPolicySelects(extension.path))
.map((extension) => extension.path),
);
// Plain entries in `tools:` are expected to be built-in names (extension tools
// go through `ext:`), so an unknown name there is unambiguously a typo. Previously
// this produced a silently broken agent (#75) — pi-mono accepted the bogus name
// into the allowlist, then dropped it at registration with no signal back.
if (agentConfig?.builtinToolNames?.length) {
const knownBuiltins = new Set(BUILTIN_TOOL_NAMES);
for (const name of agentConfig.builtinToolNames) {
if (!knownBuiltins.has(name)) {
options.onToolActivity?.({
type: "end",
toolName: `tools-error:tool "${name}" requested by agent "${type}" is not a known built-in`,
});
}
}
}
// A subagent spawns mid-task, so a bad `extensions:`/`ext:` entry warns rather
// than aborts. Two distinct misconfigurations to catch:
// - `extensions: [foo]` but no extension named foo was discovered (typo or
// path that failed to load — path entries fold their canonical name into
// `keepNames`, so this covers them too).
// - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
// didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
// loading is `extensions:`-authoritative.
// An exclude alongside extensions:false cannot affect optional loading. A
// mandatory extension may still bind, but its tools remain hidden by that policy.
if (hasExcludes && optionalExtensionsDisabled) {
options.onToolActivity?.({
type: "end",
toolName: `extension-error:exclude_extensions has no effect on optional extensions for agent "${type}" — mandatory extensions remain loaded`,
});
}
// Exclude typo check: compares against the PRE-filter discovered set (an excluded
// name absent from the surviving set is the exclude working as intended). Also
// flags path-like and "*" entries — excludes are plain names only.
if (hasExcludes && discoveredNames) {
for (const name of excludeNames) {
if (!discoveredNames.has(name)) {
options.onToolActivity?.({
type: "end",
toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`,
});
}
}
for (const extension of loader.getExtensions().extensions) {
if (!isMandatoryExtensionPath(extension.path, mandatoryExtensionPaths)) continue;
const attemptedExclusions = extensionCanonicalNames(extension.path)
.filter((name) => excludeNames.has(name));
for (const name of attemptedExclusions) {
options.onToolActivity?.({
type: "end",
toolName: `extension-warning:ignored lifecycle exclusion of mandatory extension "${name}" for agent "${type}"`,
});
}
}
}
if (keepNames.size > 0 || extNames.size > 0) {
const survivingNames = new Set(
loader.getExtensions().extensions.flatMap((e) => extensionCanonicalNames(e.path)),
);
for (const name of keepNames) {
if (!survivingNames.has(name)) {
options.onToolActivity?.({
type: "end",
toolName: excludeNames.has(name)
? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins`
: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
});
}
}
for (const name of extNames) {
if (!survivingNames.has(name)) {
options.onToolActivity?.({
type: "end",
toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`,
});
}
}
}
// Resolve model: explicit option > config.model > parent model
const model = options.model ?? resolveDefaultModel(
ctx.model, ctx.modelRegistry, agentConfig?.model,
);
// Resolve thinking level: explicit option > agent config > undefined (inherit)
const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
const disallowedSet = agentConfig?.disallowedTools
? new Set(agentConfig.disallowedTools)
: undefined;
// Nested delegation tools (opt-in, ownership-scoped). Empty unless the agent
// set `allowed_subagents` and a nestedRuntime was provided — and never when
// isolated. Their names collide with EXCLUDED_TOOL_NAMES by design, so the
// scoping below re-admits them explicitly (registry deny + active-set narrow).
const effectiveMaxDepth = options.nestedRuntime?.maxSubagentDepth ?? getMaxSubagentDepth();
// At (or past) the cap this agent can never spawn, so it can never own a child
// to fetch from or steer either — inject nothing rather than three tools whose
// every call is an error. This is also what makes `maxSubagentDepth` 0/1 mean
// "nesting off" instead of "nesting always fails".
const nestedRuntime = options.nestedRuntime && options.nestedRuntime.depth < effectiveMaxDepth
? options.nestedRuntime
: undefined;
const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && !options.isolated
? createNestedSubagentTools({
manager: nestedRuntime.manager,
pi: options.pi,
parentAgentId: nestedRuntime.parentAgentId,
depth: nestedRuntime.depth,
maxSubagentDepth: effectiveMaxDepth,
allowedSubagents: agentConfig.allowedSubagents,
configCwd,
})
: [];
const nestedToolNames = new Set(nestedTools.map(tool => tool.name));
// ─── Tool scoping ───────────────────────────────────────────────────────
//
// Some extensions register their tools ASYNCHRONOUSLY, long after the
// `loader.reload()` above: pi-mcp calls registerTool from `session_start`
// (once its MCP servers connect), context-mode from `before_agent_start`.
// That is deliberate on their part — eagerly spawning an MCP bridge during
// extension discovery orphans child processes on pi's non-agent code paths
// (--help, config, trust probing).
//
// So the tool set cannot be snapshotted here. pi's `allowedToolNames` gates
// tool *registration* (`_refreshToolRegistry`'s `isAllowedTool`), not merely
// the active set, and is frozen at construction — a name absent from the
// snapshot is dropped forever, even once the tool actually registers (#125).
//
// Whenever extensions are in play we therefore:
// - leave `allowedToolNames` unset, so pi's live gate admits tools whenever
// they register;
// - express the name-stable, permanent part of the scope (our own
// orchestration tools, built-ins the agent didn't ask for, and
// `disallowedTools`) as `excludeTools`, which pi re-applies on every
// registry refresh;
// - enforce `ext:` narrowing on the ACTIVE set via the live `inScope()`
// predicate installed after bind — the active set is what the LLM sees,
// so a registry tool that is never activated is invisible and uncallable.
//
// `noExtensions`/`isolated` keeps the historical static allowlist: nothing
// async can appear there, and a hard registry gate is the correct boundary.
const builtinToolNameSet = new Set(toolNames);
let sessionTools: string[] | undefined;
let sessionExcludeTools: string[] | undefined;
if (noExtensions) {
// Strict allowlist: built-ins the agent asked for, plus any opt-in nested
// tools (whose names would otherwise be dropped as EXCLUDED_TOOL_NAMES).
sessionTools = [
...toolNames.filter(
(t) => !EXCLUDED_TOOL_NAMES.includes(t) && !disallowedSet?.has(t),
),
...[...nestedToolNames].filter((t) => !disallowedSet?.has(t)),
];
} else {
// Deny the orchestration tools EXCEPT the nested ones this agent opted into —
// those are injected as customTools and must survive the registry gate.
const denyTools = new Set<string>(
EXCLUDED_TOOL_NAMES.filter((t) => !nestedToolNames.has(t)),
);
// Keep only the built-ins the agent asked for — deny the rest.
for (const name of BUILTIN_TOOL_NAMES) {
if (!builtinToolNameSet.has(name)) denyTools.add(name);
}
if (disallowedSet) {
// disallowed_tools wins even over an opt-in nested tool of the same name.
for (const name of disallowedSet) denyTools.add(name);
}
sessionExcludeTools = [...denyTools];
}
const settingsManager = SettingsManager.create(configCwd, agentDir);
const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd);
const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
// Frontmatter wins when it says anything; otherwise the project default,
// which `rememberAgents` supplies for top-level agents only. Same precedence
// as `outputTranscript`.
const persistSession = agentConfig?.persistSession ?? (options.nested ? false : rememberAgents);
const sessionManager = options.resumeSessionFile
// Reopening an existing conversation: the file already carries its own
// header (cwd, parent) and history, so none of the create-time options
// apply. `sessionDir` still matters for a later /new or /branch off it.
? SessionManager.open(options.resumeSessionFile, configuredSessionDir ?? defaultSessionDir)
: persistSession
? SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir, {
// Optional metadata — it only nests the subagent under its spawner in
// `/resume`. Until `rememberAgents` this ran solely for the rare
// `persist_session: true` agent; now it runs for every spawn, so a
// context without a session manager (a bare programmatic ctx) must
// still persist rather than take the whole spawn down.
parentSession: ctx.sessionManager?.getSessionFile?.(),
})
: SessionManager.inMemory(effectiveCwd);
// Pi 0.80.8 replaced createAgentSession's modelRegistry option with
// modelRuntime, but ExtensionContext still exposes only the registry facade.
// Pass both so the full supported Pi range retains the parent's providers.
const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: unknown }).runtime;
const sessionOpts: Parameters<typeof createAgentSession>[0] & {
modelRegistry: ExtensionContext["modelRegistry"];
modelRuntime?: unknown;
} = {
cwd: effectiveCwd,
agentDir,
sessionManager,
settingsManager,
modelRegistry: ctx.modelRegistry,
// `as never` is what keeps this assignable across the supported Pi range:
// pre-0.80.8 the field exists only via the `modelRuntime?: unknown` shim
// above, while newer Pi types it as `ModelRuntime` — a shape an opaque
// `unknown` read off the private facade field can never satisfy.
...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime as never }),
model,
tools: sessionTools,
customTools: nestedTools,
resourceLoader: loader,
};
if (sessionExcludeTools) {
sessionOpts.excludeTools = sessionExcludeTools;
}
if (thinkingLevel) {
sessionOpts.thinkingLevel = thinkingLevel;
}
const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
const baseSessionName = agentConfig?.name ?? type;
session.setSessionName(
options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName,
);
// pi-permission-system must know this is a headless child BEFORE its
// session_start handlers run in bindExtensions(). All nested descendants
// retain the interactive root as authority rather than forwarding to an
// intermediate child that has no UI.
registerPermissionChildSession(
options.pi,
session,
options.authoritySessionId ?? ctx.sessionManager?.getSessionId?.(),
);
// Bind extensions so that session_start fires and extensions can initialize
// (e.g. loading credentials, setting up state). Tool gating already happened
// at session construction via the `tools:` allowlist above — no separate
// post-bind filter is needed. All ExtensionBindings fields are optional.
try {
await session.bindExtensions({
onError: (err) => {
options.onToolActivity?.({
type: "end",
toolName: `extension-error:${err.extensionPath}`,
});
},
});
} catch (error) {
// The manager never receives a session whose bind failed, so close both
// halves of the lifecycle here. Disposal precedes the registry event: once
// observers see "disposed", the child cannot issue another permission ask.
try { session.dispose(); } finally { unregisterPermissionChildSession(session); }
throw error;
}
// With `allowedToolNames` unset, the registry is scoped by `excludeTools` but
// the ACTIVE set still needs managing: pi activates only its four default
// built-ins at turn 1, and `ext:` narrowing has no registry-level expression
// (we can't deny the name of a tool that hasn't registered yet). Both are
// handled below by re-deriving scope from the loader's live extension maps —
// `registerTool` writes into those same maps, so late arrivals are judged too.
if (!noExtensions) {
installExtensionToolScope(session, {
loader,
toolNames,
disallowedSet,
extNames,
narrowing,
nestedToolNames,
toolEligibleExtensionPaths,
});
}
options.onSessionCreated?.(session);
// Track turns for graceful max_turns enforcement
let turnCount = 0;
const maxTurns = resolveEffectiveMaxTurns(type, options.maxTurns);
let softLimitReached = false;
let aborted = false;
let currentMessageText = "";
const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
if (event.type === "turn_end") {
turnCount++;
options.onTurnEnd?.(turnCount);
if (maxTurns != null) {
if (!softLimitReached && turnCount >= maxTurns) {
softLimitReached = true;
session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
} else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
aborted = true;
session.abort();
}
}
}
if (event.type === "message_start") {
currentMessageText = "";
}
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
currentMessageText += event.assistantMessageEvent.delta;
options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
}
if (event.type === "tool_execution_start") {
options.onToolActivity?.({ type: "start", toolName: event.toolName });
}
if (event.type === "tool_execution_end") {
options.onToolActivity?.({ type: "end", toolName: event.toolName });
}
if (event.type === "message_end" && event.message.role === "assistant") {
const u = (event.message as any).usage;
if (u) options.onAssistantUsage?.({
input: u.input ?? 0,
output: u.output ?? 0,
cacheWrite: u.cacheWrite ?? 0,
cacheRead: u.cacheRead ?? 0,
cost: u.cost?.total ?? 0,
});
}
if (event.type === "compaction_end" && !event.aborted && event.result) {
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
}
});
const collector = collectResponseText(session);
const cleanupAbort = forwardAbortSignal(session, options.signal);
// Build the effective prompt: optionally prepend parent context
let effectivePrompt = prompt;
if (options.inheritContext) {
const parentContext = buildParentContext(ctx);
if (parentContext) {
effectivePrompt = parentContext + prompt;
}
}
// Boundary for the history fallback: only assistant text produced from here
// on counts as this run's output (a fresh session, so usually 0).
const startLen = session.messages.length;
try {
await session.prompt(effectivePrompt);
} finally {
unsubTurns();
collector.unsubscribe();
cleanupAbort();
}
const responseText = collector.getText().trim() || getLastAssistantText(session, startLen);
return { responseText, session, aborted, steered: softLimitReached, failure: finalTurnError(session, startLen) };
}
/**
* Send a new prompt to an existing session (resume).
*/
export async function resumeAgent(
session: AgentSession,
prompt: string,
options: {
onToolActivity?: (activity: ToolActivity) => void;
onAssistantUsage?: (usage: LifetimeUsage) => void;
onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
signal?: AbortSignal;
} = {},
): Promise<{ text: string; failure?: string }> {
// Boundary for the history fallback: the session already holds prior turns,
// so only assistant text produced by THIS resume prompt counts as its output
// — a failed resume must not surface the previous turn's answer (#144).
const startLen = session.messages.length;
const collector = collectResponseText(session);
const cleanupAbort = forwardAbortSignal(session, options.signal);
const unsubEvents = (options.onToolActivity || options.onAssistantUsage || options.onCompaction)
? session.subscribe((event: AgentSessionEvent) => {
if (event.type === "tool_execution_start") options.onToolActivity?.({ type: "start", toolName: event.toolName });
if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName });
if (event.type === "message_end" && event.message.role === "assistant") {
const u = (event.message as any).usage;
if (u) options.onAssistantUsage?.({
input: u.input ?? 0,
output: u.output ?? 0,
cacheWrite: u.cacheWrite ?? 0,
cacheRead: u.cacheRead ?? 0,
cost: u.cost?.total ?? 0,
});
}
if (event.type === "compaction_end" && !event.aborted && event.result) {
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
}
})
: () => {};
try {
await session.prompt(prompt);
} finally {
collector.unsubscribe();
unsubEvents();
cleanupAbort();
}
return {
text: collector.getText().trim() || getLastAssistantText(session, startLen),
failure: finalTurnError(session, startLen),
};
}
/**
* Send a steering message to a running subagent.
* The message will interrupt the agent after its current tool execution.
*/
export async function steerAgent(
session: AgentSession,
message: string,
): Promise<void> {
await session.steer(message);
}
/**
* Get the subagent's conversation messages as formatted text.
*/
export function getAgentConversation(session: AgentSession): string {
const parts: string[] = [];
for (const msg of session.messages) {
if (msg.role === "user") {
const text = typeof msg.content === "string"
? msg.content
: extractText(msg.content);
if (text.trim()) parts.push(`[User]: ${text.trim()}`);
} else if (msg.role === "assistant") {
const textParts: string[] = [];
const toolCalls: string[] = [];
for (const c of msg.content) {
if (c.type === "text" && c.text) textParts.push(c.text);
else if (c.type === "toolCall") toolCalls.push(` Tool: ${(c as any).name ?? (c as any).toolName ?? "unknown"}`);
}
if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`);
if (toolCalls.length > 0) parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
} else if (msg.role === "toolResult") {
const text = extractText(msg.content);
const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
}
}
return parts.join("\n\n");
}