kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
657 lines (636 loc) • 43.5 kB
text/typescript
/**
* # session/harness/live-agent — the BYOK live {@link Agent} adapter (provider-free, kestrel-rul)
*
* The concrete body of `liveAgent` (declared, provider-free, in `../agent.ts`). It satisfies the
* ONE {@link Agent} seam — `open(briefing) / decide(frame)` — by composing the pure prompt profile
* and the fail-closed turn parser (`./prompt.ts`) around an INJECTED {@link LlmClient}. It imports
* NO provider SDK: the wire lives only inside whatever `LlmClient` the caller supplies (the AI-SDK
* client in `./ai-sdk-client.ts`, or a mock in tests), so the deterministic core — which
* `../simulate.ts` imports — stays provider-free (ADR-0013 (c), the invariant across both drivers).
*
* ## Determinism boundary (ADR-0013, RUNTIME §0)
* The live model call is the sole non-deterministic act, ABOVE the determinism line. Below it, only
* the returned {@link AgentTurn} crosses into the graded path — and because the driver captures each
* returned turn into {@link CapturedTurns}, {@link recordedAgent} replays the SAME turns and
* re-derives a byte-identical graded Bus (dual-harness fidelity). The per-turn wire evidence
* (model id, usage, reasoning, latency, scrubbed bytes) is captured OFF that path into an optional
* {@link TurnCapture} sink — free audit evidence for the leaderboard's attention axis, never a graded input.
*
* ## Session conversation (KV) reuse (kestrel-rul, the cache-monotone doctrine)
* Under the conversation policies ({@link CachePolicy}, default `conversation-cached`) the session is a
* GROWING message list — byte-stable `system` + the OPEN briefing + each wake's appended delta frame + the
* model's prior replies — passed whole to the {@link LlmClient} each wake, so a provider can prompt-cache the
* stable prefix (Anthropic `cache_control` / Bedrock cache-point via the AI SDK) and bill the re-read prefix
* at ~0.1× instead of full price on every wake. This all sits ABOVE the determinism line: only the returned
* {@link AgentTurn} crosses into the graded path, so {@link recordedAgent} replays byte-identically no matter
* the policy. `stateless-redraw` keeps the v0 arm (each wake one fresh message, no memory) for the m9i.5 A/B.
*
* ## Config envelope (ADR-0013 (d))
* `liveAgent` derives `AuthorPolicy.prompt_sha256` from the EXACT system-prompt bytes and stamps it
* onto the config it advertises, so the run lands in the grid under a `ConfigId` that folds the
* prompt profile — any prompt edit mints a new column. The active {@link CachePolicy} is stamped too (a
* ConfigId axis only, never an envelope field). Credentials are NEVER part of the config.
*/
import {
OPEN_ORDINAL,
cacheTtlNeverReachesWire,
type ActingFrame,
type Agent,
type AgentConfig,
type AgentTurn,
type AuthoringOpenOptions,
type AuthoringStep,
type CachePolicy,
type LlmClient,
type LlmCompletion,
type LlmMessage,
type LlmUsage,
providerSupportsCacheTtl,
} from "../agent.ts";
import type { BriefingInput, Kernel } from "../../frame/types.ts";
import type { ViewSelection } from "../../frame/pane-catalog.ts";
import type { SeatId } from "../../frame/seat.ts";
import { sha256 } from "../../crypto/sha256.ts";
import { checkBoundedNumerics } from "./bounded-numerics.ts";
import {
DEFAULT_PROMPT_PROFILE,
VIEWSHOP_PROMPT_PROFILE,
systemPromptForProfile,
parseTurn,
parseAuthoringReply,
renderBriefingPrompt,
renderRepairPrompt,
renderReprompt,
renderWakePrompt,
REPROMPT_WITHHELD,
type TurnReject,
} from "./prompt.ts";
import { scanDateLeak } from "../day.ts";
/** The classification of one turn's outcome — the m9i three-way distinction, kept explicit on the
* capture record so a leaderboard never conflates an authored stand-down, an invalid reply, and a
* provider failure (ADR-0013 (a)). `authored` covers any validated turn (incl. an explicit standDown). */
export type TurnOutcome = "authored" | "invalid" | "provider-error";
/** Per-turn wire evidence captured ABOVE the determinism line (never a graded input): the model
* identity, provider-native usage, the raw reasoning trace (free evidence), the authored journal,
* measured latency, and the SCRUBBED wire bytes. Recorded so a run's cost/attention axis and audit
* trail survive without re-touching the wire; the graded replay rides {@link CapturedTurns} instead. */
export interface TurnCapture {
/** {@link OPEN_ORDINAL} for the open turn, else the wake ordinal. */
readonly ordinal: number;
readonly model: string;
readonly promptProfile: string;
readonly usage: LlmUsage;
readonly reasoning?: string;
readonly journal?: string;
/** Wall-clock latency of the model call in ms — audit evidence only (OFF the record path). */
readonly latencyMs: number;
/** The scrubbed wire evidence the client captured (`undefined` for a mock without wire). */
readonly wire?: string;
readonly outcome: TurnOutcome;
/** The KINDS of actions this turn EMITTED (`action.kind[]`, e.g. `["placeOrder"]`; `[]` for a PASS or a
* fail-closed/provider-error turn). Recorded so a leaderboard can tell a genuine PASS (empty actions)
* apart from an admitted-but-unfilled order or a held supersede WITHOUT inferring participation from P&L
* alone (kestrel harness-observability, Bug 2). Above the determinism line — audit evidence, never graded. */
readonly actionKinds: readonly string[];
/** The count of emitted actions (`= actionKinds.length`) — a convenience aggregate for run summaries. */
readonly actionCount: number;
/** Present when the reply was invalid — the fail-closed reason journalled onto the pass. */
readonly invalidReason?: string;
/** The EXACT user-message the model saw this turn (the effective wake/briefing message, incl. any
* reject-reprompt prefix). Populated on the decide path so a distillation harvest can reconstruct the
* (prompt → gold action) SFT pair faithfully, without re-deriving the frame. OFF the graded path — audit
* evidence only, never a graded input (the graded replay rides {@link CapturedTurns}). */
readonly prompt?: string;
/** The RAW model reply text (`completion.text`) this turn — the frontier watcher's verbatim emission, the
* distillation TARGET for SFT. OFF the graded path — audit evidence only. */
readonly reply?: string;
/** The {@link CachePolicy} this turn ran under — the axis m9i.5's prompt-cache economics are indexed by
* (so a sweep can compare `cacheReadTokens`/`cacheWriteTokens` across policies on identical Frames). */
readonly cachePolicy: CachePolicy;
/** CACHE-LIVENESS LOST (kestrel-wa0j.1): under `conversation-cached` with a reused prefix, the provider
* reported `cacheReadTokens === 0` on TWO OR MORE consecutive reused turns ending at this one — the stable
* prefix is provably NOT being served from cache (a TTL expiry between wakes, or a silent prefix
* invalidator), so every wake is re-writing the whole prefix at the cache-write premium. Recorded loudly
* on the capture (and warned once per session) — never a silent economics regression. Absent on healthy
* turns, on the first (cold) miss, and when the client reports no cache counters (no evidence ≠ a miss). */
readonly cacheLivenessLost?: true;
}
/** Options for {@link liveAgent}. `capture` is a sink array the adapter APPENDS one record per turn
* to (open + each decide); `promptProfile` labels the active variant set (baseline vs a model
* variant) for the leaderboard, and `now` is an injectable clock for latency capture (kept OFF the
* graded path — defaults to `Date.now`, which is legitimate here because latency is wire evidence,
* never a graded input). */
export interface LiveAgentOptions {
readonly capture?: TurnCapture[];
readonly promptProfile?: string;
readonly now?: () => number;
/** A FORCED View (kestrel-4gl.13.13) — SELECTS + ORDERS the body panes rendered to the model at
* every OPEN + WAKE frame, overriding the frame-kind default. Supplied for the **perception arm** of
* a View A/B (e.g. `{ name:"perception", panes:[…,{name:"gex"},{name:"iv-skew"}] }`) so the agent is
* SHOWN the options-analytics panes without having to author a View itself. Absent ⇒ the frame-kind
* default panes (the minimal arm), byte-identical to before. Distinct from the viewshop authoring
* loop (where the MODEL requests the View); this pins it by config. */
readonly forcedView?: ViewSelection;
}
function usageOf(completion: LlmCompletion): LlmUsage {
return completion.usage;
}
/** The adapter/harness identity `liveAgent` advertises when the caller pins none (m9i.2 owner tweak —
* adapter identity is mandatory-for-certified before the m9i.7 harness tournament). This is the harness
* naming ITSELF — the one identity it genuinely knows — never a fabricated third-party harness. */
export const LIVE_AGENT_ADAPTER = "kestrel-live-agent";
/** The adapter/harness revision token paired with {@link LIVE_AGENT_ADAPTER}. Bump on any behavioral
* change to this adapter (prompt loop, parsing, capture) — it folds into the ConfigId, so a bump mints a
* new grid column rather than contaminating an old one (ADR-0013 (d)). `v2` (kestrel-wa0j.19 §5): the
* percept-v5 merge changed BOTH the prompt loop (a scheduled/forced View is now delivered on the WAKE
* frame, not only OPEN) AND the capture schema (the `cacheLivenessLost` flag), and the delivery render is
* now fail-closed to the default WAKE panes on a materialization refusal — behavioral changes to what the
* model perceives, so they must mint a NEW grid column rather than contaminate the v1 column (the
* file-handshake precedent in the same merge bumped v1→v2 for exactly this class of change). */
export const LIVE_AGENT_ADAPTER_VERSION = "v2";
/** The default {@link CachePolicy} the live harness runs under when the caller pins none: reuse the
* session conversation AND request provider prompt-cache control on the byte-stable prefix (kestrel-rul —
* the cost lever for benchmark sweeps). A caller can pin `stateless-redraw` (the v0 arm) or `conversation`
* (reuse, no provider cache) to sweep the economics (m9i.5). */
export const DEFAULT_CACHE_POLICY: CachePolicy = "conversation-cached";
/**
* Build the BYOK live {@link Agent} over an injected {@link LlmClient}. Provider-free: the wire is
* entirely the client's concern. The returned agent's `config` carries the derived
* `promptHash = sha256(system bytes)` (unless the caller pinned one) and this harness's own
* adapter identity ({@link LIVE_AGENT_ADAPTER}/{@link LIVE_AGENT_ADAPTER_VERSION}, unless the caller
* pinned one — m9i.2), so the Simulate driver stamps a complete-enough a57.14 envelope and the run lands
* in the grid under a prompt-profile- and adapter-aware `ConfigId`. The interface `face` is deliberately
* NOT defaulted here: only the caller knows the transport its injected client rides (`sdk` for the
* AI-SDK client), and a fabricated face would be a fabricated identity — an undeclared face fails the
* envelope closed to PROVISIONAL (a57.14).
*/
export function liveAgent(config: AgentConfig, llm: LlmClient, opts: LiveAgentOptions = {}): Agent {
const now = opts.now ?? Date.now;
// The active profile selects the byte-stable system prompt: the caller may pin `baseline-v0` (the
// A/B control) or `authoring-v1` (the dry-run-1 authoring fix, the default). The profile label is
// BOTH the selector and the capture label — the bytes it names are the graded config identity
// (`prompt_sha256`), so an unknown label fails closed rather than silently using a default.
const promptProfile = opts.promptProfile ?? DEFAULT_PROMPT_PROFILE;
const system = systemPromptForProfile(promptProfile);
const promptHash = config.promptHash ?? sha256(system);
const adapter = config.adapter ?? LIVE_AGENT_ADAPTER;
const adapterVersion = config.adapterVersion ?? LIVE_AGENT_ADAPTER_VERSION;
const cachePolicy = config.cachePolicy ?? DEFAULT_CACHE_POLICY;
// Stamp the prompt hash + this harness's own adapter identity + the cache policy onto the advertised
// config so envelopeForConfig() emits AuthorPolicy.prompt_sha256 / adapter / adapter_version and the
// ConfigId folds the prompt profile, the harness, AND the cache policy (ADR-0013 (d); m9i.2 owner tweak;
// kestrel-rul). `cachePolicy` is a ConfigId axis only — NOT an envelope field (caching is transport-side,
// off the graded path), so two policies grade identically yet land in distinct columns. Credentials are
// NEVER part of the config.
const fullConfig: AgentConfig = { ...config, promptHash, adapter, adapterVersion, cachePolicy };
const sampling = { temperature: config.temperature, thinkingLevel: config.thinkingLevel } as const;
// ── Session conversation (KV) reuse ────────────────────────────────────────────────────────────────
// In the conversation policies the session is a GROWING message list (byte-stable system + briefing +
// each wake's appended delta frame + the model's prior replies), so a provider can prompt-cache the stable
// prefix (kestrel-rul, the cache-monotone doctrine). This lives ABOVE the determinism line — only the
// returned AgentTurn crosses into the graded path, so recordedAgent replays byte-identically regardless of
// caching. `stateless-redraw` keeps the v0 behaviour (each wake is one fresh message, no memory). The
// transcript is committed ONLY on a successful completion, so a provider error leaves it alternating +
// consistent (the next wake appends cleanly, never two user turns in a row).
const reuse = cachePolicy !== "stateless-redraw";
const marksCache = cachePolicy === "conversation-cached";
// FAIL-CLOSED (kestrel-wa0j.1): a cacheTtl only has meaning on the policy that marks cache breakpoints.
// On any other policy it would ride the ConfigId as a distinct column while never reaching the wire — an
// authored knob silently inert, exactly what the fail-closed doctrine forbids. Refuse at construction.
if (config.cacheTtl !== undefined && !marksCache) {
throw new Error(
cacheTtlNeverReachesWire(
config.cacheTtl,
`cachePolicy "${cachePolicy}" requests no provider cache breakpoints`,
`Use cachePolicy "conversation-cached" or drop cacheTtl.`,
),
);
}
// FAIL-CLOSED at CONSTRUCTION (kestrel-wa0j.19 §4): a cacheTtl only reaches the wire on a provider whose
// lane exposes an explicit prompt-cache breakpoint (anthropic/bedrock). The lane KNOWS its provider
// (`config.provider`), so a `cacheTtl × non-cache-provider` column is refused HERE — before a whole
// session of provider-error passes burns on a TTL the wire cannot honor. The AI-SDK wire guard
// (`cacheBreakpoint`) keeps the same refusal as defense in depth (the two can never drift). A blank/
// unset provider is NOT refused here (a mock/test may pin none) — the wire guard is its backstop.
if (config.cacheTtl !== undefined && config.provider !== undefined && config.provider !== "" && !providerSupportsCacheTtl(config.provider)) {
throw new Error(
`cacheTtl "${config.cacheTtl}" is configured but provider "${config.provider}" has no explicit ` +
`prompt-cache breakpoint to carry a TTL — refusing to construct a live agent whose configured TTL ` +
`can never reach the wire (fail-closed at construction, kestrel-wa0j.19 §4). Use provider ` +
`"anthropic" or "bedrock", or drop cacheTtl.`,
);
}
// ── Cache-liveness surfacing (kestrel-wa0j.1 / kestrel-wa0j.19 §3) ──────────────────────────────────
// Under `conversation-cached` every reused turn's stable prefix SHOULD come back as cache-read tokens.
// `cacheReadTokens === 0` on two consecutive reused turns means the prefix is provably NOT served from
// cache — but WHY it is dead is diagnosed from the WRITE counter, so the operator is told the RIGHT fix:
// • reads===0 with WRITES>0 (reported) — the prefix is being CACHED but never re-read: the entry
// expired between wakes (a live-paced session outrunning the 5m TTL) and is re-written every wake at
// the cache-write premium. Actionable: `cacheTtl "1h"` where the lane supports it.
// • reads===0 AND WRITES===0 (both reported) — the prefix is NEVER cached at all (below the model's
// minimum cacheable prefix, or a lane that does not prompt-cache): a `1h` TTL would NOT help (and on
// a non-cache lane would THROW at construction, §4), so the warning names the cause WITHOUT the 1h
// recommendation.
// A client that reports NO read counter (`cacheReadTokens` undefined) is no evidence either way — the
// streak is NOT advanced and nothing is flagged (a no-counter lane can never false-flag, §3). Recorded
// loudly: a `cacheLivenessLost` flag on the TurnCapture from the second consecutive miss on, plus ONE
// console.warn per session (the visible-degrade idiom of src/render/tokens.ts).
let cacheColdStreak = 0;
let cacheLivenessWarned = false;
const cacheLiveness = (prefixReused: boolean, usage: LlmUsage): { readonly cacheLivenessLost: true } | Record<never, never> => {
if (!marksCache || !prefixReused) return {};
const read = usage.cacheReadTokens;
if (read === undefined) return {}; // read counter not reported — never flag off missing evidence (§3)
cacheColdStreak = read === 0 ? cacheColdStreak + 1 : 0;
if (cacheColdStreak < 2) return {};
if (!cacheLivenessWarned) {
cacheLivenessWarned = true;
const write = usage.cacheWriteTokens;
// NEVER-CACHED only when the write counter is REPORTED as 0 (an unreported write is ambiguous — do
// not claim never-cached off missing evidence; treat it as the actionable TTL-expiry diagnosis).
const neverCached = write === 0;
console.warn(
neverCached
? `prompt-cache liveness lost: cacheReadTokens=0 AND cacheWriteTokens=0 on ${cacheColdStreak} ` +
`consecutive reused turns under "${cachePolicy}" — the stable prefix is NEVER being cached ` +
`(below the model's minimum cacheable prefix, or this lane does not prompt-cache), so every ` +
`wake pays full input price. A cacheTtl "1h" would NOT help here (kestrel-wa0j.19 §3).`
: `prompt-cache liveness lost: cacheReadTokens=0 on ${cacheColdStreak} consecutive reused turns ` +
`under "${cachePolicy}" — the stable prefix is being re-written every turn but never re-read: ` +
`the cache entry expired between wakes (the 5m TTL outrun by a live-paced session), so every ` +
`wake re-writes the whole prefix at the cache-write premium. Consider cacheTtl "1h" where the ` +
`lane supports it (kestrel-wa0j.1).`,
);
}
return { cacheLivenessLost: true };
};
// The bounded authoring loop (ADR-0029) is CONFIG-GATED on the prompt profile: only the viewshop-v1
// arm advertises the requestView move + the in-window re-entry. The baseline profiles genuinely lack
// it (an emitted requestView parses to the fail-closed `invalid` outcome), so the controlled division
// cannot stumble into the move. Presence of `authoringOpen` in the returned Agent IS the driver's gate.
const viewShop = promptProfile === VIEWSHOP_PROMPT_PROFILE;
// ── Founder-View opt-in (ADR-0041 §2 / A1, kestrel-wa0j.26) ──────────────────────────────────────
// The ACTING TIER's pod seat is a function of the vantage — the ADR-0032 cascade the open/decide
// split already embodies: `open()` is the STRATEGIST (the frontier framer authoring at the open),
// `decide()` is the WATCHER (the fast reflex managing at each wake). Under the `founder` opt-in the
// driver passes that seat into the render so a seat with a founder seed reads its GRADED founder View
// when it authored none (precedence in resolveView: authored View > seat founder View > phase default).
// OFF by default: `phase-default`/absent ⇒ NO seat is passed ⇒ byte-identical to before this axis
// existed (the no-churn law). Founder Views are seeds, never blessed defaults — the opt-in is the gate.
const founderSeats = fullConfig.seatViews === "founder";
const openSeat: SeatId | undefined = founderSeats ? "strategist" : undefined;
const wakeSeat: SeatId | undefined = founderSeats ? "watcher" : undefined;
const transcript: LlmMessage[] = [];
// The prior frame's kernel — the one the reader provably holds in cached context under a streaming
// (reuse) policy. Threaded into renderWakePrompt so the SAFETY/CONTROL kernel can be DELTA-encoded
// (kestrel-312): only the fields that MOVED since this kernel are re-emitted; the byte-stable
// skeleton rides the cached prefix. Set on OPEN (the briefing kernel) and after each wake. It is a
// pure RENDERING/transport concern above the determinism line — the graded Bus is unchanged, and
// recordedAgent replays byte-identically regardless of how the wake prompt was framed.
let priorKernel: Kernel | undefined;
// ── Reject-and-reprompt state (kestrel-gxz) ──────────────────────────────────────────────────────
// When an authored turn fails closed (ATOMIC — a valid sibling never arms alone), we carry its
// per-action reason forward and PREFIX the NEXT wake's user message with it, so the model self-
// corrects rather than concluding "execution is broken in this kernel". Set iff the LAST parsed turn
// failed closed; CONSUMED only on a SUCCESSFUL parse — a provider error (where the model never saw
// the frame) leaves it intact for the wake after. This lives ABOVE the determinism line: it changes
// only the model's INPUT, never the returned AgentTurn, so recordedAgent replays byte-identically.
let pendingReject: { readonly reason: string; readonly reject?: TurnReject } | undefined;
// ── Shared request assembly + capture builders (kestrel-wa0j review cleanup) ────────────────────
/** Assemble ONE wire call's request from the (possibly reused) transcript + this turn's user
* message — the SINGLE source for both the decide path ({@link runTurn}) and the authoring path
* ({@link runAuthoringCall}). Conversation policies prepend the growing transcript; the newest
* (user) block carries the moving cache breakpoint so the whole stable prefix is a cache-read
* next wake. The request object's key set and insertion order are PINNED by the request-shape
* tests (`cacheTtl` is ABSENT — not `undefined` — when unconfigured), so it is built here once,
* byte-identically for both paths. `prior` is the transcript view the once-per-completion
* cache-liveness accounting reads (non-empty ⇒ a reused prefix). */
const buildRequest = (
userMsg: LlmMessage,
): { readonly prior: readonly LlmMessage[]; readonly request: Parameters<LlmClient["complete"]>[0] } => {
const prior = reuse ? transcript : [];
const messages: LlmMessage[] = marksCache ? [...prior, { ...userMsg, cache: true }] : [...prior, userMsg];
return {
prior,
request: {
system,
cacheSystem: marksCache,
...(config.cacheTtl !== undefined ? { cacheTtl: config.cacheTtl } : {}),
messages,
sampling,
},
};
};
/** The identity + wire-evidence head every post-completion {@link TurnCapture} shares: who ran
* (ordinal, model, prompt profile), the provider-native usage, the optional reasoning/journal
* evidence, measured latency, and the scrubbed wire bytes. Spread FIRST at each capture site —
* the per-site outcome fields follow, so the record's key order is unchanged. Optional fields
* stay ABSENT (never `undefined`) exactly as the inline spreads behaved. */
const completionEvidence = (
ordinal: number,
completion: LlmCompletion,
journal: string | undefined,
latencyMs: number,
) => ({
ordinal,
model: config.model,
promptProfile,
usage: usageOf(completion),
...(completion.reasoning !== undefined ? { reasoning: completion.reasoning } : {}),
...(journal !== undefined ? { journal } : {}),
latencyMs,
...(completion.wire !== undefined ? { wire: completion.wire } : {}),
});
/** The fail-closed provider-error capture (the THIRD m9i outcome) — identical on the decide and
* authoring paths but for the ordinal: zero usage (the wire never answered), the harness journal,
* and never a fabricated action. A provider ERROR means the model never answered — the cache
* counters are OMITTED (absent), NEVER fabricated as 0 (kestrel-wa0j.19 §3): a reported 0 is
* evidence of a miss, absent is "not reported", and the liveness detector must not read a
* fabricated miss off a call that never reached a cache at all. */
const providerErrorCapture = (ordinal: number, reason: string, journal: string, latencyMs: number): TurnCapture => ({
ordinal,
model: config.model,
promptProfile,
usage: { inputTokens: 0, outputTokens: 0, thinkingTokens: 0 },
journal,
latencyMs,
outcome: "provider-error",
actionKinds: [],
actionCount: 0,
invalidReason: reason,
cachePolicy,
});
const runTurn = async (ordinal: number, userMessage: string): Promise<AgentTurn> => {
const t0 = now();
// If the prior authored turn failed closed, PREFIX its per-action reason onto this wake's frame.
// The rendered reason is re-fenced through the SAME date-blind guard as the author boundary
// (scanDateLeak): a leaking model-authored token (e.g. an OCC symbol carrying a compact date) makes
// the whole reprompt collapse to the byte-stable REPROMPT_WITHHELD fallback — fail-closed by
// redaction, never a crash. (The reject is CONSUMED below only on a successful parse.)
let effectiveMessage = userMessage;
if (pendingReject !== undefined) {
const body = renderReprompt(pendingReject.reason, pendingReject.reject);
const safe = scanDateLeak(body) === null ? body : REPROMPT_WITHHELD;
effectiveMessage = `${safe}\n\n${userMessage}`;
}
// Assemble THIS call's message list + request through the ONE shared builder (`buildRequest`).
const userMsg: LlmMessage = { role: "user", content: effectiveMessage };
const { prior, request } = buildRequest(userMsg);
let completion: LlmCompletion;
try {
completion = await llm.complete(request);
} catch (e) {
// Provider failure — fail-closed to a pass carrying the reason, DISTINCT from an invalid reply
// and from an authored standDown (m9i). Never crash the session, never fabricate an action. The
// transcript is left UNTOUCHED (the unanswered delta is not committed, so roles stay alternating).
const reason = `provider error — ${e instanceof Error ? e.message : String(e)}`;
const journal = `harness: ${reason}`;
const turn: AgentTurn = { actions: [], journal };
opts.capture?.push(providerErrorCapture(ordinal, reason, journal, now() - t0));
return turn;
}
const latencyMs = now() - t0;
// Advance the cache-liveness streak exactly ONCE per successful completion (a reused prefix is one
// whose transcript was non-empty at request assembly — the OPEN/cold turn never counts).
const liveness = cacheLiveness(prior.length > 0, usageOf(completion));
// EMPTY COMPLETION (kestrel-sgwd / kestrel-ubjc — Bug 1 corrected). A model can wake and author NO text —
// a whitespace-only / content-free completion (Sonnet under conversation-cached fanning does this at its
// first wake). This path separates the TWO concerns the original hygiene fix conflated:
// (a) DON'T POISON THE REUSED TRANSCRIPT — an empty assistant block makes EVERY later wake fail closed at
// Bedrock (HTTP 400 "The content field in the Message object … is empty"), a DETERMINISTIC validation
// error backoff will not retry, so ONE empty completion poisons the whole rest of the session. We
// leave the transcript UNTOUCHED (exactly like the provider-error path above), so roles stay
// alternating and no empty block ever rides the cached prefix. This is CORRECT and is KEPT.
// (b) DON'T SCORE A FAILURE AS A DECISION — a zero-token / whitespace-only reply is a GENERATION FAILURE,
// NOT a judicious stand-down. It classifies INVALID (fail-closed), NEVER a scored PASS, mirroring the
// hosted adapter's `invalid-empty` (scripts/bench/hosted/hosted-adapter.ts, classifyEmission) so the
// local + hosted conventions match. It flows into the SAME reject-and-reprompt path a parse-invalid
// turn does (pendingReject is SET, not cleared), so the NEXT wake reprompts rather than silently
// banking a stand-down. The returned turn stays fail-closed (empty actions — never a fabricated
// action). Off the graded path — the returned empty turn is what CapturedTurns replays.
if (completion.text.trim() === "") {
const reason = "empty/zero-token model completion — classified INVALID, never a scored PASS";
const journal = `harness: ${reason}`;
pendingReject = { reason };
const turn: AgentTurn = { actions: [], journal };
opts.capture?.push({
...completionEvidence(ordinal, completion, journal, latencyMs),
outcome: "invalid",
actionKinds: [],
actionCount: 0,
invalidReason: reason,
cachePolicy,
...liveness,
});
return turn;
}
// BOUNDED-NUMERICS (kestrel gateway-cache-fix — ported from the hosted adapter's `invalid-degenerate`,
// scripts/bench/hosted/hosted-adapter.ts `checkBoundedNumerics`). A strict schema / constrained-decoding
// grammar can force a syntactically-VALID but DEGENERATE number (a json_schema `qty:integer` probe looped
// to an 8,173-digit integer: valid JSON, garbage). Such an emission is a GENERATION FAILURE — it
// classifies INVALID (fail-closed), NEVER a scored PASS — the SAME "reject unknown input, never default
// it" rule the empty→INVALID guard above enforces, so the local + hosted conventions match. It runs
// BEFORE parseTurn so a digit-loop can never slip through as a FINITE-but-absurd float (`Number.isFinite`
// is true for a 15-digit run coerced by JSON.parse) and be scored as a real order. It flows into the SAME
// reject-and-reprompt path a parse-invalid turn does (pendingReject SET), returns a fail-closed
// empty-actions turn (never a fabricated action), and — the reply being NON-empty — commits the raw reply
// to the reused transcript exactly as the parse-invalid path below does (so the cached prefix still
// matches what the provider saw; unlike the empty guard, which must leave it untouched).
const numeric = checkBoundedNumerics(completion.text);
if (!numeric.ok) {
const reason = numeric.reason ?? "degenerate numeric emission — classified INVALID, never a scored PASS";
const journal = `harness: ${reason}`;
pendingReject = { reason };
if (reuse) transcript.push(userMsg, { role: "assistant", content: completion.text });
const turn: AgentTurn = { actions: [], journal };
opts.capture?.push({
...completionEvidence(ordinal, completion, journal, latencyMs),
prompt: effectiveMessage,
reply: completion.text,
outcome: "invalid",
actionKinds: [],
actionCount: 0,
invalidReason: reason,
cachePolicy,
...liveness,
});
return turn;
}
const parsed = parseTurn(completion.text);
// Reject-and-reprompt bookkeeping (kestrel-gxz): a SUCCESSFUL parse CONSUMES any pending reject —
// if THIS turn also failed closed we carry its OWN per-action reason forward, else we clear it. The
// provider-error path (the catch above) returns early WITHOUT touching pendingReject, so a correction
// the model never saw survives to the next wake. Only the model's next INPUT changes — the returned
// AgentTurn (the graded output) is untouched, so recordedAgent replays byte-identically.
pendingReject = parsed.ok
? undefined
: { reason: parsed.reason ?? "invalid turn", ...(parsed.reject !== undefined ? { reject: parsed.reject } : {}) };
// Commit this exchange to the session transcript — the user delta + the RAW reply the model authored
// (even when unparseable, so the reused prefix matches what the provider actually saw). Off the graded
// path; the graded replay rides CapturedTurns, never this transcript.
if (reuse) {
transcript.push(userMsg, { role: "assistant", content: completion.text });
}
opts.capture?.push({
...completionEvidence(ordinal, completion, parsed.turn.journal, latencyMs),
// SFT-harvest evidence (OFF the graded path): the exact prompt the model saw + its verbatim reply.
prompt: effectiveMessage,
reply: completion.text,
outcome: parsed.ok ? "authored" : "invalid",
actionKinds: parsed.turn.actions.map((a) => a.kind),
actionCount: parsed.turn.actions.length,
...(parsed.ok ? {} : { invalidReason: parsed.reason }),
cachePolicy,
...liveness,
});
return parsed.turn;
};
/**
* ONE in-window authoring call (ADR-0029 §2/§6 — the viewshop arm only). Same conversation-reuse /
* cache / capture machinery as {@link runTurn}, but it classifies the reply through
* {@link parseAuthoringReply} (so a `requestView` is recognized) and returns an {@link AuthoringStep}
* the DRIVER routes on. Every call is a captured turn (usage folds into cumulative session spend —
* the `ev_per_ktoken` attention axis, §7). Only the driver-selected terminal turn enters
* {@link CapturedTurns}; the iterations are evidence, not replay inputs.
*/
const runAuthoringCall = async (userMessage: string): Promise<AuthoringStep> => {
const t0 = now();
const userMsg: LlmMessage = { role: "user", content: userMessage };
const { prior, request } = buildRequest(userMsg);
let completion: LlmCompletion;
try {
completion = await llm.complete(request);
} catch (e) {
// Provider failure mid-loop — the THIRD m9i outcome, kept distinct on the capture. The driver
// treats the returned terminal standDown as fail-closed (pre-open there is no book to ride —
// ADR-0029 §6). Transcript left UNTOUCHED (roles stay alternating for a later retry).
const reason = `provider error — ${e instanceof Error ? e.message : String(e)}`;
const journal = `harness: ${reason}`;
opts.capture?.push(providerErrorCapture(OPEN_ORDINAL, reason, journal, now() - t0));
return {
reply: { kind: "turn", turn: { actions: [{ kind: "standDown", reason: journal }] } },
usage: { inputTokens: 0, outputTokens: 0, thinkingTokens: 0 },
latencyMs: now() - t0,
};
}
const latencyMs = now() - t0;
// Same once-per-successful-completion cache-liveness accounting as runTurn (the loop's iterations
// are reused turns too — a dead cache inflates the authoring budget the same way it inflates wakes).
const liveness = cacheLiveness(prior.length > 0, usageOf(completion));
// EMPTY COMPLETION (kestrel-sgwd / kestrel-ubjc — Bug 1 corrected) — same as runTurn: a zero-token /
// whitespace-only authoring reply is a GENERATION FAILURE, classified INVALID (fail-closed), NEVER a
// scored PASS (mirrors the hosted adapter's `invalid-empty`). It returns a `kind:"invalid"` reply — the
// SAME repairable/fail-closed AuthoringStep the driver already routes for a materialization failure — so
// the driver re-asks rather than banking a stand-down. Concern (a) is unchanged: the empty assistant
// message is NOT appended to the reused transcript (it would poison every later wake with a Bedrock 400).
if (completion.text.trim() === "") {
const reason = "empty/zero-token model completion — classified INVALID, never a scored PASS";
opts.capture?.push({
...completionEvidence(OPEN_ORDINAL, completion, `harness: ${reason}`, latencyMs),
outcome: "invalid",
actionKinds: [],
actionCount: 0,
invalidReason: reason,
cachePolicy,
...liveness,
});
return { reply: { kind: "invalid", reason }, usage: usageOf(completion), latencyMs };
}
const reply = parseAuthoringReply(completion.text, { allowRequestView: viewShop });
if (reuse) transcript.push(userMsg, { role: "assistant", content: completion.text });
const journal = reply.kind === "turn" ? reply.turn.journal : undefined;
opts.capture?.push({
...completionEvidence(OPEN_ORDINAL, completion, journal, latencyMs),
// A `requestView` is a legal AUTHORED move (a non-terminal control sub-outcome), so it is
// `authored`, not `invalid`; the requestView specifics ride the driver-owned emergence log (§5).
outcome: reply.kind === "invalid" ? "invalid" : "authored",
actionKinds: reply.kind === "turn" ? reply.turn.actions.map((a) => a.kind) : [],
actionCount: reply.kind === "turn" ? reply.turn.actions.length : 0,
...(reply.kind === "invalid" ? { invalidReason: reply.reason } : {}),
cachePolicy,
...liveness,
});
return { reply, usage: usageOf(completion), latencyMs };
};
return {
config: fullConfig,
open(briefing: BriefingInput): Promise<AgentTurn> {
// OPEN = the STRATEGIST tier (ADR-0032). Under the founder opt-in the strategist reads its founder
// View when no forcedView was pinned (authored/forced always wins in resolveView).
const turn = runTurn(OPEN_ORDINAL, renderBriefingPrompt(briefing, fullConfig, opts.forcedView, openSeat));
// The OPEN keyframe (full kernel) is now the reader's cached kernel — the first wake delta
// composes against it (under a streaming policy).
priorKernel = briefing.kernel;
return turn;
},
decide(frame: ActingFrame): Promise<AgentTurn> {
// Only stream a kernel delta when the conversation is REUSED (the reader holds the prior full
// kernel in cached context). Under `stateless-redraw` — reuse=false — the wake stays a
// self-complete full-kernel keyframe (prevKernel omitted).
const streamPrev = reuse ? priorKernel : undefined;
// The View delivered WITH the wake (kestrel-wa0j.4) — the scheduled wake's stored View, already
// resolved fail-closed by the driver — WINS over the config-pinned `forcedView` for THIS frame
// only: the wake decides *when*, its named View decides *what* (an authored delivery is more
// specific than the config lens). Absent both ⇒ the default WAKE panes, byte-identical to today.
// Cache-monotone by construction: the View changes only THIS wake's appended user message —
// every earlier transcript byte rides the cached prefix untouched.
const view = frame.view ?? opts.forcedView;
// WAKE = the WATCHER tier (ADR-0032). Under the founder opt-in the watcher reads its founder View
// (delta leads) when no View was delivered/forced (authored/delivered always wins in resolveView).
let userMessage: string;
try {
userMessage = renderWakePrompt(frame, fullConfig, streamPrev, view, wakeSeat);
} catch (e) {
// FAIL-CLOSED, NEVER CRASH (kestrel-wa0j.19 §1 — the BLOCKER). A delivered View that RESOLVES but
// cannot MATERIALIZE (a window arg the frame context can't serve, or an over-budget View measured
// under the real tokenizer) must never kill the session at the delivery seam. Fall back to the
// DEFAULT WAKE panes for THIS frame and SURFACE the refusal as a harness annotation on the rendered
// prompt — matching how the OPEN authoring loop already catches-and-repairs a bad requested View.
// This sits ABOVE the determinism line (it changes only the model's input message, never the
// returned AgentTurn), so recordedAgent replays byte-identically. A throw with NO View to blame is a
// genuine render defect (not agent-authored input) — re-thrown, never swallowed.
if (view === undefined) throw e;
const raw = `delivered View "${view.name}" could not be materialized — ${e instanceof Error ? e.message : String(e)}; rendering the default WAKE panes (fail-closed, kestrel-wa0j.19)`;
// Date-fence the annotation exactly like the reject-reprompt prefix: an agent-authored View name (or
// an echoed arg) that leaks a calendar token collapses to a byte-stable, date-blind fallback note.
const note = scanDateLeak(raw) === null ? raw : "delivered View could not be materialized — rendering the default WAKE panes (fail-closed, kestrel-wa0j.19)";
userMessage = `harness: ${note}\n\n${renderWakePrompt(frame, fullConfig, streamPrev, undefined)}`;
}
const turn = runTurn(frame.wakeOrdinal, userMessage);
priorKernel = frame.kernel;
return turn;
},
// The bounded authoring loop's in-window re-entry — present ONLY on the viewshop arm (the driver's
// config gate, ADR-0029 §2/§6). A repair re-ask surfaces the guiding error (no re-render); otherwise
// the briefing is materialized under the driver-supplied lens (or the default View). The briefing
// kernel becomes the reader's cached kernel for the first later `decide` delta, exactly as `open` sets it.
...(viewShop
? {
authoringOpen(briefing: BriefingInput, o: AuthoringOpenOptions): Promise<AuthoringStep> {
// The briefing kernel LEADS every materialization and becomes the reader's cached kernel for
// the first later `decide` delta, exactly as `open` sets it (regardless of the loop outcome).
priorKernel = briefing.kernel;
let userMessage: string;
try {
userMessage =
o.repairError !== undefined
? renderRepairPrompt(o.repairError)
: renderBriefingPrompt(briefing, fullConfig, o.view, openSeat);
} catch (e) {
// A requested View that fails closed at materialization (over its own token budget, or an
// unknown pane that slipped the driver's resolveView guard) is a REPAIRABLE defect, never a
// crash: return the guiding error with zero spend, and the driver re-asks (a repair iteration).
return Promise.resolve({
reply: {
kind: "invalid",
reason: `requested View could not be materialized — ${e instanceof Error ? e.message : String(e)}`,
},
usage: { inputTokens: 0, outputTokens: 0, thinkingTokens: 0 },
});
}
return runAuthoringCall(userMessage);
},
}
: {}),
};
}