SPB Git

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%
10.4 KB · 319 lines typescript
Raw Blame History
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