@sema-agent/core
Version:
Stateless, task-oriented AI agent core
636 lines (635 loc) • 47.1 kB
TypeScript
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;
}