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