/** * KHAELOR * File: src/agent/kernel.ts * Description: AgentKernel — the tiny state-derived loop: deriveNext over recorded events, stream reducer, nothing else (ADR-2). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { classifyModelError } from "../anthropic/index.js"; import type { ModelClient, ModelEvent } from "../anthropic/index.js"; import type { ContextEngine, ContextStats, VolatileSection } from "../context/index.js"; import { ulid } from "../shared/index.js"; import type { CompletionEvidence, DurableEvent, StopReason, ToolName, } from "../session/index.js"; import type { VerificationGate, VerificationGateResult } from "./completion.js"; import type { PendingToolCall, ToolBatchExecutor } from "./executor.js"; import { closeDanglingToolUses } from "./interruption.js"; import type { InterruptionController } from "./interruption.js"; import type { KernelSession } from "./session-handle.js"; import type { SteeringQueue } from "./steering.js"; // ───────────────────────── turn state (pure fold) ───────────────────────── /** Everything `deriveNext` consults — derived from the log, never loop-local flags (ADR-2). */ export interface TurnState { hasUserMessage: boolean; /** Interrupted after the last user message — the turn is over. */ interrupted: boolean; /** ToolRequested events without a terminal result, in block/seq order. */ pending: PendingToolCall[]; /** SteeringQueued events not yet injected. */ queuedSteering: number; lastResponse: { seq: number; stopReason: StopReason } | null; /** Max seq of conversation input: user text, steering, tool results, verification nudges. */ lastInputSeq: number; /** The most recent input is a verification nudge → next call carries it. */ lastInputWasVerification: boolean; lastVerification: { seq: number; detectedChecks: string[] } | null; /** A context-overflow failure not yet resolved by a compaction. */ overflowUnresolved: boolean; /** A prune/compaction already happened since the last model activity. */ compactionSinceModelActivity: boolean; } /** Fold the durable stream into the loop's decision state. */ export function foldTurnState(events: readonly DurableEvent[]): TurnState { const open = new Map(); let lastUserSeq = 0; let lastInterruptSeq = 0; const queued = new Set(); let lastResponse: TurnState["lastResponse"] = null; let lastModelActivitySeq = 0; let lastInputSeq = 0; let lastInputWasVerification = false; let lastVerification: TurnState["lastVerification"] = null; let lastOverflowSeq = 0; let lastCompactedSeq = 0; let lastContextEventSeq = 0; for (const event of events) { switch (event.type) { case "user.message-created": lastUserSeq = event.seq; lastInputSeq = event.seq; lastInputWasVerification = false; break; case "user.interrupted": lastInterruptSeq = event.seq; break; case "user.steering-queued": queued.add(event.id); break; case "user.steering-injected": queued.delete(event.payload.queuedEventId); lastInputSeq = event.seq; lastInputWasVerification = false; break; case "tool.requested": open.set(event.payload.toolUseId, { toolUseId: event.payload.toolUseId, toolName: event.payload.toolName, input: event.payload.input, blockIndex: event.payload.blockIndex, }); break; case "tool.completed": case "tool.failed": case "tool.cancelled": open.delete(event.payload.toolUseId); lastInputSeq = event.seq; lastInputWasVerification = false; break; case "model.response-completed": lastResponse = { seq: event.seq, stopReason: event.payload.stopReason }; lastModelActivitySeq = event.seq; break; case "model.request-failed": lastModelActivitySeq = event.seq; if (event.payload.kind === "context-overflow") lastOverflowSeq = event.seq; break; case "context.pruned": lastContextEventSeq = event.seq; break; case "context.compacted": lastCompactedSeq = event.seq; lastContextEventSeq = event.seq; break; case "task.verification-requested": lastInputSeq = event.seq; lastInputWasVerification = true; lastVerification = { seq: event.seq, detectedChecks: [...event.payload.detectedChecks] }; break; case "verify.result": // A failing native check is conversation input — the model repairs it // before the turn can complete (v2 §4). Passing checks are evidence only. if (!event.payload.ok) { lastInputSeq = event.seq; lastInputWasVerification = false; } break; default: break; } } return { hasUserMessage: lastUserSeq > 0, interrupted: lastInterruptSeq > lastUserSeq, pending: [...open.values()], queuedSteering: queued.size, lastResponse, lastInputSeq, lastInputWasVerification, lastVerification, overflowUnresolved: lastOverflowSeq > lastCompactedSeq, compactionSinceModelActivity: lastContextEventSeq > lastModelActivitySeq, }; } // ───────────────────────── deriveNext (pure) ───────────────────────── export type NextAction = | { kind: "idle" } | { kind: "interrupted" } | { kind: "execute-tools"; pending: PendingToolCall[] } | { kind: "inject-steering" } | { kind: "compact" } | { kind: "verify"; attempt: 1 | 2; candidateSeq: number } | { kind: "done" } | { kind: "budget-exhausted" } | { kind: "call-model"; purpose: "main" | "verification-nudge" }; /** Re-derive "what next" from recorded state — the whole kernel is this function plus dispatch (ADR-2). */ export function deriveNext( state: TurnState, gate: VerificationGateResult, opts: { shouldCompact: boolean; iterationsLeft: number }, ): NextAction { if (!state.hasUserMessage) return { kind: "idle" }; if (state.interrupted) return { kind: "interrupted" }; if (state.pending.length > 0) return { kind: "execute-tools", pending: state.pending }; // Safe seam: no tool batch pending, no stream running (ADR-11). if (state.queuedSteering > 0) return { kind: "inject-steering" }; if (state.overflowUnresolved || (opts.shouldCompact && !state.compactionSinceModelActivity)) { return { kind: "compact" }; } const answered = state.lastResponse !== null && state.lastInputSeq < state.lastResponse.seq; if (answered && state.lastResponse !== null && state.lastResponse.stopReason !== "tool_use") { if (gate.required && gate.attempts < 2) { return { kind: "verify", attempt: (gate.attempts + 1) as 1 | 2, candidateSeq: gate.candidateSeq, }; } return { kind: "done" }; } if (opts.iterationsLeft <= 0) return { kind: "budget-exhausted" }; return { kind: "call-model", purpose: state.lastInputWasVerification ? "verification-nudge" : "main", }; } // ───────────────────────── stream reducer ───────────────────────── /** * Map ModelClient events onto session events: deltas → ephemeral, settled * blocks / tool_use / usage → durable (EVENT_MODEL.md §3). Pure with respect * to the loop — no decisions, only recording. */ class StreamRecorder { requestId: string | null = null; readonly #session: KernelSession; readonly #model: string; readonly #purpose: "main" | "verification-nudge"; readonly #stats: ContextStats; constructor( session: KernelSession, model: string, purpose: "main" | "verification-nudge", stats: ContextStats, ) { this.#session = session; this.#model = model; this.#purpose = purpose; this.#stats = stats; } #started(requestId: string): void { this.requestId = requestId; this.#session.publishDurable({ type: "model.request-started", payload: { requestId, model: this.#model, purpose: this.#purpose, contextStats: this.#stats, }, }); } #id(): string { if (this.requestId === null) this.#started(`req_${ulid()}`); return this.requestId as string; } record(event: ModelEvent): void { switch (event.type) { case "started": if (this.requestId === null) this.#started(event.requestId); return; case "text-delta": this.#session.publishEphemeral({ type: "model.text-delta", payload: { requestId: this.#id(), blockIndex: event.blockIndex, text: event.text }, }); return; case "thinking-delta": this.#session.publishEphemeral({ type: "model.thinking-delta", payload: { requestId: this.#id(), blockIndex: event.blockIndex, text: event.text }, }); return; case "tool-call-started": this.#session.publishEphemeral({ type: "model.tool-call-started", payload: { requestId: this.#id(), blockIndex: event.blockIndex, toolUseId: event.toolUseId, toolName: event.toolName as ToolName, }, }); return; case "tool-input-delta": this.#session.publishEphemeral({ type: "model.tool-input-delta", payload: { requestId: this.#id(), blockIndex: event.blockIndex, toolUseId: event.toolUseId, partialJson: event.partialJson, }, }); return; case "text-block-completed": this.#session.publishDurable({ type: "model.text-block-completed", payload: { requestId: this.#id(), blockIndex: event.blockIndex, text: event.text }, }); return; case "thinking-block-completed": this.#session.publishDurable({ type: "model.thinking-block-completed", payload: { requestId: this.#id(), blockIndex: event.blockIndex, thinking: event.thinking, signature: event.signature, }, }); return; case "tool-call-completed": this.#session.publishDurable({ type: "tool.requested", payload: { requestId: this.#id(), blockIndex: event.blockIndex, toolUseId: event.toolUseId, toolName: event.toolName as ToolName, input: event.input, }, }); return; case "completed": this.#session.publishDurable({ type: "model.response-completed", payload: { requestId: this.#id(), stopReason: event.stopReason, usage: event.usage, durationMs: event.durationMs, }, }); return; } } } // ───────────────────────── the kernel ───────────────────────── export type TurnOutcome = | { kind: "idle" } | { kind: "done"; evidence: CompletionEvidence } | { kind: "interrupted" } | { kind: "failed"; reason: "model-fatal-error" | "iteration-budget-exhausted"; detail: string }; /** Optional proactive-compaction trigger — satisfied by ContextBudget. */ export interface CompactionSignal { shouldCompact(): boolean; } /** The five services the kernel touches — everything else lives behind them (ARCHITECTURE.md §4.1). */ export interface AgentKernelDeps { session: KernelSession; context: ContextEngine; model: ModelClient; executor: ToolBatchExecutor; verifier: VerificationGate; steering: SteeringQueue; interruption: InterruptionController; compaction?: CompactionSignal; /** Model-call budget per turn. Default 40. */ maxIterations?: number; } const DEFAULT_MAX_ITERATIONS = 40; /** * The agent kernel (ADR-2): a loop that re-derives "what next" from the * recorded session log each iteration and dispatches to services. It owns * no retry policy, no permission logic, no budgeting, no rendering — only * coordination (Absolute Rule #3). */ export class AgentKernel { readonly #deps: AgentKernelDeps; readonly #maxIterations: number; constructor(deps: AgentKernelDeps) { this.#deps = deps; this.#maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS; } /** Run until the current user request is done, interrupted, or failed. */ async runTurn(): Promise { const turn = new AbortController(); this.#deps.interruption.beginTurn(turn); try { let iterationsLeft = this.#maxIterations; for (;;) { const events = this.#deps.session.events(); const state = foldTurnState(events); if (state.interrupted || turn.signal.aborted) { closeDanglingToolUses(this.#deps.session, "interrupted"); return { kind: "interrupted" }; } const gate = this.#deps.verifier.needsVerification(events); const next = deriveNext(state, gate, { shouldCompact: this.#deps.compaction?.shouldCompact() ?? false, iterationsLeft, }); switch (next.kind) { case "idle": return { kind: "idle" }; case "interrupted": closeDanglingToolUses(this.#deps.session, "interrupted"); return { kind: "interrupted" }; case "execute-tools": await this.#deps.executor.executeBatch(next.pending, turn.signal); continue; case "inject-steering": this.#deps.steering.injectPending(); continue; case "compact": { const failure = await this.#compact(events, turn.signal); if (failure !== null) return failure; continue; } case "verify": { const detectedChecks = await this.#deps.verifier.detectChecks(); this.#deps.session.publishDurable({ type: "task.verification-requested", payload: { attempt: next.attempt, detectedChecks, withheldCandidateSeq: next.candidateSeq, }, }); continue; } case "call-model": { iterationsLeft -= 1; const failure = await this.#callModel(events, state, turn.signal, next.purpose); if (failure !== null) return failure; continue; } case "budget-exhausted": { const detail = `Turn stopped after ${this.#maxIterations} model calls without completion.`; this.#deps.session.publishDurable({ type: "task.failed", payload: { reason: "iteration-budget-exhausted", detail }, }); return { kind: "failed", reason: "iteration-budget-exhausted", detail }; } case "done": { const evidence = await this.#deps.verifier.collectEvidence(events); this.#deps.session.publishDurable({ type: "task.completed", payload: { evidence } }); return { kind: "done", evidence }; } } } } finally { this.#deps.interruption.endTurn(); } } /** One model call: build context, stream, record. Returns a fatal outcome or null. */ async #callModel( events: readonly DurableEvent[], state: TurnState, signal: AbortSignal, purpose: "main" | "verification-nudge", ): Promise { const volatile: VolatileSection[] = []; if (purpose === "verification-nudge" && state.lastVerification !== null) { volatile.push({ name: "verification", text: this.#deps.verifier.buildNudge(state.lastVerification.detectedChecks), }); } const built = await this.#deps.context.selectContext({ events, ...(volatile.length > 0 ? { volatile } : {}), }); const recorder = new StreamRecorder( this.#deps.session, built.request.model, purpose, built.stats, ); try { for await (const event of this.#deps.model.stream(built.request, signal)) { recorder.record(event); if (event.type === "completed") this.#deps.context.onTurnComplete(event.usage); } return null; } catch (error) { const failure = classifyModelError(error); this.#deps.session.publishDurable({ type: "model.request-failed", payload: { requestId: recorder.requestId ?? "unknown", kind: failure.kind, message: failure.message, ...(failure.status !== undefined ? { status: failure.status } : {}), retriesExhausted: failure.retriesExhausted, }, }); // cancelled folds into interrupted state; overflow routes to compaction. if (failure.kind === "cancelled" || failure.kind === "context-overflow") return null; this.#deps.session.publishDurable({ type: "task.failed", payload: { reason: "model-fatal-error", detail: failure.message }, }); return { kind: "failed", reason: "model-fatal-error", detail: failure.message }; } } /** Prune first (cheap, deterministic), compress only when nothing prunable remains (§6.2). */ async #compact( events: readonly DurableEvent[], signal: AbortSignal, ): Promise { const prune = this.#deps.context.pruneToolResults({ events }); if (prune.toolUseIds.length > 0) { this.#deps.session.publishDurable({ type: "context.pruned", payload: prune }); return null; } try { const checkpoint = await this.#deps.context.compress({ events }, signal); this.#deps.session.publishDurable({ type: "context.compacted", payload: checkpoint }); return null; } catch (error) { const detail = error instanceof Error ? error.message : String(error); this.#deps.session.publishDurable({ type: "task.failed", payload: { reason: "model-fatal-error", detail: `Context compaction failed: ${detail}` }, }); return { kind: "failed", reason: "model-fatal-error", detail }; } } }