/** * KHAELOR * File: src/agent/executor.ts * Description: Tool Runtime — permission gate, sequential execution, durable tool events, cancellation-safe results (ARCHITECTURE.md §5.4). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import * as path from "node:path"; import type { CapabilityMappingContext, PermissionService } from "../permissions/index.js"; import { mapToolCapabilities } from "../permissions/index.js"; import type { PhaseService } from "../phases/index.js"; import type { ToolCompleted, ToolFailed, ToolName } from "../session/index.js"; import { CANCELLED_RESULT_CONTENT, parseToolInput } from "../tools/index.js"; import type { RepoGraphFacet, ToolContext, ToolRegistry, ToolResult } from "../tools/index.js"; import type { VerifyRunner } from "../verify/index.js"; import type { FileTimeRegistry, ProcessManager, Workspace } from "../workspace/index.js"; import type { KernelSession } from "./session-handle.js"; // ───────────────────────── batch vocabulary ───────────────────────── /** One `tool_use` block awaiting execution — derived from a recorded ToolRequested. */ export interface PendingToolCall { toolUseId: string; toolName: ToolName; input: unknown; blockIndex: number; } /** The kernel's view of the Tool Runtime (ARCHITECTURE.md §4.1 `tools`). */ export interface ToolBatchExecutor { /** Execute one model turn's tool calls sequentially, in block order (§11). */ executeBatch(pending: readonly PendingToolCall[], signal: AbortSignal): Promise; } type ToolFailureKind = ToolFailed["payload"]["errorKind"]; type ToolUiMeta = ToolCompleted["payload"]["ui"]; /** Hard backstop on model-facing tool_result content (TOOL_PROTOCOL §1.3). */ export const MODEL_TEXT_BACKSTOP = 64 * 1024; const BACKSTOP_MARKER = "\n[... output truncated: 64 KB model-facing backstop reached]"; function enforceBackstop(content: string): string { if (content.length <= MODEL_TEXT_BACKSTOP) return content; return content.slice(0, MODEL_TEXT_BACKSTOP) + BACKSTOP_MARKER; } function uiKindFor(toolName: string): ToolUiMeta["kind"] { switch (toolName) { case "read": return "read"; case "grep": case "glob": case "symbols": case "refs": return "search"; case "write": case "edit": return "edit"; case "process": return "process"; default: return "exec"; } } // ───────────────────────── the executor ───────────────────────── export interface ToolExecutorOptions { session: KernelSession; registry: ToolRegistry; permissions: PermissionService; workspace: Workspace; fileTimes: FileTimeRegistry; processes: ProcessManager; /** Directory for oversized-output spill files (written through the workspace). */ spillDir: string; /** Canonical project root for capability classification. Defaults to workspace.cwd(). */ projectRoot?: string; /** Home directory for `~` expansion in capability subjects. */ home?: string; now?: () => number; /** Phase-gate service (v2 §1); absent → gates off. */ phases?: PhaseService; /** Semantic-index facet for the symbols/refs tools (v2 §3). */ repograph?: RepoGraphFacet; /** Native verification runner (v2 §4); absent → no native verify loop. */ verify?: VerifyRunner; } /** * The Tool Runtime (ADR-8/9): between `ToolRequested` and `ToolStarted` sits * exactly one permission evaluation; execution is sequential per batch; * every call ends in exactly one durable terminal event (ToolCompleted / * ToolFailed / ToolCancelled) so tool_use/tool_result pairing holds at all * times. Recoverable errors become model-facing repair prose — course * corrections, never dead ends (TOOL_PROTOCOL §1.2). */ export class ToolExecutor implements ToolBatchExecutor { readonly #session: KernelSession; readonly #registry: ToolRegistry; readonly #permissions: PermissionService; readonly #workspace: Workspace; readonly #fileTimes: FileTimeRegistry; readonly #processes: ProcessManager; readonly #spillDir: string; readonly #projectRoot: string; readonly #home: string | undefined; readonly #now: () => number; readonly #phases: PhaseService | undefined; readonly #repograph: RepoGraphFacet | undefined; readonly #verify: VerifyRunner | undefined; #spillCounter = 0; constructor(options: ToolExecutorOptions) { this.#session = options.session; this.#registry = options.registry; this.#permissions = options.permissions; this.#workspace = options.workspace; this.#fileTimes = options.fileTimes; this.#processes = options.processes; this.#spillDir = options.spillDir; this.#projectRoot = options.projectRoot ?? options.workspace.cwd(); this.#home = options.home; this.#now = options.now ?? Date.now; this.#phases = options.phases; this.#repograph = options.repograph; this.#verify = options.verify; } async executeBatch(pending: readonly PendingToolCall[], signal: AbortSignal): Promise { let hadEdit = false; for (let i = 0; i < pending.length; i += 1) { const call = pending[i] as PendingToolCall; if (signal.aborted) { this.#cancelCalls(pending.slice(i)); return; } const outcome = await this.#executeOne(call, signal); if (outcome === "cancelled") { this.#cancelCalls(pending.slice(i + 1)); return; } if (outcome === "completed" && (call.toolName === "write" || call.toolName === "edit")) { hadEdit = true; } } // Native verification after each edit batch (v2 §4) — bounded repair loop: // once maxRepairLoops failing rounds are recorded since the last user // message, checks stop re-running and the honest failure report stands. if ( hadEdit && this.#verify !== undefined && this.#verify.policy === "after-each-edit-batch" && this.#verify.hasChecks && !signal.aborted && this.#verify.withinRepairBudget() ) { await this.#verify.runAll(signal); } } // ── one call ── async #executeOne( call: PendingToolCall, signal: AbortSignal, ): Promise<"completed" | "failed" | "cancelled"> { const startedAt = this.#now(); const tool = this.#registry.get(call.toolName); if (tool === undefined) { return this.#fail( call, "invalid-input", `Unknown tool "${call.toolName}". Available tools: ${this.#registry .list() .map((t) => t.name) .join(", ")}.`, startedAt, ); } const parsed = parseToolInput(tool, call.input); if (!parsed.ok) { return this.#fail(call, "invalid-input", parsed.error, startedAt); } const capabilities = mapToolCapabilities(call.toolName, call.input, this.#pathContext()); if (!capabilities.ok) { return this.#fail( call, "invalid-input", `Invalid input for tool "${call.toolName}": ${capabilities.error.message}. ` + "Please rewrite the input so it satisfies the expected schema.", startedAt, ); } // Phase gate (v2 §1): evaluated BEFORE permissions — a blocked call is a // structured course-correction telling the model to design first. if (this.#phases !== undefined) { const gate = this.#phases.checkToolCall(capabilities.value); if (!gate.allowed) { return this.#fail(call, "phase-blocked", gate.feedback, startedAt); } } let outcome; try { outcome = await this.#permissions.check({ toolUseId: call.toolUseId, toolName: call.toolName, requests: capabilities.value, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return this.#fail(call, "internal", `Permission evaluation failed: ${message}`, startedAt); } if (outcome.kind === "deny") { return this.#fail(call, "permission-denied", outcome.feedback, startedAt); } this.#session.publishDurable({ type: "tool.approved", payload: { toolUseId: call.toolUseId, via: outcome.via }, }); this.#session.publishDurable({ type: "tool.started", payload: { toolUseId: call.toolUseId, toolName: call.toolName }, }); let result: ToolResult; try { result = await tool.execute(parsed.value, this.#buildContext(call, signal)); } catch (error) { if (signal.aborted) return this.#cancel(call); const message = error instanceof Error ? error.message : String(error); return this.#fail( call, "internal", `Tool "${call.toolName}" failed unexpectedly: ${message}. ` + "Adjust the input and try again, or take a different approach.", startedAt, ); } if (signal.aborted) return this.#cancel(call); const durationMs = this.#now() - startedAt; const modelText = enforceBackstop(result.content); if (result.isError === true) { this.#session.publishDurable({ type: "tool.failed", payload: { toolUseId: call.toolUseId, modelText, errorKind: "exec-error", durationMs }, }); return "failed"; } const meta = result.metadata; const ui: ToolUiMeta = { kind: uiKindFor(call.toolName), summary: meta?.title ?? call.toolName, ...(meta?.additions !== undefined || meta?.deletions !== undefined ? { diffStats: { added: meta?.additions ?? 0, removed: meta?.deletions ?? 0 } } : {}), ...(typeof meta?.exitCode === "number" ? { exitCode: meta.exitCode } : {}), ...(meta?.matches !== undefined ? { matchCount: meta.matches } : {}), }; this.#session.publishDurable({ type: "tool.completed", payload: { toolUseId: call.toolUseId, modelText, durationMs, ui, ...(meta?.truncation?.spillPath !== undefined ? { spillFile: meta.truncation.spillPath } : {}), }, }); return "completed"; } // ── terminal-event helpers ── #fail( call: PendingToolCall, errorKind: ToolFailureKind, modelText: string, startedAt: number, ): "failed" { this.#session.publishDurable({ type: "tool.failed", payload: { toolUseId: call.toolUseId, modelText: enforceBackstop(modelText), errorKind, durationMs: this.#now() - startedAt, }, }); return "failed"; } #cancel(call: PendingToolCall): "cancelled" { this.#session.publishDurable({ type: "tool.cancelled", payload: { toolUseId: call.toolUseId, reason: "interrupted", modelText: CANCELLED_RESULT_CONTENT, }, }); return "cancelled"; } #cancelCalls(calls: readonly PendingToolCall[]): void { for (const call of calls) this.#cancel(call); } // ── context construction ── #pathContext(): CapabilityMappingContext { return { projectRoot: this.#projectRoot, cwd: this.#workspace.cwd(), ...(this.#home !== undefined ? { home: this.#home } : {}), processCommandLookup: (processId: string) => this.#processes.list().find((proc) => proc.id === processId)?.command, }; } #buildContext(call: PendingToolCall, signal: AbortSignal): ToolContext { return { sessionId: this.#session.sessionId, callId: call.toolUseId, workspace: this.#workspace, signal, fileTimes: this.#fileTimes, processes: this.#processes, emit: (event) => { // ToolEmittedEvent members are structurally identical to the // corresponding DurableEventInput members (src/tools/types.ts). this.#session.publishDurable(event); }, progress: () => { // UI-facing progress metadata — no V1 tool streams it; intentionally inert. }, spill: async (label, content) => { this.#spillCounter += 1; const file = path.join( this.#spillDir, `${label}-${call.toolUseId}-${this.#spillCounter}.txt`, ); await this.#workspace.writeFile(file, content); return file; }, ...(this.#phases !== undefined ? { phases: { mode: this.#phases.mode, current: () => this.#phases?.current() ?? "implement", submitDesign: (artifact) => (this.#phases as PhaseService).submitDesign(artifact), }, } : {}), ...(this.#repograph !== undefined ? { repograph: this.#repograph } : {}), }; } }