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.

164 lines 12.3 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 { type Agent, type AgentConfig, type AgentTurn } from "../agent.ts"; /** 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 declare 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 declare 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 declare 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 declare const DEFAULT_TIMEOUT_MS = 300000; /** The classification of one handshake turn — the m9i three-way distinction kept explicit on the capture * record so a leaderboard never conflates an authored reply, an unusable one, and a brain that never * answered (ADR-0013 (a)). Mirrors `liveAgent`'s {@link import("./live-agent.ts").TurnOutcome} with * `provider-error` specialized to `harness-timeout` (the file-handshake failure mode). */ export type HandshakeOutcome = "authored" | "invalid" | "harness-timeout"; /** One frame written to the run dir: the machine envelope `{ kind, ordinal, frame }` serialized to * `frame-<ord>.json`, and rendered to a human-readable `frame-<ord>.txt` companion. */ export interface FrameEnvelope { readonly kind: "open" | "wake"; /** `open` for the briefing, else the 4-digit wake ordinal (`0000`, `0001`, …). */ readonly ordinal: string; readonly frame: unknown; } /** Per-turn handshake evidence — recorded OFF the graded path (never a graded input). Keeps the three * outcomes distinct and points at the exact frame/turn files, so an audit can replay a cell by hand. */ export interface HandshakeCapture { /** {@link OPEN_ORDINAL} for the open turn, else the 0-based wake ordinal. */ readonly ordinal: number; /** The `<ord>` label used in the file names (`open` | `0000` | …). */ readonly ordLabel: string; readonly outcome: HandshakeOutcome; /** Present for `invalid` / `harness-timeout` — the fail-closed reason journalled onto the pass. */ readonly reason?: string; readonly framePath: string; readonly turnPath: string; } /** Options for {@link fileHandshakeAgent}. `harness`/`harnessVersion` are the externally-declared brain * identity (the tournament pivot); `now`/`sleep` are injectable so the host wait — and the timeout — are * deterministic in tests without a real delay; `render` supplies the rendered-text companion (defaults to * a pure, matched-interface renderer); `capture` is a sink one record is appended to per turn. */ export interface FileHandshakeOptions { /** The run directory frames/turns live in. Created if absent. Kept OUT of the graded record path. */ readonly dir: string; /** The externally-declared brain identity, e.g. `claude-code/2.x` (kestrel-m9i.7). A caller-pinned * `config.harness` wins over this. */ readonly harness: string; /** The brain version/revision token, paired with {@link harness}. A caller-pinned `config.harnessVersion` * wins over this. */ readonly harnessVersion: string; /** The deterministic deadline in ms; a missing turn past it fails closed to `harness-timeout`. */ readonly timeoutMs?: number; /** The host-wait poll interval in ms (default 100). Off the graded clock. */ readonly pollMs?: number; /** Injectable clock for the host wait/deadline (default `Date.now`) — off the graded path. */ readonly now?: () => number; /** Injectable sleep for the poll loop (default `Bun.sleep`) — off the graded path. */ readonly sleep?: (ms: number) => Promise<void>; /** The rendered-text companion renderer — a PURE function of the frame (matched-interface: same bus ⇒ * same text regardless of harness). Defaults to {@link renderFrameText}. */ readonly render?: (env: FrameEnvelope) => string; /** A sink array one {@link HandshakeCapture} is appended to per turn (open + each decide). */ readonly capture?: HandshakeCapture[]; } /** 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 declare function ordLabelOf(ordinal: number): string; /** 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 declare function serializeFrameEnvelope(env: FrameEnvelope): string; /** 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 declare function renderFrameText(env: FrameEnvelope): string; /** The fail-closed `harness-timeout` turn: a PASS whose JOURNAL names the missed deadline (never a * fabricated standDown — standing Plans keep managing). Pure. */ export declare function timeoutTurn(ordLabel: string, timeoutMs: number): { readonly turn: AgentTurn; readonly reason: string; }; /** * 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 declare function parseHandshakeTurn(text: string): { readonly turn: AgentTurn; readonly outcome: HandshakeOutcome; readonly reason?: string; }; /** 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 declare function stampHarnessIdentity(config: AgentConfig, harness: string, harnessVersion: string): AgentConfig; /** * 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 declare function fileHandshakeAgent(config: AgentConfig, opts: FileHandshakeOptions): Agent; //# sourceMappingURL=file-handshake.d.ts.map