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.

228 lines (227 loc) 15.1 kB
/** * # session/harness/file-handshake — the versioned, identity-stamped file-handshake Agent adapter (kestrel-m9i.7 stage-0) * * THE adapter every external CLI harness drives through. It satisfies the ONE ADR-0012 {@link Agent} * seam — `open(briefing) / decide(frame)` — over a **file handshake**: the runtime writes each * frozen-at-wake vantage to a run directory (`frame-<ord>.json` + a rendered `frame-<ord>.txt`), then * BLOCKS on a host wait until the external brain writes its reply (`turn-<ord>.json`, an * {@link AgentTurn} — or the bare `STAND_DOWN` sentinel). The de-facto v0 of this protocol drove three * models overnight through a Claude-Code subagent (scratchpad `sim-harness/PROTOCOL.md`); this module * PROMOTES it into a versioned seam with a stamped harness identity and a fail-closed parse identical * to `liveAgent`. * * ## Matched-interface discipline (kestrel-m9i.7 stage-0) * The frames written to the run dir are a PURE function of the graded bus (the engine renderer, never * the harness) — so `first-party-liveagent`, `claude-code`, `codex`, and `opencode` all see BYTE- * IDENTICAL frames for a given cell. The only degrees of freedom are the external brain and its * declared identity. Two adapter instances over the same bus therefore write byte-identical frames. * * ## Where the determinism boundary sits (ADR-0012 §3, ADR-0013, RUNTIME §0) * The file wait is a HOST wait — OFF the graded clock. Nothing on the deterministic record path touches * the wall clock or an RNG: the returned {@link AgentTurn} is the ONLY value that crosses into the graded * path, and its bytes are identical across reruns. A recorded run dir (turn files pre-present) replays * byte-identically: `open`/`decide` read the existing turn immediately, re-derive the same graded bus. * * ## Fail-closed — the three m9i outcomes, kept DISTINCT (identical to `liveAgent`) * - **authored** — a valid `turn-<ord>.json` (any validated {@link AgentTurn}, incl. an explicit * `standDown` or the `STAND_DOWN` sentinel). Parsed through the SAME {@link parseTurn} the live agent * uses — never repaired. * - **invalid** — a present-but-unusable turn (bad JSON / wrong shape / an invalid action) ⇒ a PASS * whose JOURNAL names the defect (never a fabricated action, never a crash). `parseTurn`'s wording. * - **harness-timeout** — no `turn-<ord>.json` within the deterministic deadline ⇒ a PASS journalled * `harness-timeout`. Distinct from `invalid` (a provider/brain failure vs an unusable reply), exactly * as `liveAgent` keeps `provider-error` distinct. * None is a fabricated `standDown`: a missing/invalid turn de-arms NOTHING on its own — standing Plans * keep managing (an empty `actions[]` is a legitimate pass, ADR-0012 §3). * * ## Harness identity (System Profile v1.1 — mandatory-for-certified) * The adapter stamps its OWN transport identity `adapter = {@link FILE_HANDSHAKE_ADAPTER}` / * `adapterVersion = {@link FILE_HANDSHAKE_ADAPTER_VERSION}` (the shared seam — constant across every * external harness) AND the EXTERNALLY-DECLARED brain identity `harness` / `harnessVersion` onto the * advertised config. A caller-pinned value always WINS (never overwritten). `harness`/`harnessVersion` * are CFG-only (the a57.14 envelope stays exactly 14 fields) yet fold into the ConfigId — which the grid * CellKey keys on — so each harness is a DISTINCT grid column while `file-handshake` remains the one * transport all four brains share. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { OPEN_ORDINAL } from "../agent.js"; import { renderBriefing, renderWakeDelta } from "../../frame/render.js"; import { parseTurn } from "./prompt.js"; /** The transport identity this adapter stamps as `config.adapter` — the shared seam every external CLI * harness drives through (constant across `claude-code`/`codex`/`opencode`/`first-party-liveagent`). The * `harness` field carries the brain that pivots the tournament; THIS names the mechanism. */ export const FILE_HANDSHAKE_ADAPTER = "file-handshake"; /** The transport revision paired with {@link FILE_HANDSHAKE_ADAPTER}. Bump on any behavioral change to * this adapter (frame serialization, parse, timeout policy) — it folds into the ConfigId, so a bump mints * a new grid column rather than contaminating an old one (ADR-0013 (d)). `v2`: the default * `frame-<ord>.txt` companion is the canonical `src/frame` Rendering, not a JSON dump (kestrel-wa0j.2) — * a change to the bytes an external brain perceives, so it must mint a new column. */ export const FILE_HANDSHAKE_ADAPTER_VERSION = "v2"; /** The bare sentinel a harness may write to `turn-<ord>.json` instead of a JSON turn to de-arm cleanly. * An AUTHORED outcome (the brain chose to step aside), kept distinct from an `invalid`/unparseable reply. */ export const STAND_DOWN_SENTINEL = "STAND_DOWN"; /** The default deterministic deadline (5 min) after which a missing turn fails closed to a * `harness-timeout` pass. Off the graded clock — it only bounds the host wait. */ export const DEFAULT_TIMEOUT_MS = 300_000; // ───────────────────────────────────────────────────────────────────────────── // Pure core (no I/O, no clock) — the byte-stable frame/turn contract // ───────────────────────────────────────────────────────────────────────────── /** The `<ord>` label for a wake ordinal: `open` for the OPEN turn ({@link OPEN_ORDINAL}), else the * 0-based wake ordinal zero-padded to 4 digits (`0000`, `0001`, …) — the PROTOCOL.md convention. Pure. */ export function ordLabelOf(ordinal) { return ordinal === OPEN_ORDINAL ? "open" : String(ordinal).padStart(4, "0"); } /** Serialize a {@link FrameEnvelope} to the byte-stable `frame-<ord>.json` text — deterministic 2-space * JSON (a pure function of the frame, so two adapter instances over one bus write identical bytes). */ export function serializeFrameEnvelope(env) { return JSON.stringify(env, null, 2); } /** The DEFAULT rendered-text companion for a frame — THE canonical `src/frame` Rendering * ({@link renderBriefing} for the open envelope, {@link renderWakeDelta} for a wake — an * {@link ActingFrame} extends {@link WakeDeltaInput}), plus a reply-instruction TRAILER (protocol * scaffolding, after the frame so the kernel still LEADS). It replaces the pre-wa0j.2 JSON dump: the * external brain now perceives the SAME screen an in-process agent does; the machine twin stays * `frame-<ord>.json`. PURE and matched-interface (a function of the frame only, never the harness), so * every brain sees the same `frame-<ord>.txt`. The `env.kind` discriminant selects the renderer; the * frame is the driver's own typed value (this module never parses one from disk), so no runtime shape * re-validation happens here. A caller may still inject its own renderer via `opts.render`. */ export function renderFrameText(env) { // A wake frame that carries a delivered View (kestrel-wa0j.4 — a scheduled wake's stored View, // already resolved fail-closed by the driver) renders under THAT selection, exactly as the // in-process live agent does; absent ⇒ the default WAKE panes, byte-identical to before. const wakeView = env.kind === "wake" ? env.frame.view : undefined; const { rendered, refusal } = renderFrameBody(env, wakeView); return [ ...(refusal !== undefined ? [refusal, ""] : []), rendered, "", `→ you are the author (the trader): decide on this frozen, date-blind vantage and write turn-${env.ordinal}.json (an AgentTurn, or the bare ${STAND_DOWN_SENTINEL} sentinel).`, "", ].join("\n"); } /** Render the frame body under its delivered View, FAIL-CLOSED to the default panes on a materialization * refusal (kestrel-wa0j.19 §1 — the BLOCKER). The file handshake writes this text to disk for an external * brain; a delivered View that resolves but cannot materialize (a window arg the frame can't serve, an * over-budget View) must never crash the frame-text write. On a throw we re-render WITHOUT the View (the * default WAKE panes — known-good) and return a `refusal` note the caller LEADS the frame with (surfaced, * never silent). A throw with NO View to blame is a genuine render defect and is re-thrown. Pure. */ function renderFrameBody(env, wakeView) { if (env.kind === "open") return { rendered: renderBriefing(env.frame) }; try { return { rendered: renderWakeDelta(env.frame, wakeView !== undefined ? { view: wakeView } : {}) }; } catch (e) { if (wakeView === undefined) throw e; // no View to blame — a genuine render defect, not agent-authored input return { rendered: renderWakeDelta(env.frame, {}), refusal: `⚠ harness: delivered View "${wakeView.name}" could not be materialized — ${e instanceof Error ? e.message : String(e)}; showing the default WAKE panes (fail-closed, kestrel-wa0j.19).`, }; } } /** The fail-closed `harness-timeout` turn: a PASS whose JOURNAL names the missed deadline (never a * fabricated standDown — standing Plans keep managing). Pure. */ export function timeoutTurn(ordLabel, timeoutMs) { const reason = `harness-timeout — no turn-${ordLabel}.json within ${timeoutMs / 1000}s`; return { turn: { actions: [], journal: `harness: ${reason}` }, reason }; } /** * Parse one turn-file's raw text into a validated {@link AgentTurn} with its {@link HandshakeOutcome} — * the fail-closed core, IDENTICAL to `liveAgent` (it delegates to the same {@link parseTurn}), never * repaired. The bare {@link STAND_DOWN_SENTINEL} is intercepted first as an AUTHORED clean de-arm (so a * brain can step aside without composing JSON); anything else flows through `parseTurn` — `ok` ⇒ * `authored`, otherwise `invalid` carrying `parseTurn`'s reason on a PASS + journal. Pure: no I/O. */ export function parseHandshakeTurn(text) { if (text.trim() === STAND_DOWN_SENTINEL) { return { turn: { actions: [{ kind: "standDown", reason: `harness: ${STAND_DOWN_SENTINEL}` }] }, outcome: "authored" }; } const parsed = parseTurn(text); if (parsed.ok) return { turn: parsed.turn, outcome: "authored" }; return { turn: parsed.turn, outcome: "invalid", ...(parsed.reason !== undefined ? { reason: parsed.reason } : {}) }; } /** The identity-stamped config the adapter advertises: the caller's config with the transport identity * ({@link FILE_HANDSHAKE_ADAPTER}/{@link FILE_HANDSHAKE_ADAPTER_VERSION}) and the externally-declared * `harness`/`harnessVersion` folded in — a caller-pinned value always wins. PURE (no I/O), so a driver can * derive a ConfigId for a cell WITHOUT opening the run dir. */ export function stampHarnessIdentity(config, harness, harnessVersion) { return { ...config, adapter: config.adapter ?? FILE_HANDSHAKE_ADAPTER, adapterVersion: config.adapterVersion ?? FILE_HANDSHAKE_ADAPTER_VERSION, harness: config.harness ?? harness, harnessVersion: config.harnessVersion ?? harnessVersion, }; } // ───────────────────────────────────────────────────────────────────────────── // The adapter — the ADR-0012 Agent seam over the file handshake // ───────────────────────────────────────────────────────────────────────────── /** * Build the file-handshake {@link Agent} over a run directory. `open`/`decide` write the frame * (`frame-<ord>.json` + `frame-<ord>.txt`) then block on a host wait until `turn-<ord>.json` lands (or * the deadline passes). The returned {@link AgentTurn} is the only value crossing the determinism * boundary; the wait, the clock, and the files sit entirely OFF the graded path. Fail-closed exactly like * `liveAgent` (invalid ⇒ pass + journal; missing ⇒ `harness-timeout` pass + journal). The advertised * `config` carries the stamped harness identity ({@link stampHarnessIdentity}). */ export function fileHandshakeAgent(config, opts) { const dir = opts.dir; const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const pollMs = opts.pollMs ?? 100; const now = opts.now ?? Date.now; const sleep = opts.sleep ?? ((ms) => Bun.sleep(ms)); const render = opts.render ?? renderFrameText; const fullConfig = stampHarnessIdentity(config, opts.harness, opts.harnessVersion); mkdirSync(dir, { recursive: true }); const awaitTurn = async (kind, ordinal, frame) => { const ordLabel = ordLabelOf(ordinal); const framePath = join(dir, `frame-${ordLabel}.json`); const textPath = join(dir, `frame-${ordLabel}.txt`); const turnPath = join(dir, `turn-${ordLabel}.json`); const env = { kind, ordinal: ordLabel, frame }; // Matched-interface: the frame bytes are a pure function of the bus, never of the harness. writeFileSync(framePath, serializeFrameEnvelope(env)); writeFileSync(textPath, render(env)); const deadline = now() + timeoutMs; while (!existsSync(turnPath)) { if (now() >= deadline) { const { turn, reason } = timeoutTurn(ordLabel, timeoutMs); opts.capture?.push({ ordinal, ordLabel, outcome: "harness-timeout", reason, framePath, turnPath }); return turn; } await sleep(pollMs); } let text; try { text = readFileSync(turnPath, "utf8"); } catch (e) { // A present-but-unreadable turn is an unusable reply — fail closed to an invalid PASS (not a crash). const reason = `unreadable turn-${ordLabel}.json — ${e instanceof Error ? e.message : String(e)}`; opts.capture?.push({ ordinal, ordLabel, outcome: "invalid", reason, framePath, turnPath }); return { actions: [], journal: `harness: ${reason}` }; } const parsed = parseHandshakeTurn(text); opts.capture?.push({ ordinal, ordLabel, outcome: parsed.outcome, ...(parsed.reason !== undefined ? { reason: parsed.reason } : {}), framePath, turnPath, }); return parsed.turn; }; return { config: fullConfig, open(briefing) { return awaitTurn("open", OPEN_ORDINAL, briefing); }, decide(frame) { return awaitTurn("wake", frame.wakeOrdinal, frame); }, }; }