/** * KHAELOR * File: src/agent/steering.ts * Description: Queued steering — mid-turn user text queued durably, injected only at the two safe seams (ADR-11). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { DurableEvent } from "../session/index.js"; import type { KernelSession } from "./session-handle.js"; /** A queued instruction not yet injected into LLM history. */ export interface QueuedSteering { /** Envelope id of the SteeringQueued event (SteeringInjected references it). */ queuedEventId: string; text: string; } /** Steering-queued events without a matching SteeringInjected, in seq order. */ export function pendingSteering(events: readonly DurableEvent[]): QueuedSteering[] { const queued = new Map(); for (const event of events) { if (event.type === "user.steering-queued") { queued.set(event.id, event.payload.text); } else if (event.type === "user.steering-injected") { queued.delete(event.payload.queuedEventId); } } return [...queued.entries()].map(([queuedEventId, text]) => ({ queuedEventId, text })); } /** * Determine the injection seam from recorded state (EVENT_MODEL.md §4): * post-tool-batch when the most recent conversation event is a tool result * (the queued text joins that tool-result user message), pre-model-call * otherwise (appended to the last user message) — never a bare mid-alternation * user message. */ export function steeringSeam(events: readonly DurableEvent[]): { seam: "post-tool-batch" | "pre-model-call"; afterSeq: number; } { let lastSeq = 0; let lastToolResultSeq = 0; let lastOtherConversationSeq = 0; for (const event of events) { lastSeq = event.seq; switch (event.type) { case "tool.completed": case "tool.failed": case "tool.cancelled": lastToolResultSeq = event.seq; break; case "user.message-created": case "model.text-block-completed": case "model.thinking-block-completed": case "tool.requested": lastOtherConversationSeq = event.seq; break; default: break; } } if (lastToolResultSeq > 0 && lastToolResultSeq > lastOtherConversationSeq) { return { seam: "post-tool-batch", afterSeq: lastToolResultSeq }; } return { seam: "pre-model-call", afterSeq: lastSeq }; } /** * The steering queue (ARCHITECTURE.md §8). `queue()` records the instruction * durably the moment it arrives (it survives a crash before injection and * renders as `Queued instruction`); the kernel drains the queue with * `injectPending()` only at a safe seam — after tool results, before the * next model call — recorded as durable SteeringInjected events that make * replay byte-exact (ADR-7). */ export class SteeringQueue { readonly #session: KernelSession; constructor(session: KernelSession) { this.#session = session; } /** Record a mid-turn instruction. Safe to call at any time, even mid-stream. */ queue(text: string): DurableEvent { return this.#session.publishDurable({ type: "user.steering-queued", payload: { text } }); } /** Queued instructions not yet injected. */ pending(): QueuedSteering[] { return pendingSteering(this.#session.events()); } /** * Drain the queue at the current (safe) seam. Called by the kernel only * when no tool batch is pending and no model stream is running. * Returns the number of instructions injected. */ injectPending(): number { const events = this.#session.events(); const pending = pendingSteering(events); if (pending.length === 0) return 0; const { seam, afterSeq } = steeringSeam(events); for (const item of pending) { this.#session.publishDurable({ type: "user.steering-injected", payload: { queuedEventId: item.queuedEventId, seam, afterSeq }, }); } return pending.length; } }