import type { Static, TSchema } from "typebox"; import type { AgentHistoryEntry } from "./agent-history.js"; import { WorkflowError, WorkflowErrorCode } from "./errors.js"; import { createDefaultExecutorRegistry, type ExecutorRegistry, type ExecutorRunRequest, type ExecutorUsage, type WorkflowExecutor, } from "./executor.js"; /** * Dependency-neutral constructor options shared by the portable runner and the * Pi-backed WorkflowAgent it creates on demand. * * Pi-specific values intentionally remain opaque here. Keeping their types out * of this module prevents importing the Pi SDK merely to run Codex or Claude * Code; the values are forwarded unchanged if the caller later selects Pi. */ export interface PortableAgentRunnerOptions { cwd?: string; tools?: readonly unknown[]; excludeTools?: string[]; session?: unknown; instructions?: string; mainModel?: string; modelRegistry?: unknown; executorRegistry?: ExecutorRegistry; persistAgentSessions?: boolean; } /** Run options accepted without loading a host-specific agent SDK. */ export interface PortableAgentRunOptions { label?: string; executor?: WorkflowExecutor; sessionName?: string; schema?: TSchemaDef; tools?: readonly unknown[]; instructions?: string; signal?: AbortSignal; onUsage?: (usage: ExecutorUsage) => void; model?: string; tier?: string; onModelResolved?: (modelId: string) => void; onModelFallback?: (requestedSpec: string) => void; onHistory?: (history: AgentHistoryEntry[]) => void; cwd?: string; toolNames?: string[]; disallowedToolNames?: string[]; maxSchemaRetries?: number; systemTools?: readonly unknown[]; modelRegistry?: unknown; } export type PortableAgentRunResult = TSchemaDef extends TSchema ? Static : string; /** The small structural surface used after the optional Pi module is loaded. */ export interface PortablePiAgent { run( prompt: string, options?: PortableAgentRunOptions, ): Promise>; } export interface PortablePiAgentModule { WorkflowAgent: new (options?: PortableAgentRunnerOptions) => PortablePiAgent; } /** Injectable only so embedders/tests can control how the optional Pi host loads. */ export interface PortableAgentRunnerDependencies { loadPiAgent?: () => Promise; } const loadDefaultPiAgent = async (): Promise => (await import("./agent.js")) as unknown as PortablePiAgentModule; /** * Default workflow agent runner for host-neutral runtimes. * * Codex and Claude Code are dispatched directly through ExecutorRegistry. The * Pi implementation and its SDK peers are imported only when a call actually * selects `executor: "pi"` (also the default), then the WorkflowAgent instance * is cached for all subsequent Pi calls. */ export class PortableAgentRunner { private readonly cwd: string; private readonly instructions?: string; private readonly executorRegistry: ExecutorRegistry; private readonly piOptions: PortableAgentRunnerOptions; private readonly loadPiAgent: () => Promise; private piAgentPromise?: Promise; constructor(options: PortableAgentRunnerOptions = {}, dependencies: PortableAgentRunnerDependencies = {}) { this.cwd = options.cwd ?? process.cwd(); this.instructions = options.instructions; this.executorRegistry = options.executorRegistry ?? createDefaultExecutorRegistry(); this.piOptions = { ...options, cwd: this.cwd, executorRegistry: this.executorRegistry }; this.loadPiAgent = dependencies.loadPiAgent ?? loadDefaultPiAgent; } async run( prompt: string, options: PortableAgentRunOptions = {}, ): Promise> { const executor = options.executor ?? "pi"; if (executor === "pi") { const agent = await this.getPiAgent(); return agent.run(prompt, options); } const adapter = this.executorRegistry.require(executor); let usageReported = false; const reportUsage = (usage: ExecutorUsage) => { if (usageReported) return; usageReported = true; try { options.onUsage?.(usage); } catch { // Usage is telemetry only; never let it mask the execution result/error. } }; const request: ExecutorRunRequest = { prompt: this.buildExternalPrompt(prompt, options), cwd: options.cwd ?? this.cwd, model: options.model, schema: options.schema, signal: options.signal, env: process.env, label: options.label, onUsage: options.onUsage ? reportUsage : undefined, }; const response = await adapter.run(request); if (options.onModelResolved && options.model) options.onModelResolved(options.model); if (response.usage) reportUsage(response.usage); if (response.history) options.onHistory?.(response.history); const value = response.result !== undefined ? response.result : (response.text ?? ""); return value as PortableAgentRunResult; } private async getPiAgent(): Promise { if (!this.piAgentPromise) { const pending = Promise.resolve() .then(() => this.loadPiAgent()) .then(({ WorkflowAgent }) => new WorkflowAgent(this.piOptions)) .catch((error: unknown) => { // A failed optional import must not poison this runner forever. This // also lets a long-lived runtime retry after the peer is installed. if (this.piAgentPromise === pending) this.piAgentPromise = undefined; if (isMissingPiDependency(error)) throw piUnavailableError(error); throw error; }); this.piAgentPromise = pending; } return this.piAgentPromise; } /** Build the same adapter-neutral prompt used by WorkflowAgent's external path. */ private buildExternalPrompt( prompt: string, options: PortableAgentRunOptions, ): string { const parts = [ this.instructions, options.instructions, options.label ? `Task label: ${options.label}` : undefined, prompt, options.schema ? "Return final output matching the supplied structured schema." : undefined, ].filter((part): part is string => Boolean(part)); return parts.join("\n\n"); } } function isMissingPiDependency(error: unknown): boolean { const seen = new Set(); let current: unknown = error; while (current && !seen.has(current)) { seen.add(current); const candidate = current as { code?: unknown; message?: unknown; cause?: unknown }; const code = typeof candidate.code === "string" ? candidate.code : ""; const message = typeof candidate.message === "string" ? candidate.message : ""; if ( code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED" || /cannot find (?:package|module).*(?:@earendil-works\/pi-|typebox)|@earendil-works\/pi-/i.test(message) ) { return true; } current = candidate.cause; } return false; } function piUnavailableError(cause: unknown): WorkflowError { const message = cause instanceof Error ? cause.message : String(cause); return new WorkflowError( 'Pi executor is unavailable because its optional SDK dependencies could not be loaded. Install @earendil-works/pi-coding-agent and the package peer dependencies, or choose executor "codex" or "claude-code".', WorkflowErrorCode.EXECUTOR_UNAVAILABLE, { recoverable: false, details: { executor: "pi", cause: message }, }, ); }