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 { ToolCompleted, ToolFailed, ToolName } from "../session/index.js";14import { CANCELLED_RESULT_CONTENT, parseToolInput } from "../tools/index.js";15import type { ToolContext, ToolRegistry, ToolResult } from "../tools/index.js";16import type { FileTimeRegistry, ProcessManager, Workspace } from "../workspace/index.js";17import type { KernelSession } from "./session-handle.js";1819// ───────────────────────── batch vocabulary ─────────────────────────2021/** One `tool_use` block awaiting execution — derived from a recorded ToolRequested. */22export interface PendingToolCall {23 toolUseId: string;24 toolName: ToolName;25 input: unknown;26 blockIndex: number;27}2829/** The kernel's view of the Tool Runtime (ARCHITECTURE.md §4.1 `tools`). */30export interface ToolBatchExecutor {31 /** Execute one model turn's tool calls sequentially, in block order (§11). */32 executeBatch(pending: readonly PendingToolCall[], signal: AbortSignal): Promise<void>;33}3435type ToolFailureKind = ToolFailed["payload"]["errorKind"];36type ToolUiMeta = ToolCompleted["payload"]["ui"];3738/** Hard backstop on model-facing tool_result content (TOOL_PROTOCOL §1.3). */39export const MODEL_TEXT_BACKSTOP = 64 * 1024;40const BACKSTOP_MARKER = "\n[... output truncated: 64 KB model-facing backstop reached]";4142function enforceBackstop(content: string): string {43 if (content.length <= MODEL_TEXT_BACKSTOP) return content;44 return content.slice(0, MODEL_TEXT_BACKSTOP) + BACKSTOP_MARKER;45}4647function uiKindFor(toolName: string): ToolUiMeta["kind"] {48 switch (toolName) {49 case "read":50 return "read";51 case "grep":52 case "glob":53 return "search";54 case "write":55 case "edit":56 return "edit";57 case "process":58 return "process";59 default:60 return "exec";61 }62}6364// ───────────────────────── the executor ─────────────────────────6566export interface ToolExecutorOptions {67 session: KernelSession;68 registry: ToolRegistry;69 permissions: PermissionService;70 workspace: Workspace;71 fileTimes: FileTimeRegistry;72 processes: ProcessManager;73 /** Directory for oversized-output spill files (written through the workspace). */74 spillDir: string;75 /** Canonical project root for capability classification. Defaults to workspace.cwd(). */76 projectRoot?: string;77 /** Home directory for `~` expansion in capability subjects. */78 home?: string;79 now?: () => number;80}8182/**83 * The Tool Runtime (ADR-8/9): between `ToolRequested` and `ToolStarted` sits84 * exactly one permission evaluation; execution is sequential per batch;85 * every call ends in exactly one durable terminal event (ToolCompleted /86 * ToolFailed / ToolCancelled) so tool_use/tool_result pairing holds at all87 * times. Recoverable errors become model-facing repair prose — course88 * corrections, never dead ends (TOOL_PROTOCOL §1.2).89 */90export class ToolExecutor implements ToolBatchExecutor {91 readonly #session: KernelSession;92 readonly #registry: ToolRegistry;93 readonly #permissions: PermissionService;94 readonly #workspace: Workspace;95 readonly #fileTimes: FileTimeRegistry;96 readonly #processes: ProcessManager;97 readonly #spillDir: string;98 readonly #projectRoot: string;99 readonly #home: string | undefined;100 readonly #now: () => number;101 #spillCounter = 0;102103 constructor(options: ToolExecutorOptions) {104 this.#session = options.session;105 this.#registry = options.registry;106 this.#permissions = options.permissions;107 this.#workspace = options.workspace;108 this.#fileTimes = options.fileTimes;109 this.#processes = options.processes;110 this.#spillDir = options.spillDir;111 this.#projectRoot = options.projectRoot ?? options.workspace.cwd();112 this.#home = options.home;113 this.#now = options.now ?? Date.now;114 }115116 async executeBatch(pending: readonly PendingToolCall[], signal: AbortSignal): Promise<void> {117 for (let i = 0; i < pending.length; i += 1) {118 const call = pending[i] as PendingToolCall;119 if (signal.aborted) {120 this.#cancelCalls(pending.slice(i));121 return;122 }123 const outcome = await this.#executeOne(call, signal);124 if (outcome === "cancelled") {125 this.#cancelCalls(pending.slice(i + 1));126 return;127 }128 }129 }130131 // ── one call ──132133 async #executeOne(134 call: PendingToolCall,135 signal: AbortSignal,136 ): Promise<"completed" | "failed" | "cancelled"> {137 const startedAt = this.#now();138139 const tool = this.#registry.get(call.toolName);140 if (tool === undefined) {141 return this.#fail(142 call,143 "invalid-input",144 `Unknown tool "${call.toolName}". Available tools: ${this.#registry145 .list()146 .map((t) => t.name)147 .join(", ")}.`,148 startedAt,149 );150 }151152 const parsed = parseToolInput(tool, call.input);153 if (!parsed.ok) {154 return this.#fail(call, "invalid-input", parsed.error, startedAt);155 }156157 const capabilities = mapToolCapabilities(call.toolName, call.input, this.#pathContext());158 if (!capabilities.ok) {159 return this.#fail(160 call,161 "invalid-input",162 `Invalid input for tool "${call.toolName}": ${capabilities.error.message}. ` +163 "Please rewrite the input so it satisfies the expected schema.",164 startedAt,165 );166 }167168 let outcome;169 try {170 outcome = await this.#permissions.check({171 toolUseId: call.toolUseId,172 toolName: call.toolName,173 requests: capabilities.value,174 });175 } catch (error) {176 const message = error instanceof Error ? error.message : String(error);177 return this.#fail(call, "internal", `Permission evaluation failed: ${message}`, startedAt);178 }179 if (outcome.kind === "deny") {180 return this.#fail(call, "permission-denied", outcome.feedback, startedAt);181 }182183 this.#session.publishDurable({184 type: "tool.approved",185 payload: { toolUseId: call.toolUseId, via: outcome.via },186 });187 this.#session.publishDurable({188 type: "tool.started",189 payload: { toolUseId: call.toolUseId, toolName: call.toolName },190 });191192 let result: ToolResult;193 try {194 result = await tool.execute(parsed.value, this.#buildContext(call, signal));195 } catch (error) {196 if (signal.aborted) return this.#cancel(call);197 const message = error instanceof Error ? error.message : String(error);198 return this.#fail(199 call,200 "internal",201 `Tool "${call.toolName}" failed unexpectedly: ${message}. ` +202 "Adjust the input and try again, or take a different approach.",203 startedAt,204 );205 }206 if (signal.aborted) return this.#cancel(call);207208 const durationMs = this.#now() - startedAt;209 const modelText = enforceBackstop(result.content);210 if (result.isError === true) {211 this.#session.publishDurable({212 type: "tool.failed",213 payload: { toolUseId: call.toolUseId, modelText, errorKind: "exec-error", durationMs },214 });215 return "failed";216 }217218 const meta = result.metadata;219 const ui: ToolUiMeta = {220 kind: uiKindFor(call.toolName),221 summary: meta?.title ?? call.toolName,222 ...(meta?.additions !== undefined || meta?.deletions !== undefined223 ? { diffStats: { added: meta?.additions ?? 0, removed: meta?.deletions ?? 0 } }224 : {}),225 ...(typeof meta?.exitCode === "number" ? { exitCode: meta.exitCode } : {}),226 ...(meta?.matches !== undefined ? { matchCount: meta.matches } : {}),227 };228 this.#session.publishDurable({229 type: "tool.completed",230 payload: {231 toolUseId: call.toolUseId,232 modelText,233 durationMs,234 ui,235 ...(meta?.truncation?.spillPath !== undefined236 ? { spillFile: meta.truncation.spillPath }237 : {}),238 },239 });240 return "completed";241 }242243 // ── terminal-event helpers ──244245 #fail(246 call: PendingToolCall,247 errorKind: ToolFailureKind,248 modelText: string,249 startedAt: number,250 ): "failed" {251 this.#session.publishDurable({252 type: "tool.failed",253 payload: {254 toolUseId: call.toolUseId,255 modelText: enforceBackstop(modelText),256 errorKind,257 durationMs: this.#now() - startedAt,258 },259 });260 return "failed";261 }262263 #cancel(call: PendingToolCall): "cancelled" {264 this.#session.publishDurable({265 type: "tool.cancelled",266 payload: {267 toolUseId: call.toolUseId,268 reason: "interrupted",269 modelText: CANCELLED_RESULT_CONTENT,270 },271 });272 return "cancelled";273 }274275 #cancelCalls(calls: readonly PendingToolCall[]): void {276 for (const call of calls) this.#cancel(call);277 }278279 // ── context construction ──280281 #pathContext(): CapabilityMappingContext {282 return {283 projectRoot: this.#projectRoot,284 cwd: this.#workspace.cwd(),285 ...(this.#home !== undefined ? { home: this.#home } : {}),286 processCommandLookup: (processId: string) =>287 this.#processes.list().find((proc) => proc.id === processId)?.command,288 };289 }290291 #buildContext(call: PendingToolCall, signal: AbortSignal): ToolContext {292 return {293 sessionId: this.#session.sessionId,294 callId: call.toolUseId,295 workspace: this.#workspace,296 signal,297 fileTimes: this.#fileTimes,298 processes: this.#processes,299 emit: (event) => {300 // ToolEmittedEvent members are structurally identical to the301 // corresponding DurableEventInput members (src/tools/types.ts).302 this.#session.publishDurable(event);303 },304 progress: () => {305 // UI-facing progress metadata — no V1 tool streams it; intentionally inert.306 },307 spill: async (label, content) => {308 this.#spillCounter += 1;309 const file = path.join(310 this.#spillDir,311 `${label}-${call.toolUseId}-${this.#spillCounter}.txt`,312 );313 await this.#workspace.writeFile(file, content);314 return file;315 },316 };317 }318}319