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%
18.0 KB · 510 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/agent/kernel.ts4 * Description: AgentKernel — the tiny state-derived loop: deriveNext over recorded events, stream reducer, nothing else (ADR-2).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { classifyModelError } from "../anthropic/index.js";11import type { ModelClient, ModelEvent } from "../anthropic/index.js";12import type { ContextEngine, ContextStats, VolatileSection } from "../context/index.js";13import { ulid } from "../shared/index.js";14import type {15  CompletionEvidence,16  DurableEvent,17  StopReason,18  ToolName,19} from "../session/index.js";20import type { VerificationGate, VerificationGateResult } from "./completion.js";21import type { PendingToolCall, ToolBatchExecutor } from "./executor.js";22import { closeDanglingToolUses } from "./interruption.js";23import type { InterruptionController } from "./interruption.js";24import type { KernelSession } from "./session-handle.js";25import type { SteeringQueue } from "./steering.js";2627// ───────────────────────── turn state (pure fold) ─────────────────────────2829/** Everything `deriveNext` consults — derived from the log, never loop-local flags (ADR-2). */30export interface TurnState {31  hasUserMessage: boolean;32  /** Interrupted after the last user message — the turn is over. */33  interrupted: boolean;34  /** ToolRequested events without a terminal result, in block/seq order. */35  pending: PendingToolCall[];36  /** SteeringQueued events not yet injected. */37  queuedSteering: number;38  lastResponse: { seq: number; stopReason: StopReason } | null;39  /** Max seq of conversation input: user text, steering, tool results, verification nudges. */40  lastInputSeq: number;41  /** The most recent input is a verification nudge → next call carries it. */42  lastInputWasVerification: boolean;43  lastVerification: { seq: number; detectedChecks: string[] } | null;44  /** A context-overflow failure not yet resolved by a compaction. */45  overflowUnresolved: boolean;46  /** A prune/compaction already happened since the last model activity. */47  compactionSinceModelActivity: boolean;48}4950/** Fold the durable stream into the loop's decision state. */51export function foldTurnState(events: readonly DurableEvent[]): TurnState {52  const open = new Map<string, PendingToolCall>();53  let lastUserSeq = 0;54  let lastInterruptSeq = 0;55  const queued = new Set<string>();56  let lastResponse: TurnState["lastResponse"] = null;57  let lastModelActivitySeq = 0;58  let lastInputSeq = 0;59  let lastInputWasVerification = false;60  let lastVerification: TurnState["lastVerification"] = null;61  let lastOverflowSeq = 0;62  let lastCompactedSeq = 0;63  let lastContextEventSeq = 0;6465  for (const event of events) {66    switch (event.type) {67      case "user.message-created":68        lastUserSeq = event.seq;69        lastInputSeq = event.seq;70        lastInputWasVerification = false;71        break;72      case "user.interrupted":73        lastInterruptSeq = event.seq;74        break;75      case "user.steering-queued":76        queued.add(event.id);77        break;78      case "user.steering-injected":79        queued.delete(event.payload.queuedEventId);80        lastInputSeq = event.seq;81        lastInputWasVerification = false;82        break;83      case "tool.requested":84        open.set(event.payload.toolUseId, {85          toolUseId: event.payload.toolUseId,86          toolName: event.payload.toolName,87          input: event.payload.input,88          blockIndex: event.payload.blockIndex,89        });90        break;91      case "tool.completed":92      case "tool.failed":93      case "tool.cancelled":94        open.delete(event.payload.toolUseId);95        lastInputSeq = event.seq;96        lastInputWasVerification = false;97        break;98      case "model.response-completed":99        lastResponse = { seq: event.seq, stopReason: event.payload.stopReason };100        lastModelActivitySeq = event.seq;101        break;102      case "model.request-failed":103        lastModelActivitySeq = event.seq;104        if (event.payload.kind === "context-overflow") lastOverflowSeq = event.seq;105        break;106      case "context.pruned":107        lastContextEventSeq = event.seq;108        break;109      case "context.compacted":110        lastCompactedSeq = event.seq;111        lastContextEventSeq = event.seq;112        break;113      case "task.verification-requested":114        lastInputSeq = event.seq;115        lastInputWasVerification = true;116        lastVerification = { seq: event.seq, detectedChecks: [...event.payload.detectedChecks] };117        break;118      case "verify.result":119        // A failing native check is conversation input — the model repairs it120        // before the turn can complete (v2 §4). Passing checks are evidence only.121        if (!event.payload.ok) {122          lastInputSeq = event.seq;123          lastInputWasVerification = false;124        }125        break;126      default:127        break;128    }129  }130131  return {132    hasUserMessage: lastUserSeq > 0,133    interrupted: lastInterruptSeq > lastUserSeq,134    pending: [...open.values()],135    queuedSteering: queued.size,136    lastResponse,137    lastInputSeq,138    lastInputWasVerification,139    lastVerification,140    overflowUnresolved: lastOverflowSeq > lastCompactedSeq,141    compactionSinceModelActivity: lastContextEventSeq > lastModelActivitySeq,142  };143}144145// ───────────────────────── deriveNext (pure) ─────────────────────────146147export type NextAction =148  | { kind: "idle" }149  | { kind: "interrupted" }150  | { kind: "execute-tools"; pending: PendingToolCall[] }151  | { kind: "inject-steering" }152  | { kind: "compact" }153  | { kind: "verify"; attempt: 1 | 2; candidateSeq: number }154  | { kind: "done" }155  | { kind: "budget-exhausted" }156  | { kind: "call-model"; purpose: "main" | "verification-nudge" };157158/** Re-derive "what next" from recorded state — the whole kernel is this function plus dispatch (ADR-2). */159export function deriveNext(160  state: TurnState,161  gate: VerificationGateResult,162  opts: { shouldCompact: boolean; iterationsLeft: number },163): NextAction {164  if (!state.hasUserMessage) return { kind: "idle" };165  if (state.interrupted) return { kind: "interrupted" };166  if (state.pending.length > 0) return { kind: "execute-tools", pending: state.pending };167  // Safe seam: no tool batch pending, no stream running (ADR-11).168  if (state.queuedSteering > 0) return { kind: "inject-steering" };169  if (state.overflowUnresolved || (opts.shouldCompact && !state.compactionSinceModelActivity)) {170    return { kind: "compact" };171  }172  const answered = state.lastResponse !== null && state.lastInputSeq < state.lastResponse.seq;173  if (answered && state.lastResponse !== null && state.lastResponse.stopReason !== "tool_use") {174    if (gate.required && gate.attempts < 2) {175      return {176        kind: "verify",177        attempt: (gate.attempts + 1) as 1 | 2,178        candidateSeq: gate.candidateSeq,179      };180    }181    return { kind: "done" };182  }183  if (opts.iterationsLeft <= 0) return { kind: "budget-exhausted" };184  return {185    kind: "call-model",186    purpose: state.lastInputWasVerification ? "verification-nudge" : "main",187  };188}189190// ───────────────────────── stream reducer ─────────────────────────191192/**193 * Map ModelClient events onto session events: deltas → ephemeral, settled194 * blocks / tool_use / usage → durable (EVENT_MODEL.md §3). Pure with respect195 * to the loop — no decisions, only recording.196 */197class StreamRecorder {198  requestId: string | null = null;199  readonly #session: KernelSession;200  readonly #model: string;201  readonly #purpose: "main" | "verification-nudge";202  readonly #stats: ContextStats;203204  constructor(205    session: KernelSession,206    model: string,207    purpose: "main" | "verification-nudge",208    stats: ContextStats,209  ) {210    this.#session = session;211    this.#model = model;212    this.#purpose = purpose;213    this.#stats = stats;214  }215216  #started(requestId: string): void {217    this.requestId = requestId;218    this.#session.publishDurable({219      type: "model.request-started",220      payload: {221        requestId,222        model: this.#model,223        purpose: this.#purpose,224        contextStats: this.#stats,225      },226    });227  }228229  #id(): string {230    if (this.requestId === null) this.#started(`req_${ulid()}`);231    return this.requestId as string;232  }233234  record(event: ModelEvent): void {235    switch (event.type) {236      case "started":237        if (this.requestId === null) this.#started(event.requestId);238        return;239      case "text-delta":240        this.#session.publishEphemeral({241          type: "model.text-delta",242          payload: { requestId: this.#id(), blockIndex: event.blockIndex, text: event.text },243        });244        return;245      case "thinking-delta":246        this.#session.publishEphemeral({247          type: "model.thinking-delta",248          payload: { requestId: this.#id(), blockIndex: event.blockIndex, text: event.text },249        });250        return;251      case "tool-call-started":252        this.#session.publishEphemeral({253          type: "model.tool-call-started",254          payload: {255            requestId: this.#id(),256            blockIndex: event.blockIndex,257            toolUseId: event.toolUseId,258            toolName: event.toolName as ToolName,259          },260        });261        return;262      case "tool-input-delta":263        this.#session.publishEphemeral({264          type: "model.tool-input-delta",265          payload: {266            requestId: this.#id(),267            blockIndex: event.blockIndex,268            toolUseId: event.toolUseId,269            partialJson: event.partialJson,270          },271        });272        return;273      case "text-block-completed":274        this.#session.publishDurable({275          type: "model.text-block-completed",276          payload: { requestId: this.#id(), blockIndex: event.blockIndex, text: event.text },277        });278        return;279      case "thinking-block-completed":280        this.#session.publishDurable({281          type: "model.thinking-block-completed",282          payload: {283            requestId: this.#id(),284            blockIndex: event.blockIndex,285            thinking: event.thinking,286            signature: event.signature,287          },288        });289        return;290      case "tool-call-completed":291        this.#session.publishDurable({292          type: "tool.requested",293          payload: {294            requestId: this.#id(),295            blockIndex: event.blockIndex,296            toolUseId: event.toolUseId,297            toolName: event.toolName as ToolName,298            input: event.input,299          },300        });301        return;302      case "completed":303        this.#session.publishDurable({304          type: "model.response-completed",305          payload: {306            requestId: this.#id(),307            stopReason: event.stopReason,308            usage: event.usage,309            durationMs: event.durationMs,310          },311        });312        return;313    }314  }315}316317// ───────────────────────── the kernel ─────────────────────────318319export type TurnOutcome =320  | { kind: "idle" }321  | { kind: "done"; evidence: CompletionEvidence }322  | { kind: "interrupted" }323  | { kind: "failed"; reason: "model-fatal-error" | "iteration-budget-exhausted"; detail: string };324325/** Optional proactive-compaction trigger — satisfied by ContextBudget. */326export interface CompactionSignal {327  shouldCompact(): boolean;328}329330/** The five services the kernel touches — everything else lives behind them (ARCHITECTURE.md §4.1). */331export interface AgentKernelDeps {332  session: KernelSession;333  context: ContextEngine;334  model: ModelClient;335  executor: ToolBatchExecutor;336  verifier: VerificationGate;337  steering: SteeringQueue;338  interruption: InterruptionController;339  compaction?: CompactionSignal;340  /** Model-call budget per turn. Default 40. */341  maxIterations?: number;342}343344const DEFAULT_MAX_ITERATIONS = 40;345346/**347 * The agent kernel (ADR-2): a loop that re-derives "what next" from the348 * recorded session log each iteration and dispatches to services. It owns349 * no retry policy, no permission logic, no budgeting, no rendering — only350 * coordination (Absolute Rule #3).351 */352export class AgentKernel {353  readonly #deps: AgentKernelDeps;354  readonly #maxIterations: number;355356  constructor(deps: AgentKernelDeps) {357    this.#deps = deps;358    this.#maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;359  }360361  /** Run until the current user request is done, interrupted, or failed. */362  async runTurn(): Promise<TurnOutcome> {363    const turn = new AbortController();364    this.#deps.interruption.beginTurn(turn);365    try {366      let iterationsLeft = this.#maxIterations;367      for (;;) {368        const events = this.#deps.session.events();369        const state = foldTurnState(events);370        if (state.interrupted || turn.signal.aborted) {371          closeDanglingToolUses(this.#deps.session, "interrupted");372          return { kind: "interrupted" };373        }374        const gate = this.#deps.verifier.needsVerification(events);375        const next = deriveNext(state, gate, {376          shouldCompact: this.#deps.compaction?.shouldCompact() ?? false,377          iterationsLeft,378        });379        switch (next.kind) {380          case "idle":381            return { kind: "idle" };382          case "interrupted":383            closeDanglingToolUses(this.#deps.session, "interrupted");384            return { kind: "interrupted" };385          case "execute-tools":386            await this.#deps.executor.executeBatch(next.pending, turn.signal);387            continue;388          case "inject-steering":389            this.#deps.steering.injectPending();390            continue;391          case "compact": {392            const failure = await this.#compact(events, turn.signal);393            if (failure !== null) return failure;394            continue;395          }396          case "verify": {397            const detectedChecks = await this.#deps.verifier.detectChecks();398            this.#deps.session.publishDurable({399              type: "task.verification-requested",400              payload: {401                attempt: next.attempt,402                detectedChecks,403                withheldCandidateSeq: next.candidateSeq,404              },405            });406            continue;407          }408          case "call-model": {409            iterationsLeft -= 1;410            const failure = await this.#callModel(events, state, turn.signal, next.purpose);411            if (failure !== null) return failure;412            continue;413          }414          case "budget-exhausted": {415            const detail = `Turn stopped after ${this.#maxIterations} model calls without completion.`;416            this.#deps.session.publishDurable({417              type: "task.failed",418              payload: { reason: "iteration-budget-exhausted", detail },419            });420            return { kind: "failed", reason: "iteration-budget-exhausted", detail };421          }422          case "done": {423            const evidence = await this.#deps.verifier.collectEvidence(events);424            this.#deps.session.publishDurable({ type: "task.completed", payload: { evidence } });425            return { kind: "done", evidence };426          }427        }428      }429    } finally {430      this.#deps.interruption.endTurn();431    }432  }433434  /** One model call: build context, stream, record. Returns a fatal outcome or null. */435  async #callModel(436    events: readonly DurableEvent[],437    state: TurnState,438    signal: AbortSignal,439    purpose: "main" | "verification-nudge",440  ): Promise<TurnOutcome | null> {441    const volatile: VolatileSection[] = [];442    if (purpose === "verification-nudge" && state.lastVerification !== null) {443      volatile.push({444        name: "verification",445        text: this.#deps.verifier.buildNudge(state.lastVerification.detectedChecks),446      });447    }448    const built = await this.#deps.context.selectContext({449      events,450      ...(volatile.length > 0 ? { volatile } : {}),451    });452    const recorder = new StreamRecorder(453      this.#deps.session,454      built.request.model,455      purpose,456      built.stats,457    );458    try {459      for await (const event of this.#deps.model.stream(built.request, signal)) {460        recorder.record(event);461        if (event.type === "completed") this.#deps.context.onTurnComplete(event.usage);462      }463      return null;464    } catch (error) {465      const failure = classifyModelError(error);466      this.#deps.session.publishDurable({467        type: "model.request-failed",468        payload: {469          requestId: recorder.requestId ?? "unknown",470          kind: failure.kind,471          message: failure.message,472          ...(failure.status !== undefined ? { status: failure.status } : {}),473          retriesExhausted: failure.retriesExhausted,474        },475      });476      // cancelled folds into interrupted state; overflow routes to compaction.477      if (failure.kind === "cancelled" || failure.kind === "context-overflow") return null;478      this.#deps.session.publishDurable({479        type: "task.failed",480        payload: { reason: "model-fatal-error", detail: failure.message },481      });482      return { kind: "failed", reason: "model-fatal-error", detail: failure.message };483    }484  }485486  /** Prune first (cheap, deterministic), compress only when nothing prunable remains (§6.2). */487  async #compact(488    events: readonly DurableEvent[],489    signal: AbortSignal,490  ): Promise<TurnOutcome | null> {491    const prune = this.#deps.context.pruneToolResults({ events });492    if (prune.toolUseIds.length > 0) {493      this.#deps.session.publishDurable({ type: "context.pruned", payload: prune });494      return null;495    }496    try {497      const checkpoint = await this.#deps.context.compress({ events }, signal);498      this.#deps.session.publishDurable({ type: "context.compacted", payload: checkpoint });499      return null;500    } catch (error) {501      const detail = error instanceof Error ? error.message : String(error);502      this.#deps.session.publishDurable({503        type: "task.failed",504        payload: { reason: "model-fatal-error", detail: `Context compaction failed: ${detail}` },505      });506      return { kind: "failed", reason: "model-fatal-error", detail };507    }508  }509}510