/** * Parent-side agent execution for workflow child requests. * * When the sandbox child sends an "agent" IPC message, this module * creates an in-process AgentSession, runs the prompt, and returns * the result. No subprocess — agent runs inside the parent process. * * Blocked tools in workflow agents: * - workflow (recursive prevention) * - subagent, subagent_parallel (subagent recursion) * - ask_user (no user interaction in workflows) * - bg_start, bg_status, bg_list, bg_kill (background terminals) * * Tool blocking uses startsWith matching for subagent* and bg_* patterns. */ import { createAgentSession, type CreateAgentSessionResult } from "@earendil-works/pi-coding-agent"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { UsageStats, AgentOptions, ParentMessage } from "./types.ts"; import { enforceResultLimit } from "./limits.ts"; // ── Blocked Tools ────────────────────────────────────────────────────────────── const BLOCKED_TOOLS = new Set([ "workflow", "subagent", "subagent_parallel", "ask_user", "bg_start", "bg_status", "bg_list", "bg_kill", "background", ]); /** Tools blocked by prefix pattern. */ const BLOCKED_PREFIXES = ["subagent", "bg_"]; /** * Check if a tool name should be blocked in workflow agent sessions. */ export function isToolBlockedInChild(toolName: string): boolean { if (BLOCKED_TOOLS.has(toolName)) return true; return BLOCKED_PREFIXES.some((prefix) => toolName.startsWith(prefix)); } /** * Get the list of explicitly blocked tools. */ export function getBlockedChildTools(): string[] { return Array.from(BLOCKED_TOOLS); } // ── Safe Tools ───────────────────────────────────────────────────────────────── /** * Safe tool allowlist for child agents. * Only real tools that exist in pi. No web_search/web_fetch (repo mandates ketch CLI). * No recursive workflow/subagent/background tools. No ask_user. */ function getDefaultChildTools(): string[] { return [ "read", "write", "edit", "bash", "grep", "find", "ls", "memory_search", "session_search", ]; } // ── Agent Execution ──────────────────────────────────────────────────────────── export interface ExecuteAgentOptions { /** Task prompt */ prompt: string; /** Agent call options from script */ options: AgentOptions; /** Working directory */ cwd: string; /** Abort signal */ signal?: AbortSignal; /** Model registry for resolving model/provider */ modelRegistry: any; /** Default model ID */ defaultModel?: string; /** Default provider */ defaultProvider?: string; /** Default thinking level */ thinking?: ThinkingLevel; /** Agent directory */ agentDir?: string; } export interface ExecuteAgentResult { id: string; success: boolean; output: string; error?: string; usage: UsageStats; model?: string; structuredResult?: unknown; truncated: boolean; } /** * Execute a single agent call in-process. * * Called when the sandbox child requests an agent via IPC. * Creates an AgentSession, sends the prompt, collects the result, * and disposes the session. */ export async function executeAgent( id: string, execOptions: ExecuteAgentOptions, ): Promise { const { prompt, options, cwd, signal, modelRegistry, defaultModel, defaultProvider, thinking = "medium", } = execOptions; const usage: UsageStats = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0, totalTokens: 0, }; let session: any = undefined; let sessionResult: CreateAgentSessionResult | undefined; let output = ""; let error: string | undefined; let model: string | undefined; let structuredResult: unknown = undefined; let stepTimer: ReturnType | null = null; let abortHandler: (() => void) | undefined; let unsubscribe: (() => void) | undefined; try { // Resolve model: options override > defaults const provider = options.provider || defaultProvider; const modelId = options.model || defaultModel; let resolvedModel: any = undefined; if (modelRegistry && provider && modelId) { try { if (typeof modelRegistry.find === "function") { resolvedModel = modelRegistry.find(provider, modelId); } } catch { // Fall through to default } } // If only modelId is provided without provider, try to find across all providers if (!resolvedModel && modelId && modelRegistry && typeof modelRegistry.getAll === "function") { try { const allModels = modelRegistry.getAll(); resolvedModel = allModels.find((m: any) => m.id === modelId); } catch { // Fall through } } // Resolve thinking/effort level let thinkingLevel: ThinkingLevel = thinking; if (options.effort) { const effort = options.effort.toLowerCase(); if (effort === "low" || effort === "minimal") thinkingLevel = "low"; else if (effort === "medium") thinkingLevel = "medium"; else if (effort === "high") thinkingLevel = "high"; else if (effort === "xhigh" || effort === "maximum") thinkingLevel = "xhigh"; } // Determine tool allowlist const allowedTools = getDefaultChildTools(); const excludedTools = Array.from(BLOCKED_TOOLS).filter( (t) => !allowedTools.includes(t), ); // Build structured output prompt addendum let systemPromptAddendum = ""; if (options.schema) { systemPromptAddendum = ` ## Structured Output Requirement You MUST respond with a single JSON object conforming to this JSON Schema: \`\`\`json ${JSON.stringify(options.schema, null, 2)} \`\`\` Wrap your JSON response in a \`\`\`json code block at the end of your message. Only emit the JSON once, at the end. `; } // Create in-process agent session — inherit parent modelRegistry for // accurate provider/model resolution and API key access. sessionResult = await createAgentSession({ cwd, model: resolvedModel, modelRegistry, thinkingLevel, tools: allowedTools, excludeTools: excludedTools, }); session = sessionResult.session; // Collect events from the session unsubscribe = session.subscribe((event: any) => { if (event.type === "message_end" && event.message?.role === "assistant") { const u = event.message.usage; if (u) { usage.input += u.input || 0; usage.output += u.output || 0; usage.cacheRead += u.cacheRead || 0; usage.cacheWrite += u.cacheWrite || 0; usage.cost += u.cost?.total || 0; usage.totalTokens += u.totalTokens || (u.input || 0) + (u.output || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0); } if (event.message.model) model = event.message.model; const content = event.message.content; if (content && !event.message.errorMessage) { output = extractTextContent(content); usage.turns++; } if (event.message.errorMessage && !error) { error = event.message.errorMessage; } } }); // Create one signal-aware timeout promise. The named abort handler is // removed on every exit path, avoiding listener accumulation in workflows. const timeoutPromise = new Promise((_, reject) => { stepTimer = setTimeout(() => { stepTimer = null; void session?.abort?.().catch?.(() => {}); reject(new Error(`Agent call timed out after 5 minutes`)); }, 300_000); if (signal) { abortHandler = () => { if (stepTimer) { clearTimeout(stepTimer); stepTimer = null; } void session?.abort?.().catch?.(() => {}); reject(new Error("Agent call aborted")); }; signal.addEventListener("abort", abortHandler, { once: true }); if (signal.aborted) abortHandler(); } }); // Send the prompt const promptText = systemPromptAddendum ? `${systemPromptAddendum}\n\n## Task\n\n${prompt}` : prompt; try { await Promise.race([ session.prompt(promptText), timeoutPromise, ]); } catch (err: any) { if (!error) error = err?.message ?? String(err); } finally { // Always clear the timeout timer to avoid leaking if (stepTimer) { clearTimeout(stepTimer); stepTimer = null; } } // Wait for session to become idle if (!error) { try { await Promise.race([ session.waitForIdle?.(), new Promise((resolve) => setTimeout(resolve, 5_000)), ]); } catch { // Best effort } } // Get final output if (!output || output.length === 0) { const lastText = session.getLastAssistantText?.(); if (lastText) output = lastText; } // Extract structured output if (options.schema && output) { structuredResult = extractStructuredOutput(output, options.schema); if (structuredResult === undefined) { error = "Agent did not return JSON matching the requested schema"; } } unsubscribe?.(); unsubscribe = undefined; if (abortHandler) signal?.removeEventListener("abort", abortHandler); abortHandler = undefined; try { session.dispose?.(); } catch { /* best effort */ } } catch (err: any) { if (!error) error = err?.message ?? String(err); if (stepTimer) { clearTimeout(stepTimer); stepTimer = null; } try { unsubscribe?.(); } catch { /* best effort */ } if (abortHandler) signal?.removeEventListener("abort", abortHandler); try { session?.dispose?.(); } catch { /* best effort */ } } // Enforce result size limit const { text: finalOutput, truncated } = enforceResultLimit(output || error || ""); return { id, success: !error && finalOutput.length > 0, output: finalOutput, error, usage, model, structuredResult: error ? undefined : structuredResult, truncated, }; } // ── Helpers ───────────────────────────────────────────────────────────────────── function extractTextContent(content: unknown): string { if (!content) return ""; if (typeof content === "string") return content; if (Array.isArray(content)) { return content .filter((c: any) => c?.type === "text") .map((c: any) => c.text) .join("\n"); } return String(content); } /** * Extract and validate a JSON object from output against an optional schema. * Tries ```json blocks first, then full output. * If a schema is provided, validates the extracted JSON against it. */ function extractStructuredOutput( output: string, schema?: Record, ): unknown { // Look for ```json ... ``` blocks const jsonBlockRegex = /```json\s*([\s\S]*?)```/g; const matches = output.matchAll(jsonBlockRegex); for (const match of matches) { try { const parsed = JSON.parse(match[1].trim()); if (schema && !validateAgainstSchema(parsed, schema)) continue; return parsed; } catch { // Try next match } } // Fallback: try the whole output as JSON try { const trimmed = output.trim(); if (trimmed.startsWith("{") || trimmed.startsWith("[")) { const parsed = JSON.parse(trimmed); if (!schema || validateAgainstSchema(parsed, schema)) { return parsed; } } } catch { // Not valid JSON } return undefined; } /** * Lightweight JSON Schema validation. * Validates required fields and types. Not a full validator, but real validation. */ function validateAgainstSchema( value: unknown, schema: Record, ): boolean { if (!schema || typeof schema !== "object") return true; const schemaType = schema.type; if (schemaType === "object" && typeof value === "object" && value !== null) { const obj = value as Record; // Check required fields const required = schema.required as string[] | undefined; if (required) { for (const key of required) { if (!(key in obj)) return false; } } // Check property types const properties = schema.properties as Record | undefined; if (properties) { for (const [key, propSchema] of Object.entries(properties)) { if (key in obj) { if (!validateType(obj[key], propSchema.type)) return false; } } } // Check additionalProperties if (schema.additionalProperties === false) { const knownKeys = new Set(Object.keys(properties || {})); for (const key of Object.keys(obj)) { if (!knownKeys.has(key)) return false; } } return true; } if (schemaType === "array" && Array.isArray(value)) { const items = schema.items as Record | undefined; if (items) { for (const item of value) { if (!validateAgainstSchema(item, items)) return false; } } return true; } return true; // Non-object schemas: best-effort pass } function validateType(value: unknown, expectedType: string): boolean { switch (expectedType) { case "string": return typeof value === "string"; case "number": return typeof value === "number"; case "boolean": return typeof value === "boolean"; case "object": return typeof value === "object" && value !== null && !Array.isArray(value); case "array": return Array.isArray(value); case "null": return value === null; case "integer": return typeof value === "number" && Number.isInteger(value); default: return true; } }