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%
17.7 KB · 502 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      default:119        break;120    }121  }122123  return {124    hasUserMessage: lastUserSeq > 0,125    interrupted: lastInterruptSeq > lastUserSeq,126    pending: [...open.values()],127    queuedSteering: queued.size,128    lastResponse,129    lastInputSeq,130    lastInputWasVerification,131    lastVerification,132    overflowUnresolved: lastOverflowSeq > lastCompactedSeq,133    compactionSinceModelActivity: lastContextEventSeq > lastModelActivitySeq,134  };135}136137// ───────────────────────── deriveNext (pure) ─────────────────────────138139export type NextAction =140  | { kind: "idle" }141  | { kind: "interrupted" }142  | { kind: "execute-tools"; pending: PendingToolCall[] }143  | { kind: "inject-steering" }144  | { kind: "compact" }145  | { kind: "verify"; attempt: 1 | 2; candidateSeq: number }146  | { kind: "done" }147  | { kind: "budget-exhausted" }148  | { kind: "call-model"; purpose: "main" | "verification-nudge" };149150/** Re-derive "what next" from recorded state — the whole kernel is this function plus dispatch (ADR-2). */151export function deriveNext(152  state: TurnState,153  gate: VerificationGateResult,154  opts: { shouldCompact: boolean; iterationsLeft: number },155): NextAction {156  if (!state.hasUserMessage) return { kind: "idle" };157  if (state.interrupted) return { kind: "interrupted" };158  if (state.pending.length > 0) return { kind: "execute-tools", pending: state.pending };159  // Safe seam: no tool batch pending, no stream running (ADR-11).160  if (state.queuedSteering > 0) return { kind: "inject-steering" };161  if (state.overflowUnresolved || (opts.shouldCompact && !state.compactionSinceModelActivity)) {162    return { kind: "compact" };163  }164  const answered = state.lastResponse !== null && state.lastInputSeq < state.lastResponse.seq;165  if (answered && state.lastResponse !== null && state.lastResponse.stopReason !== "tool_use") {166    if (gate.required && gate.attempts < 2) {167      return {168        kind: "verify",169        attempt: (gate.attempts + 1) as 1 | 2,170        candidateSeq: gate.candidateSeq,171      };172    }173    return { kind: "done" };174  }175  if (opts.iterationsLeft <= 0) return { kind: "budget-exhausted" };176  return {177    kind: "call-model",178    purpose: state.lastInputWasVerification ? "verification-nudge" : "main",179  };180}181182// ───────────────────────── stream reducer ─────────────────────────183184/**185 * Map ModelClient events onto session events: deltas → ephemeral, settled186 * blocks / tool_use / usage → durable (EVENT_MODEL.md §3). Pure with respect187 * to the loop — no decisions, only recording.188 */189class StreamRecorder {190  requestId: string | null = null;191  readonly #session: KernelSession;192  readonly #model: string;193  readonly #purpose: "main" | "verification-nudge";194  readonly #stats: ContextStats;195196  constructor(197    session: KernelSession,198    model: string,199    purpose: "main" | "verification-nudge",200    stats: ContextStats,201  ) {202    this.#session = session;203    this.#model = model;204    this.#purpose = purpose;205    this.#stats = stats;206  }207208  #started(requestId: string): void {209    this.requestId = requestId;210    this.#session.publishDurable({211      type: "model.request-started",212      payload: {213        requestId,214        model: this.#model,215        purpose: this.#purpose,216        contextStats: this.#stats,217      },218    });219  }220221  #id(): string {222    if (this.requestId === null) this.#started(`req_${ulid()}`);223    return this.requestId as string;224  }225226  record(event: ModelEvent): void {227    switch (event.type) {228      case "started":229        if (this.requestId === null) this.#started(event.requestId);230        return;231      case "text-delta":232        this.#session.publishEphemeral({233          type: "model.text-delta",234          payload: { requestId: this.#id(), blockIndex: event.blockIndex, text: event.text },235        });236        return;237      case "thinking-delta":238        this.#session.publishEphemeral({239          type: "model.thinking-delta",240          payload: { requestId: this.#id(), blockIndex: event.blockIndex, text: event.text },241        });242        return;243      case "tool-call-started":244        this.#session.publishEphemeral({245          type: "model.tool-call-started",246          payload: {247            requestId: this.#id(),248            blockIndex: event.blockIndex,249            toolUseId: event.toolUseId,250            toolName: event.toolName as ToolName,251          },252        });253        return;254      case "tool-input-delta":255        this.#session.publishEphemeral({256          type: "model.tool-input-delta",257          payload: {258            requestId: this.#id(),259            blockIndex: event.blockIndex,260            toolUseId: event.toolUseId,261            partialJson: event.partialJson,262          },263        });264        return;265      case "text-block-completed":266        this.#session.publishDurable({267          type: "model.text-block-completed",268          payload: { requestId: this.#id(), blockIndex: event.blockIndex, text: event.text },269        });270        return;271      case "thinking-block-completed":272        this.#session.publishDurable({273          type: "model.thinking-block-completed",274          payload: {275            requestId: this.#id(),276            blockIndex: event.blockIndex,277            thinking: event.thinking,278            signature: event.signature,279          },280        });281        return;282      case "tool-call-completed":283        this.#session.publishDurable({284          type: "tool.requested",285          payload: {286            requestId: this.#id(),287            blockIndex: event.blockIndex,288            toolUseId: event.toolUseId,289            toolName: event.toolName as ToolName,290            input: event.input,291          },292        });293        return;294      case "completed":295        this.#session.publishDurable({296          type: "model.response-completed",297          payload: {298            requestId: this.#id(),299            stopReason: event.stopReason,300            usage: event.usage,301            durationMs: event.durationMs,302          },303        });304        return;305    }306  }307}308309// ───────────────────────── the kernel ─────────────────────────310311export type TurnOutcome =312  | { kind: "idle" }313  | { kind: "done"; evidence: CompletionEvidence }314  | { kind: "interrupted" }315  | { kind: "failed"; reason: "model-fatal-error" | "iteration-budget-exhausted"; detail: string };316317/** Optional proactive-compaction trigger — satisfied by ContextBudget. */318export interface CompactionSignal {319  shouldCompact(): boolean;320}321322/** The five services the kernel touches — everything else lives behind them (ARCHITECTURE.md §4.1). */323export interface AgentKernelDeps {324  session: KernelSession;325  context: ContextEngine;326  model: ModelClient;327  executor: ToolBatchExecutor;328  verifier: VerificationGate;329  steering: SteeringQueue;330  interruption: InterruptionController;331  compaction?: CompactionSignal;332  /** Model-call budget per turn. Default 40. */333  maxIterations?: number;334}335336const DEFAULT_MAX_ITERATIONS = 40;337338/**339 * The agent kernel (ADR-2): a loop that re-derives "what next" from the340 * recorded session log each iteration and dispatches to services. It owns341 * no retry policy, no permission logic, no budgeting, no rendering — only342 * coordination (Absolute Rule #3).343 */344export class AgentKernel {345  readonly #deps: AgentKernelDeps;346  readonly #maxIterations: number;347348  constructor(deps: AgentKernelDeps) {349    this.#deps = deps;350    this.#maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;351  }352353  /** Run until the current user request is done, interrupted, or failed. */354  async runTurn(): Promise<TurnOutcome> {355    const turn = new AbortController();356    this.#deps.interruption.beginTurn(turn);357    try {358      let iterationsLeft = this.#maxIterations;359      for (;;) {360        const events = this.#deps.session.events();361        const state = foldTurnState(events);362        if (state.interrupted || turn.signal.aborted) {363          closeDanglingToolUses(this.#deps.session, "interrupted");364          return { kind: "interrupted" };365        }366        const gate = this.#deps.verifier.needsVerification(events);367        const next = deriveNext(state, gate, {368          shouldCompact: this.#deps.compaction?.shouldCompact() ?? false,369          iterationsLeft,370        });371        switch (next.kind) {372          case "idle":373            return { kind: "idle" };374          case "interrupted":375            closeDanglingToolUses(this.#deps.session, "interrupted");376            return { kind: "interrupted" };377          case "execute-tools":378            await this.#deps.executor.executeBatch(next.pending, turn.signal);379            continue;380          case "inject-steering":381            this.#deps.steering.injectPending();382            continue;383          case "compact": {384            const failure = await this.#compact(events, turn.signal);385            if (failure !== null) return failure;386            continue;387          }388          case "verify": {389            const detectedChecks = await this.#deps.verifier.detectChecks();390            this.#deps.session.publishDurable({391              type: "task.verification-requested",392              payload: {393                attempt: next.attempt,394                detectedChecks,395                withheldCandidateSeq: next.candidateSeq,396              },397            });398            continue;399          }400          case "call-model": {401            iterationsLeft -= 1;402            const failure = await this.#callModel(events, state, turn.signal, next.purpose);403            if (failure !== null) return failure;404            continue;405          }406          case "budget-exhausted": {407            const detail = `Turn stopped after ${this.#maxIterations} model calls without completion.`;408            this.#deps.session.publishDurable({409              type: "task.failed",410              payload: { reason: "iteration-budget-exhausted", detail },411            });412            return { kind: "failed", reason: "iteration-budget-exhausted", detail };413          }414          case "done": {415            const evidence = await this.#deps.verifier.collectEvidence(events);416            this.#deps.session.publishDurable({ type: "task.completed", payload: { evidence } });417            return { kind: "done", evidence };418          }419        }420      }421    } finally {422      this.#deps.interruption.endTurn();423    }424  }425426  /** One model call: build context, stream, record. Returns a fatal outcome or null. */427  async #callModel(428    events: readonly DurableEvent[],429    state: TurnState,430    signal: AbortSignal,431    purpose: "main" | "verification-nudge",432  ): Promise<TurnOutcome | null> {433    const volatile: VolatileSection[] = [];434    if (purpose === "verification-nudge" && state.lastVerification !== null) {435      volatile.push({436        name: "verification",437        text: this.#deps.verifier.buildNudge(state.lastVerification.detectedChecks),438      });439    }440    const built = await this.#deps.context.selectContext({441      events,442      ...(volatile.length > 0 ? { volatile } : {}),443    });444    const recorder = new StreamRecorder(445      this.#deps.session,446      built.request.model,447      purpose,448      built.stats,449    );450    try {451      for await (const event of this.#deps.model.stream(built.request, signal)) {452        recorder.record(event);453        if (event.type === "completed") this.#deps.context.onTurnComplete(event.usage);454      }455      return null;456    } catch (error) {457      const failure = classifyModelError(error);458      this.#deps.session.publishDurable({459        type: "model.request-failed",460        payload: {461          requestId: recorder.requestId ?? "unknown",462          kind: failure.kind,463          message: failure.message,464          ...(failure.status !== undefined ? { status: failure.status } : {}),465          retriesExhausted: failure.retriesExhausted,466        },467      });468      // cancelled folds into interrupted state; overflow routes to compaction.469      if (failure.kind === "cancelled" || failure.kind === "context-overflow") return null;470      this.#deps.session.publishDurable({471        type: "task.failed",472        payload: { reason: "model-fatal-error", detail: failure.message },473      });474      return { kind: "failed", reason: "model-fatal-error", detail: failure.message };475    }476  }477478  /** Prune first (cheap, deterministic), compress only when nothing prunable remains (§6.2). */479  async #compact(480    events: readonly DurableEvent[],481    signal: AbortSignal,482  ): Promise<TurnOutcome | null> {483    const prune = this.#deps.context.pruneToolResults({ events });484    if (prune.toolUseIds.length > 0) {485      this.#deps.session.publishDurable({ type: "context.pruned", payload: prune });486      return null;487    }488    try {489      const checkpoint = await this.#deps.context.compress({ events }, signal);490      this.#deps.session.publishDurable({ type: "context.compacted", payload: checkpoint });491      return null;492    } catch (error) {493      const detail = error instanceof Error ? error.message : String(error);494      this.#deps.session.publishDurable({495        type: "task.failed",496        payload: { reason: "model-fatal-error", detail: `Context compaction failed: ${detail}` },497      });498      return { kind: "failed", reason: "model-fatal-error", detail };499    }500  }501}502