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.

705 lines (672 loc) 71.6 kB
/** * # 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": "<optional one-line pre-hoc thesis + invalidation>", "actions": [ <Action>, ... ] }', "", "Each Action is one of these tagged shapes:", ' { "kind": "supersede", "document": "<a Kestrel View/Wake/Plan document>", "note": "<optional>" }', ' - 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": <n> }, "reason": "<why>" }', ' - or "at": { "kind": "atClockET", "clockET": "HH:MM" }. Sets your next monitoring wake.', ' { "kind": "placeOrder", "order": { "side": "buy"|"sell", "instrument": "<sym>", "strike": <n>,', ' "right": "C"|"P", "qty": <n>, "price": "<Kestrel price expr, e.g. @fair or cap fair,0.80>" } }', ' - 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": "<sym>", "qty": <shares>, "price": "@mid" } }.', ' { "kind": "cancelOrder", "ref": "<resting order ref>" }', ' { "kind": "standDown", "reason": "<why>" } - 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 <SYM>` + `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": "<optional one-line pre-hoc thesis + invalidation>", "actions": [ <Action>, ... ] }', '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": "<a Kestrel PLAN/VIEW/WAKE document>", "note": "<optional>" }', " 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": <n> }, "reason": "<why>" }', ' or "at": { "kind": "atClockET", "clockET": "HH:MM" }. Sets your next monitoring wake.', ' { "kind": "placeOrder", "order": { "side": "buy"|"sell", "instrument": "<sym>", "strike": <n>,', ' "right": "C"|"P", "qty": <n>, "price": "<Kestrel price expr, e.g. @fair or cap fair,0.80>" } }', " 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": "<sym>", "qty": <shares>, "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": "<a resting order ref from the frame kernel>" }', ' { "kind": "flatten", "instrument": "<sym>", "note": "<optional>" }', " CLOSE your whole position in <sym> 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": "<why>" } — 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 <name> [budget <n>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 <SYM>`. 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 <level>` (edge — arm ahead of the break) or", " `WHEN spot > <level>` (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": "<a Kestrel VIEW document>", "reason": "<why you need this lens now>" } }', "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": "<SYM>", "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 <SYM> 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": "<SYM>", "qty": <part of your 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\": \"<resting sell ref>\" },", ' { "kind": "placeOrder", "order": { "side": "sell", "instrument": "<SYM>", "qty": <part of your 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: <one line on what broke and why a re-brief is needed>', "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<string, string> = 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<string, unknown> { 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}] sche