import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai"; import { type CreateAgentSessionOptions, createAgentSession, createCodingTools, getAgentDir, SessionManager, SettingsManager, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import type { Static, TSchema } from "typebox"; import { createStructuredOutputTool, type StructuredOutputCapture } from "./structured-output.js"; export interface WorkflowAgentOptions { cwd?: string; /** Extra tools available to the subagent in addition to the structured output tool. */ tools?: ToolDefinition[]; /** Override any createAgentSession option (model, authStorage, resourceLoader, etc.). */ session?: Partial; /** Extra system guidance prepended to every subagent task. */ instructions?: string; } export interface AgentRunOptions { label?: string; schema?: TSchemaDef; tools?: ToolDefinition[]; instructions?: string; signal?: AbortSignal; } export type AgentRunResult = TSchemaDef extends TSchema ? Static : string; export class WorkflowAgent { private readonly cwd: string; private readonly baseTools: ToolDefinition[]; private readonly sessionOptions: Partial; private readonly instructions?: string; constructor(options: WorkflowAgentOptions = {}) { this.cwd = options.cwd ?? process.cwd(); this.baseTools = options.tools ?? createCodingTools(this.cwd); this.sessionOptions = options.session ?? {}; this.instructions = options.instructions; } async run( prompt: string, options: AgentRunOptions = {}, ): Promise> { const capture: StructuredOutputCapture = { called: false, value: undefined }; const customTools: ToolDefinition[] = [...this.baseTools, ...(options.tools ?? [])]; if (options.schema) { customTools.push(createStructuredOutputTool({ schema: options.schema, capture }) as unknown as ToolDefinition); } const agentDir = getAgentDir(); const { session } = await createAgentSession({ cwd: this.cwd, agentDir, sessionManager: SessionManager.inMemory(this.cwd), settingsManager: SettingsManager.create(this.cwd, agentDir), customTools, ...this.sessionOptions, }); let removeAbortListener: (() => void) | undefined; try { if (options.signal?.aborted) throw new Error("Subagent was aborted"); if (options.signal) { const onAbort = () => void session.abort(); options.signal.addEventListener("abort", onAbort, { once: true }); removeAbortListener = () => options.signal?.removeEventListener("abort", onAbort); } await session.prompt(this.buildPrompt(prompt, options as AgentRunOptions, Boolean(options.schema))); if (options.signal?.aborted) throw new Error("Subagent was aborted"); if (options.schema) { if (!capture.called) { throw new Error("Subagent finished without calling structured_output"); } return capture.value as AgentRunResult; } return this.lastAssistantText(session.messages) as AgentRunResult; } finally { removeAbortListener?.(); session.dispose(); } } private buildPrompt(prompt: string, options: AgentRunOptions, structured: boolean): string { const parts = [ this.instructions, options.instructions, options.label ? `Task label: ${options.label}` : undefined, prompt, ].filter(Boolean); if (structured) { parts.push( [ "Final output contract:", "- Your final action MUST be a structured_output tool call.", "- The structured_output arguments are the return value of this subagent.", "- Do not emit a prose final answer instead of structured_output.", "- If you need to inspect files or run commands first, do so, then call structured_output exactly once.", ].join("\n"), ); } return parts.join("\n\n"); } private lastAssistantText(messages: unknown[]): string { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] as Partial | undefined; if (message?.role !== "assistant" || !Array.isArray(message.content)) continue; const text = message.content .filter((part): part is TextContent => part.type === "text") .map((part) => part.text) .join(""); if (text.trim()) return text; } return ""; } }