import { execSync } from "node:child_process"; import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, getAgentDir, } from "@earendil-works/pi-coding-agent"; import type { AgentSession, AgentSessionEvent, ToolDefinition } from "@earendil-works/pi-coding-agent"; import type { AgentDefinition, EnvInfo, RunOptions, RunResult, ToolActivity, } from "../shared/types.js"; import { preloadSkills } from "./skill-loader.js"; import { evaluateToolCall } from "./tool-budget.js"; import { buildMemoryInjection } from "./memory.js"; interface SkillBlock { name: string; content: string; } /** * Detect environment info for prompt construction. */ export function detectEnv(cwd: string): EnvInfo { let isGitRepo = false; let branch = ""; try { execSync("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" }); isGitRepo = true; branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd, stdio: "pipe", }) .toString() .trim(); } catch { // Not a git repo or git not available } return { isGitRepo, branch, platform: process.platform }; } /** * Build the system prompt for an agent session. * * - `"replace"` (default): Agent owns its entire system prompt. * - `"append"`: Agent inherits parent prompt and layers specialization. */ export function buildAgentPrompt( agentDef: AgentDefinition, cwd: string, env: EnvInfo, parentSystemPrompt?: string, skillBlocks?: SkillBlock[], ): string { if (agentDef.promptMode === "append") { return buildAppendPrompt(agentDef, cwd, env, parentSystemPrompt, skillBlocks); } return buildReplacePrompt(agentDef, cwd, env, skillBlocks); } function buildReplacePrompt( agentDef: AgentDefinition, cwd: string, env: EnvInfo, skillBlocks?: SkillBlock[], ): string { const envLine = `Environment: cwd=${cwd}${env.isGitRepo ? `, git branch=${env.branch}` : ""}, platform=${env.platform}`; const parts: string[] = [ ``, "", envLine, ]; if (agentDef.systemPrompt.trim()) { parts.push("", agentDef.systemPrompt.trim()); } appendSkillBlocks(parts, skillBlocks); return parts.join("\n"); } function buildAppendPrompt( agentDef: AgentDefinition, cwd: string, env: EnvInfo, parentSystemPrompt?: string, skillBlocks?: SkillBlock[], ): string { const base = parentSystemPrompt?.trim() || "You are a general-purpose coding agent."; const envLine = `Environment: cwd=${cwd}${env.isGitRepo ? `, git branch=${env.branch}` : ""}, platform=${env.platform}`; const parts: string[] = [ base, "", "", "You are operating as a specialized sub-agent. Your parent session has", "delegated a specific task to you. Focus on completing the delegated", "task efficiently.", "", "", ``, envLine, ]; if (agentDef.systemPrompt.trim()) { parts.push( "", "", agentDef.systemPrompt.trim(), "", ); } appendSkillBlocks(parts, skillBlocks); return parts.join("\n"); } function appendSkillBlocks(parts: string[], skillBlocks?: SkillBlock[]): void { if (skillBlocks && skillBlocks.length > 0) { for (const skill of skillBlocks) { parts.push("", `\n${skill.content}\n`); } } } /** * Build a formatted string of the parent conversation history for context forking. */ export function buildParentContext(entries: unknown[]): string { const lines: string[] = []; for (const entry of entries) { const e = entry as { type?: string; role?: string; content?: Array<{ type: string; text?: string }>; summary?: string; }; if (e.type === "message" && e.role === "user") { const text = e.content ?.filter((c) => c.type === "text") .map((c) => c.text ?? "") .join("") ?? ""; if (text) lines.push(`[User]: ${text}`); } else if (e.type === "message" && e.role === "assistant") { const text = e.content ?.filter((c) => c.type === "text") .map((c) => c.text ?? "") .join("") ?? ""; if (text) lines.push(`[Assistant]: ${text}`); } else if (e.type === "compaction") { if (e.summary) lines.push(`[Summary]: ${e.summary}`); } // Skip toolResult entries and anything else } return [ "", "The following is the conversation history from the parent session that", "delegated this task to you. Use it for context but focus on your", "assigned task.", "", ...lines, "", ].join("\n"); } /** * Stateless session execution. Creates an AgentSession, subscribes to events, * executes the prompt, and returns the result. */ export async function runAgent( agentDef: AgentDefinition, options: RunOptions, ctx: { model?: unknown; modelRegistry?: unknown; sessionManager?: { getBranch?: () => unknown[] }; }, ): Promise { // 1. Resolve tools — exclude "subagent" unless recursion is allowed, then filter disallowed const allowedTools = ( options.allowRecursion ? agentDef.tools : agentDef.tools.filter((t) => t !== "subagent") ).filter((t) => !(agentDef.disallowedTools ?? []).includes(t)); // 2. Build system prompt const env = detectEnv(options.cwd); const preloaded = Array.isArray(agentDef.skills) && agentDef.skills.length > 0 ? preloadSkills(agentDef.skills, options.cwd) : []; const skillBlocks = preloaded.length > 0 ? preloaded : undefined; let systemPrompt = buildAgentPrompt( agentDef, options.cwd, env, options.parentSystemPrompt, skillBlocks, ); // Inject agent memory block (append position) if (agentDef.memory) { const hasWriteTools = allowedTools.includes("write") || allowedTools.includes("edit"); const memoryBlock = buildMemoryInjection( agentDef.memory, options.cwd, hasWriteTools, ); if (memoryBlock) { systemPrompt += `\n\n${memoryBlock}`; } } // 2b. If inheritContext, prepend parent conversation to prompt let fullPrompt = options.prompt; if (options.inheritContext) { const ctxWithSession = ctx as { sessionManager?: { getBranch?: () => unknown[] }; }; if (ctxWithSession.sessionManager?.getBranch) { const parentContext = buildParentContext( ctxWithSession.sessionManager.getBranch(), ); fullPrompt = `${parentContext}\n\n${options.prompt}`; } } // 3. Create ResourceLoader with policy-driven extension loading. // Extensions disabled when agent is isolated, or explicitly disables them. // A string[] list (selective) or true (all) means extensions are enabled. const agentDir = getAgentDir(); const noExtensions = agentDef.isolated === true || agentDef.extensions === false; const loader = new DefaultResourceLoader({ cwd: options.cwd, agentDir, noExtensions, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, systemPromptOverride: () => systemPrompt, appendSystemPromptOverride: () => [], }); await loader.reload(); // 4. Resolve model const model = (options.model ?? ctx.model) as never; // 5. Create session const settingsManager = SettingsManager.create(options.cwd, agentDir); const sessionManager = SessionManager.inMemory(options.cwd); const thinkingLevel = options.thinking ?? agentDef.thinking; const customTools = (options.customTools ?? []) as ToolDefinition[]; // Custom tool names must be in the allowed tools list, otherwise // createAgentSession's internal allowlist filter silently drops them. const effectiveAllowedTools = [...allowedTools, ...customTools.map((t) => t.name)]; const { session } = await createAgentSession({ cwd: options.cwd, agentDir, sessionManager, settingsManager, model, tools: effectiveAllowedTools, resourceLoader: loader, ...(thinkingLevel ? { thinkingLevel: thinkingLevel as never } : {}), ...(customTools.length > 0 ? { customTools } : {}), }); // 6. Bind extensions (required even when empty) await session.bindExtensions({}); options.onSessionCreated?.(session); // 7. Subscribe to events + turn-based limits let responseText = ""; let turnCount = 0; let aborted = false; let steered = false; let budgetToolCount = 0; let budgetSoftNudged = false; const maxTurns = options.maxTurns ?? 0; const graceTurns = options.graceTurns ?? 5; const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if (event.type === "message_start") { responseText = ""; } if ( event.type === "message_update" && event.assistantMessageEvent.type === "text_delta" ) { responseText += event.assistantMessageEvent.delta; options.onTextDelta?.(event.assistantMessageEvent.delta, responseText); } if (event.type === "tool_execution_start") { options.onToolActivity?.({ type: "start", toolName: event.toolName }); if (options.toolBudget) { budgetToolCount++; const budgetResult = evaluateToolCall( options.toolBudget, budgetToolCount, event.toolName, ); if (budgetResult.outcome === "soft-reached" && !budgetSoftNudged) { budgetSoftNudged = true; session.steer(budgetResult.message ?? "Tool budget soft limit reached."); steered = true; } else if (budgetResult.outcome === "hard-blocked") { session.steer(budgetResult.message ?? "Tool budget hard limit reached."); aborted = true; session.abort(); } } } if (event.type === "tool_execution_end") { options.onToolActivity?.({ type: "end", toolName: event.toolName }); } if (event.type === "turn_end") { turnCount++; options.onTurnEnd?.(turnCount); // Soft limit: steer the agent to wrap up if (maxTurns > 0 && turnCount === maxTurns && !steered) { session.steer( "You have reached the turn limit. Wrap up your work immediately and return your final result.", ); steered = true; } // Hard limit: abort after grace period if (maxTurns > 0 && turnCount >= maxTurns + graceTurns) { aborted = true; session.abort(); } } if (event.type === "message_end" && event.message.role === "assistant") { const usage = ( event.message as { usage?: { input?: number; output?: number; cacheWrite?: number }; } ).usage; if (usage) { options.onUsage?.({ input: usage.input ?? 0, output: usage.output ?? 0, cacheWrite: usage.cacheWrite ?? 0, }); } } if (event.type === "agent_settled") { options.onSettled?.(); } }); // 8. Wire parent abort signal const cleanupAbort = forwardAbortSignal(session, options.signal); // 9. Execute prompt (use fullPrompt which may include parent context) try { await session.prompt(fullPrompt); } catch (error) { if (!aborted && !options.signal?.aborted) throw error; aborted = true; } finally { unsubscribe(); cleanupAbort(); } // 10. Fallback: get text from session messages if streaming didn't capture it if (!responseText.trim()) { responseText = getLastAssistantText(session); } return { responseText, session: session as unknown, aborted, steered }; } /** * Resume an existing session with a new prompt. * Reuses forwardAbortSignal and getLastAssistantText helpers. */ export async function resumeAgent( session: AgentSession, prompt: string, options: { onToolActivity?: (activity: ToolActivity) => void; onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void; onTextDelta?: (delta: string, fullText: string) => void; onTurnEnd?: () => void; onSettled?: () => void; signal?: AbortSignal; } = {}, ): Promise { let responseText = ""; const cleanupAbort = forwardAbortSignal(session, options.signal); const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if (event.type === "message_start") responseText = ""; if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { responseText += event.assistantMessageEvent.delta; options.onTextDelta?.(event.assistantMessageEvent.delta, responseText); } if (event.type === "tool_execution_start") { options.onToolActivity?.({ type: "start", toolName: event.toolName }); } if (event.type === "tool_execution_end") { options.onToolActivity?.({ type: "end", toolName: event.toolName }); } if (event.type === "message_end" && event.message.role === "assistant") { const usage = (event.message as { usage?: { input?: number; output?: number; cacheWrite?: number } }).usage; if (usage) { options.onAssistantUsage?.({ input: usage.input ?? 0, output: usage.output ?? 0, cacheWrite: usage.cacheWrite ?? 0, }); } } if (event.type === "turn_end") { options.onTurnEnd?.(); } if (event.type === "agent_settled") { options.onSettled?.(); } }); try { await session.prompt(prompt); } finally { unsubscribe(); cleanupAbort(); } return responseText.trim() || getLastAssistantText(session); } /** Extract readable conversation text from a session for verbose output. */ export function getAgentConversation(session: unknown): string { const s = session as { messages?: Array<{ role: string; content?: unknown }> }; if (!s.messages) return ""; const lines: string[] = []; for (const msg of s.messages) { if (msg.role === "assistant") { const content = msg.content as Array<{ type: string; text?: string }> | undefined; if (content) { const text = content.filter(b => b.type === "text").map(b => b.text ?? "").join(""); if (text.trim()) lines.push(`[assistant] ${text.trim()}`); } } else if (msg.role === "user") { const content = msg.content; const text = typeof content === "string" ? content : Array.isArray(content) ? (content as Array<{ type: string; text?: string }>).filter(b => b.type === "text").map(b => b.text ?? "").join("") : ""; if (text.trim()) lines.push(`[user] ${text.trim()}`); } } return lines.join("\n\n"); } /** Wire an AbortSignal to abort a session. Returns cleanup function. */ function forwardAbortSignal( session: AgentSession, signal?: AbortSignal, ): () => void { if (!signal) return () => {}; if (signal.aborted) { session.abort(); return () => {}; } const onAbort = () => { session.abort(); }; signal.addEventListener("abort", onAbort, { once: true }); return () => signal.removeEventListener("abort", onAbort); } /** Get last assistant text from session transcript (fallback when streaming missed it). */ function getLastAssistantText(session: AgentSession): string { const messages = session.messages; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role !== "assistant") continue; const content = ( msg as { content?: Array<{ type: string; text?: string }> } ).content; if (!content) continue; const text = content .filter((block) => block.type === "text") .map((block) => block.text ?? "") .join(""); if (text.trim()) return text.trim(); } return ""; }