import { randomUUID } from "node:crypto"; import { unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { AssistantMessage, Model, TextContent } from "@earendil-works/pi-ai"; import { type CreateAgentSessionOptions, createAgentSession, createCodingTools, DefaultResourceLoader, getAgentDir, ModelRegistry, ModelRuntime, SessionManager, SettingsManager, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import type { Static, TSchema } from "typebox"; import { Check, Convert } from "typebox/value"; import { type AgentHistoryEntry, compactAgentHistory } from "./agent-history.js"; import { applyToolPolicy } from "./agent-registry.js"; import { classifyProviderLimit, WorkflowError, WorkflowErrorCode } from "./errors.js"; import { createDefaultExecutorRegistry, type ExecutorRegistry, type ExecutorRunRequest, type ExecutorRunResult, type ExecutorUsage, type WorkflowExecutor, } from "./executor.js"; import { canonicalModelSpec, resolveModelSpecWithThinking } from "./model-spec.js"; import { formatTierFallbackNotice, loadModelTierConfig, type ModelTierConfig, type RankableModel, resolveTierModel, } from "./model-tier-config.js"; import { createStructuredOutputTool, type StructuredOutputCapture } from "./structured-output.js"; /** * Find a JSON object/array in free-form text: a fenced ```json block if present, * else the first balanced {...} or [...]. Best-effort (the schema check is the * real gate). Returns the raw JSON string, or undefined when none is found. */ function findJsonBlock(text: string): string | undefined { const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i); if (fence?.[1]) return fence[1].trim(); const start = text.search(/[{[]/); if (start === -1) return undefined; const open = text[start]; const close = open === "{" ? "}" : "]"; let depth = 0; for (let i = start; i < text.length; i++) { if (text[i] === open) depth++; else if (text[i] === close && --depth === 0) return text.slice(start, i + 1); } return undefined; } /** * Last-resort structured-output recovery: extract a JSON block from prose, coerce * it toward the schema, and accept it only if it then validates. Never fabricates * — returns undefined unless the parsed value genuinely satisfies the schema. */ export function extractValidated(text: string, schema: TSchema): T | undefined { const json = findJsonBlock(text); if (json === undefined) return undefined; let parsed: unknown; try { parsed = JSON.parse(json); } catch { return undefined; } try { const converted = Convert(schema, parsed); if (Check(schema, converted)) return converted as T; } catch { // typebox can throw on exotic schemas; treat as no match. } return undefined; } /** * The last assistant message's terminal metadata (stopReason/errorMessage). The pi * SDK does NOT throw provider usage/quota limits — it records them as an assistant * message with stopReason "error" and an errorMessage. This is the only place that * metadata is observable to the workflow layer. */ export function lastAssistantError(messages: unknown[]): { stopReason?: string; errorMessage?: string } | undefined { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] as Partial | undefined; if (message?.role !== "assistant") continue; return { stopReason: message.stopReason, errorMessage: message.errorMessage }; } return undefined; } /** * If the subagent's turn ended in a provider usage/quota/rate-limit error, throw a * PROVIDER_USAGE_LIMIT WorkflowError carrying the real provider message + reset hint. * Gated on stopReason === "error" so a successful turn whose text merely mentions * "rate limit" is never misclassified. recoverable:false so the run checkpoints * (paused) rather than being retried into the same wall or collapsed to a silent null. */ export function throwIfProviderLimit(messages: unknown[], label?: string): void { const err = lastAssistantError(messages); if (err?.stopReason !== "error") return; const { matched, resetHint } = classifyProviderLimit(err.errorMessage); if (!matched) return; throw new WorkflowError( err.errorMessage ?? "Provider usage/quota limit reached", WorkflowErrorCode.PROVIDER_USAGE_LIMIT, { recoverable: false, agentLabel: label, resetHint }, ); } /** Minimal session surface resolveStructuredOutput needs (real session or a test double). */ export interface StructuredSession { prompt(text: string): Promise; setActiveToolsByName?(names: string[]): void; messages: unknown[]; } /** * Resolve a schema agent's result. If the tool was called, return the captured * value. Otherwise re-prompt up to maxSchemaRetries (tools restricted to * structured_output), then try strict schema-validated prose extraction, else * throw SCHEMA_NONCOMPLIANCE (non-recoverable — surfaced, never a silent null). * Module-level with an injected `lastText` so it is unit-testable. */ export async function resolveStructuredOutput( session: StructuredSession, capture: StructuredOutputCapture, schema: TSchema, options: { maxSchemaRetries?: number; signal?: AbortSignal; label?: string }, lastText: (messages: unknown[]) => string, ): Promise { if (capture.called) return capture.value as T; const maxRetries = Math.max(0, options.maxSchemaRetries ?? 2); // Restrict to the schema tool so the only useful next action is calling it // (takes effect on the next prompt turn). Best-effort. try { session.setActiveToolsByName?.(["structured_output"]); } catch { // ignore — the re-prompt alone still drives most models to comply } for (let attempt = 0; attempt < maxRetries && !capture.called; attempt++) { if (options.signal?.aborted) throw new Error("Subagent was aborted"); await session.prompt( "You did not call the structured_output tool. Call structured_output now as your only action, with the required fields filled in. Do not write a prose answer.", ); } if (capture.called) return capture.value as T; const extracted = extractValidated(lastText(session.messages), schema); if (extracted !== undefined) { console.warn( "[workflow] structured_output recovered from prose extraction (the model never called the tool); prefer a tool-reliable model", ); return extracted; } // A repair re-prompt can itself hit the provider limit. Surface that as the real // (recoverable) cause instead of the misleading non-recoverable SCHEMA_NONCOMPLIANCE. throwIfProviderLimit(session.messages, options.label); throw new WorkflowError( "Subagent did not produce valid structured_output after repair attempts", WorkflowErrorCode.SCHEMA_NONCOMPLIANCE, { recoverable: false, agentLabel: options.label }, ); } /** * Resolve which concrete model spec a subagent should use. Precedence, most * specific first: * 1. options.model — an explicit per-agent model (also carries agentType / * phase model, which the workflow layer folds into options.model). * 2. options.tier — resolved via the model-tiers config, falling back to the * session's main model when the tier has no configured entry. * 3. DEFAULT TIER — when neither is set but the user has a model-tiers config, * untagged agents default to the "medium" tier so a configured tier set * actually affects the whole workflow (not just agents the script tagged). * Fresh-install medium == the session model, so this is a no-op until the * user customizes tiers via /workflows-models. * Returns undefined when nothing applies, so the session default is used. * * `loadConfig` is injectable for testing; it defaults to reading from disk. */ export function resolveAgentModelSpec( options: { model?: string; tier?: string }, mainModel: string | undefined, loadConfig: () => ModelTierConfig | null = loadModelTierConfig, onTierWithoutConfig?: (tier: string) => void, ): string | undefined { if (options.model) return options.model; const config = loadConfig(); if (options.tier) { // Tier requested but unconfigured → it silently falls back to mainModel. // Let the caller surface that (once) so the no-op is discoverable. if (!config) onTierWithoutConfig?.(options.tier); return (config ? resolveTierModel(options.tier, config) : undefined) ?? mainModel; } // Untagged agent: default to the configured medium tier when one exists. if (config) { const medium = resolveTierModel("medium", config); if (medium) return medium; } return undefined; } export interface WorkflowAgentOptions { cwd?: string; /** Extra tools available to the subagent in addition to the structured output tool. */ tools?: ToolDefinition[]; /** * Extra tool NAMES to deny in the subagent session, on top of the always-on * defaults ({@link DEFAULT_EXCLUDED_SUBAGENT_TOOLS}). Lets the host exclude * other recursive-orchestration tools it registers (e.g. a pi-subagents tool) * so a workflow subagent can't fan out through them either (#107). */ excludeTools?: string[]; /** Override any createAgentSession option (model, modelRuntime, resourceLoader, etc.). */ session?: Partial; /** Extra system guidance prepended to every subagent task. */ instructions?: string; /** * The session's main model (`provider/modelId`). Used as a fallback when * resolving opts.tier and no model-tiers.json config exists. Without this, * a workflow using `{ tier: "small" }` would log a warning and fall through * to the session default when no config is saved yet. */ mainModel?: string; /** * Shared model registry from the host Pi session. When provided, subagents * resolve tier/model specs against the same registry the main session uses, * including dynamically-registered providers such as ollama-cloud. Without * this, the agent builds an isolated registry from disk and may miss models * that are only available via extension registration. */ modelRegistry?: ModelRegistry; /** Registry used to dispatch external executors. */ executorRegistry?: ExecutorRegistry; /** * Persist each subagent transcript as a real pi session file under the * standard sessions directory (keyed by the runner's project cwd), instead * of the default in-memory session that is discarded when the run ends. * Default: false (current behavior). */ persistAgentSessions?: boolean; } // pi >= 0.80.8: ModelRegistry is a sync facade over an async-created ModelRuntime // (AuthStorage/ModelRegistry.create are gone). The disk-backed fallback is built // lazily; sync callers see [] until it resolves and real specs on later reads. let fallbackRuntimePromise: Promise | undefined; let fallbackRegistry: ModelRegistry | undefined; function ensureFallbackRegistry(): Promise { if (!fallbackRuntimePromise) { const dir = getAgentDir(); // Same auth.json/models.json createAgentSession uses by default, so a model // resolved here carries valid credentials. fallbackRuntimePromise = (async () => { const runtime = await ModelRuntime.create({ authPath: join(dir, "auth.json"), modelsPath: join(dir, "models.json"), }); // Warm the availability snapshot so the facade's sync getAvailable() is // populated immediately after this promise resolves. await runtime.getAvailable().catch(() => {}); return runtime; })(); // Don't cache a rejection: a transient failure (e.g. auth.json lock) would // otherwise wedge the fallback for the rest of the process. fallbackRuntimePromise.catch(() => { fallbackRuntimePromise = undefined; }); } return fallbackRuntimePromise.then((runtime) => { fallbackRegistry ??= new ModelRegistry(runtime); return fallbackRegistry; }); } let warnedNoRuntime = false; /** * The ModelRuntime behind a registry facade. pi's ModelRegistry does not expose * its runtime publicly, so reach into the private field (stable since 0.80.8); * subagent sessions need it to share the host session's exact catalog and auth * (createAgentSession takes modelRuntime, not a registry, since 0.80.8). * * Exported so the test suite can pin this pi-internals contract: the cast means * neither tsc nor mock-based tests would notice pi renaming the field, and the * runtime consequence is silent (subagents fall back to a default runtime and * extension-registered providers vanish from routing). */ export function runtimeOf(registry: ModelRegistry): ModelRuntime | undefined { const runtime = (registry as unknown as { runtime?: ModelRuntime }).runtime; if (!runtime && !warnedNoRuntime) { warnedNoRuntime = true; console.warn( "[workflow] ModelRegistry no longer carries a private `runtime` field (pi internals changed); subagents fall back to a default-built runtime and may miss extension-registered providers", ); } return runtime; } /** * List the user's currently available models (those with auth configured) with * the minimal fields tier ranking needs: canonical spec, output price, and * context window. This is the single place the SDK `Model` is projected into * the SDK-agnostic `RankableModel`. Best-effort: returns [] if the registry * can't be built (or while the disk-backed fallback is still initializing). */ export function listAvailableModels(registry?: ModelRegistry): RankableModel[] { try { const modelRegistry = registry ?? fallbackRegistry; if (!modelRegistry) { // Kick off the async fallback build; this call reports [] and later // calls (e.g. the tool's lazy promptGuidelines re-reads) see real specs. void ensureFallbackRegistry().catch(() => {}); return []; } return modelRegistry.getAvailable().map((model) => ({ spec: canonicalModelSpec(model), costOutput: model.cost?.output, contextWindow: model.contextWindow, })); } catch { return []; } } /** * List the user's currently available models as `provider/modelId` specs. Used * to tell the workflow author which models it may route agents to. Best-effort: * returns [] if the registry can't be built. */ export function listAvailableModelSpecs(registry?: ModelRegistry): string[] { return listAvailableModels(registry).map((model) => model.spec); } /** * Emitted at most once per process: when an agent asks for a tier but no * model-tiers.json exists, the tier silently falls back to the session model. * Surface that once (with the mapping the user would get by configuring) so the * no-op is discoverable. Diagnostics only — never lets a failure break a run. */ let warnedTierUnconfigured = false; function warnTierUnconfiguredOnce(mainModel: string | undefined, registry: ModelRegistry): void { if (warnedTierUnconfigured) return; warnedTierUnconfigured = true; try { console.warn(formatTierFallbackNotice(mainModel, listAvailableModels(registry))); } catch { // best-effort diagnostic } } /** * Emitted at most once per process when persistAgentSessions is enabled and a * session is actually persisted: full subagent transcripts (which may include * secrets or other sensitive context) are being written to disk. Surface the * privacy trade-off at run time, not only in the docs. */ let warnedPersistSecrets = false; function warnPersistSecretsOnce(sessionDir: string): void { if (warnedPersistSecrets) return; warnedPersistSecrets = true; console.warn( `[workflow] persistAgentSessions is ON: full subagent transcripts (which may include secrets or other sensitive context) are being written to disk under ${sessionDir}. Disable persistAgentSessions if that isn't intended.`, ); } /** Real token/cost usage for a single subagent run, read from the SDK session. */ export type AgentUsage = ExecutorUsage; /** * Map session stats to an AgentUsage, or undefined when the provider reported * no usage at all (all-zero stats). Returning undefined — instead of a zero * breakdown — lets displays fall back to their scalar token count, so setups * on non-reporting providers render the same as before the split existed. */ export function usageFromStats(stats: { tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; cost: number; }): AgentUsage | undefined { const { tokens, cost } = stats; if (tokens.total <= 0 && cost <= 0) return undefined; return { input: tokens.input, output: tokens.output, cacheRead: tokens.cacheRead, cacheWrite: tokens.cacheWrite, total: tokens.total, cost, }; } export interface AgentRunOptions { label?: string; /** Executor to use for this call. Omitted means the Pi implementation. */ executor?: WorkflowExecutor; /** * Display name recorded on the persisted session (session_info entry) when * `persistAgentSessions` is enabled, so transcripts are identifiable in * session pickers (e.g. `workflow: