/** * # session/harness/prompt — the prompt profile + fail-closed turn parser (provider-free) * * The PURE half of the BYOK live harness (kestrel-rul, ADR-0013): it turns a frozen, date-blind * {@link BriefingInput}/{@link ActingFrame} into the model prompt, and turns the model's raw reply * back into a validated {@link AgentTurn} — with NO provider SDK import, NO wall clock, and NO * network. `{@link liveAgent}` composes these two functions around an injected {@link LlmClient}; * the concrete AI-SDK client is the only file that touches the wire. * * ## Fail-closed by construction (ADR-0013 (a), m9i) * The reply is parsed into an {@link AgentTurn}, never silently repaired. The three m9i outcomes * are kept DISTINCT and never collapsed: an *explicit* `standDown` the model authored, an *invalid * output* (unparseable / malformed), and a *provider failure* (raised by the client). An invalid * output is turned into a **legitimate pass** (`actions: []`) carrying a JOURNAL note naming the * failure — never a fabricated `standDown` (which would conflate "the agent chose to de-arm" with * "the harness could not read the reply"), and never a crash. The standing book keeps riding its * own TP/EXIT and the platform staleness backstop, so a pass is fail-closed-enough while staying * honest about what happened. The `standDown` action described in the prompt copy below is the ONE * agent-path de-arm — its discriminant, predicates, and turn factory live in `src/engine/disarm.ts` * (kestrel-z473.5), the single home for the de-arm rule. * * ## Scrub at capture time (ADR-0013 (e)) * {@link scrubSecrets} is the credential fence the AI-SDK client runs over every byte of wire * evidence BEFORE it hits any artifact — never a credential in a committed record (an m9i * acceptance criterion, met structurally). It lives here (provider-free) so the scrub can be * tested with no network and no provider import. */ import type { AgentTurn, Action, AgentConfig, AuthoringReply } from "../agent.ts"; import type { BriefingInput, Kernel, WakeDeltaInput } from "../../frame/types.ts"; import { assertFormatMaterialized, renderBriefing, renderWakeDelta, renderWakeDeltaStreamed, type MaterializedFormat } from "../../frame/render.ts"; import { renderBriefingJson, renderWakeDeltaJson, serializeFrameJson } from "../../frame/render-json.ts"; import { PANE_CATALOG, type ViewSelection } from "../../frame/pane-catalog.ts"; import type { SeatId } from "../../frame/seat.ts"; import { parse } from "../../lang/index.ts"; import { defaultSeriesRegistry, unknownSeriesDiagnostic } from "../../series/index.ts"; // ───────────────────────────────────────────────────────────────────────────── // The prompt profile — a byte-stable system prompt (AuthorPolicy.prompt_sha256 hashes THIS) // ───────────────────────────────────────────────────────────────────────────── /** * The **baseline prompt profile** (ADR-0013 (d)): the system bytes held constant across every model * in the controlled division. It teaches the {@link AgentTurn} contract — the reply is ONE JSON * object `{ journal?, actions[] }`, each action a tagged union member, and the `supersede` document * is a Kestrel surface statement. MUST be byte-stable: {@link liveAgent} hashes it into the config's * `promptHash` (`AuthorPolicy.prompt_sha256`), so any edit mints a new grid column. No wall clock, * no per-run interpolation. */ export const BASELINE_SYSTEM_PROMPT = [ "You are Kestrel, a disciplined options-trading agent acting inside a deterministic simulator.", "At each vantage you are handed a FROZEN, date-blind cockpit frame (relative time only — never a calendar date).", "You decide what to DO and reply with EXACTLY ONE JSON object and nothing else (no prose, no markdown fences).", "", "The JSON object is a turn:", ' { "journal": "", "actions": [ , ... ] }', "", "Each Action is one of these tagged shapes:", ' { "kind": "supersede", "document": "", "note": "" }', ' - Author or revise your standing book. The FIRST turn (OPEN) supersede is your initial arm.', ' - `document` MUST be valid Kestrel source, e.g. PLAN rider budget 0.5R ttl +60m', ' WHEN phase open DO buy 1 atm C @ lean(bid, fair, 0.5)', ' { "kind": "scheduleWake", "at": { "kind": "inMinutes", "minutes": }, "reason": "" }', ' - or "at": { "kind": "atClockET", "clockET": "HH:MM" }. Sets your next monitoring wake.', ' { "kind": "placeOrder", "order": { "side": "buy"|"sell", "instrument": "", "strike": ,', ' "right": "C"|"P", "qty": , "price": "" } }', ' - A single-leg OPTION order. price is a Kestrel expression, never a bare mid. SELL is floored at intrinsic.', ' - To cross EQUITY/SPOT immediately, DROP strike+right (ADR-0017): { "kind": "placeOrder",', ' "order": { "side": "buy"|"sell", "instrument": "", "qty": , "price": "@mid" } }.', ' { "kind": "cancelOrder", "ref": "" }', ' { "kind": "standDown", "reason": "" } - de-arm clean; inventory rides its TP/EXIT. The safe default.', "", 'To PASS (do nothing this wake — standing plans keep managing), reply with an empty actions list: { "actions": [] }.', "Bounded risk always: never place a naked sell, never anchor a price on the mid, keep within your R budget.", "If you are unsure, PASS or standDown rather than guess. Output ONLY the JSON object.", ].join("\n"); /** The stable identity of this profile (a label, not the hash). */ // BYTE-FROZEN — the A/B control (its bytes ARE its `prompt_sha256` grid identity; never pad or "fix" it). // UNCACHED-CONTROL CAVEAT (kestrel-wa0j.1): baseline-v0 runs predate the cache-TTL axis, so its recorded // `conversation-cached` economics were measured under the provider-default 5-minute TTL — in any live-paced // session (wakes > 5 min apart) every wake missed the expired cache and re-wrote the full prefix at the // cache-write premium. Read those columns as an effectively UNCACHED control, not as cached-policy costs; // the TTL knob is `AgentConfig.cacheTtl` (documented here, deliberately not retrofitted into this profile). export const BASELINE_PROMPT_PROFILE = "baseline-v0"; // ───────────────────────────────────────────────────────────────────────────── // The authoring-taught profile — teaches the Kestrel DSL by worked, copyable example // (kestrel-rul followup, dry-run-1 fix) // ───────────────────────────────────────────────────────────────────────────── // // WHY THIS PROFILE EXISTS (dry-run-1, docs/results/dry-run-1-live-baseline.md): all three live // models wasted 30–47% of their turns on the SAME parse escape — *"unexpected `DO` after the WHEN // clause"* — because they COLLAPSED the multi-line `PLAN … / WHEN … / DO …` document onto one line. // The old `baseline-v0` profile actually TAUGHT that collapse: its worked example rendered // `WHEN phase open DO buy …` on one line and never showed the `\n`-escaped JSON string the model // must author. Meanwhile the external tournament arms authored VALID plans because the tournament // PROTOCOL.md handed them the `\n `-escaped document verbatim. On the equity (EQ) cell the models // under-engaged entirely — journalling "no chain legs available" and never reaching for the // equity-leg grammar (`buy N shares`), which the option-only baseline never showed them. // // This profile fixes the teaching, not the grammar (the parser is unchanged — honesty/fail-closed // guards intact). It shows, VERBATIM and COPYABLE: the multi-line block layout, the exact JSON // turn bytes (with the `\n ` clause separators spelled out), an options rider with a TP/EXIT // bracket, an equity-leg plan, a VIEW/pane request, a stand-down, and a pass. Each worked document // is a member of {@link AUTHORING_EXAMPLE_DOCUMENTS}, and every JSON illustration is // `JSON.stringify` of one of those documents — so a prompt example can NEVER teach syntax the real // parser rejects (proven by a test that runs each through `parse()`), and the JSON never drifts // from the parseable Kestrel it wraps. /** * The worked Kestrel documents the authoring profile teaches — each is CANONICAL, PARSEABLE source. * Exported so a determinism test can run EACH through the real {@link parse} (the prompt can never * teach invalid syntax) and assert the prompt embeds each verbatim. Keep these BYTE-STABLE: they are * folded into {@link AUTHORING_SYSTEM_PROMPT}, whose sha256 is the profile's `prompt_sha256`. */ export const AUTHORING_EXAMPLE_DOCUMENTS = { /** Options rider: the multi-line spine — header on line 1, then EACH clause on its OWN indented * line. `WHEN` and `DO` are NEVER on the same line (that collapse is the #1 dry-run-1 failure). * A deliberately generic warm-up shape, not any benchmark cell's setup. */ optionsRider: [ "PLAN atm-rider budget 0.5R ttl +60m", " WHEN spot > vwap", " DO buy 1 atm C @ lean(bid, fair, 0.5)", " TP +80% frac 0.5 @ fair", " EXIT spot < vwap held 60s @ fair", ].join("\n"), /** Equity-leg plan (ADR-0017): `USING exec ` + `DO buy N shares` — a spot leg has NO strike * or right. Shown so a model on a chain-less SPOT tape reaches for the equity grammar instead of * standing down. A generic FADE shape (price COMES to you): the buy rests at `@ mid` BELOW the * cross and fills on the pullback — the correct pricing when you fade a cross toward VWAP (NOT the * EQ cell's breakout thesis — teach grammar, not the setup). */ equityReversion: [ "PLAN eq-revert budget 1R ttl 16:00", " USING exec SPY", " WHEN spot crosses below vwap", " DO buy 100 shares @ mid", " TP +2%", " EXIT spot crosses above vwap @ bid", ].join("\n"), /** Equity-leg plan (ADR-0017), the CHASE mirror of {@link equityReversion}: a breakout you must * LIFT THE OFFER to catch. Because a resting BUY fills only when the ask crosses strictly BELOW it, * a continuation UP never fills a `@ mid` buy (the ask climbs away) — so the buy prices `@ ask+2c`, * STRICTLY THROUGH the ask, to cross now. A generic breakout shape (teach the pricing, not the * setup). */ equityBreakout: [ "PLAN eq-breakout budget 1R ttl 16:00", " USING exec QQQ", " WHEN spot crosses above hod", " DO buy 100 shares @ ask+2c", " TP +2%", " EXIT spot crosses below vwap @ bid", ].join("\n"), /** Level-break entry with a STATE trigger (ADR-0017 equity leg). The trap this teaches against * (kestrel-4pm): `WHEN spot crosses below X` is an EDGE — it fires ONLY on a fresh crossing (spot * must be ABOVE X at arm, then fall through). Armed when spot is ALREADY below X it can NEVER fire * (no transition to observe), so the plan sits armed forever and never enters. When the level is * already broken, use the STATE trigger `WHEN spot < X` (fires whenever the condition HOLDS, * including already-true at arm). A generic already-through shape — teach the trigger, not the trade. */ levelBreakState: [ "PLAN brk-entry budget 1R", " USING exec SPY", " WHEN spot < 499.29", " DO buy 100 shares @ ask+2c", " TP +2%", " EXIT spot > 500 @ bid", ].join("\n"), /** A VIEW request: a name (+ optional `budget`), then one indented pane line each (a pane is a * catalogued pane id, optionally followed by args the pane understands). Every pane + arg here is one * the catalog actually SERVES against a product-shaped frame (kestrel-wa0j.19 §6 — the CI fence * `harness.prompt-profile` resolves + materializes this View so a taught example the arg gate would * refuse can never ship again): `tape 5m` re-buckets the served 1-minute tape to 5-minute candles * (the ONE window arg the tape pane takes); `chain` + `levels` are plain, arg-less panes. */ view: ["VIEW cockpit", " tape 5m", " chain", " levels"].join("\n"), } as const; /** Every worked document, in the order the prompt presents them — the corpus the profile test parses. */ export const AUTHORING_EXAMPLE_DOCUMENT_LIST: readonly string[] = [ AUTHORING_EXAMPLE_DOCUMENTS.optionsRider, AUTHORING_EXAMPLE_DOCUMENTS.equityReversion, AUTHORING_EXAMPLE_DOCUMENTS.equityBreakout, AUTHORING_EXAMPLE_DOCUMENTS.levelBreakState, AUTHORING_EXAMPLE_DOCUMENTS.view, ]; /** Render a supersede turn as the EXACT JSON bytes a model must emit — `JSON.stringify` of the same * verified `document`, so the `\n ` clause separators are shown literally and the illustration can * never drift from the parseable Kestrel it wraps. */ function supersedeTurnJson(document: string): string { return JSON.stringify({ actions: [{ kind: "supersede", document }] }); } /** * The **authoring-taught prompt profile** (kestrel-rul followup) — the corrected baseline for the * controlled division and the profile every request-loop teaching extends. Same {@link AgentTurn} * contract as `baseline-v0`, but it TEACHES the Kestrel authoring grammar by worked, copyable * example. It is example-driven ON PURPOSE: a live A/B showed a compact grammar-spec-only variant * regressed authoring (opus 0%→15% invalid; equity engagement lost), so the worked JSON turns stay — * but the examples are deliberately GENERIC shapes (a warm-up call, a VWAP reversion), NOT any * benchmark cell's setup, so this teaches SYNTAX, not the trade. MUST be byte-stable: {@link liveAgent} * hashes it into the config's `promptHash`, so any edit mints a new grid column. No wall clock. */ export const AUTHORING_SYSTEM_PROMPT = [ "You are Kestrel, a disciplined trading agent acting inside a deterministic simulator.", "At each vantage you are handed a FROZEN, date-blind cockpit frame (relative time + an HH:MM ET clock only — never a calendar date).", "You decide what to DO and reply with EXACTLY ONE JSON object and nothing else (no prose, no markdown fences).", "", "═══ THE TURN ENVELOPE ═══", "Your whole reply is ONE turn object:", ' { "journal": "", "actions": [ , ... ] }', 'To PASS (do nothing this wake — your standing plans keep managing themselves), reply with an empty list: { "actions": [] }', "", "Each Action is exactly one of these tagged shapes:", ' { "kind": "supersede", "document": "", "note": "" }', " Author or revise your standing book. Your FIRST turn (OPEN) supersede is your initial arm; a later", " supersede REPLACES the whole document (use it to tighten a stop, add a leg, or re-arm).", ' { "kind": "scheduleWake", "at": { "kind": "inMinutes", "minutes": }, "reason": "" }', ' or "at": { "kind": "atClockET", "clockET": "HH:MM" }. Sets your next monitoring wake.', ' { "kind": "placeOrder", "order": { "side": "buy"|"sell", "instrument": "", "strike": ,', ' "right": "C"|"P", "qty": , "price": "" } }', " A single-leg OPTION order. price is a Kestrel expression, never a bare mid. SELL is floored at intrinsic.", ' To cross EQUITY/SPOT NOW, DROP strike+right (ADR-0017 — a spot leg has neither): { "kind": "placeOrder",', ' "order": { "side": "buy"|"sell", "instrument": "", "qty": , "price": "@ask+2c" } }. Same @-anchors as a plan leg.', " PRICE TO YOUR INTENT: a resting BUY fills only when the ask crosses STRICTLY BELOW it. To cross NOW —", " a breakout/continuation you must CHASE — LIFT THE OFFER: price STRICTLY THROUGH the ask (a marketable", " number ABOVE it, e.g. `@ask+2c`; `@ask` alone does NOT cross under the strict `<` floor). `@mid` /", " `@lean(bid, ask, …)` are RESTING / FADE prices BELOW the ask — they fill only when price PULLS BACK to", " you, so a `@mid` buy on a rising tape NEVER fills (the ask climbs away). Mirror for a sell (lift = through the bid).", ' { "kind": "cancelOrder", "ref": "" }', ' { "kind": "flatten", "instrument": "", "note": "" }', " CLOSE your whole position in NOW: cancels the managing plan's resting orders for it AND crosses a", " COVERED sell to settle — one step, always covered (never naked). Unlike standDown (de-arm, inventory RIDES),", " flatten LIQUIDATES. A flat book is a clean no-op. After a flatten the position is flat and its 1R is freed.", ' { "kind": "standDown", "reason": "" } — de-arm clean; inventory rides its own TP/EXIT. The safe default.', "", "═══ HOW TO WRITE A KESTREL `document` (this is where most mistakes happen) ═══", "A Kestrel document is MULTI-LINE and INDENTATION-STRUCTURED:", " • line 1 is the PLAN header: PLAN [budget R] [ttl <+45m|16:00>] [regime {intraday: trend}]", " Do NOT add a `regime {…}` gate unless the frame shows a regime/predictor feed: with no feed it", " fail-closes and the plan NEVER arms — it sits `authored (blocked: regime … UNKNOWN)` in the KERNEL", " (acting) plan states, holding no position. Omit the gate on a plain SPOT/equity tape.", " • EVERY clause after it is on its OWN line, indented by two spaces: USING, WHEN, DO, TP, EXIT, RELOAD, ALSO.", " • `WHEN` and `DO` are ALWAYS separate lines. NEVER put two clauses on one line — `WHEN … DO …` is", " the single most common parse error (\"unexpected `DO` after the WHEN clause\"). One clause per line.", " • WHEN/DO/TP/EXIT are clauses INSIDE the PLAN block — never top-level statements on their own.", " • TP on a percentage/multiple KEEPS its sigil: `TP +80%` or `TP 2x` (a bare `TP +2` is rejected).", "", "Because the document lives INSIDE a JSON string, each line break is a literal \\n and the two-space", "indent is two spaces after it. So this plan you want to author:", "", AUTHORING_EXAMPLE_DOCUMENTS.optionsRider, "", "is written in your JSON turn EXACTLY like this (copy this shape — note the \\n between every clause):", " " + supersedeTurnJson(AUTHORING_EXAMPLE_DOCUMENTS.optionsRider), "", "═══ WORKED, COPYABLE EXAMPLES (generic shapes — teach the grammar, decide the trade yourself) ═══", "", "① Arm an OPTIONS rider (a call with a take-profit + a time-stop bracket). The document + its JSON turn:", AUTHORING_EXAMPLE_DOCUMENTS.optionsRider, " " + supersedeTurnJson(AUTHORING_EXAMPLE_DOCUMENTS.optionsRider), "", "② Arm an EQUITY plan — when the frame is a SPOT/equity tape with NO option chain, TRADE THE SHARES.", " An equity leg is `buy N shares` (no strike, no right) under `USING exec `. Do NOT stand down", " just because there are no option legs — reach for the equity grammar. (a) FADE a cross — price COMES", " to you, so REST the buy at `@ mid` below the level; it fills on the pullback:", AUTHORING_EXAMPLE_DOCUMENTS.equityReversion, " " + supersedeTurnJson(AUTHORING_EXAMPLE_DOCUMENTS.equityReversion), " (b) CHASE a breakout — a continuation UP that you must LIFT THE OFFER to catch. A `@ mid` buy would", " NEVER fill (the ask climbs away), so price `@ ask+2c` — STRICTLY THROUGH the ask — to cross now:", AUTHORING_EXAMPLE_DOCUMENTS.equityBreakout, " " + supersedeTurnJson(AUTHORING_EXAMPLE_DOCUMENTS.equityBreakout), " (c) ARM ON THE RIGHT SIDE OF THE LEVEL. `spot crosses below|above X` is an EDGE — it fires ONLY on a", " FRESH crossing, so spot must still be on the ENTRY side when you arm: to catch a BREAK, arm the edge at", " OPEN, AHEAD of it. If spot has ALREADY crossed X, `crosses` NEVER fires (no transition to observe) — the", " plan sits armed forever and never enters (the #1 missed-entry bug). For an already-through level use the", " STATE trigger `WHEN spot < X` (or `> X`): it is true whenever the condition HOLDS, INCLUDING already-true", " at arm, so it fires the instant you arm below the line:", AUTHORING_EXAMPLE_DOCUMENTS.levelBreakState, " " + supersedeTurnJson(AUTHORING_EXAMPLE_DOCUMENTS.levelBreakState), " (d) DON'T GATE A MID-SESSION ENTRY ON `phase`. `phase` is a LEVEL state — pre|open|regular|close|post —", " and `open` is the MOMENTARY opening print (09:30), already elapsed before your first wake: every", " mid-session wake reads `phase regular`. So `WHEN phase open` armed mid-session is DEAD — it is false", " at every tick, NEVER fires, and fails SILENT (no reject, no fill — the plan just sits `armed`). To gate", " a mid-session entry, gate on PRICE: `WHEN spot crosses above ` (edge — arm ahead of the break) or", " `WHEN spot > ` (state — fires when already through), as in (a)–(c). Never `phase open`.", "", "③ Request a VIEW (name, then one pane per indented line; a pane is a name + plain args, no `:`/`{}`):", AUTHORING_EXAMPLE_DOCUMENTS.view, " " + supersedeTurnJson(AUTHORING_EXAMPLE_DOCUMENTS.view), "", "④ Stand down (de-arm cleanly — the correct move on an edgeless/untradeable tape):", ' ' + JSON.stringify({ actions: [{ kind: "standDown", reason: "edgeless chop — no defined-risk setup; standing down" }] }), "", "⑤ Pass (do nothing this wake; standing plans keep managing):", ' ' + JSON.stringify({ actions: [] }), "", "═══ PRICE EXPRESSIONS (the `@ …` and order `price`) ═══", "Never anchor on a bare mid. Legal anchors: fair, intrinsic, basis, bid, ask, mid, last, join, improve, stub.", "Combine them: @fair @lean(bid, fair, 0.5) @min(fair, mid) cap fair peg +2c / -1c offsets.", "FADE vs CHASE decides your price (a resting BUY fills only when the ask crosses STRICTLY BELOW it):", " • FADE — let price come to you: REST a buy at/below the mid (`@mid`, `@lean(bid, ask, …)`); it fills on the pullback.", " • CHASE a breakout — LIFT THE OFFER: price a buy STRICTLY THROUGH the ask (`@ask+2c`); it crosses now.", " A resting `@mid` buy on a rising tape NEVER fills. Mirror for a sell: fade rests above the mid, chase lifts through the bid.", "", "═══ DISCIPLINE ═══", "Bounded risk always: never place a naked sell, never anchor a price on the mid, keep within your R budget.", "SIZE BY COST BASIS, NOT STOP DISTANCE (ADR-0017): an equity/spot leg's bounded risk is its FULL cost basis", "`qty × entry_px` (an option's is `qty × premium × mult`), so `qty ≤ 1R_budget / entry_px` — size to the kernel's", "`sizing: max ~N shares` cue. Sizing by stop-loss distance oversizes and the entry is SILENTLY clamped (never fires).", "On an edgeless or untradeable tape the correct move is to PASS or standDown for $0 — manufacturing edge is punished.", "If you are unsure, PASS or standDown rather than guess. Output ONLY the JSON turn object.", ].join("\n"); /** The stable identity of the authoring-taught profile (a label, not the hash). */ export const AUTHORING_PROMPT_PROFILE = "authoring-v1"; // ───────────────────────────────────────────────────────────────────────────── // The viewshop-v1 profile (ADR-0029 §4) — the OPTIMIZED-division arm that TEACHES the bounded loop // ───────────────────────────────────────────────────────────────────────────── // // This profile EXTENDS the DSL-taught baseline ({@link AUTHORING_SYSTEM_PROMPT}) and adds what the // controlled baseline deliberately lacks: (a) that `requestView` is a legal move + when to use it, // (b) the pane MENU interpolated from the ONE {@link PANE_CATALOG} (so it can never advertise a pane // that does not materialize, and a catalog edit mints a new promptHash ⇒ a new ConfigId), (c) that // authoring errors are RECOVERABLE (a guided error, fix + resubmit), (d) the Brief/Mandate two-channel // split + the hard guard (the Brief directs perception, never admission), (e) the T-5m invariant (a // view costs attention, not time). New promptHash ⇒ new ConfigId ⇒ the measured optimized arm; the // baseline stays BYTE-FROZEN (this is additive, a distinct registry entry). /** The pane MENU — generated from the single {@link PANE_CATALOG}, so it is the SAME source `render` * and the `View` grammar read. Byte-stable given a fixed catalog; a catalog change changes these * bytes ⇒ the profile's `prompt_sha256` ⇒ a new ConfigId (ADR-0029 §4, ADR-0013 (d)). */ function paneMenuLines(): readonly string[] { return PANE_CATALOG.map( (e) => ` • ${e.id} — ${e.title}: ${e.description} [${e.attribution}, ~${e.tokenCostEstimate} tok]`, ); } /** The viewshop VIEW-request illustration — `JSON.stringify` of a real, parseable VIEW document (the * same {@link AUTHORING_EXAMPLE_DOCUMENTS.view} the profile test parses), so the wire shape can never * drift from the Kestrel it wraps. */ function requestViewJson(view: string, reason: string): string { return JSON.stringify({ requestView: { view, reason } }); } /** * The **viewshop-v1 prompt profile** (ADR-0029 §4). Byte-stable GIVEN a fixed catalog; {@link liveAgent} * hashes it into `promptHash`, so it is a distinct grid column from `authoring-v1`. No wall clock, no * per-run interpolation (the pane menu is a pure function of the compile-time catalog). */ export const VIEWSHOP_SYSTEM_PROMPT = [ AUTHORING_SYSTEM_PROMPT, "", "═══ YOU MAY ASK FOR A DIFFERENT VIEW (the authoring loop) ═══", "Right now you are at a FROZEN vantage — one moment, which you may look at again. If the current screen", "does not show what you need to test the thesis your BRIEF points at, do NOT force a Plan you do not", "believe and do NOT stand down for a look you could have had. Ask for a DIFFERENT lens on the SAME frozen", "market. Reply with a view request INSTEAD of a turn (never both actions AND requestView):", ' { "requestView": { "view": "", "reason": "" } }', "The `view` is a VIEW document exactly like example ③ above (a name + optional budget, then one pane per", "indented line). For instance:", " " + requestViewJson(AUTHORING_EXAMPLE_DOCUMENTS.view, "need the near-money chain + levels before I commit"), "", "The panes you may select (this menu IS the single source the renderer materializes from — a pane not", "listed here cannot be shown):", ...paneMenuLines(), "", "A view costs ATTENTION, not TIME (the T-5m invariant): it NEVER advances the clock, NEVER consumes a", "wake, NEVER reveals future data — you are looking again at the SAME moment. View requests are BOUNDED:", "you get a small number of lenses and a shared attention budget, and when it is spent the session stands", "down flat. Shop only when a lens would genuinely change your decision; the default View is meant to be", "enough that shopping is rarely worth its cost.", "", "═══ AUTHORING ERRORS ARE RECOVERABLE ═══", "If your author (a Plan document OR a view request) is malformed, you will be handed a GUIDING ERROR that", "names the defect. That is NOT a rejection — read it, fix ONLY what it names, and resubmit. A repair", "counts against the SAME bounded budget as a view request, so fix it in as few attempts as you can.", "", "═══ YOUR CONTEXT HAS TWO CHANNELS — MANDATE vs BRIEF (keep them apart) ═══", " • the MANDATE is HARD and machine-checked (envelope, R budget, never-naked). It is a FENCE and the", " ONLY input to admission — you may never do what it forbids, and it never tells you what to look for.", " • the BRIEF is SOFT, directional English (your goal, approach, persona). It directs what you go LOOK", " for and how you author. It NEVER authorizes anything and NEVER enters an admission check.", "Decide which View to request FROM YOUR BRIEF: if the current lens will not let you test the thesis your", "Brief points at, request the lens that would — within budget. Then commit a Plan, stand down, or pass.", ].join("\n"); /** The stable identity of the viewshop profile (a label, not the hash). */ export const VIEWSHOP_PROMPT_PROFILE = "viewshop-v1"; // ───────────────────────────────────────────────────────────────────────────── // The manage-v1 profile (ADR-0032 §5, owner watcher-v1) — the MANAGE-ONLY watcher tier // ───────────────────────────────────────────────────────────────────────────── // // WHY THIS PROFILE EXISTS (docs/results/author-and-fan/, PR #8): the frozen-plan fan-out ran its // watcher under `authoring-v1` — the STRATEGIST's authoring prompt — so at its first wake the small // watcher tried to ARM-NEW-AUTHORITY (a `supersede`, the authoring prompt's headline move). The // admission Gate correctly refused it at the tier boundary and ESCALATED; on the frozen fan there is // no captured strategist re-brief, so every watcher collapsed the armed plan and the whole fan // measured $0. The watcher was mis-cast as an AUTHOR. This profile casts it as a MANAGER: it teaches // the SAME Kestrel order/price grammar as `authoring-v1` (the watcher still places/cancels/sizes // orders and takes profit/exits within the armed book) but REMOVES the author's mandate — it may not // arm new standing authority (`supersede`), and it escalates the few decisions that need one via the // minimal owner-v1 escalation move. This is the tier `fanWatchers` / a small watcher should default // to (ADR-0032 §5, owner watcher-v1 = manage-only + minimal escalation). A distinct promptHash ⇒ a // distinct ConfigId ⇒ a distinct grid column; `authoring-v1` stays BYTE-FROZEN (this is additive). /** * The **manage-v1 prompt profile** (ADR-0032 §5, owner watcher-v1). EXTENDS the DSL-taught baseline * ({@link AUTHORING_SYSTEM_PROMPT} — so the watcher knows the exact order/price grammar it manages * with) and OVERLAYS the manage-only doctrine: manage WITHIN the strategist's armed Plan and budget * (reload/exit/adjust/size, place/cancel orders inside the envelope), never arm NEW authority * (`supersede`), and ESCALATE — do not guess — when the regime breaks. Byte-stable; {@link liveAgent} * hashes it into `promptHash`, so it is a distinct grid column from `authoring-v1`/`viewshop-v1`. The * escalation sentinel below is the token {@link import("./cascade.ts").classifyEscalation} matches; * the `manage-v1` profile test asserts they agree (they are coupled by design). */ export const MANAGE_SYSTEM_PROMPT = [ AUTHORING_SYSTEM_PROMPT, "", "═══ YOU ARE A MANAGE-ONLY WATCHER (this OVERRIDES the authoring mandate above) ═══", "A STRATEGIST has ALREADY authored and ARMED the standing Plan+Brief+Mandate you are handed. You are", "the small, fast WATCHER that MANAGES that armed book at every wake — you are NOT its author. Your job", "is to keep the armed book healthy WITHIN its budget and Mandate: press a winner toward its target, and", "CUT a loser whose premise has broken. You manage the POSITION, not just the plan object.", "", "═══ READ YOUR BOOK FIRST — P&L, then PREMISE (do this EVERY wake, before you decide) ═══", "Mark your position to the tape and check the thesis is still alive BEFORE you choose a move:", " • UNREALIZED P&L: take each open position's `basis` (its entry, on the POSITIONS line) and compare", " it to the CURRENT price in the market pane. For a long, `(spot − basis) × qty` — a spot BELOW your", " basis is an OPEN LOSS you are carrying RIGHT NOW, not a paper abstraction.", " • PREMISE — JUDGE IT FROM THE FRAME, NOT FROM PLAN TEXT YOU CANNOT SEE. You are handed the POSITION and", " the tape, never the strategist's authored thesis prose — so RECONSTRUCT the premise from what the frame", " SHOWS you: your direction (the kernel POSITIONS line) plus the LEVEL your entry leans on, read off the", " `levels` and `tape` panes on your screen. A long implies a bullish premise — a break/level HOLDING —", " so ask whether that is STILL TRUE against those panes. If price has fallen back THROUGH the level your", " long leans on — a poke that failed, a breakout that reversed — the setup is INVALIDATED. That is a", " FAKEOUT: the reason you are long is GONE, and the position is now an un-thesised loser bleeding toward", " the close.", "", "═══ WHEN THE PREMISE IS BROKEN AND YOU ARE LOSING, CUT IT — THIS IS THE EXPECTED MOVE ═══", "A losing position on a broken premise is the ONE situation a watcher exists to handle. Do NOT ride it,", "and do NOT merely NARRATE it — a JOURNAL that says \"this is a fakeout, I should exit\" paired with an", "empty actions list is a NO-OP: the book keeps bleeding to the close. To exit you must CHANGE THE BOOK.", "The CLEAN way to get flat is ONE action — `flatten`:", ' { "actions": [ { "kind": "flatten", "instrument": "", "note": "premise broke — cutting" } ] }', "It cancels the managing plan's resting protective/TP sell that RESERVES your shares AND crosses a COVERED", "closing sell to settle, in ONE step — so you never fight the \"never naked\" refusal, and it can never leave", "you short. Use `flatten` to close your WHOLE position in now; after it, you are flat and your 1R is freed.", "", "To close only PART of the position (a TRIM, not a full flatten), do the two-step manage turn by hand:", " 1. CANCEL the resting protective / take-profit SELL that RESERVES your shares (its `ref` is on the", " RESTING line of the frame kernel) — an armed plan usually rests a TP over the whole lot, and while it", " reserves the inventory a fresh closing sell alongside it is refused \"never naked\" (already spoken for).", " 2. PLACE the closing SELL of the size you want gone, priced to CROSS NOW:", ' { "kind": "placeOrder", "order": { "side": "sell", "instrument": "", "qty": , "price": "@bid-2c" } }', " — an equity leg (drop strike+right), lifted THROUGH the bid (`@bid-2c`, strictly below it) so it fills", " at once. A resting `@bid`/`@mid` sell can sit unfilled as the tape drops away — to GET OUT, cross.", "So the trim turn is: { \"actions\": [ { \"kind\": \"cancelOrder\", \"ref\": \"\" },", ' { "kind": "placeOrder", "order": { "side": "sell", "instrument": "", "qty": , "price": "@bid-2c" } } ] }', "Both flatten and the two-step route straight through the admission Gate (closing a long you hold is never a", "naked sell, floored at intrinsic) — this is what RECLAIMS the loss. Prefer `flatten` for a full exit.", "", "TWO MOVES THAT LOOK LIKE AN EXIT BUT ARE NOT — do NOT reach for these to cut a loser:", " • `standDown` DE-ARMS THE PLAN BUT DOES NOT LIQUIDATE — your inventory keeps riding to settle. On a", " plan with no protective stop, standing down leaves the losing shares FULLY exposed to the close.", " standDown is a clean de-arm, NOT a way out of a position — to get flat, `flatten` (or SELL, step 2 above).", " • `supersede` (arming a new plan, or bolting a new EXIT onto the book) is BEYOND your authority — it is", " REFUSED at the tier boundary and does NOTHING (your frozen book is HELD, the loss runs on). Reaching", " for it to \"add a stop\" just wastes the turn. Manage the book you hold with ORDERS, not authorship.", "", "YOU MAY (manage WITHIN the armed Plan + budget):", " • CUT a loser — `flatten` for a full exit, or cancel-then-SELL to trim (as above) — take profit, trim", " a runner, reload, add, scale or hedge; place / cancel / adjust orders that MANAGE the armed book,", " priced with the SAME `@…` grammar as above and SIZED within the standing R budget;", " • `standDown` to de-arm cleanly when the plan is spent AND you are flat (or content to let residual", " inventory ride its own TP/EXIT) — never as a substitute for SELLING a loser;", " • `scheduleWake` your next monitoring wake, or PASS ({ \"actions\": [] }). But PASS is correct ONLY when", " the armed plan is genuinely managing itself — winning toward its target, or its premise still intact", " with nothing to adjust. PASS is NOT the default, and it is the WRONG move over a losing, premise-broken", " position: an empty reply there is a no-op that books the full loss.", "The admission Gate still bounds every order you place (never naked, floored at intrinsic, within the", "envelope): judgment does not buy authority — an order OUTSIDE the Mandate is refused fail-closed.", "", "YOU MAY NOT (this is the strategist's job, not yours):", " • arm NEW standing authority. DO NOT emit a `supersede` — that AUTHORS/RE-ARMS a whole Plan (a new", " thesis, a new mandate). It is BEYOND your manage-only authority: a `supersede` from you is REFUSED", " at the tier boundary and escalated, never admitted. Manage the plan you were handed with ORDERS; do", " not re-author it — and do not use it as a roundabout way to add the stop you should just SELL into.", "", "═══ MINIMAL ESCALATION — when you need a NEW plan, ASK; do not guess ═══", "Cutting a broken-premise loser is YOUR job — do it with a cancel-then-SELL, do NOT escalate to dodge it.", "Escalate ONLY when the situation genuinely needs a NEW thesis/mandate beyond managing the armed book (a", "regime change that calls for a fresh plan, not merely an exit). To escalate, `standDown` with a `reason`", "— or journal — that OPENS with the token", ' RE-BRIEF: ', "The driver routes a RE-BRIEF: escalation to the strategist for a re-author; your armed book is not torn", "down by you on a whim. Escalate SPARINGLY (minimal escalation): most wakes need only a manage action or", "a PASS. Reserve escalation for a real regime break the armed Plan cannot absorb — never as a way to", "avoid the closing SELL that cutting a loser requires.", ].join("\n"); /** The stable identity of the manage-only watcher profile (a label, not the hash). */ export const MANAGE_PROMPT_PROFILE = "manage-v1"; /** * The profile `liveAgent` uses when the caller pins none. Defaults to the corrected * {@link AUTHORING_PROMPT_PROFILE} — the dry-run-1 fix — while `baseline-v0` stays registered for A/B. */ export const DEFAULT_PROMPT_PROFILE = AUTHORING_PROMPT_PROFILE; /** The registry of byte-stable system prompts by profile label. A named profile resolves to its * EXACT bytes — the graded config axis (`prompt_sha256`) — never a silent fallback. */ const PROMPT_PROFILE_SYSTEM: ReadonlyMap = new Map([ [BASELINE_PROMPT_PROFILE, BASELINE_SYSTEM_PROMPT], [AUTHORING_PROMPT_PROFILE, AUTHORING_SYSTEM_PROMPT], [VIEWSHOP_PROMPT_PROFILE, VIEWSHOP_SYSTEM_PROMPT], [MANAGE_PROMPT_PROFILE, MANAGE_SYSTEM_PROMPT], ]); /** Resolve a profile label to its byte-stable system prompt. Fails closed on an unknown label — a * named-but-unregistered profile is a caller error (the bytes ARE the config identity), never a * guess. */ export function systemPromptForProfile(profile: string): string { const system = PROMPT_PROFILE_SYSTEM.get(profile); if (system === undefined) { throw new Error(`unknown prompt profile "${profile}"; known: ${[...PROMPT_PROFILE_SYSTEM.keys()].join(", ")}`); } return system; } // ───────────────────────────────────────────────────────────────────────────── // Rendering the frame into the user message // ───────────────────────────────────────────────────────────────────────────── /** * The MATERIALIZED format to render under: the config's `format`, vouched for by the renderer's own * fail-closed boundary — or a REFUSAL (kestrel-4gl.26). * * This used to answer a `json`/`html` config by quietly rendering `ascii` instead. That is a fail-OPEN * honesty violation with two teeth: the agent is handed a text screen while its config says it asked * for structured output, and the run's ConfigId/`promptHash` record a format the run never used — so * the leaderboard column is mislabelled and the result is not recomputable from its own envelope. * `assertFormatMaterialized` is the single source of that answer (AGENTS.md: "never a silent default"): * an unmaterialized format is refused, loudly, naming the format. Since ADR-0052 §2 `json` is a * materialized Rendering, so it PASSES here and the prompt renderers dispatch it to the json adapter * (never a silent downgrade); `html` is still charter-only and is refused. */ function renderFormatOf(config: AgentConfig): MaterializedFormat { const format = config.format; assertFormatMaterialized(format); return format; } /** Render the OPEN briefing into the user message (the date-blind keyframe cockpit). Pure. When a * `view` is supplied (the bounded authoring loop materializing a requested lens, ADR-0029 §2) the * body panes are SELECTED + ORDERED by it against the one catalog; absent ⇒ the OPEN default View * (byte-identical to today). The kernel LEADS non-configurably regardless. An unknown pane / * over-budget View fails closed inside the renderer (the driver catches it → a repair iteration). */ export function renderBriefingPrompt(briefing: BriefingInput, config: AgentConfig, view?: ViewSelection, seat?: SeatId): string { const format = renderFormatOf(config); // The materialized `json` Rendering (ADR-0052 §2) is served through its own adapter as byte-stable // canonical JSON — NEVER downgraded to a text screen (the kestrel-4gl.26 fail-OPEN). A View/seat // SELECTS + ORDERS text-screen body panes; json is structural (it carries the whole Frame), so those // text-only knobs do not apply to it. if (format === "json") return serializeFrameJson(renderBriefingJson(briefing)); // `seat` (ADR-0041 §2 / A1, kestrel-wa0j.26) is threaded into resolveView so a seat with a founder // seed for OPEN reads it when no `view` was authored (precedence: authored > seat founder > phase // default). Absent ⇒ byte-identical to before the role axis. The opt-in that supplies it is the // driver's `AgentConfig.seatViews`. return renderBriefing(briefing, { format, ...(view !== undefined ? { view } : {}), ...(seat !== undefined ? { seat } : {}), }); } /** The repair re-ask message (ADR-0029 §2, the repair half of the bounded loop): surface the * repair-guiding error and ask the model to fix + resubmit. Kept terse so it does not re-explain the * whole contract — the model already has the system profile + its prior attempt in context. Pure. */ export function renderRepairPrompt(error: string): string { return [ "Your last reply could not be used. The defect was:", ` ${error}`, "Fix ONLY what this names and resubmit — either a corrected turn ({ actions: [...] }) or a corrected", "requestView. Emit ONLY the JSON, no explanation. This repair counts against your authoring budget.", ].join("\n"); } /** * Render a WAKE delta frame into the user message (the date-blind since-last-vantage cockpit). Pure. * * CACHE-MONOTONE kernel delta-encoding (kestrel-312): when the session runs a STREAMING policy * (`conversation`/`conversation-cached` — the reader provably holds the prior full kernel in cached * context) AND the caller threads the prior frame's kernel (`prevKernel`), the SAFETY/CONTROL kernel * is delta-encoded — only the fields that MOVED are re-emitted, the byte-stable skeleton rides the * cached prefix ({@link renderWakeDeltaStreamed}). Under `stateless-redraw` (no prior context) — or * when no `prevKernel` is available (e.g. the first wake, or a non-harness caller) — the wake frame * is a KEYFRAME carrying the COMPLETE kernel ({@link renderWakeDelta}); self-complete frames stay * self-complete. The composition is fail-closed-verified inside the renderer. * * A supplied `view` (kestrel-wa0j.4 — a scheduled wake's stored View, or a config-pinned forced * View) SELECTS + ORDERS the body panes against the one catalog; absent ⇒ the default WAKE panes, * byte-identical to today. The kernel LEADS non-configurably regardless, and the View's own token * budget is guarded at materialization exactly as on OPEN (fail-closed). */ export function renderWakePrompt(frame: WakeDeltaInput, config: AgentConfig, prevKernel?: Kernel, view?: ViewSelection, seat?: SeatId): string { const format = renderFormatOf(config); // The materialized `json` Rendering (ADR-0052 §2) is served structurally (byte-stable canonical JSON), // never downgraded to a text screen. json is NOT the KV-cache-optimized text delta path — a consumer // diffs the structural frame — so the streaming/prevKernel text-delta knobs do not apply to it. if (format === "json") return serializeFrameJson(renderWakeDeltaJson(frame)); const policy = config.cachePolicy ?? "conversation-cached"; const streaming = policy !== "stateless-redraw" && prevKernel !== undefined; // `seat` (ADR-0041 §2 / A1, kestrel-wa0j.26): consulted only when no `view` was authored/delivered // (precedence in resolveView: authored > seat founder > phase default). Absent ⇒ byte-identical. const viewOpt = { ...(view !== undefined ? { view } : {}), ...(seat !== undefined ? { seat } : {}), }; return streaming ? renderWakeDeltaStreamed(frame, prevKernel, { format, ...viewOpt }) : renderWakeDelta(frame, { format, ...viewOpt }); } // ───────────────────────────────────────────────────────────────────────────── // Parsing the reply into a validated AgentTurn (fail-closed, never repaired) // ───────────────────────────────────────────────────────────────────────────── /** The per-action attribution of an ATOMIC turn rejection (kestrel-gxz). Present iff the fail-closed * cause was an invalid ACTION (not a top-level JSON/shape defect). Names WHICH action index failed * and its reason, AND the VALID sibling actions that were NOT applied because the turn is * all-or-nothing — the exact facts the reject-and-reprompt renderer needs so the model can resubmit * a valid sibling alone (or fix the invalid one) rather than conclude "execution is broken". */ export interface TurnReject { /** The index of the FIRST invalid action in the turn. */ readonly failedIndex: number; /** The validator's reason for that action (e.g. `action[1] supersede: document parse escape — …`). */ readonly failedReason: string; /** The individually-VALID sibling actions, dropped by atomicity — index + a legible one-line summary. */ readonly validSiblings: readonly { readonly index: number; readonly summary: string }[]; } /** The outcome of parsing one model reply. `ok` distinguishes a validated turn from a fail-closed * pass; `reason` (present iff `!ok`) is the human-readable failure the pass carries as its JOURNAL — * the *invalid output* signal, kept distinct from an authored `standDown` and a provider failure. * `reject` (present iff `!ok` AND the cause was an invalid action) carries the per-action attribution * the reject-and-reprompt loop surfaces to the model on its NEXT wake. */ export interface TurnParse { readonly turn: AgentTurn; readonly ok: boolean; readonly reason?: string; readonly reject?: TurnReject; } /** The fail-closed pass: an empty turn whose JOURNAL names why the reply was unusable (never a * fabricated action, never a crash — ADR-0013 (a), m9i "keep the three outcomes distinct"). */ function failClosedPass(reason: string): TurnParse { return { turn: { actions: [], journal: `harness: ${reason}` }, ok: false, reason }; } /** Extract the single JSON object from a raw reply: strip a ```json/``` fence if present, else take * the substring from the first `{` to the last `}`. Returns `null` when no object is present. */ function extractJsonObject(reply: string): string | null { const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(reply); const body = (fenced !== null ? fenced[1]! : reply).trim(); const start = body.indexOf("{"); const end = body.lastIndexOf("}"); if (start === -1 || end === -1 || end < start) return null; return body.slice(start, end + 1); } function isObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } /** Validate ONE action object into a typed {@link Action}, or return an error string naming the * defect (fail-closed — a supersede document that is not valid Kestrel is a defect, never armed). */ function validateAction(raw: unknown, i: number): Action | string { if (!isObject(raw)) return `action[${i}] is not an object`; const kind = raw.kind; switch (kind) { case "pass": // A `pass` action is a convenience for "do nothing" — folded to an empty actions list upstream. return { kind: "standDown", reason: PASS_SENTINEL_REASON }; // sentinel; filtered by parseTurn (never emitted) case "supersede": { if (typeof raw.document !== "string" || raw.document.trim() === "") return `action[${i}] supersede: missing document`; let node; try { node = parse(raw.document); // pre-validate the Kestrel surface — never hand the driver an unparseable doc } catch (e) { return `action[${i}] supersede: document parse escape — ${e instanceof Error ? e.message : String(e)}`; } // Registry-aware semantic check: a trigger operand that names an UNKNOWN series (a case-clash // like `VWAP` for `vwap`, or a typo like `vwpa`) parses fine but resolves UNKNOWN — the plan // arms then silently never fires. Surface it loudly + repair-guiding rather than let it go dark // (fail-closed legibility; the phonebook never guesses — registry.ts). A legitimate org fact or // symbolic literal is NOT flagged (only a near-match to the known market vocabulary is). const unknownSeries = unknownSeriesDiagnostic(node, defaultSeriesRegistry); if (unknownSeries !== undefined) return `action[${i}] supersede: ${unknownSeries.message}`; return { kind: "supersede", document: raw.document, ...(typeof raw.note === "string" ? { note: raw.note } : {}) }; } case "scheduleWake": { const at = raw.at; if (!isObject(at)) return `action[${i}] scheduleWake: missing at`; if (at.kind === "inMinutes") { if (typeof at.minutes !== "number" || !Number.isFinite(at.minutes)) return `action[${i}] scheduleWake: bad inMinutes`; } else if (at.kind === "atClockET") { if (typeof at.clockET !== "string") return `action[${i}] scheduleWake: bad atClockET`; } else { return `action[${i}] scheduleWake: unknown at.kind`; } if (typeof raw.reason !== "string") return `action[${i}] scheduleWake: missing reason`; const atOut = at.kind === "inMinutes" ? ({ kind: "inMinutes", minutes: at.minutes as number } as const) : ({ kind: "atClockET", clockET: at.clockET as string } as const); return { kind: "scheduleWake", at: atOut, reason: raw.reason, ...(typeof raw.view === "string" ? { view: raw.view } : {}), }; } case "placeOrder": { const o = raw.order; if (!isObject(o)) return `action[${i}] placeOrder: missing order`; if (o.side !== "buy" && o.side !== "sell") return `action[${i}] placeOrder: bad side`; if (typeof o.instrument !== "string" || o.instrument === "") return `action[${i}] placeOrder: bad instrument`; if (typeof o.qty !== "number" || !Number.isFinite(o.qty) || o.qty <= 0) return `action[${i}] placeOrder: bad qty`; if (typeof o.price !== "string" || o.price.trim() === "") return `action[${i}] placeOrder: bad price`; // The ADR-0017 leg convention on an immediate order (mirrors the PLAN equity-leg): BOTH strike+right // present ⇒ an OPTION order; BOTH absent ⇒ an EQUITY/SPOT order. Exactly ONE present is a defect — // fail closed with a clear message, never a silent equity fallback (never a fictional strike either). const hasStrike = o.strike !== undefined && o.strike !== null; const hasRight = o.right !== undefined && o.right !== null; if (hasStrike !== hasRight) { return `action[${i}] placeOrder: an OPTION order needs BOTH strike and right; an EQUITY order needs NEITHER (ADR-0017) — got exactly one`; } if (hasStrike) { // Option order — validate the option leg exactly as before (option-chain fill path). if (typeof o.strike !== "number" || !Number.isFinite(o.strike)) return `action[${i}] placeOrder: bad strike`; if (o.right !== "C" && o.right !== "P") return `action[${i}] placeOrder: bad right`; return { kind: "placeOrder", order: { side: o.side, instrument: o.instrument, strike: o.strike, right: o.right, qty: o.qty, price: o.price }, ...(typeof raw.note === "string" ? { note: raw.note } : {}), }; } // Equity/spot order — no strike, no right (ADR-0017). Routes to the spot-fill path (`DO buy N shares`). return { kind: "placeOrder", order: { side: o.side, instrument: o.instrument, qty: o.qty, price: o.price }, ...(typeof raw.note === "string" ? { note: raw.note } : {}), }; } case "cancelOrder": { if (typeof raw.ref !== "string" || raw.ref === "") return `action[${i}] cancelOrder: missing ref`; return { kind: "cancelOrder", ref: raw.ref, ...(typeof raw.note === "string" ? { note: raw.note } : {}) }; } case "flatten": { if (typeof raw.instrument !== "string" || raw.instrument === "") return `action[${i}] flatten: missing instrument`; return { kind: "flatten", instrument: raw.instrument, ...(typeof raw.note === "string" ? { note: raw.note } : {}) }; } case "standDown": { if (typeof raw.reason !== "string") return `action[${i}] standDown: missing reason`; return { kind: "standDown", reason: raw.reason }; } default: return `action[${i}] unknown kind ${JSON.stringify(kind)}`; } } /** The `pass` sentinel {@link validateAction} emits for a `{ "kind": "pass" }` action (filtered by * {@link parseTurn}, never surfaced). Isolated as a constant so the drop-check and the sibling * describer agree on exactly what a pass looks like. */ const PASS_SENTINEL_REASON = "__pass__"; function isPassSentinel(a: Action): boolean { return a.kind === "standDown" && a.reason === PASS_SENTINEL_REASON; } /** A short, DATE-BLIND-INTENT, legible one-line summary of a validated action — the token the * reject-and-reprompt renderer shows the model so it recognizes its own un-applied valid sibling * (e.g. `placeOrder buy 200 SPY @ @mid`). Pure. Any model-authored token it echoes (an instrument, * a document header) is re-fenced through {@link scanDateLeak} at the harness boundary before it can * reach the model, so a leaking symbol is WITHHELD there, never surfaced. */ export function describeAction(a: Action): string { switch (a.kind) { case "supersede": { const head = a.document.split("\n", 1)[0]?.trim() ?? ""; return head === "" ? "supersede" : `supersede (${head})`; } case "placeOrder": { const o = a.order; const leg = o.strike !== undefined && o.right !== undefined ? ` ${o.strike}${o.right}` : ""; return `placeOrder ${o.side} ${o.qty} ${o.instrument}${leg} @ ${o.price}`; } case "scheduleWake": return a.at.kind === "inMinutes" ? `scheduleWake +${a.at.minutes}m` : `scheduleWake @ ${a.at.clockET}`; case "cancelOrder": return `cancelOrder ${a.ref}`; case "flatten": return `flatten ${a.instrument}`; case "standDown": return isPassSentinel(a) ? "pass" : "standDown"; } } /** * Parse a raw model reply into a validated {@link TurnParse}. Fail-closed and never repaired: * - no JSON object, malformed JSON, or wrong top-level shape ⇒ a pass whose JOURNAL names the defect; * - ANY invalid action ⇒ the WHOLE turn is rejected to that same fail-closed pass (never partially * applied — an invalid action never silently vanishes, a valid neighbour never arms alone). The * fail-closed pass additionally carries a {@link TurnReject} (the failed index + its reason + the * valid siblings that were dropped) so the harness can FEED THE REASON back to the model next wake; * - a `{ "kind": "pass" }` action (or an empty `actions`) ⇒ a legitimate pass, carrying the model's * own `journal` if it supplied one. * Pure: no clock, no RNG, no I/O. */ export function parseTurn(reply: string): TurnParse { const jsonText = extractJsonObject(reply); if (jsonText === null) return failClosedPass("unparseable reply — no JSON object found"); let parsed: unknown; try { parsed = JSON.parse(jsonText); } catch (e) { return failClosedPass(`unparseable reply — invalid JSON (${e instanceof Error ? e.message : String(e)})`); } if (!isObject(parsed)) return failClosedPass("unparseable reply — top-level value is not an object"); const rawActions = parsed.actions; if (rawActions !== undefined && !Array.isArray(rawActions)) return failClosedPass("malformed reply — actions is not an array"); const journal = typeof parsed.journal === "string" && parsed.journal.trim() !== "" ? parsed.journal : undefined; // Validate EVERY action so a reject can name the first failure AND describe the valid siblings that // atomicity dropped (never partial-apply — the atomic reject is preserved; we only make it legible). const rawArr = (rawActions ?? []) as unknown[]; const valid: { readonly index: number; readonly action: Action }[] = []; let firstFail: { readonly index: number; readonly reason: string } | undefined; for (let i = 0; i < rawArr.length; i++) { const validated = validateAction(rawArr[i], i); if (typeof validated === "string") { if (firstFail === undefined) firstFail = { index: i, reason: validated }; continue; // keep scanning to collect the VALID siblings for the reject attribution } valid.push({ index: i, action: validated }); } if (firstFail !== undefined) { const base = failClosedPass(`invalid action — ${firstFail.reason}`); const validSiblings = valid .filter(({ action }) => !isPassSentinel(action)) .map(({ index, action }) => ({ index, summary: describeAction(action) })); return { ...base, reject: { failedIndex: firstFail.index, failedReason: firstFail.reason, validSiblings } }; } const actions: Action[] = []; for (const { action } of valid) { // Drop the `pass` sentinel — an explicit "do nothing" collapses to an empty actions list. if (isPassSentinel(action)) continue; actions.push(action); } return { turn: { actions, ...(journal !== undefined ? { journal } : {}) }, ok: true }; } // ───────────────────────────────────────────────────────────────────────────── // Reject-and-reprompt — feed the ATOMIC turn-rejection reason back to the model (kestrel-gxz) // ───────────────────────────────────────────────────────────────────────────── /** The date-blind WITHHELD fallback the harness substitutes when a rendered reprompt would leak a * calendar token (fail-closed by redaction — the atomic-reject fact is preserved, the leaking * specifics are dropped, never a crash). Byte-stable; carries no per-turn material. */ export const REPROMPT_WITHHELD = [ "Your last turn was REJECTED and NOTHING from it was applied (a turn is ALL-OR-NOTHING).", "The specific reason was withheld here to keep this frame date-blind. Your standing book is UNCHANGED", 'and no order was placed. Resubmit a corrected turn, or pass with { "actions": [] }. Emit ONLY the JSON turn.', ].join("\n"); /** * Render the PER-ACTION reject attribution the harness prefixes onto the NEXT wake's user message * after a turn failed closed (kestrel-gxz). Pure and date-agnostic: it names the invalid action index * + its reason, and — the step beyond a bare reason — lists the individually-VALID sibling actions * that atomicity DROPPED, telling the model they were NOT applied and to resubmit them (alone, or with * a corrected sibling). This turns "execution is broken in this kernel" into a self-correcting signal * while PRESERVING the atomic reject (bounded-risk / never-naked: a valid order never arms alone). * * The text is re-fenced through {@link scanDateLeak} at the harness boundary before it reaches the * model — a leaking model-authored token (e.g. an OCC symbol with a compact date) causes the whole * reprompt to be replaced by {@link REPROMPT_WITHHELD}. Pure: no clock, no RNG, no I/O. */ export function renderReprompt(reason: string, reject?: TurnReject): string { const lines: string[] = []; lines.push("Your last turn was REJECTED and NOTHING from it was applied (a turn is ALL-OR-NOTHING)."); if (reject !== undefined) { // Strip the validator's own `action[i] ` prefix; we re-attach the index explicitly for clarity. const detail = reject.failedReason.replace(/^action\[\d+\]\s*/, ""); lines.push(` • action[${reject.failedIndex}] was INVALID: ${detail}`); if (reject.validSiblings.length > 0) { lines.push(" • Your other action(s) were individually VALID but were NOT applied, because the whole"); lines.push(" turn is rejected together:"); for (const s of reject.validSiblings) lines.push(` – action[${s.index}]: ${s.summary}`); lines.push( ` Resubmit the valid action(s) ALONE, or together with a corrected action[${reject.failedIndex}].`, ); } else { lines.push(" Fix the invalid action and resubmit."); } } else { lines.push(` • Reason: ${reason}`); lines.push(" Resubmit a corrected turn, or pass."); } lines.push('Your standing book is UNCHANGED and no order was placed. Emit ONLY the JSON turn (or { "actions": [] } to pass).'); return lines.join("\n"); } /** * Parse a raw model reply in the AUTHORING window into a classified {@link AuthoringReply} (ADR-0029 * §1, Option C). This is the ONE new parser branch the bounded loop reads; it composes over * {@link parseTurn} rather than replacing it, so the three m9i outcomes stay distinct. * * - When `allowRequestView` (the **viewshop** arm), a top-level `{ requestView: { view, reason } }` * object is validated: `view` a non-empty string that `parse()`s to EXACTLY ONE `View` statement (a * `Plan`/`Wake`/`Grade` or a multi-statement payload is a defect), `reason` a string, and NO * `actions` present (a mixed reply is a defect). A well-formed one ⇒ the non-terminal `requestView` * variant the driver materializes + re-asks on. * - Under the baseline (`allowRequestView=false`), a top-level `requestView` key is an unknown/illegal * outcome ⇒ the `invalid` variant (the controlled division genuinely lacks the move). * - ANY defect (malformed JSON, wrong shape, a bad requestView, a mixed reply, an invalid action) ⇒ * the `invalid` variant carrying the repair-guiding `reason`. The driver surfaces it for a repair * re-ask and it becomes the terminal fail-closed pass only when the budget is spent. * - A valid normal turn (incl. an authored `standDown` / empty pass) ⇒ the `turn` variant. * * Pure: no clock, no RNG, no I/O. */ export function parseAuthoringReply(reply: string, opts: { readonly allowRequestView: boolean }): AuthoringReply { const jsonText = extractJsonObject(reply); if (jsonText === null) return { kind: "invalid", reason: "unparseable reply — no JSON object found" }; let parsed: unknown; try { parsed = JSON.parse(jsonText); } catch (e) { return { kind: "invalid", reason: `unparseable reply — invalid JSON (${e instanceof Error ? e.message : String(e)})` }; } if (!isObject(parsed)) return { kind: "invalid", reason: "unparseable reply — top-level value is not an object" }; // The requestView fork (ADR-0029 §1, Option C) — a distinct top-level outcome, not an Action. if (parsed.requestView !== undefined) { if (!opts.allowRequestView) { return { kind: "invalid", reason: "requestView is not an available move under this profile — reply with a turn (actions) instead", }; } if (parsed.actions !== undefined) { return { kind: "invalid", reason: "a reply carries EITHER requestView OR actions, never both (mixed reply)" }; } const rv = parsed.requestView; if (!isObject(rv)) return { kind: "invalid", reason: "requestView must be an object { view, reason }" }; if (typeof rv.view !== "string" || rv.view.trim() === "") { return { kind: "invalid", reason: "requestView.view must be a non-empty Kestrel VIEW document" }; } if (typeof rv.reason !== "string") return { kind: "invalid", reason: "requestView.reason must be a string" }; let node; try { node = parse(rv.view); } catch (e) { return { kind: "invalid", reason: `requestView.view is not valid Kestrel — ${e instanceof Error ? e.message : String(e)}` }; } const stmt = node.kind === "module" ? (node.statements.length === 1 ? node.statements[0] : undefined) : node; if (stmt === undefined || stmt.kind !== "view") { return { kind: "invalid", reason: "requestView.view must be EXACTLY ONE VIEW statement (not a Plan/Wake/Grade, and not multiple statements)", }; } return { kind: "requestView", request: { view: rv.view, reason: rv.reason } }; } // Otherwise a normal turn — reuse the fail-closed turn parser (the three m9i outcomes intact). const parsedTurn = parseTurn(reply); if (!parsedTurn.ok) return { kind: "invalid", reason: parsedTurn.reason ?? "invalid reply" }; return { kind: "turn", turn: parsedTurn.turn }; } // ───────────────────────────────────────────────────────────────────────────── // Scrub — the credential fence over captured wire evidence (ADR-0013 (e)) // ───────────────────────────────────────────────────────────────────────────── /** Env var names whose values are credentials the harness must never let into an artifact. */ export const CREDENTIAL_ENV_VARS: readonly string[] = [ "AWS_BEARER_TOKEN_BEDROCK", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY", "AI_GATEWAY_API_KEY", // The cash/credits lanes added for the CFG EARN re-test (bead cfg-earn-retest). Registered HERE, not // just read in `ai-sdk-client.ts`: this list is what `scrubSecrets` redacts from captured wire evidence, // so a lane whose key is absent from it would leak that key into every artifact the run writes. "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", "FIREWORKS_API_KEY", ]; const REDACTED = "[REDACTED]"; /** Escape a literal string for use inside a RegExp. */ function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Redact credential material from a byte string BEFORE it becomes an artifact (ADR-0013 (e)). Three * belt-and-suspenders passes, so a key is caught by identity OR by shape: * 1. every explicitly-supplied secret value (e.g. the env bearer token, passed at capture time) — * exact-substring redaction, so any occurrence anywhere is scrubbed regardless of framing; * 2. `Authorization: Bearer ` / bearer-token shapes, including the AWS Bedrock `ABSK…` and * `bedrock-api-key-…` bearer-token formats; * 3. JSON auth-header VALUES (`authorization`, `x-api-key`, `x-amz-security-token`, `set-cookie`). * Pure — a deterministic function of (text, secrets). Empty/whitespace secrets are ignored (a blank * secret must never redact the whole document to `[REDACTED]`). */ export function scrubSecrets(text: string, secrets: readonly string[] = []): string { let out = text; for (const secret of secrets) { if (typeof secret !== "string" || secret.trim().length < 6) continue; // ignore blanks / trivially-short values out = out.replaceAll(secret, REDACTED); } // Bearer tokens (Authorization header or bare), incl. AWS Bedrock ABSK… / bedrock-api-key-… shapes. out = out.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi, `$1${REDACTED}`); out = out.replace(/ABSK[A-Za-z0-9+/=]{16,}/g, REDACTED); out = out.replace(/bedrock-api-key-[A-Za-z0-9._~+/=-]{8,}/gi, REDACTED); // AWS long-term / session key shapes (AKIA…, ASIA…) and their secrets. out = out.replace(/A(?:KIA|SIA)[A-Z0-9]{16}/g, REDACTED); // JSON auth-header values: "authorization": "….", "x-api-key": "….", etc. out = out.replace( /("(?:authorization|x-api-key|x-amz-security-token|set-cookie|api-key)"\s*:\s*")[^"]*(")/gi, `$1${REDACTED}$2`, ); return out; }