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%
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