UNPKG

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.

534 lines (533 loc) 39.4 kB
/** * # 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, providerSupportsCacheTtl, } from "../agent.js"; import { sha256 } from "../../crypto/sha256.js"; import { checkBoundedNumerics } from "./bounded-numerics.js"; import { DEFAULT_PROMPT_PROFILE, VIEWSHOP_PROMPT_PROFILE, systemPromptForProfile, parseTurn, parseAuthoringReply, renderBriefingPrompt, renderRepairPrompt, renderReprompt, renderWakePrompt, REPROMPT_WITHHELD, } from "./prompt.js"; import { scanDateLeak } from "../day.js"; function usageOf(completion) { 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 = "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, llm, opts = {}) { 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 = { ...config, promptHash, adapter, adapterVersion, cachePolicy }; const sampling = { temperature: config.temperature, thinkingLevel: config.thinkingLevel }; // ── 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, usage) => { 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 = founderSeats ? "strategist" : undefined; const wakeSeat = founderSeats ? "watcher" : undefined; const transcript = []; // 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; // ── 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; // ── 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) => { const prior = reuse ? transcript : []; const messages = 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, completion, journal, latencyMs) => ({ 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, reason, journal, latencyMs) => ({ 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, userMessage) => { 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 = { role: "user", content: effectiveMessage }; const { prior, request } = buildRequest(userMsg); let completion; 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 = { 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 = { 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 = { 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) => { const t0 = now(); const userMsg = { role: "user", content: userMessage }; const { prior, request } = buildRequest(userMsg); let completion; 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) { // 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) { // 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; 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, o) { // 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; 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); }, } : {}), }; }