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.
357 lines (356 loc) • 18.3 kB
JavaScript
/**
* # kestrel.markets/client — the launch-min programmatic client (kestrel-109).
*
* The SDK face of the "four equal faces" (http / sdk / cli / mcp — ADR-0004). A
* small, dependency-light client for the day-one programmatic flow:
*
* mint a trial capability → run a sim → stream the operation → read the
* proof / receipt (the certified Blotter, and the judged Grade over it).
*
* It speaks the SAME canonical platform contract the CLI's `RemoteBackend`
* (`src/cli/backend/remote.ts`, post-kestrel-5rb) speaks, byte-for-byte, because
* it decodes through the ONE shared wire dialect in `src/cli/backend/wire.ts`
* (snake_case bodies, {@link WireMoney} Money, opaque-STRING cursor, the
* `operation.*` SSE vocabulary). Sharing `wire.ts` is what makes drift
* impossible: there is exactly one definition of the shapes / SSE event names,
* pinned to the platform OpenAPI fixture by the conformance suite — this client
* cannot re-fabricate the old divergent dialect (the failure kestrel-5rb killed).
*
* ## Why this is a standalone module, not a wrapper over RemoteBackend
*
* `RemoteBackend` implements the CLI-internal `ExecutionBackend` seam, whose
* argument/result types (SessionArgs/EpisodeReport/FillModelName/WakeRecord/…)
* transitively type-import `src/cli/heavy.ts` → `src/session/*` → the engine /
* ledger tree, which value-imports `bun:sqlite` and `Bun.*`. The published
* library is emitted by `tsconfig.build.json` with `types: []` (zero built-ins),
* so pulling ANY of that graph into the client's emit closure fails the build.
* `remote.ts` itself also relies on web ambient types the library build only gets
* via the `DOM` lib. So a thin wrapper — or even a bare re-export — of
* `RemoteBackend` is NOT buildable as a public library entry point.
*
* This module therefore reuses the DIALECT (`wire.ts`) and the DOMAIN types
* (`src/protocol`) — both zero-dependency — and mirrors `remote.ts`'s HTTP/SSE
* plumbing (deterministic Idempotency-Key, opaque-cursor SSE resume, structured
* 402/Offer surfaced as DATA, fail-closed on unknown SSE events). It imports
* NOTHING from `src/cli`. The public surface is clean `src/protocol` types.
*
* CLEANUP FOR kestrel-djm.5 (the full dual-transport SDK, POST-LAUNCH):
* - DONE (kestrel-bsn8): the SSE framing + opaque-cursor resume loop is no longer
* mirrored here — this client and `remote.ts` share the ONE library-safe transport
* leaf `./sse-transport.ts`; the CLI's `../cli/backend/sse.ts` is a thin adapter over
* the same leaf.
* - DONE (kestrel-z473.2): the whole platform-wire DECODE surface — `DEFAULT_API`, the
* Idempotency-Key, the JSON/auth headers, the trial mint, the effectful 402-gated POST,
* the 402/Offer decode, and the OperationEvent SSE decode/accumulate loop — is now the
* ONE library-safe module {@link ./platform-wire.ts}. This face is a thin adapter that
* supplies its {@link KestrelClientError} factories and owns caching; it no longer
* hand-mirrors any of the dialect, so `remote.ts` can never drift from it.
* - `contractMockFetch` is re-exported from `src/cli/backend/mock.ts` (the only
* `src/cli` coupling, and it is library-safe); relocate the mock to a neutral
* home (e.g. `src/client/mock.ts`) so the client owns its own test double.
* - Decouple `remote.ts` from `heavy.ts` so the CLI backend can itself be built
* on top of this client instead of the reverse.
*
* Determinism: no wall clock, no RNG — the Idempotency-Key is a pure sha256 of
* the canonical request, so a replay is effect-once (ADR-0009). node ≥18 + bun +
* Workers: web `fetch`/`ReadableStream`/`TextDecoder` only, no native deps.
*/
import { decodeOperation, } from "../cli/backend/wire.js";
import { DEFAULT_API, jsonHeaders, idemKey, bearerHeader, decodeOfferResponse, parseValidateDiagnostics, mintTrialCapability, postEffectful, consumeOperationStream, } from "./platform-wire.js";
/** The canonical managed API base — the ONE definition, owned by
* {@link ./platform-wire.ts platform-wire} and re-exported here so a bare/empty base
* resolves identically across every face (kestrel-z473.2). */
export { DEFAULT_API } from "./platform-wire.js";
/**
* A fail-closed client error with a stable `code` (agents match on `code`, not
* prose) and the originating HTTP `status` when there is one. A structured 402 is
* NOT an error — it is returned as {@link Gated} data; everything else off the
* happy path (non-2xx, non-JSON, unknown SSE event, missing artifact) is a loud,
* typed throw. Never silent, never a default. When the non-2xx body carried
* `application/problem+json` (or problem-shaped JSON), the parsed {@link ProblemDetails}
* ride on `problem` — the server's own diagnostics, preserved through the throw so the
* agent face can surface them (kestrel-3w9r).
*/
export class KestrelClientError extends Error {
name = "KestrelClientError";
code;
status;
problem;
constructor(o) {
super(o.message);
this.code = o.code;
if (o.status !== undefined)
this.status = o.status;
if (o.problem !== undefined)
this.problem = o.problem;
}
}
/**
* The launch-min kestrel.markets client. Construct once, then `mint`/`validate`/
* `sim`/`grade`. The capability is minted lazily on the first effectful call and
* cached; every effectful POST carries a deterministic Idempotency-Key.
*
* @example
* ```ts
* import { KestrelClient } from "kestrel.markets/client";
* const client = new KestrelClient(); // → api.kestrel.markets
* await client.mint(); // trial capability
* const sim = await client.sim({ source, dataset }); // → Gated<SimResult>
* if (!sim.gated) {
* const grade = await client.grade({ blotters: [sim.value.blotter!.sessionId] });
* }
* ```
*/
export class KestrelClient {
base;
fetchImpl;
cap;
bearer;
constructor(opts = {}) {
this.base = (opts.baseUrl ?? DEFAULT_API).replace(/\/+$/, "");
const f = opts.fetch ?? globalThis.fetch;
if (f === undefined)
throw new KestrelClientError({
code: "NO_FETCH",
message: "global fetch unavailable — need node ≥18, bun, or pass { fetch } explicitly",
});
this.fetchImpl = f;
if (opts.capability !== undefined) {
this.cap = opts.capability;
this.bearer = opts.capability.capabilityId;
}
else if (opts.bearer !== undefined) {
// A REGISTERED-agent credential (kestrel-markets-0t0) — present it directly; no anon
// trial is minted. The registered durable capability supersedes the trial path.
this.bearer = opts.bearer;
}
}
/** Ensure a bearer is set before an authorized call. A pre-seeded capability or a
* REGISTERED bearer short-circuits; otherwise an anonymous trial is minted lazily. */
async ensureBearer() {
if (this.bearer !== undefined)
return;
await this.mint();
}
/** `POST /capabilities/trial` — mint (once, then cached) an anonymous trial
* capability; its bearer token authorizes every subsequent call. Effectful →
* carries a deterministic Idempotency-Key. */
async mint() {
if (this.cap)
return this.cap;
const { capability, bearer } = await mintTrialCapability({
fetch: this.fetchImpl,
base: this.base,
onHttpError: httpErrFrom,
});
this.bearer = bearer; // the bearer token (openapi: `capability`)
this.cap = capability;
return this.cap;
}
/** `POST /validate` — the pure, deterministic parse/validate. No Operation, no
* side effect (no Idempotency-Key). Fail-closed: a 422 surfaces diagnostics
* with `ok: false`, never a 200 hiding a silent default. */
async validate(source) {
await this.ensureBearer();
const res = await this.fetchImpl(`${this.base}/validate`, {
method: "POST",
headers: { ...jsonHeaders(), ...this.authHeader() },
body: JSON.stringify({ source }),
});
if (res.status === 422)
return { ok: false, diagnostics: await parseValidateDiagnostics(res) };
if (!res.ok)
throw await httpErrFrom(res, "POST /validate failed");
const wire = (await res.json());
return {
ok: wire.valid === true,
...(wire.artifact_id !== undefined ? { artifactId: wire.artifact_id } : {}),
diagnostics: (wire.diagnostics ?? []).map((d) => `${d.severity} ${d.code}: ${d.message}`),
};
}
/** `POST /sim` — create an Operation and run the deterministic Session; the
* report/Blotter arrive over the operation's SSE stream, which this consumes to
* a terminal event. Returns the {@link SimResult}, or — at the paid-dataset
* boundary — a {@link Gated} 402/Offer surfaced as DATA (before any work). */
async sim(input) {
const req = {
source: input.source,
dataset: { artifact_id: input.dataset },
params: { fill_model: input.params?.fillModel, r_usd: input.params?.rUsd },
};
const opened = await this.effectfulPost("/sim", req);
if (opened.gated)
return opened; // 402 before any work → the Offer (+ proof) as data
const { payload } = await this.consumeStream(opened.value, input.onEvent);
return {
gated: false,
value: {
operation: decodeOperation(opened.value),
...(payload["blotter"] ? { blotter: payload["blotter"] } : {}),
...(payload["report"] !== undefined ? { report: payload["report"] } : {}),
},
};
}
/** `POST /grade` — create an Operation grading one or more Blotter artifacts; the
* certified verdict arrives over the operation's SSE stream. May 402 (a grade
* scope beyond the trial). */
async grade(input) {
const req = { blotters: input.blotters };
const opened = await this.effectfulPost("/grade", req);
if (opened.gated)
return opened;
const { payload } = await this.consumeStream(opened.value, input.onEvent);
const result = (payload["grade"] ?? payload["result"]);
if (result === undefined)
throw httpErr(502, "grade stream carried no grade artifact");
return { gated: false, value: result };
}
/**
* Resume a durable Operation's OUTCOME by REPLAYING its canonical event stream
* (`GET /operations/{id}/events`) — the SAME resumable stream {@link sim}/{@link grade}
* consume — to its terminal event, accumulating the domain payload from
* `operation.artifact` / `operation.receipt` frames. This is how a COMPLETED operation's
* outcome (blotter / report / receipt + artifact refs) is read back over the wire: it is
* carried on the EVENT stream, NOT on the plain `GET /operations/{id}` status envelope
* (which carries no domain payload for a completed op — the empty-payload defect a
* wire-first resume hit, kestrel-o3bi). `after` is the opaque resume cursor (absent ⇒ from
* the beginning); an off-vocabulary frame / `operation.failed` / a bad status is a loud,
* fail-closed throw, never a silent empty payload. No new side effect (a pure GET, no
* Idempotency-Key), so no Operation is minted — an existing one is replayed.
*/
async resumeOperation(input) {
await this.ensureBearer();
return consumeOperationStream({
fetch: this.fetchImpl,
base: this.base,
operationId: input.operationId,
cursor: input.after ?? "", // opaque STRING (never `.token` of a JSON object); "" ⇒ from the beginning
headers: this.authHeader(),
httpError: httpErr, // fail closed as KestrelClientError, not CliError
unknownEventError: unknownEvent,
...(input.onEvent !== undefined ? { onEvent: input.onEvent } : {}),
});
}
// ── HTTP core: effectful POST (Idempotency-Key) with structured-402 gating ──
async effectfulPost(path, body) {
await this.ensureBearer();
return postEffectful({
fetch: this.fetchImpl,
base: this.base,
path,
body,
authHeaders: this.authHeader(),
httpError: httpErr, // fail closed as KestrelClientError, not CliError
onHttpError: httpErrFrom,
});
}
// ── SSE: GET /operations/{id}/events, opaque-string cursor, operation.* events ──
/** Read the Operation's canonical event stream to a terminal event, accumulating the
* domain payload from artifact/receipt events. The whole decode/accumulate loop is the
* ONE shared transport helper ({@link consumeOperationStream} in
* {@link ./platform-wire.ts}); this face supplies only its {@link KestrelClientError}
* factories and the optional observer. Resumable + fail-closed on an off-vocabulary
* event (both properties belong to the shared helper). */
consumeStream(op, onEvent) {
return consumeOperationStream({
fetch: this.fetchImpl,
base: this.base,
operationId: op.operation_id,
cursor: op.cursor,
headers: this.authHeader(),
httpError: httpErr, // fail closed as KestrelClientError, not CliError
unknownEventError: unknownEvent,
...(onEvent !== undefined ? { onEvent } : {}),
});
}
authHeader() {
return bearerHeader(this.bearer);
}
}
/* ---------- helpers (module-scope; node + bun + workers) ---------- */
function httpErr(status, m) {
return new KestrelClientError({ code: `HTTP_${status}`, message: `${m} (HTTP ${status})`, status });
}
/**
* Build a fail-closed error from a non-2xx {@link Response}, ENRICHED with any
* `application/problem+json` (or problem-shaped JSON) diagnostics the server sent — the
* reason the wire already carried (kestrel-3w9r). The reason is folded into the message
* (`… (HTTP 422): <title> — <detail>`) AND preserved as structured {@link ProblemDetails}
* on the error. FAIL-SAFE: this consumes the body inside a try; an absent, non-JSON, or
* non-problem-shaped body degrades to the bare {@link httpErr} message and NEVER throws
* inside error handling (an error path that throws would mask the original failure).
*/
async function httpErrFrom(res, m) {
const problem = await readProblem(res);
if (problem === undefined)
return httpErr(res.status, m);
const parts = [problem.title, problem.detail].filter((s) => s !== undefined);
const suffix = parts.length > 0 ? `: ${parts.join(" — ")}` : "";
return new KestrelClientError({
code: `HTTP_${res.status}`,
message: `${m} (HTTP ${res.status})${suffix}`,
status: res.status,
problem,
});
}
/**
* Parse a non-2xx body into {@link ProblemDetails}, or `undefined` when there is nothing
* problem-shaped to carry. Accepts `application/problem+json` AND any JSON object bearing
* a `title` / `detail` / `code` (or a remediation) — the server's diagnostics regardless
* of the exact content-type. Fail-safe: any read/parse failure returns `undefined` (the
* caller degrades to the bare message); it never throws.
*/
async function readProblem(res) {
try {
const body = await res.json();
if (body === null || typeof body !== "object")
return undefined;
const b = body;
// Bound each passed-through diagnostic string: a server (or a MITM'd error page) must not be able to
// balloon the refusal envelope / error message with an unbounded body. 2 KiB per field comfortably holds
// every real platform diagnostic; anything longer is truncated with a marker (never dropped silently).
const MAX_FIELD = 2048;
const str = (v) => {
if (typeof v !== "string" || v.length === 0)
return undefined;
return v.length > MAX_FIELD ? `${v.slice(0, MAX_FIELD)}… [truncated]` : v;
};
const title = str(b["title"]);
const detail = str(b["detail"]);
const code = str(b["code"]);
const remediation = str(b["remediation"]) ?? str(b["remedy"]) ?? str(b["fix"]);
// A body is "problem-shaped" only if it carries at least one diagnostic field — otherwise
// there is nothing to surface and we degrade to the bare transport message.
if (title === undefined && detail === undefined && code === undefined && remediation === undefined) {
return undefined;
}
return {
...(title !== undefined ? { title } : {}),
...(detail !== undefined ? { detail } : {}),
...(code !== undefined ? { code } : {}),
...(remediation !== undefined ? { remediation } : {}),
status: res.status,
};
}
catch {
return undefined; // absent / non-JSON / truncated body → degrade, never throw
}
}
/** Fail-closed error for an SSE event whose `type` is outside the OperationEvent
* enum (a protocol drift). Loud, never silently ignored (AGENTS.md non-negotiable). */
function unknownEvent(type) {
return new KestrelClientError({
code: "SSE_PROTOCOL_VIOLATION",
message: `unknown SSE event type ${JSON.stringify(type)} — not in the OperationEvent contract vocabulary`,
});
}
/* ---------- re-exports (DX) ---------- */
/**
* An in-process implementation of the kestrel.markets contract as a `fetch`
* stand-in — for local development, the examples, and CI. Pass it as
* `new KestrelClient({ fetch: contractMockFetch })` to exercise the full
* mint→sim→grade flow with NO network and NO secrets. It serves canned artifacts
* over the exact shared wire dialect (`wire.ts`); it is NOT a real backend.
* (The only `src/cli` coupling — relocated to a neutral home in kestrel-djm.5.)
*/
export { mockFetch as contractMockFetch } from "../cli/backend/mock.js";