UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

636 lines (635 loc) • 47.1 kB
import type { ActorAssertion, AssistantMessage, ImageContent, Model, UserMessage } from "../llm/index.js"; import { type ToolTurnRepair } from "../llm/index.js"; import { type ExecutedToolCallOutcome } from "../loop/agent-loop.js"; import type { HaltRemainingDirective } from "../loop/types.js"; import type { AgentMessage, AgentTool, AgentToolCall, LoopMalformedToolUseRecovery, LoopThinkingOnlyRecovery, LoopTruncatedOutputRecovery, QueueMode, ThinkingLevel } from "../loop/types.js"; import { type EngineSegment } from "../../core/untrusted-text.js"; import type { CommittedFrameCarrier } from "../../core/frame-settlement.js"; import type { AbortResult, AgentHarnessEvent, AgentHarnessEventResultMap, AgentHarnessOptions, AgentHarnessOwnEvent, AgentHarnessResources, AgentHarnessStreamOptions, ExecutionEnv, PromptTemplate, Skill } from "./types.js"; /** [c209-C] 收口 C1/C2 — engine-provenance metadata a caller can stamp on the user message it * mints (see the field docs on {@link UserMessage}). Threaded from `prompt`/`steer`/`followUp`/ * `nextTurn` options into the persisted message so derived views (auto-mode window, compaction * serializer) work from METADATA, never from reminder-shape guessing. */ /** Who aborted a turn seat: the bare user halt or the steer-now accelerator. A run-level abort reaches the seat * through the loop's composition listener and stamps nothing. */ export type SeatCutSource = "halt" | "steer"; /** Who asked the clean-stop latch to end the run: the bare user halt, or a soft stop ({@link AgentHarness.requestStopAfterTurn}). */ export type StopRequestSource = "halt" | "soft"; /** The harness's STATEMENT of what a run's halt (and its stop siblings) did — the result assembly reads this in * place of any inference from message shape: `haltedByUser` ⇔ `turnCut === "halt"` ∨ `"halt" ∈ boundaryStop.sources`. * Per prompt: every seat is cleared at the next `prompt()`, so a read after the prompt settled describes that run. */ export interface HaltConsumption { /** The loop's interrupt settlement consumed the cut of the LAST-published seat, stamped with its aborter. Absent: no * seat cut was consumed on the final turn (never cut / a later seat cleared it / a run abort owned the ending / no stamp). */ readonly turnCut?: SeatCutSource; /** The first boundary consult that stopped a non-unwinding loop, with the latch's sources at that instant. */ readonly boundaryStop?: { readonly face: "pre_turn" | "post_turn"; readonly sources: ReadonlyArray<StopRequestSource>; }; } /** `prompt()` option: settle the transcript's trailing UNANSWERED tool batch through the loop's own batch pipeline * BEFORE the prompt is delivered (a resumed run whose suspended batch still holds unanswered calls). `halt` settles * every unanswered call as not-executed instead (the batch-halt form). No unanswered tail ⇒ a no-op. */ export interface TrailingToolBatchOptions { readonly halt?: HaltRemainingDirective; } /** `prompt()` option: called when the prompt and next-turn queue's messages enter the loop transcript, before * their frames are emitted and committed. Not called when a trailing-batch settlement ends the run first — the * caller then still owns whatever the prompt was carrying. */ export type PromptDeliveredCallback = () => void; export interface UserMessageProvenance { /** Exact char length of the engine-prepended prefix on `text` (first-frame reminders etc.). * Sugar for the single segment `[0, chars)` — when `engineSegments` is also supplied, segments win. */ enginePrefixChars?: number; /** [c209-C] R4: engine-trusted `[start, end)` UTF-16 segments of `text` — the general form of * `enginePrefixChars` for engine frames that are NOT a pure head (a resume continuation keeps * issue order, so a trusted frame may follow an untrusted fenced one). See UserMessage.engineSegments. */ engineSegments?: ReadonlyArray<EngineSegment>; /** The ENTIRE message is engine-minted guidance (nudges/finalize/attachment bundles), not user speech. * [c209-C] R4: mutually exclusive with `provenance` — on a double-stamp, `provenance` wins (see below). */ engineMinted?: true; /** [c209-C] R3: engine-injected note whose content must stay VISIBLE in derived views (diagnostics, * task notifications, steering, recall) but never under user authority — see UserMessage.provenance. */ provenance?: "engine-note"; /** The frame's CONTENT is caller/supervisor speech relayed by the engine (a TaskStream steer), not * engine-authored guidance. Rides only WITH `provenance:"engine-note"` (the projection semantics are * identical — visible, never `[user]` authority); what it changes is the run-control judgment: the * human-halt hold below treats a caller-authored frame as USER-provenance input (always passes), * while an engine-authored one is held. Minter-stated at the single relay seam that mints such * frames — never inferred from text. Metadata only; not persisted on the message. */ callerAuthored?: true; /** #1036 — the frame's injection-queue dedup key, persisted on the message it becomes * ({@link UserMessage.engineNoteKey}) so a reader of the TRANSCRIPT alone can join an entry back * to its frame. Rides only WITH `provenance:"engine-note"`. */ engineNoteKey?: string; /** RB-30 terminal fix — an OPAQUE payload the engine rides on this queued message (the runner's * task-notification frame). Never serialized, never interpreted by the harness: if the message is * still undrained at agent_end, the payload is handed back through `onUndrainedEngineNotes` so * the runner can pend it per session (the final-drain stranding window closes losslessly). */ enginePayload?: unknown; /** design/171 §5.2 — WHO spoke this message (shared-session multiplayer attribution). Threaded * verbatim onto {@link UserMessage.actor}: metadata only, never authority, never on the provider * wire. The model-facing speaker envelope is rendered INTO `text` by the caller (the single * projection point `projectHumanInput`) BEFORE the frame wrapping — the harness never rewrites * text, so annotation stays adjacent to the frame it attributes. */ actor?: ActorAssertion; /** backlog #389 — an OPAQUE record the caller rides on a CALLER-AUTHORED queued frame so a durable * park can carry the instruction across the suspend instead of losing it. Same sidecar posture as * {@link UserMessageProvenance.enginePayload} (a WeakMap entry, never a message field, never * serialized, never interpreted here) but a different lane and a different fate: an engine payload * is handed back for per-session redelivery, a park record is handed to the checkpoint mint through * {@link AgentHarness.readParkableUserInputs}. The harness holds the FRAMED text; the record is what * the parked-steer queue needs (pre-framing text + trust + inputId), which only the minting seam * knows — hence opaque. */ parkRecord?: unknown; /** design/373 — an IMMEDIATE-class ("now") frame: the steer-lane enqueue inserts it ahead of the * first non-immediate frame (FIFO among immediates — the dequeue-priority form), the loop's * pre-request re-check sweeps it into the imminent turn, and {@link AgentHarness.interruptTurn} * may force the boundary for it. Class metadata only (a sidecar, judged at the mint) — it never * rides the message or the wire, and it grants NOTHING by itself: the interrupt is a separate, * caller-provenance verb. */ immediate?: true; /** design/373 (#445 saturation prerequisite) — terminal-preference at the engine-note backlog * cap: when the target queue's engine-note count is AT the cap, a frame carrying this mark * displaces the OLDEST non-preferred engine-note frame instead of being refused — the displaced * payload is handed to the undrained sink (the runner's lossless per-session park lane), so a * watcher's event storm can delay its own batches but never crowd a terminal completion out of * the run that is waiting on it (the pend store's terminal-preferred eviction, mirrored at the * queue mouth). Only meaningful WITH `enginePayload`; a preferred frame among only preferred * frames still gets the cap refusal (nothing displaceable). */ capPreferred?: true; } /** * Harness-level recovery wiring (design/118 ④b/⑤). The loop's prompt-too-long seam wants a * replacement TRANSCRIPT; at the harness level the correct policy is a SESSION-persisted reduction * (e.g. a forced compaction) — otherwise the next turn's `createTurnState()` rebuilds the context * from the session and the in-memory shrink evaporates. So the harness contract is: `recover` * persists the reduction to the session and returns whether anything shrank; the harness then * rebuilds the turn state FROM the session and hands the loop the rebuilt context — transcript AND * system prompt (#474 ①), because the reduction pass may commit a prompt-epoch change of its own. */ export interface HarnessLoopRecovery { truncatedOutput?: LoopTruncatedOutputRecovery; malformedToolUse?: LoopMalformedToolUseRecovery; thinkingOnly?: LoopThinkingOnlyRecovery; /** design/128-follow-up (TB 尸检 T1-4): recover from a DEGENERATE-REPETITION cutoff instead of ending the * run — the brain already cut the stream and stamped the turn errored; when `detect` matches, the loop * nudges "don't repeat that, take a different approach" and runs another turn, bounded to `maxContinues` * CONSECUTIVE times (default 2). The cut-off turn stays in the transcript (its salvaged text is real * context). `detect` is injected by the caller (core owns the brain's DEGENERATE_MESSAGE constant). */ degenerateOutput?: { detect: (message: AssistantMessage) => boolean; maxContinues?: number; }; promptTooLong?: { /** Persist a context reduction to the SESSION; return true if anything shrank. Must not throw. * `turnSignal` is the TURN-scoped abort (#474 ②): a policy whose work is expensive (a forced * compaction's summary call) must ride it so a turn interrupt cuts the pass short instead of * being waited out — and a policy that declines under it leaves the loop settling the turn as * an interrupted one, never as the provider error's terminal. */ recover: (attempt: number, turnSignal?: AbortSignal) => Promise<boolean>; /** Override the loop's default prompt-too-long classifier. */ detect?: (message: AssistantMessage) => boolean; /** Max recovery retries per turn (loop default: 2). */ maxRetries?: number; /** M1 CC-withhold parity: hold the failure's stream events until the recovery outcome is * known (loop default: true). See LoopPromptTooLongRecovery.withholdErrorEvents. */ withholdErrorEvents?: boolean; }; } export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate, TTool extends AgentTool = AgentTool> { readonly env: ExecutionEnv; private session; private phase; /** Can an engine injection land at a turn boundary RIGHT NOW? A producer choosing a destination * BEFORE it commits (the session notification route) needs this as a fact, not as a rejected * promise afterwards. Three conjuncts, each a condition a later stage enforces: a turn must EXIST * (`steer`/`followUp` throw while idle); the run must not be UNWINDING; it must not be STOPPING * (`halt()` / `requestStopAfterTurn()` latch a source and the loop exits at the next boundary) — * the phase reads `"turn"` through all of that. Nothing awaits between this read and the injection * it gates, so a `true` cannot go stale under its caller. */ get acceptsInjections(): boolean; /** design/74 R3-A: the one-way clean-stop latch — SET iff this set is non-empty. When set, the boundary consults * answer true so the loop exits CLEANLY (emit `agent_end` + return) WITHOUT an abort/interrupt — the path a * resource-slice suspend needs (an abort would trip the orphan-[INTERRUPTED] reconcile = corrupt resume). It is * the SET of its requesters (`halt()` adds `"halt"`, `requestStopAfterTurn()` adds `"soft"`; both may be present, * no first-writer arbitration) so the consumption record can say WHOSE stop the loop stopped for. */ private readonly stopRequestSources; private get stopRequested(); private boundaryStopConsumed?; /** The seat the loop published LAST — retract does not clear it, only the next publish replaces it (a fresh controller * per turn, so identity carries the generation); the loop's cut-consumption report is accepted for THIS seat only. */ private lastPublishedSeat?; private readonly seatCutSources; private trailingBatchEndedOn?; private turnCutConsumed?; /** backlog #389 — one-way latch set by {@link abort}. Read by `drainQueuedMessages`: no boundary may * hand a queued frame to the model once the run is unwinding, and — crucially — none may SPLICE one * out of the queue either (that would delete the terminal undrained account the abort path owes it). */ private aborting; /** backlog #389 (HIGH-1) — frames a boundary has REMOVED from a queue but that * have not yet entered the model's context. * * The abort latch alone leaves a real window: `drainQueuedMessages` splices first and then awaits * `emitQueueUpdate`, and even after it returns the loop has one more decision point before it injects * anything. An `abort()` landing in either gap finds both queues empty, so the terminal account — * which reads the queues — reports zero for a frame that will never reach the model. That is the very * silent loss this ticket exists to close, one layer deeper. * * A frame sits here from the splice until its `message_end` (the moment it is in the transcript); * the terminal account, the abort snapshot and the durable-park migration all read it alongside the * queues, so "accepted" is covered end to end rather than only while it happens to be in an array. */ private drainedPendingInjection; private runAbortController?; private runPromise?; private pendingSessionWrites; private model; /** The ref that chose {@link model}, moved by the SAME assignment (one value, never two answers). */ private modelRef; /** The run's thinking level; `undefined` = undeclared (provider default, no wire key) — distinct from "off". */ private thinkingLevel; /** RB-30 terminal fix — runner-set sink for engine-note payloads left undrained at agent_end. */ onUndrainedEngineNotes?: (payloads: unknown[]) => void; /** Runner-set sink for the provider boundary's tool-turn repairs ({@link ensureToolTurnInvariants}) — called * with every request view that needed one (the transcript is never rewritten, so a broken turn is repaired, and * reported, on each request; the sink owns its dedup). Advisory: a throwing sink never breaks the request. */ onToolTurnRepaired?: (repairs: readonly ToolTurnRepair[]) => void; /** The assistant messages standing in the transcript when the current run began — what the request view reads as * "before this run" (a message is the run's own unless it is in here). */ private runHistory?; /** backlog #259 — USER-authored queue remnants at agent_end: steers/follow-ups whose receipts said * "queued" but that no turn will ever drain (the run ended first). The engine-note sweep above * hands ENGINE payloads back for redelivery; user inputs have no redelivery semantics (a steer * aimed at a finished run must not silently fire at the next one), so their loss is ANNOUNCED * instead — the runner surfaces it as an operator notice, closing the "accepted then silently * dropped" window the #257 contract could only document. */ onUndrainedUserInputs?: (counts: { steer: number; followUp: number; }) => void; /** design/176 — runner-set sink fired at the CONSUMPTION boundary, once per engine-note payload, * in consumption order (steer/followUp drain and the turn-open nextTurn splice — the two points * where a queued frame actually enters the model's input). The runner uses it to record the * inbound peer hop chain of the message the model is about to see; writing at ENQUEUE time * instead would let an outbound send inherit the chain of a message the model has not read yet. * Advisory (swallow-guarded at the call sites via the runner's own closure); never re-entered * for the same payload — consumption deletes the sidecar entry in the same step. */ onEngineNoteConsumed?: (payload: unknown) => void; /** backlog #1273 — runner-set sink fired ONCE per CALLER frame at the moment the frame is in the transcript (its * `message_end` append — the next request carries it), with the opaque park record the frame rode in on * ({@link UserMessageProvenance.parkRecord}). The consumption point of a caller input, deliberately NOT the drain: * a drained frame can still be dropped before its turn starts (a halt latched or an abort landing after the drain) * and goes back into the park / undrained account, so a drain-time report would announce an input the model never * saw. Advisory (swallow-guarded); the record is released in the same step, so a delivered frame reports once. */ onUserInputConsumed?: (parkRecord: unknown) => void; /** #1036 — THE RECEIPT SINK: a frame reached the TRANSCRIPT, reported at the `message_end` append * that proves it, with its carrier and entry id. Advisory. */ onFrameCommitted?: (carrier: CommittedFrameCarrier, entryId: string) => void; /** Runner-set HOLD — the single gate for every engine-authored injection against the run's live * human-halt fact. When it reports true (a person's bare rejection halted the run at this * boundary, and only that person's own input may continue it): * · a NEW engine-authored steer/followUp is refused at entry (the caller's existing rejection * path parks the payload for the session's next run — same lane an idle-race refusal takes); * · engine-authored frames ALREADY queued are not drained — they stay queued, delayed past the * halted boundary: delivered at the next un-halted boundary if the user's own queued input * legitimately continues the run, otherwise handed to the terminal sweep (payload frames park * losslessly; payload-less boundary advisories end with the run, the same best-effort fate * their mint sites already accept for a final turn). * Caller/user-provenance input always passes — it is the awaited direction. One predicate here * instead of a per-lane check at every injection site: a future engine lane that never heard of * the halt is held by construction rather than by remembering. `nextTurn` is deliberately NOT * gated: its splice opens a NEW run (idle-park redelivery), and a halt belongs to the run that * minted it. */ engineInjectionsHeld?: () => boolean; /** The provider boundary's tool-turn law: the repaired view goes to the brain, the repairs to the runner's sink. */ private holdToolTurnLaw; /** RB-30 R2 belt-and-braces: runner-callable idempotent recovery for exit paths that reach * neither agent_end nor abort() (a pre-prompt throw after prepare). Swept entries are removed, * so a double call is a no-op. */ recoverUndrainedEngineNotes(): void; /** RB-30: shared recovery sweep — collects engine-note payloads from the given queues * in DELIVERY order (steer before followUp, each queue forward — the live loop serves steering * first, so the recovered redelivery must not present "later" frames ahead of "now/next"), * removes those entries, and hands the payloads to the runner sink. Called from BOTH terminal * paths: the natural agent_end AND abort() — which does NOT clear the queues: #389 retired that * clearing, so they are LEFT STANDING (the durable-park migration reads them AFTER abort() * returns) and the abort-side pass is the belt for an exit that never reached agent_end. */ private sweepUndrainedEngineNotes; /** * How much of a PERSON's input is currently accepted-but-unserved, per lane — the one predicate behind * both readers below, so "a person's input is waiting" cannot come to mean two things. Engine-AUTHORED * frames are excluded, and the IN-FLIGHT slot counts (a frame spliced out of a queue and never injected * is as pending as one that never left it). */ private pendingUserInputsByLane; /** * Is a PERSON's input waiting for this run's NEXT TURN BOUNDARY? The live read face over * {@link pendingUserInputsByLane}, for the producer that must decide whether to YIELD that boundary * sooner — the shell's auto-backgrounding leg. * * THE STEER LANE ONLY: the follow-up lane's contract is delivery at the run's natural * would-otherwise-stop seat, which backgrounding a command cannot bring forward. * `announceUndrainedUserInputs` still reads both lanes. * * Synchronous and side-effect-free by contract, the same posture as {@link pendingInjectionCount}: * nothing is awaited between the read and the action it gates. An UNWINDING run answers `false`. */ hasPendingUserInput(): boolean; /** backlog #259 — what remains in the steer/follow-up queues AFTER the engine-note sweep is USER * input that was accepted ("queued") and will never be consumed: the run reached agent_end first. * User inputs have no redelivery semantics (unlike engine notes — a steer aimed at a finished run * must not fire at the next one), so the loss is ANNOUNCED, never silent. The window is a narrow * race (an injection landing after the loop's final queue check), which is exactly why it needs a * loud terminal account rather than an e2e reproduction. Returns the counts for the settled frame. */ private announceUndrainedUserInputs; private systemPrompt; /** S4: physical system blocks (static per leg, additive — see AgentHarnessOptions.systemBlocks). */ private systemBlocks; private streamOptions; private getApiKeyAndHeaders?; private runtime?; private resources; private tools; private activeToolNames; private steerQueue; private steeringQueueMode; private maxOutputTokens?; private maxOutputTokensPerCall?; private stallTimeoutsPerCall?; private abortResultDetails?; private loopTrace?; private onToolInputValidationFault?; private resilience?; private maxToolConcurrency?; private streamingToolExecution?; private followUpQueue; private followUpQueueMode; private nextTurnQueue; private loopRecovery?; /** roadmap #5 (CC Stop hook): the runner-wired stop gate — see {@link setStopGate}. */ private stopGate?; /** design/373 — the loop-published turn-scoped AbortController (live exactly while a main turn's * work phase is in flight; `undefined` between turns). Read by {@link interruptTurn} only. */ private turnInterruptSeat?; private handlers; constructor(options: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>); private getHandlers; private emitOwn; private emitAny; /** * Emit a hook event to its registered handlers and return a single result. * * **Last-write-wins:** handlers run in registration order; each non-`undefined` result overwrites * the previous one, and the LAST non-`undefined` result is returned (`undefined` = abstain, keeps * the prior winner). Handlers do NOT compose — registering two handlers that both return a result * for the same event silently drops all but the last. A handler that throws aborts the whole emit * (errors are not swallowed). Callers that need ordered, composable semantics for one event (e.g. * the design/37 two-phase tool gate: collect arg rewrites, then adjudicate policy) must funnel that * logic through a SINGLE handler rather than relying on multiple handlers + this fold. */ private emitHook; private emitBeforeProviderRequest; private emitBeforeProviderPayload; private emitQueueUpdate; private startRunPromise; private createTurnState; private createContext; private createStreamFn; private drainQueuedMessages; /** #389 — drop these frames from the in-flight account (they went back on a queue, or they landed). */ private unbookInFlight; /** #389 — a queued frame has reached the transcript: it is delivered, so it leaves the in-flight * account and gives up its park record (a suspend must never re-park what the model has read). */ private settleInjectedFrame; private createLoopConfig; /** * design/118 ④b/⑤: wire (or clear) the loop's recovery chains. The runner calls this after * construction — truncated-output auto-continue and the prompt-too-long recover policy are * PROFILE decisions (the harness only relays them to the loop). */ setLoopRecovery(recovery: HarnessLoopRecovery | undefined): void; /** * roadmap #5 (CC Stop hook): wire (or clear) the stop gate — consulted ONLY when the run would * otherwise end (no tool calls, steer + followUp queues dry). Non-empty return = the run * continues with those messages injected; empty = the run ends. Policy (the Hooks.stop call, * consecutive-block cap, reason formatting) lives in the runner; the harness only supplies the * drain-point timing. */ setStopGate(gate: (() => Promise<AgentMessage[]>) | undefined): void; private validateToolNames; private flushPendingSessionWrites; /** * #506 ㋐ — write the transcript's record of a TERMINAL provider failure (CC 2.1.250 parity, `Oo`/`CIt`). * * A SEPARATE object, never the live frame: `failure` is the engine's terminal error shell and every * other consumer of it (the run result, failover, telemetry, usage accounting) reads its real * `model`, so re-stamping that field in place would corrupt all of them. The record is minted here * with the reserved {@link SYNTHETIC_MESSAGE_MODEL} instead, which is what makes it recognizable — and * therefore droppable — at the request boundary and at the derived-state walks. * * Two CC fields are deliberately NOT copied, because this tree already states the same facts more * honestly: CC writes `stop_reason: "stop_sequence"` on an entry that stopped for no such reason (its * own consumers must read `isApiErrorMessage` to know better), and CC writes a zero `usage` with * nothing marking it synthetic. Here the stop reason stays `"error"` and `usageMissing` says the zeros * are a shell rather than a measurement — the same discipline the live frame already carries. * * BEST-EFFORT: a record of a failure must never become a second failure. The append can lose the * session's optimistic lock (another writer moved the leaf) or hit a storage fault, and the run is * already over — swallowing that leaves the pre-#506 behavior (no record) rather than converting a * provider outage into a harness throw on the teardown path. */ private recordApiFailure; private handleAgentEvent; private emitRunFailure; private executeTurn; /** Returns the run's final assistant message — or `undefined` on exactly one arm (#504): a bare * user halt ({@link halt}) stopped the run before any assistant output existed. Every other * zero-assistant ending still throws (see the executeTurn tail). */ prompt(text: string, options?: { images?: ImageContent[]; settleUnansweredToolCalls?: TrailingToolBatchOptions; onDelivered?: PromptDeliveredCallback; } & UserMessageProvenance): Promise<AssistantMessage | undefined>; /** R2-③: an empty/whitespace-only injection with no images carries ZERO information — it * must not mint a user frame (strict endpoints reject empty user content, and an empty follow-up would * pointlessly extend the run by one turn). No-op, not a throw: injection callers are fire-and- * forget lanes (wakeups, reminders) and a shell bug upstream must not crash the run. */ private static emptyInjection; /** REF-B6 — the ONE injection enqueue behind steer/followUp/nextTurn: the R2-③ empty-injection * short circuit, the user frame, the RB-30 engine-payload SIDECAR (a WeakMap entry, never a message field — * pre-prompt exits recover these too), the queue push, and the broadcast. All three lanes ship in ONE * `queue_update` event (see emitQueueUpdate), so they are one protocol contract and must stay one * implementation: R2-③ and RB-30 each had to be patched in three places. * * The idle check is deliberately NOT here: its message differs per lane, and nextTurn() legitimately has * none (queueing the NEXT prompt while idle is exactly what it is for). */ private enqueueInjection; /** The human-halt hold's ENTRY half (see {@link engineInjectionsHeld}): a new engine-authored * injection during a halt is refused before it can queue — the refusal is the caller's park * signal (the same posture as the backlog-cap refusal above: pend per session, redeliver on the * session's next run; nothing is lost). Belt-and-braces beside the drain-side hold: today's * engine lanes each check the halt fact before injecting, so this arm exists for the lane that * does not — including the one not written yet. */ private refuseHeldEngineInjection; /** design/373 — returns the minted queue frame (or `undefined` on the empty-injection no-op), so * a caller-provenance immediate steer can hand the SAME frame to {@link interruptTurn} (the * consumption guard is identity-keyed). Additive: every existing caller awaited `void`. */ steer(text: string, options?: { images?: ImageContent[]; } & UserMessageProvenance): Promise<UserMessage | undefined>; followUp(text: string, options?: { images?: ImageContent[]; } & UserMessageProvenance): Promise<void>; /** * design/373 §3.4 — force the running turn's boundary FOR a queued immediate-class frame: * abort the CURRENT main turn (provider stream + tool batch — never the run, never boundary * housekeeping), whose loop-side settlement reconciles the cut and continues the run with the * frame at the queue head. Best-effort accelerator by contract: `false` means no interruption * happened AND none was needed — the frame is already consumed or already rides the imminent * boundary — so delivery semantics are exactly the non-interrupting ones either way. * * Guards, in order: * - an unwinding run (`abort()` latched) owns its ending — never contest it; * - CONSUMPTION guard (r4/R3-1): the frame must still sit in the steer queue. Drained/consumed ⇒ * it is already part of a turn's input — aborting THAT turn would cut the very delivery this * verb exists to speed up. Same-tick check-then-abort (single-threaded: no window); * - seat liveness: no published turn (idle tail / between turns / boundary housekeeping) ⇒ no-op, * the frame rides the next boundary; an already-fired seat ⇒ idempotent no-op (one boundary per * turn — later immediates join the same boundary batch). */ interruptTurn(frame: AgentMessage): boolean; /** * design/373 (#504) — the BARE user interrupt, the CC Esc form: CUT the in-flight turn (when one * is in flight) and STOP the run at that manufactured boundary — no new model turn starts, the * run collects to a clean, resumable ending (never the orphan-[INTERRUPTED] run-abort reconcile). * The run-model equivalent of "the session stays alive waiting for the user's next input". * * Two halves, one call: * - STOP — the one-way `requestStopAfterTurn` latch, now consulted at BOTH boundary faces * (post-turn `shouldStopAfterTurn` and #504's pre-turn `shouldHaltBeforeTurn`), so the loop * ends at whichever boundary comes first and never starts another provider request; * - CUT — the design/373 S1 turn seat, aborted WITHOUT the frame-consumption guard: unlike * {@link interruptTurn} (an accelerator FOR a queued immediate frame, guarded so it can never * cut the delivery it exists to speed up), a bare halt is not delivering anything — there is * no frame whose consumption could make the cut self-defeating, so the seat's own liveness is * the only guard. The cut settles exactly as every S1 cut does: finished tool calls keep * their REAL results, never-started ones settle as paired interrupted results, the * CC-verbatim interruption marker lands. * * Returns `{ turnCut, accepted }` — `turnCut` `true` iff THIS call aborted a live turn seat (an * in-flight provider stream / tool batch will settle as interrupted; the completion-race arm is * honest: a turn whose work already finished when the abort landed ends normally and the run * still stops at its boundary). `false` = nothing was in flight (between turns / boundary * housekeeping / a boundary already being manufactured) — the stop half alone answers, at the * imminent boundary. `accepted` (#504 codex r3) is the ATOMIC ownership bit: `true` iff this * call latched the stop on a run whose harness-channel abort had NOT already fired — captured * BEFORE the seat abort dispatches, so a re-entrant abort fired by the cut's own listeners can * never rewrite the answer. The unwinding arm answers `{ turnCut:false, accepted:false }`: an * abort-owned ending is never this verb's claim (the runner's attribution seat keys on it). * * Guards, in order (the interruptTurn discipline, minus the frame guard): * - idle ⇒ typed refusal (the steer posture — misuse must be loud; the runner's birth-window * poll owns the not-yet-started arm); * - an unwinding run (`abort()` latched) owns its ending — never contest it, and never latch a * stale stop onto a harness a later `prompt()` would inherit... (the latch is per-prompt * reset, but the honest answer while unwinding is still "nothing for this verb to do"); * - stop latch, THEN seat: latch-before-abort ordering is load-bearing — the cut's settlement * consults the latch at its own boundary, so the stop is provably in place when the forced * boundary arrives (the enqueue-then-abort discipline, one lane over). * * Queued injection frames are deliberately NOT touched (CC 2.1.223 `control_request:"interrupt"` * keeps the queue and answers `still_queued` — see {@link abort}'s recorded ruling): whatever * the queues hold settles by the run's ordinary terminal contract for accepted-but-undelivered * input (durable park when a suspend seat exists, else the loud undrained accounts). */ halt(): { turnCut: boolean; accepted: boolean; }; /** The ONE seat-cut dispatch behind both verbs: stamp the aborter FIRST (a seat is never aborted twice), then abort. * A run abort fired INSIDE this dispatch (a listener on the cut signal tearing the run down) was fired BY the cut — * the loop takes the run-terminal path and never reports that settlement, so the consumption is recorded here, the * only point that can see the causation (synchronous, single-threaded). A LATER abort is the run's own ending. */ private cutSeat; /** The harness's statement of what this run's halt (and its stop siblings) did — {@link HaltConsumption}. Readable * during the run and after `prompt()` settled (cleared by the NEXT prompt). */ haltConsumption(): HaltConsumption; /** The ONE consult behind both boundary faces: a set latch answers true, and the FIRST true answer on a run that is * NOT unwinding is the consumption (the loop exits on it), recorded with the latch's sources at that instant. The run * signal always wins (the loop's cut arms' rule): an unwinding run ends FOR its abort — the latch's true answer is * incidental (the next turn's abort guard would have exited anyway), so the attribution is withheld, not the exit. */ private consultStopLatch; /** The loop's report that a seat's cut settled its turn (the loop-config seam). Identity is checked against the * LAST-published seat, never the live one (the arms report around retraction); any other seat is refused loudly. */ private noteTurnCutConsumed; /** * design/373 §3.3 (the final-commit-point double check) — how many queued injection frames a * boundary drain could deliver RIGHT NOW: both lanes, minus frames the current state refuses to * drain (an unwinding run drains nothing — those frames belong to the terminal account; a live * human-halt hold skips engine-authored frames). Synchronous and side-effect-free by contract: * the loop reads it between its last drain and the terminal commit, with zero awaits. */ pendingInjectionCount(): number; /** design/373 §3.4-3 — the pre-request re-check's drain half (see the loop-config seam): sweep * queued immediate-class frames into the imminent turn's batch, merged at the class position * (after immediates already pending, ahead of everything else — FIFO within the class holds * across the drain/re-check split). Same booking discipline as {@link drainQueuedMessages} * (in-flight account, queue_update broadcast, consumption sink, throw-rollback); the hold skips * engine-authored immediates exactly as the boundary drain would (a theoretical lane today — * no engine mint issues immediates — held by construction rather than by remembering). */ private absorbImmediateSteering; nextTurn(text: string, options?: { images?: ImageContent[]; } & UserMessageProvenance): Promise<void>; appendMessage(message: AgentMessage): Promise<void>; /** The loop's `afterToolCall`: one `tool_result` hook emit per executed call (every registered handler, one fold). */ private afterToolCallHook; /** * Finalize a call this harness's caller executed OUTSIDE a turn (a resumed, already-approved call) exactly as the * loop finalizes its own: the same {@link finalizeToolOutcome} over the same `tool_result` emit, so the call meets the * same handlers, the same patch application and the same throwing-hook posture as a live one. */ finalizeToolCall(toolCall: AgentToolCall, args: unknown, executed: ExecutedToolCallOutcome): Promise<ExecutedToolCallOutcome & { toolCall: AgentToolCall; }>; getModel(): Model; /** The ref that chose the CURRENT model — {@link getModel}'s companion (a word re-resolves, an object pins). */ getModelRef(): import("../../core/model-seat.js").ModelRef; /** The current level, or `undefined` when none was declared (provider default — no wire key). */ getThinkingLevel(): ThinkingLevel | undefined; /** `ref` = the word that chose `model`; omitted ⇒ the object pins itself. The pair moves together. */ setModel(model: Model, ref?: import("../../core/model-seat.js").ModelRef): Promise<void>; setThinkingLevel(level: ThinkingLevel): Promise<void>; setActiveTools(toolNames: string[]): Promise<void>; getSteeringMode(): QueueMode; setSteeringMode(mode: QueueMode): Promise<void>; getFollowUpMode(): QueueMode; setFollowUpMode(mode: QueueMode): Promise<void>; getResources(): AgentHarnessResources<TSkill, TPromptTemplate>; setResources(resources: AgentHarnessResources<TSkill, TPromptTemplate>): Promise<void>; getStreamOptions(): AgentHarnessStreamOptions; setStreamOptions(streamOptions: AgentHarnessStreamOptions): Promise<void>; /** RB-31 (design/148 S2) — swap the system prompt (+ physical blocks) for the NEXT turn snapshot, * the setTools-mirror seam: a compaction-boundary center adoption changes the stable prefix at a * legal boundary only (the running turn keeps its snapshot; createTurnState picks this up). * Blocks and prompt swap ATOMICALLY here; the existing blocksSafe guard still verifies equality * per snapshot, so a mismatched pair degrades to the single-block face, never a divergent wire. */ setSystemPrompt(prompt: string, blocks?: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>["systemBlocks"]): void; setTools(tools: TTool[], activeToolNames?: string[]): Promise<void>; /** * design/74 R3-A: request a CLEAN stop at the next turn boundary. Unlike {@link abort} (a hard * AbortController cancel that — with no committed pause — drives the orphan-[INTERRUPTED] session reconcile), * this sets a one-way flag the loop reads via its `shouldStopAfterTurn` callback: the current turn finishes * its tool calls, the loop emits `agent_end`, and returns. The runner uses it for a resource-slice suspend * so the suspended session is left clean (resumable), never reconciled as interrupted. Idempotent. */ requestStopAfterTurn(): void; /** * VENDOR.md mod #5 (fixed at de-vendoring, design/118 ⑤): drain session writes queued during an * ABORTED run. The harness intentionally does NOT flush on the abort path — the caller's interrupt * reconcile must append its synthetic tool_results first (closing the open tool_call batch), then * call this, so queued writes always land on a legal transcript. Safe to call anytime; no-op when * the queue is empty. */ flushQueuedSessionWrites(): Promise<void>; /** * backlog #389 — the accepted-but-undelivered CALLER frames a durable suspend can carry forward, * in delivery order (steer before followUp, each queue forward — the order the live loop serves). * NON-DESTRUCTIVE by design: the mint that reads this may still fail to commit, and a frame dropped * for a checkpoint that never landed would be lost twice over. {@link dropParkedUserInputs} is the * second half, called only once the checkpoint is durably committed. * * Only frames carrying a park record are returned — engine-authored notes have their own lossless * lane (`onUndrainedEngineNotes` → pend per session) and must not be parked twice. */ readParkableUserInputs(): unknown[]; /** * backlog #389 — the COMMIT half of {@link readParkableUserInputs}: the named records are now durable * on a checkpoint, so their live frames leave the queues (identity-matched on the sidecar). Dropping * them here is what keeps the terminal `#259` account honest — a migrated instruction is not "accepted * then lost", and one this is never called for (a failed commit) still gets its loud undrained notice. * Returns the per-lane drop counts so the caller can subtract them from an account already taken. */ dropParkedUserInputs(records: readonly unknown[]): { steer: number; followUp: number; }; /** * backlog #389 (CC 2.1.223 `control_request:"interrupt"`, `CC:1064478-1064513`) — an interrupt does * NOT destroy accepted user input. CC's default keeps the queue and answers `still_queued`; only an * explicit `cancel_queued` cancels, with a per-uuid `cancelled` receipt. This harness had the opposite * polarity: both queues were emptied unconditionally and the contents handed back on a result object * with ZERO readers in the repo — so the #259 "accepted then never consumed must be LOUD" account (run * at `agent_end`) counted the emptied queues and found nothing to announce. An input accepted one * millisecond before `interrupt()` therefore vanished behind its own `queued` receipt. * * The queues are now LEFT STANDING. The abort latch above stops any surviving boundary from draining * (or silently splicing) them, and the terminal `agent_end` account — the one disclosure leg, unchanged * — reports them through `onUndrainedUserInputs` + the `settled` frame, exactly as it does for a run * that ended naturally with input still queued. Engine notes keep their own lossless recovery sweep, * which must still run HERE: it is the one path `agent_end` cannot cover on its own (this sweep also * serves a `waitForIdle` that never reaches `agent_end`). * * A sema `interrupt()` ends the RUN (CC's ends a turn inside a living session), so there is no state in * which "still queued" means "will run next" — hence no `cancel_queued` opt-in: its two settings would * be indistinguishable at the package face, which is the dead-knob shape this same ticket family flags * elsewhere. The CC-shaped "survives the pause" seat is the DURABLE PARK, served by * {@link readParkableUserInputs}. * * RECORDED, because the two halves must not be read as contradicting each other (adversarial r2 * MEDIUM-6): the #259 notice says a stranded input is "NOT redelivered", and that is a statement about * the RUNNER's contract — a `TaskStream.steer` is never served to a later run, because the Runner * builds one harness per run and prompts it exactly once. A host driving `AgentHarness` DIRECTLY (it is * not on the package export surface) and re-prompting the same instance WILL see the leftovers served, * both the queued ones (true before this ticket too) and the in-flight ones the next `prompt()` * restores. Clearing them here instead is not available: the durable-park migration reads them AFTER * this method returns, so an abort that empties the queues would take the park's payload with it. */ abort(): Promise<AbortResult>; waitForIdle(): Promise<void>; subscribe(listener: (event: AgentHarnessEvent<TSkill, TPromptTemplate>, signal?: AbortSignal) => Promise<void> | void): () => void; on<TType extends keyof AgentHarnessEventResultMap>(type: TType, handler: (event: Extract<AgentHarnessOwnEvent, { type: TType; }>) => Promise<AgentHarnessEventResultMap[TType]> | AgentHarnessEventResultMap[TType]): () => void; }