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
text/typescript
/**
* # 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;
}