/** * SDK-based runner for subagent execution. * * Creates in-process AgentSession instances instead of spawning child processes. * Handles model resolution, resource loading, event subscription, and abort signals. */ import type { Message, Model, Api } from "@earendil-works/pi-ai"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { type AgentSessionEvent, createAgentSession, DefaultResourceLoader, getAgentDir, ModelRegistry, SessionManager, SettingsManager, type Skill, type ResourceDiagnostic, } from "@earendil-works/pi-coding-agent"; import type { AgentConfig } from "./agent.js"; export interface UsageStats { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; contextTokens: number; turns: number; } export interface SingleResult { agent: string; agentSource: "user" | "project" | "unknown"; task: string; exitCode: number; messages: Message[]; stderr: string; usage: UsageStats; model?: string; stopReason?: string; errorMessage?: string; } export type OnUpdateCallback = (result: SingleResult) => void; // --------------------------------------------------------------------------- // Model resolution // --------------------------------------------------------------------------- /** * Resolve a "/" model string through the registry. * Returns the `Model` on success, or an error string on failure. * No silent fallback — the caller must handle errors. */ export function resolveModel( registry: ModelRegistry, name: string, ): Model | string { const i = name.indexOf("/"); if (i < 0) { return `malformed model "${name}", expected /`; } const provider = name.slice(0, i); const id = name.slice(i + 1); const model = registry.find(provider, id); if (!model) { return `model "${name}" not found`; } if (!registry.hasConfiguredAuth(model)) { return `no auth configured for provider "${provider}"`; } return model; } // --------------------------------------------------------------------------- // Resource loader // --------------------------------------------------------------------------- /** * Build a ResourceLoader for an agent session. * - No extensions / no skills by default (explicit-only). * - System prompt appended when non-empty. */ export function buildResourceLoader(agent: AgentConfig, cwd: string): DefaultResourceLoader { const exts: string[] = agent.extensions ?? []; const skills: string[] = agent.skills ?? []; return new DefaultResourceLoader({ cwd, agentDir: getAgentDir(), appendSystemPrompt: agent.systemPrompt?.trim() ? [agent.systemPrompt] : undefined, noExtensions: true, additionalExtensionPaths: exts.length > 0 ? exts : undefined, noSkills: skills.length === 0, skillsOverride: skills.length > 0 ? (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => ({ skills: base.skills.filter((s) => skills.includes(s.name)), diagnostics: base.diagnostics, }) : undefined, }); } // --------------------------------------------------------------------------- // Single agent run // --------------------------------------------------------------------------- /** * Run a single agent via an in-process SDK session. */ export async function runAgent( agent: AgentConfig, task: string, cwd: string, modelRegistry: ModelRegistry, signal?: AbortSignal, onUpdate?: OnUpdateCallback, ): Promise { const result: SingleResult = { agent: agent.name, agentSource: agent.source, task, exitCode: 0, messages: [], stderr: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, }; let session: Awaited>["session"] | undefined; try { let model: Model | undefined; let thinkingLevel: ThinkingLevel | undefined; if (agent.model) { const resolved = resolveModel(modelRegistry, agent.model); if (typeof resolved === "string") { result.exitCode = 1; result.errorMessage = resolved; return result; } model = resolved; thinkingLevel = agent.thinkingLevel as ThinkingLevel | undefined; } const loader = buildResourceLoader(agent, cwd); await loader.reload(); const { session: createdSession } = await createAgentSession({ cwd, model, thinkingLevel, tools: agent.tools, resourceLoader: loader, sessionManager: SessionManager.create(cwd), settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }), }); session = createdSession; session.subscribe((event: AgentSessionEvent) => { if (event.type === "turn_end") { result.usage.turns++; const msg = event.message as Message; const u = (msg as any).usage; if (u) { result.usage.input += u.input || 0; result.usage.output += u.output || 0; result.usage.cacheRead += u.cacheRead || 0; result.usage.cacheWrite += u.cacheWrite || 0; result.usage.cost += u.cost?.total || 0; result.usage.contextTokens = u.totalTokens || 0; } if (!result.model && (msg as any).model) result.model = (msg as any).model; if ((msg as any).stopReason) result.stopReason = (msg as any).stopReason; if ((msg as any).errorMessage) result.errorMessage = (msg as any).errorMessage; result.messages.push(msg, ...event.toolResults); onUpdate?.(result); } }); if (signal) { if (signal.aborted) { session.abort(); } else { signal.addEventListener("abort", () => session!.abort(), { once: true }); } } await session.prompt(task); result.exitCode = 0; return result; } catch (err) { result.exitCode = 1; result.errorMessage = err instanceof Error ? err.message : String(err); return result; } finally { session?.dispose(); } } // --------------------------------------------------------------------------- // Parallel execution // --------------------------------------------------------------------------- async function mapWithConcurrencyLimit( items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise, ): Promise { if (items.length === 0) return []; const limit = Math.max(1, Math.min(concurrency, items.length)); const results: TOut[] = new Array(items.length); let nextIndex = 0; const workers = new Array(limit).fill(null).map(async () => { while (true) { const current = nextIndex++; if (current >= items.length) return; results[current] = await fn(items[current], current); } }); await Promise.all(workers); return results; } export const MAX_CONCURRENCY = 4; /** * Run multiple agents in parallel with a concurrency cap. */ export async function runParallel( cwd: string, agents: AgentConfig[], tasks: Array<{ agent: string; task: string; cwd?: string }>, modelRegistry: ModelRegistry, signal?: AbortSignal, onUpdate?: (results: SingleResult[]) => void, ): Promise { // Track all results; exitCode: -1 = still running const allResults: SingleResult[] = tasks.map((t) => ({ agent: t.agent, agentSource: "unknown" as const, task: t.task, exitCode: -1, messages: [], stderr: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, })); const emitUpdate = () => onUpdate?.([...allResults]); const results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (t, index) => { const agent = agents.find((a) => a.name === t.agent); if (!agent) { const result: SingleResult = { agent: t.agent, agentSource: "unknown", task: t.task, exitCode: 1, messages: [], stderr: `Unknown agent: "${t.agent}"`, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, }; allResults[index] = result; emitUpdate(); return result; } const result = await runAgent( agent, t.task, t.cwd ?? cwd, modelRegistry, signal, (partial) => { allResults[index] = partial; emitUpdate(); }, ); allResults[index] = result; emitUpdate(); return result; }); return results; }