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%
12.5 KB · 372 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 { 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