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.

382 lines (350 loc) 21.1 kB
/** * # sdk/op-schema — the ONE self-description of the agent JSONL protocol (kestrel-yuw4) * * The `kestrel agent` JSONL face lists its ops (`kestrel agent --help`, and the `unknown-op` refusal, #115) * but historically documented NONE of the per-op ARGUMENT OBJECT shapes — the `{ kind: … }` unions a * `subject` / `advance.response` must carry, and the exact accepted field NAMES (`document`, not * `text`/`bytes`/`source`/`content`). A cold integrator had to brute-force ~14 trial-and-error guesses to * discover them. This module is the SINGLE, machine-readable catalogue of those shapes: * * - {@link AGENT_OP_SCHEMAS} — one {@link AgentOpSchema} per op (its args + response shape), the source the * `describe` op emits VERBATIM on the wire, the source `kestrel agent --help` renders, and the source the * MCP face's argument descriptions read. ONE definition; the cross-face drift guards * (tests/cli.agent-describe.test.ts, tests/mcp.projection.test.ts) pin that nothing forks from it. * - The shape VOCABULARY constants ({@link SUBJECT_SHAPE} et al.) — referenced by BOTH the catalogue entries * AND the argument VALIDATORS below, so a refusal that names the expected shape can never drift from the * shape the catalogue advertises (they read the same string). * - The argument VALIDATORS ({@link asSessionRef} / {@link asAuthoredResponse} / {@link asBoundResponse} / * {@link asEventCursor}) — a malformed argument object is a fail-closed {@link ArgShapeError} whose message * NAMES the expected `{ kind: … }` vocabulary. STRUCTURE only: a well-formed-but-unresolvable VALUE (an * unknown catalog id, an unsupported subject kind) is deliberately passed through to the SDK, whose typed * DOMAIN refusal answers it — the transport/argument-error class must never absorb a Kestrel refusal. * * ZERO-WEIGHT: pure data + pure functions over type-only imports (erased at build). No runtime-semantics * import, so the light agent/SDK projection stays light (pinned by tests/package.boundary.test.ts). */ import type { AuthoredResponse, BoundResponse, SessionRef } from "./types.ts"; import type { EventCursor } from "../protocol/index.ts"; /* ──────────────────────────── the shape vocabulary (single source) ─────────── */ /** The `openSession.subject` shape — a discriminated {@link SessionRef} union (a STRING is rejected). */ export const SUBJECT_SHAPE = '{ "kind": "catalog", "id": string } | { "kind": "dataset", "source": string, "dataset": string }'; /** * The `advance.response` / `revise.response` shape — an {@link AuthoredResponse} union. `document` is the * ONLY accepted authored-body field name (NOT `text` / `bytes` / `source` / `content`), the exact fact a cold * integrator could not discover from any published surface (kestrel-yuw4). */ export const AUTHORED_RESPONSE_SHAPE = '{ "kind": "authored", "document": string } | { "kind": "pass" } | { "kind": "stand-down", "reason": string }'; /** * The CLOSED `AuthoredResponse.kind` union (kestrel-lzz7). UNLIKE `subject.kind` — an open discovery union * whose unknown values the SDK answers with an `unsupported-subject` DOMAIN refusal — the authored-response * kind is a fixed protocol enum with NO domain answer for an unknown value: a `frobnicate` kind reaches the * controller's exhaustive `responseToBody` switch, matches no case, and dereferences `body.kind` on the * resulting `undefined` (a raw `TypeError` surfaced as `code:"error"`). An unknown value is therefore a * malformed ARGUMENT, caught at the boundary as `bad-arg`, not passed through. */ export const AUTHORED_RESPONSE_KINDS = ["authored", "pass", "stand-down"] as const; /** The `submit.response` shape — a fully-bound {@link BoundResponse} (the full pentad header + a TurnBody). */ export const BOUND_RESPONSE_SHAPE = '{ "sessionId": string, "ordinal": number, "parentHash": string, "frameRoot": string, "body": TurnBody }'; /** * The CLOSED {@link import("../protocol/session.ts").TurnBody}`.kind` union (kestrel-lzz7). A `submit` * response whose `body` is absent or carries an unknown `kind` reaches the controller's exhaustive * `reconcileTurn` switch, matches no case, and dereferences `body.kind` on `undefined` — the same raw * `code:"error"` leak as an unknown authored kind. A missing/unknown body kind is a malformed argument. */ export const TURN_BODY_KINDS = ["authored", "stand-down", "failure"] as const; /** The optional `resume.after` / `resumeOperation.after` resume-cursor shape — an {@link EventCursor}. */ export const EVENT_CURSOR_SHAPE = '{ "sequence": number, "token": string }'; /* The REQUIRED scalar/array arg shapes (kestrel-8h9f) — single-sourced here so the `describe` catalogue entry * AND the boundary validator name the SAME string (a missing required arg refuses `bad-arg` naming this shape, * never silently forwarding the absent value as `""` for the server to misread as `not-found`). */ /** The `validate.document` shape — the Kestrel plan document source text (REQUIRED). */ export const VALIDATE_DOCUMENT_SHAPE = "the Kestrel plan document (source text)"; /** The `grade.blotters` shape — a REQUIRED non-empty array of Blotter artifact ids. */ export const BLOTTERS_SHAPE = "string[] — Blotter artifact ids (a Blotter's sessionId is its id)"; /** The `artifact.ref` shape — a REQUIRED content-addressed artifact reference. */ export const ARTIFACT_REF_SHAPE = "a content-addressed artifact ref"; /** The `resumeOperation.operationId` shape — the REQUIRED durable Operation id. */ export const OPERATION_ID_SHAPE = "the durable Operation id"; /** * The `catalog` op's response ROW shape — a {@link import("../protocol/catalog.ts").CatalogListing} (kestrel-dnxq). * The catalog op UNDER-specified its response before dnxq (`"CatalogEntry[] — each carries the id"`, no field * schema), so two divergent shapes both satisfied it; this pins the exact canonical fields BOTH transports emit * byte-identically. The reproducibility pins (content roots / engine versions / View identities) are NOT part of * discovery — they resolve at `openSession`/grade. */ export const CATALOG_LISTING_SHAPE = '{ "id": string, "title": string, "description": string, "instrument": string, "period": { "start": string, "end": string }, "frameCount": number, "free": boolean }'; /* ──────────────────────────── the op-schema catalogue ──────────────────────── */ /** One argument of an agent op: its wire field name, whether it is required, its JSON type, and — the point * of this whole module — the exact `{ kind: … }` object SHAPE it must carry. */ export interface AgentOpArg { readonly name: string; readonly required: boolean; readonly type: "string" | "object" | "array"; /** The exact accepted shape, in the `{ kind: … }` vocabulary — what a cold integrator needs to stop guessing. */ readonly shape: string; } /** The self-description of one agent op: its args and its response shape, plus whether it needs a live Session. */ export interface AgentOpSchema { /** The `op` field a request line carries (`{ "op": <name>, …args }`). */ readonly op: string; /** `true` ⇒ the op requires a prior `openSession` in the same stream (else it refuses `no-session`). */ readonly session: boolean; /** A one-line human summary of what the op does. */ readonly summary: string; /** The op's argument fields (empty for the session-free discovery ops). */ readonly args: readonly AgentOpArg[]; /** A one-line description of the protocol payload the op emits on the `value` channel. */ readonly response: string; } /** * THE single ordered catalogue of the agent protocol's ops — the source `describe` emits, `--help` renders, * and the MCP argument descriptions read. `describe` leads (the self-description entry point); the order is * the handler/dispatch order and is the order {@link agentOpNames} (and thus `AGENT_OPS`) preserves. */ export const AGENT_OP_SCHEMAS: readonly AgentOpSchema[] = [ { op: "describe", session: false, summary: "emit this protocol's own op / argument / response shapes (self-description); needs no session — call it FIRST to learn every shape", args: [], response: '{ "protocol": "kestrel.agent/v1", "ops": AgentOpSchema[] } — the catalogue this describe emitted', }, { op: "catalog", session: false, summary: "list the capability catalog — the subject-discovery op; needs no session. Contents are transport-specific (a self-hosted sample vs the managed catalog); the object SHAPE is byte-identical across transports", args: [], response: `CatalogPage \`{ "entries": CatalogListing[], "pricing"?: CatalogPricing }\` — each entry row ${CATALOG_LISTING_SHAPE}; \`pricing\` is the OPTIONAL ex-ante price sheet (kestrel-adge; present when the catalog serves one, absent ⇒ a bare listing). \`entry.id\` is the addressable catalog handle — the self-hosted LOCAL transport opens it as an incremental \`openSession\`, while the managed transport serves it as a BATCH simulation (\`sim\`): managed incremental \`openSession\` over a catalog subject is not yet served and refuses fail-closed with a typed hint naming \`sim\``, }, { op: "validate", session: false, summary: "parse + validate a Kestrel document string", args: [{ name: "document", required: true, type: "string", shape: VALIDATE_DOCUMENT_SHAPE }], response: "ValidateOutcome — { ok: boolean, diagnostics }", }, { op: "openSession", session: false, summary: "open an incremental Session over a subject; the 402/Offer boundary is DATA, never a throw. A catalog subject opens incrementally on the self-hosted LOCAL transport; on the managed transport it is a BATCH simulate subject — managed incremental catalog Sessions are not yet served, so a managed catalog openSession refuses fail-closed with a typed hint naming the batch `sim` path", args: [ { name: "subject", required: true, type: "object", shape: SUBJECT_SHAPE }, { name: "document", required: false, type: "string", shape: "the customer strategy (Kestrel plan text) the managed author-no-strategy fence requires for a catalog subject (ADR-0012); the LOCAL transport already carries the pinned recipe's strategy and ignores it", }, ], response: '{ "gated": false, "sessionId": string } | { "gated": true, "payment": Offer }', }, { op: "start", session: true, summary: "deliver the date-blind OPEN Frame for the opened Session", args: [], response: "Delivery — { sessionId, ordinal, parentHash, frameRoot, frame } (frame is the TYPED SessionFrame struct)", }, { op: "advance", session: true, summary: "author-or-stand-down at the pending slot; deliver the next eligible Wake (next: null at settle)", args: [{ name: "response", required: true, type: "object", shape: AUTHORED_RESPONSE_SHAPE }], response: '{ "ok": true, "receipt", "next": Delivery | null } | { "ok": false, "diagnostic" }', }, { op: "revise", session: true, summary: "author a superseding document at the current slot (behaviourally identical to advance)", args: [{ name: "response", required: true, type: "object", shape: AUTHORED_RESPONSE_SHAPE }], response: '{ "ok": true, "receipt", "next": Delivery | null } | { "ok": false, "diagnostic" }', }, { op: "submit", session: true, summary: "explicit-binding turn: reconcile a fully-bound response against the recorded slot", args: [{ name: "response", required: true, type: "object", shape: BOUND_RESPONSE_SHAPE }], response: '{ "ok": true, "receipt", "next": Delivery | null } | { "ok": false, "diagnostic" }', }, { op: "resume", session: true, summary: "re-derive the committed pentad-chained transcript from an optional cursor", args: [{ name: "after", required: false, type: "object", shape: EVENT_CURSOR_SHAPE }], response: "SessionTranscript — { sessionId, entries: TurnEntry[] }", }, { op: "finalize", session: true, summary: "seal the chain → protocol Blotter + tip + conformance root + artifacts", args: [], response: '{ "blotter", "sessionId", "tipHash", "conformanceRoot", "artifacts": string[] }', }, { op: "grade", session: false, summary: "grade one or more finalized Blotter artifacts (may gate as DATA)", args: [ { name: "blotters", required: true, type: "array", shape: BLOTTERS_SHAPE, }, ], response: "Gated<GradeOutcome>", }, { op: "artifact", session: false, summary: "resolve a content-addressed artifact reference", args: [{ name: "ref", required: true, type: "string", shape: ARTIFACT_REF_SHAPE }], response: "ArtifactResult — { ref, kind, value }", }, { op: "resumeOperation", session: false, summary: "resume a durable managed Operation from its handle", args: [ { name: "operationId", required: true, type: "string", shape: OPERATION_ID_SHAPE }, { name: "after", required: false, type: "object", shape: EVENT_CURSOR_SHAPE }, ], response: "OperationResumption — { operationId, cursor, payload }", }, ]; /** The ordered op names — derived from {@link AGENT_OP_SCHEMAS} so the advertised set can never fork from the * described set. `AGENT_OPS` (src/cli/commands/agent.ts) is this list; the switch dispatches exactly it. */ export const agentOpNames: readonly string[] = AGENT_OP_SCHEMAS.map((s) => s.op); /** The `describe` op's on-the-wire payload — the whole catalogue, tagged with the protocol version. */ export interface AgentDescribe { readonly protocol: string; readonly ops: readonly AgentOpSchema[]; } /** Build the `describe` payload for a given protocol version tag (the agent face passes its own version). */ export function agentDescribe(protocolVersion: string): AgentDescribe { return { protocol: protocolVersion, ops: AGENT_OP_SCHEMAS }; } /* ──────────────────────────── the argument validators ──────────────────────── */ /** * A fail-closed ARGUMENT-SHAPE refusal: a malformed argument OBJECT whose message NAMES the expected shape. * Carries a stable `code` (`bad-arg`) an agent matches on, and the offending `field`. DISTINCT from a Kestrel * DOMAIN refusal (an unresolvable but well-formed value) — validators throw this only for STRUCTURE errors. */ export class ArgShapeError extends Error { override readonly name = "ArgShapeError"; readonly code = "bad-arg"; readonly field: string; constructor(field: string, expected: string, got: unknown) { super(`malformed \`${field}\` argument — expected ${expected}, got ${describeArg(got)}`); this.field = field; } } /** A short, safe description of an ill-typed argument (never leaks the value). */ function describeArg(v: unknown): string { return v === null ? "null" : Array.isArray(v) ? "array" : typeof v; } function isRecord(v: unknown): v is Record<string, unknown> { return v !== null && typeof v === "object" && !Array.isArray(v); } /** * VALIDATE an `openSession.subject` — an OBJECT with a string `kind` and the fields its known kind requires * (`catalog` → `id`; `dataset` → `source` + `dataset`). An UNKNOWN kind value is passed through UNCHANGED to * the SDK (its `unsupported-subject` domain refusal answers it) — only STRUCTURE is enforced here. */ export function asSessionRef(v: unknown, field = "subject"): SessionRef { if (!isRecord(v) || typeof v["kind"] !== "string") { throw new ArgShapeError(field, `a SessionRef object — ${SUBJECT_SHAPE}`, v); } if (v["kind"] === "catalog" && typeof v["id"] !== "string") { throw new ArgShapeError(field, `a catalog SessionRef — ${SUBJECT_SHAPE}`, v); } if (v["kind"] === "dataset" && (typeof v["source"] !== "string" || typeof v["dataset"] !== "string")) { throw new ArgShapeError(field, `a dataset SessionRef — ${SUBJECT_SHAPE}`, v); } return v as unknown as SessionRef; } /** * VALIDATE an `advance.response` / `revise.response` — an {@link AuthoredResponse} union. Enforces the exact * accepted authored-body field name (`document`, a string) — the fact a cold integrator could not discover. * `pass` needs no field; `stand-down` needs a string `reason`. An UNKNOWN kind is passed through to the SDK. */ export function asAuthoredResponse(v: unknown, field = "response"): AuthoredResponse { if (!isRecord(v) || typeof v["kind"] !== "string") { throw new ArgShapeError(field, `an AuthoredResponse object — ${AUTHORED_RESPONSE_SHAPE}`, v); } // CLOSED-UNION check (kestrel-lzz7): an unknown `kind` VALUE has NO domain answer — the controller's // exhaustive `responseToBody`/`responseToTurn` switch would fall through and dereference `body.kind` on // `undefined` (a raw `TypeError` surfaced as `code:"error"`). Refuse it here as `bad-arg` naming the union. if (!(AUTHORED_RESPONSE_KINDS as readonly string[]).includes(v["kind"])) { throw new ArgShapeError(field, `a known AuthoredResponse kind (${AUTHORED_RESPONSE_KINDS.join("|")}) — ${AUTHORED_RESPONSE_SHAPE}`, v); } if (v["kind"] === "authored" && typeof v["document"] !== "string") { throw new ArgShapeError(field, `an authored response — ${AUTHORED_RESPONSE_SHAPE} (\`document\` is the ONLY accepted body field)`, v); } if (v["kind"] === "stand-down" && typeof v["reason"] !== "string") { throw new ArgShapeError(field, `a stand-down response — ${AUTHORED_RESPONSE_SHAPE}`, v); } return v as unknown as AuthoredResponse; } /** * VALIDATE a `submit.response` — a fully-bound {@link BoundResponse} OBJECT carrying a well-formed * {@link import("../protocol/session.ts").TurnBody} `body`. Field-level RECONCILIATION (a sessionId / ordinal * / parentHash mismatch against the recorded slot) stays the Session's DOMAIN job; only the STRUCTURE is * enforced here — including that `body` is present with a known `kind` (kestrel-lzz7), because a missing or * unknown body kind reaches the exhaustive `reconcileTurn` switch and dereferences `body.kind` on `undefined`. */ export function asBoundResponse(v: unknown, field = "response"): BoundResponse { if (!isRecord(v)) { throw new ArgShapeError(field, `a BoundResponse object — ${BOUND_RESPONSE_SHAPE}`, v); } const body = v["body"]; if (!isRecord(body) || typeof body["kind"] !== "string" || !(TURN_BODY_KINDS as readonly string[]).includes(body["kind"])) { throw new ArgShapeError(field, `a BoundResponse with a TurnBody body (kind ${TURN_BODY_KINDS.join("|")}) — ${BOUND_RESPONSE_SHAPE}`, v); } return v as unknown as BoundResponse; } /** * VALIDATE a REQUIRED string arg — `artifact.ref`, `resumeOperation.operationId`, `validate.document` * (kestrel-8h9f). An ABSENT value (`undefined`, or an empty `""` string) is a malformed ARGUMENT: the * `describe` catalogue marks the field `required: true`, and forwarding the absent value as `""` made the * server answer a MISDIRECTING `not-found` (404) / `422` instead of naming the omitted field. Refuse it * client-side as `bad-arg` naming the described shape, matching `openSession.subject` / `resume.after`. */ export function asRequiredString(v: unknown, field: string, shape: string): string { if (typeof v !== "string" || v.length === 0) { throw new ArgShapeError(field, shape, v); } return v; } /** * VALIDATE `grade.blotters` — a REQUIRED non-empty array of Blotter id strings (kestrel-8h9f). An absent / * empty / non-string-element array is a malformed argument refused `bad-arg` naming the shape, rather than the * server-side `empty-grade` domain refusal (inconsistent validation locus with the client-side arg checks). */ export function asBlotters(v: unknown, field = "blotters"): readonly string[] { if (!Array.isArray(v) || v.length === 0 || !v.every((x) => typeof x === "string")) { throw new ArgShapeError(field, BLOTTERS_SHAPE, v); } return v as readonly string[]; } /** * VALIDATE an optional resume `after` cursor. ABSENT (`undefined`) is fine (resume from the start); a PRESENT * one must be a well-formed {@link EventCursor} `{ sequence:number, token:string }`. A malformed present * cursor is a bad ARGUMENT SHAPE (never silently ignored). STRICT: `null` is a present, wrong-typed value and * fails closed (deliberately NOT coalesced to undefined). */ export function asEventCursor(v: unknown, field = "after"): EventCursor | undefined { if (v === undefined) return undefined; if (!isRecord(v) || typeof v["sequence"] !== "number" || typeof v["token"] !== "string") { throw new ArgShapeError(field, `an EventCursor — ${EVENT_CURSOR_SHAPE}`, v); } return v as unknown as EventCursor; }