mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
1226 lines
53 KiB
TypeScript
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");
|
|
}
|