spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/agent/executor.ts4 * Description: Tool Runtime — permission gate, sequential execution, durable tool events, cancellation-safe results (ARCHITECTURE.md §5.4).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import * as path from "node:path";11import type { CapabilityMappingContext, PermissionService } from "../permissions/index.js";12import { mapToolCapabilities } from "../permissions/index.js";13import type { PhaseService } from "../phases/index.js";14import type { ToolCompleted, ToolFailed, ToolName } from "../session/index.js";15import { CANCELLED_RESULT_CONTENT, parseToolInput } from "../tools/index.js";16import type { RepoGraphFacet, ToolContext, ToolRegistry, ToolResult } from "../tools/index.js";17import type { VerifyRunner } from "../verify/index.js";18import type { FileTimeRegistry, ProcessManager, Workspace } from "../workspace/index.js";19import type { KernelSession } from "./session-handle.js";2021// ───────────────────────── batch vocabulary ─────────────────────────2223/** One `tool_use` block awaiting execution — derived from a recorded ToolRequested. */24export interface PendingToolCall {25 toolUseId: string;26 toolName: ToolName;27 input: unknown;28 blockIndex: number;29}3031/** The kernel's view of the Tool Runtime (ARCHITECTURE.md §4.1 `tools`). */32export interface ToolBatchExecutor {33 /** Execute one model turn's tool calls sequentially, in block order (§11). */34 executeBatch(pending: readonly PendingToolCall[], signal: AbortSignal): Promise<void>;35}3637type ToolFailureKind = ToolFailed["payload"]["errorKind"];38type ToolUiMeta = ToolCompleted["payload"]["ui"];3940/** Hard backstop on model-facing tool_result content (TOOL_PROTOCOL §1.3). */41export const MODEL_TEXT_BACKSTOP = 64 * 1024;42const BACKSTOP_MARKER = "\n[... output truncated: 64 KB model-facing backstop reached]";4344function enforceBackstop(content: string): string {45 if (content.length <= MODEL_TEXT_BACKSTOP) return content;46 return content.slice(0, MODEL_TEXT_BACKSTOP) + BACKSTOP_MARKER;47}4849function uiKindFor(toolName: string): ToolUiMeta["kind"] {50 switch (toolName) {51 case "read":52 return "read";53 case "grep":54 case "glob":55 case "symbols":56 case "refs":57 return "search";58 case "write":59 case "edit":60 return "edit";61 case "process":62 return "process";63 default:64 return "exec";65 }66}6768// ───────────────────────── the executor ─────────────────────────6970export interface ToolExecutorOptions {71 session: KernelSession;72 registry: ToolRegistry;73 permissions: PermissionService;74 workspace: Workspace;75 fileTimes: FileTimeRegistry;76 processes: ProcessManager;77 /** Directory for oversized-output spill files (written through the workspace). */78 spillDir: string;79 /** Canonical project root for capability classification. Defaults to workspace.cwd(). */80 projectRoot?: string;81 /** Home directory for `~` expansion in capability subjects. */82 home?: string;83 now?: () => number;84 /** Phase-gate service (v2 §1); absent → gates off. */85 phases?: PhaseService;86 /** Semantic-index facet for the symbols/refs tools (v2 §3). */87 repograph?: RepoGraphFacet;88 /** Native verification runner (v2 §4); absent → no native verify loop. */89 verify?: VerifyRunner;90}9192/**93 * The Tool Runtime (ADR-8/9): between `ToolRequested` and `ToolStarted` sits94 * exactly one permission evaluation; execution is sequential per batch;95 * every call ends in exactly one durable terminal event (ToolCompleted /96 * ToolFailed / ToolCancelled) so tool_use/tool_result pairing holds at all97 * times. Recoverable errors become model-facing repair prose — course98 * corrections, never dead ends (TOOL_PROTOCOL §1.2).99 */100export class ToolExecutor implements ToolBatchExecutor {101 readonly #session: KernelSession;102 readonly #registry: ToolRegistry;103 readonly #permissions: PermissionService;104 readonly #workspace: Workspace;105 readonly #fileTimes: FileTimeRegistry;106 readonly #processes: ProcessManager;107 readonly #spillDir: string;108 readonly #projectRoot: string;109 readonly #home: string | undefined;110 readonly #now: () => number;111 readonly #phases: PhaseService | undefined;112 readonly #repograph: RepoGraphFacet | undefined;113 readonly #verify: VerifyRunner | undefined;114 #spillCounter = 0;115116 constructor(options: ToolExecutorOptions) {117 this.#session = options.session;118 this.#registry = options.registry;119 this.#permissions = options.permissions;120 this.#workspace = options.workspace;121 this.#fileTimes = options.fileTimes;122 this.#processes = options.processes;123 this.#spillDir = options.spillDir;124 this.#projectRoot = options.projectRoot ?? options.workspace.cwd();125 this.#home = options.home;126 this.#now = options.now ?? Date.now;127 this.#phases = options.phases;128 this.#repograph = options.repograph;129 this.#verify = options.verify;130 }131132 async executeBatch(pending: readonly PendingToolCall[], signal: AbortSignal): Promise<void> {133 let hadEdit = false;134 for (let i = 0; i < pending.length; i += 1) {135 const call = pending[i] as PendingToolCall;136 if (signal.aborted) {137 this.#cancelCalls(pending.slice(i));138 return;139 }140 const outcome = await this.#executeOne(call, signal);141 if (outcome === "cancelled") {142 this.#cancelCalls(pending.slice(i + 1));143 return;144 }145 if (outcome === "completed" && (call.toolName === "write" || call.toolName === "edit")) {146 hadEdit = true;147 }148 }149 // Native verification after each edit batch (v2 §4) — bounded repair loop:150 // once maxRepairLoops failing rounds are recorded since the last user151 // message, checks stop re-running and the honest failure report stands.152 if (153 hadEdit &&154 this.#verify !== undefined &&155 this.#verify.policy === "after-each-edit-batch" &&156 this.#verify.hasChecks &&157 !signal.aborted &&158 this.#verify.withinRepairBudget()159 ) {160 await this.#verify.runAll(signal);161 }162 }163164 // ── one call ──165166 async #executeOne(167 call: PendingToolCall,168 signal: AbortSignal,169 ): Promise<"completed" | "failed" | "cancelled"> {170 const startedAt = this.#now();171172 const tool = this.#registry.get(call.toolName);173 if (tool === undefined) {174 return this.#fail(175 call,176 "invalid-input",177 `Unknown tool "${call.toolName}". Available tools: ${this.#registry178 .list()179 .map((t) => t.name)180 .join(", ")}.`,181 startedAt,182 );183 }184185 const parsed = parseToolInput(tool, call.input);186 if (!parsed.ok) {187 return this.#fail(call, "invalid-input", parsed.error, startedAt);188 }189190 const capabilities = mapToolCapabilities(call.toolName, call.input, this.#pathContext());191 if (!capabilities.ok) {192 return this.#fail(193 call,194 "invalid-input",195 `Invalid input for tool "${call.toolName}": ${capabilities.error.message}. ` +196 "Please rewrite the input so it satisfies the expected schema.",197 startedAt,198 );199 }200201 // Phase gate (v2 §1): evaluated BEFORE permissions — a blocked call is a202 // structured course-correction telling the model to design first.203 if (this.#phases !== undefined) {204 const gate = this.#phases.checkToolCall(capabilities.value);205 if (!gate.allowed) {206 return this.#fail(call, "phase-blocked", gate.feedback, startedAt);207 }208 }209210 let outcome;211 try {212 outcome = await this.#permissions.check({213 toolUseId: call.toolUseId,214 toolName: call.toolName,215 requests: capabilities.value,216 });217 } catch (error) {218 const message = error instanceof Error ? error.message : String(error);219 return this.#fail(call, "internal", `Permission evaluation failed: ${message}`, startedAt);220 }221 if (outcome.kind === "deny") {222 return this.#fail(call, "permission-denied", outcome.feedback, startedAt);223 }224225 this.#session.publishDurable({226 type: "tool.approved",227 payload: { toolUseId: call.toolUseId, via: outcome.via },228 });229 this.#session.publishDurable({230 type: "tool.started",231 payload: { toolUseId: call.toolUseId, toolName: call.toolName },232 });233234 let result: ToolResult;235 try {236 result = await tool.execute(parsed.value, this.#buildContext(call, signal));237 } catch (error) {238 if (signal.aborted) return this.#cancel(call);239 const message = error instanceof Error ? error.message : String(error);240 return this.#fail(241 call,242 "internal",243 `Tool "${call.toolName}" failed unexpectedly: ${message}. ` +244 "Adjust the input and try again, or take a different approach.",245 startedAt,246 );247 }248 if (signal.aborted) return this.#cancel(call);249250 const durationMs = this.#now() - startedAt;251 const modelText = enforceBackstop(result.content);252 if (result.isError === true) {253 this.#session.publishDurable({254 type: "tool.failed",255 payload: { toolUseId: call.toolUseId, modelText, errorKind: "exec-error", durationMs },256 });257 return "failed";258 }259260 const meta = result.metadata;261 const ui: ToolUiMeta = {262 kind: uiKindFor(call.toolName),263 summary: meta?.title ?? call.toolName,264 ...(meta?.additions !== undefined || meta?.deletions !== undefined265 ? { diffStats: { added: meta?.additions ?? 0, removed: meta?.deletions ?? 0 } }266 : {}),267 ...(typeof meta?.exitCode === "number" ? { exitCode: meta.exitCode } : {}),268 ...(meta?.matches !== undefined ? { matchCount: meta.matches } : {}),269 };270 this.#session.publishDurable({271 type: "tool.completed",272 payload: {273 toolUseId: call.toolUseId,274 modelText,275 durationMs,276 ui,277 ...(meta?.truncation?.spillPath !== undefined278 ? { spillFile: meta.truncation.spillPath }279 : {}),280 },281 });282 return "completed";283 }284285 // ── terminal-event helpers ──286287 #fail(288 call: PendingToolCall,289 errorKind: ToolFailureKind,290 modelText: string,291 startedAt: number,292 ): "failed" {293 this.#session.publishDurable({294 type: "tool.failed",295 payload: {296 toolUseId: call.toolUseId,297 modelText: enforceBackstop(modelText),298 errorKind,299 durationMs: this.#now() - startedAt,300 },301 });302 return "failed";303 }304305 #cancel(call: PendingToolCall): "cancelled" {306 this.#session.publishDurable({307 type: "tool.cancelled",308 payload: {309 toolUseId: call.toolUseId,310 reason: "interrupted",311 modelText: CANCELLED_RESULT_CONTENT,312 },313 });314 return "cancelled";315 }316317 #cancelCalls(calls: readonly PendingToolCall[]): void {318 for (const call of calls) this.#cancel(call);319 }320321 // ── context construction ──322323 #pathContext(): CapabilityMappingContext {324 return {325 projectRoot: this.#projectRoot,326 cwd: this.#workspace.cwd(),327 ...(this.#home !== undefined ? { home: this.#home } : {}),328 processCommandLookup: (processId: string) =>329 this.#processes.list().find((proc) => proc.id === processId)?.command,330 };331 }332333 #buildContext(call: PendingToolCall, signal: AbortSignal): ToolContext {334 return {335 sessionId: this.#session.sessionId,336 callId: call.toolUseId,337 workspace: this.#workspace,338 signal,339 fileTimes: this.#fileTimes,340 processes: this.#processes,341 emit: (event) => {342 // ToolEmittedEvent members are structurally identical to the343 // corresponding DurableEventInput members (src/tools/types.ts).344 this.#session.publishDurable(event);345 },346 progress: () => {347 // UI-facing progress metadata — no V1 tool streams it; intentionally inert.348 },349 spill: async (label, content) => {350 this.#spillCounter += 1;351 const file = path.join(352 this.#spillDir,353 `${label}-${call.toolUseId}-${this.#spillCounter}.txt`,354 );355 await this.#workspace.writeFile(file, content);356 return file;357 },358 ...(this.#phases !== undefined359 ? {360 phases: {361 mode: this.#phases.mode,362 current: () => this.#phases?.current() ?? "implement",363 submitDesign: (artifact) =>364 (this.#phases as PhaseService).submitDesign(artifact),365 },366 }367 : {}),368 ...(this.#repograph !== undefined ? { repograph: this.#repograph } : {}),369 };370 }371}372