/** * subagent.ts — Subagent class: identity, lifecycle status, and per-subagent behavior. * * Status/stats are delegated to the SubagentState value object; listener * lifecycle to RunListeners; workspace prepare/dispose to WorkspaceBracket. * Behavior (abort, steer buffering) lives here rather than on SubagentManager. */ import type { Model } from "@earendil-works/pi-ai"; import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import { debugLog } from "#src/debug"; import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session"; import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot"; import { RunListeners } from "#src/lifecycle/run-listeners"; import type { SubagentSession, TurnLoopResult } from "#src/lifecycle/subagent-session"; import { SubagentState, type SubagentStatus } from "#src/lifecycle/subagent-state"; import type { LifetimeUsage } from "#src/lifecycle/usage"; import type { WorkspaceProvider } from "#src/lifecycle/workspace"; import { WorkspaceBracket } from "#src/lifecycle/workspace-bracket"; import { NotificationState } from "#src/observation/notification-state"; import { subscribeSubagentObserver } from "#src/observation/record-observer"; import type { RunConfig } from "#src/runtime"; import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types"; /** Per-subagent lifecycle observer — created by SubagentManager for each spawn. */ export interface SubagentLifecycleObserver { /** Fires when the subagent transitions to running (inside run(), after markRunning). */ onStarted?(agent: Subagent): void; /** Fires once the session is created — the subagent's subagentSession is now available. */ onSessionCreated?(agent: Subagent): void; /** Fires once when the run completes or fails (for concurrency drain). */ onRunFinished?(agent: Subagent): void; /** Fires on compaction events during the run. */ onCompacted?(agent: Subagent, info: CompactionInfo): void; } export type { SubagentStatus } from "#src/lifecycle/subagent-state"; /** * The execution machinery a Subagent needs to run. A single mandatory * collaborator: production (SubagentManager.spawn) always supplies it, so run() * needs no "not configured" guards. The genuinely-optional behavior knobs stay * optional; the four inputs run() cannot proceed without are required. */ export interface SubagentExecution { /** Assembly factory that produces a born-complete SubagentSession. */ createSubagentSession: (params: CreateSubagentSessionParams) => Promise; /** Immutable spawn-time parent snapshot handed to the session factory. */ snapshot: ParentSnapshot; /** Initial prompt for the turn loop. */ prompt: string; /** Parent working directory handed to a workspace provider's prepare(). */ baseCwd: string; observer?: SubagentLifecycleObserver; getRunConfig?: () => RunConfig; /** Resolves the registered workspace provider (if any) at run-start. */ getWorkspaceProvider?: () => WorkspaceProvider | undefined; model?: Model; maxTurns?: number; thinkingLevel?: ThinkingLevel; parentSession?: ParentSessionInfo; signal?: AbortSignal; } export interface SubagentInit { // Identity id: string; type: SubagentType; description: string; invocation?: AgentInvocation; /** Execution machinery — always supplied; construct-complete, no test fallbacks. */ execution: SubagentExecution; /** Lifecycle status and metrics. Defaults to a fresh queued state. */ state?: SubagentState; } export class Subagent { // Identity — set once at construction readonly id: string; readonly type: SubagentType; readonly description: string; readonly invocation?: AgentInvocation; // Lifecycle status and metrics — owned by a private value object; getters and // mutation methods below delegate to it one line. private readonly state: SubagentState; get status(): SubagentStatus { return this.state.status; } get result(): string | undefined { return this.state.result; } get error(): string | undefined { return this.state.error; } get startedAt(): number { return this.state.startedAt; } get completedAt(): number | undefined { return this.state.completedAt; } get toolUses(): number { return this.state.toolUses; } get lifetimeUsage(): Readonly { return this.state.lifetimeUsage; } get compactionCount(): number { return this.state.compactionCount; } get turnCount(): number { return this.state.turnCount; } get activeTools(): ReadonlyMap { return this.state.activeTools; } get responseText(): string { return this.state.responseText; } get maxTurns(): number | undefined { return this.execution.maxTurns; } readonly abortController: AbortController; private _promise?: Promise; get promise(): Promise | undefined { return this._promise; } private readonly execution: SubagentExecution; private readonly listeners = new RunListeners(); private readonly workspaceBracket: WorkspaceBracket; subagentSession?: SubagentSession; private _notification?: NotificationState; get notification(): NotificationState | undefined { return this._notification; } // Steer buffer — messages queued before the session is ready private _pendingSteers: string[] = []; /** Number of steer messages waiting to be delivered. */ get pendingSteerCount(): number { return this._pendingSteers.length; } /** Path to the agent's session JSONL file, or undefined if not yet available. */ get outputFile(): string | undefined { return this.subagentSession?.outputFile; } /** Returns true when a SubagentSession is available (session is ready). */ isSessionReady(): boolean { return this.subagentSession != null; } /** * Deliver or buffer a steer message. * Returns true when delivered immediately; false when buffered for later delivery. */ async steer(message: string): Promise { if (!this.subagentSession) { this.queueSteer(message); return false; } await this.subagentSession.steer(message); return true; } /** Return the session conversation as formatted text, or undefined if no session. */ getConversation(): string | undefined { return this.subagentSession?.getConversation(); } /** Return the session context window utilization (0-100), or null if unavailable. */ getContextPercent(): number | null { return this.subagentSession?.getContextPercent() ?? null; } /** * Subscribe to session events for live updates (e.g., conversation viewer). * Returns an unsubscribe function, or undefined if no session is available. */ subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined { return this.subagentSession?.subscribe(fn); } /** The session's message history, or an empty array if no session. */ get messages(): readonly unknown[] { return this.subagentSession?.messages ?? []; } constructor(init: SubagentInit) { // Identity this.id = init.id; this.type = init.type; this.description = init.description; this.invocation = init.invocation; // Lifecycle status and metrics — fresh queued state unless one is supplied this.state = init.state ?? new SubagentState(); // Abort controller — always created, never injected this.abortController = new AbortController(); // Execution machinery — a single mandatory collaborator this.execution = init.execution; // Per-run lifecycle collaborators this.workspaceBracket = new WorkspaceBracket( this.execution.getWorkspaceProvider ?? (() => undefined), ); // Notification state — created from parentSession.toolCallId if present const toolCallId = init.execution.parentSession?.toolCallId; if (toolCallId) { this._notification = new NotificationState(toolCallId); } } /** * Execute the full agent lifecycle: workspace preparation, session creation * via the factory, observer wiring, the turn loop, workspace disposal, and * status transitions. * * Execution is supplied at construction (mandatory), so run() needs no * "not configured" guards. The returned promise always resolves (errors are * captured internally). */ async run(): Promise { this.markRunning(Date.now()); this.execution.observer?.onStarted?.(this); this.listeners.wireSignal(this.execution.signal, () => this.abort()); // Guard the await so the no-provider path stays synchronous, preserving // the original run() timing: the factory is called in the same turn as // spawn() when no workspace provider is registered. let cwd: string | undefined; if (this.workspaceBracket.hasProvider()) { try { cwd = await this.workspaceBracket.prepare({ agentId: this.id, agentType: this.type, baseCwd: this.execution.baseCwd, invocation: this.invocation, }); } catch (err) { this.markError(err); this.listeners.release(); this.execution.observer?.onRunFinished?.(this); return; } } try { this.subagentSession = await this.execution.createSubagentSession({ snapshot: this.execution.snapshot, type: this.type, cwd, parentSession: this.execution.parentSession, model: this.execution.model, thinkingLevel: this.execution.thinkingLevel, }); } catch (err) { // The factory disposed its own session on a post-creation failure. this.failRun(err); return; } this.flushPendingSteers(); this.listeners.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, { onCompact: (info) => this.execution.observer?.onCompacted?.(this, info), })); this.execution.observer?.onSessionCreated?.(this); const runConfig = this.execution.getRunConfig?.(); try { const result = await this.subagentSession.runTurnLoop(this.execution.prompt, { maxTurns: this.execution.maxTurns, defaultMaxTurns: runConfig?.defaultMaxTurns, graceTurns: runConfig?.graceTurns, signal: this.abortController.signal, }); this.completeRun(result); } catch (err) { this.failRun(err); } } /** * Start execution immediately (foreground / bypassQueue paths). * Stores the run promise so it is awaitable via the `promise` getter. */ start(): void { this._promise = this.guardedRun(); } /** * Schedule execution through an external concurrency scheduler (the limiter). * Captures the scheduler's promise eagerly, so a still-queued agent is * awaitable via the `promise` getter from spawn — not only once its slot opens. * The guard in guardedRun() makes an abort-while-queued run a no-op when the * slot finally frees. */ scheduleVia(schedule: (thunk: () => Promise) => Promise): void { this._promise = schedule(() => this.guardedRun()); } /** * Run unless the agent left the active set before its slot opened * (e.g. abort-while-queued): a non-queued, non-running status resolves * immediately without running. */ private guardedRun(): Promise { if (this.status !== "queued" && this.status !== "running") return Promise.resolve(); return this.run(); } /** * Resume an existing session with a new prompt, managing the observer * subscription lifecycle internally (same wiring as run()). * * Requires an existing SubagentSession (set when the original run created it). * The returned promise always resolves (errors are captured internally). * The parent signal flows straight through to resumeTurnLoop — resume does not * route through this.abortController. */ async resume(prompt: string, signal?: AbortSignal): Promise { const subagentSession = this.subagentSession; if (!subagentSession) { throw new Error("Subagent not configured for resume — missing session"); } this.resetForResume(Date.now()); this.listeners.attachObserver(subscribeSubagentObserver(subagentSession, this.state, { onCompact: (info) => this.execution.observer?.onCompacted?.(this, info), })); try { const responseText = await subagentSession.resumeTurnLoop(prompt, signal); this.markCompleted(responseText); } catch (err) { this.markError(err); } finally { this.listeners.release(); } } /** Increment tool use count. Called by record-observer on tool_execution_end. */ incrementToolUses(): void { this.state.incrementToolUses(); } /** Accumulate a usage delta into lifetimeUsage. Called by record-observer on message_end. */ addUsage(delta: { input: number; output: number; cacheWrite: number }): void { this.state.addUsage(delta); } /** Increment compaction count. Called by record-observer on compaction_end. */ incrementCompactions(): void { this.state.incrementCompactions(); } /** Transition to running state. Sets status and startedAt. */ markRunning(startedAt: number): void { this.state.markRunning(startedAt); } /** * Transition to completed state. * Always sets result and completedAt (??=). Only changes status if not stopped. */ markCompleted(result: string, completedAt?: number): void { this.state.markCompleted(result, completedAt); } /** * Transition to aborted state. * Always sets result and completedAt (??=). Only changes status if not stopped. */ markAborted(result: string, completedAt?: number): void { this.state.markAborted(result, completedAt); } /** * Transition to steered state. * Always sets result and completedAt (??=). Only changes status if not stopped. */ markSteered(result: string, completedAt?: number): void { this.state.markSteered(result, completedAt); } /** * Transition to error state. * Always sets error (formatted) and completedAt (??=). Only changes status if not stopped. */ markError(error: unknown, completedAt?: number): void { this.state.markError(error, completedAt); } /** Transition to stopped state. Always valid — no guard. */ markStopped(completedAt?: number): void { this.state.markStopped(completedAt); } /** * Abort a running agent: fire AbortController and transition to stopped. * Returns false if the agent is not running. * A still-queued agent is stopped by SubagentManager; its scheduled thunk * then no-ops on the queued-status guard. */ abort(): boolean { if (this.status !== "running") return false; this.abortController.abort(); this.markStopped(); return true; } /** * Buffer a steer message for delivery once the session is ready. * Called internally from steer() before the session is ready. */ private queueSteer(message: string): void { this._pendingSteers.push(message); } /** * Flush all buffered steer messages to the session and clear the buffer. * Called once the session is available (inside run()). */ private flushPendingSteers(): void { for (const msg of this._pendingSteers) { this.subagentSession?.steer(msg).catch(() => {}); } this._pendingSteers = []; } /** Reset for resume: running status, new startedAt, clear completedAt/result/error/listeners. */ resetForResume(startedAt: number): void { this.state.resetForResume(startedAt); this.listeners.release(); } /** Complete a run: release listeners, dispose the workspace, status transition, notify observer. */ completeRun(result: TurnLoopResult): void { this.listeners.release(); const finalStatus: SubagentStatus = result.aborted ? "aborted" : result.steered ? "steered" : "completed"; const finalResult = result.responseText + this.workspaceBracket.dispose({ status: finalStatus, description: this.description }); if (result.aborted) this.markAborted(finalResult); else if (result.steered) this.markSteered(finalResult); else this.markCompleted(finalResult); this.execution.observer?.onRunFinished?.(this); } /** Dispose the wrapped session, firing the `disposed` lifecycle event. */ disposeSession(): void { this.subagentSession?.dispose(); } /** Fail a run: mark error, release listeners, best-effort workspace dispose, notify observer. */ failRun(err: unknown): void { this.markError(err); this.listeners.release(); try { this.workspaceBracket.dispose({ status: "error", description: this.description }); } catch (cleanupErr) { debugLog("workspace dispose on agent error", cleanupErr); } this.execution.observer?.onRunFinished?.(this); } }