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.
279 lines • 16 kB
TypeScript
/**
* # 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 type { TrialCapability, Operation, Blotter, GradeResult, CertifiedGrade } from "../protocol/index.ts";
import { type WireOfferResponse } from "../cli/backend/wire.ts";
import { type OperationEventView } from "./platform-wire.ts";
/** 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.ts";
export type { OperationEventView } from "./platform-wire.ts";
/** The web `fetch` shape the client speaks. Inject {@link contractMockFetch} (or
* any conforming stand-in) for offline / CI use; defaults to `globalThis.fetch`. */
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
export interface ClientOptions {
/** API base URL; defaults to {@link DEFAULT_API}. Trailing slashes are trimmed. */
readonly baseUrl?: string;
/** Transport override (tests / CI / a custom agent). Defaults to global fetch. */
readonly fetch?: FetchLike;
/** Pre-seeded capability; otherwise one is minted lazily on first effectful call. */
readonly capability?: TrialCapability;
/**
* A pre-resolved bearer token from a REGISTERED-agent credential (`kestrel register`,
* PLAT-ADR-0021 tier 1). When present it authorizes every call and NO anonymous trial
* is minted — the registered durable capability supersedes the anon path.
*/
readonly bearer?: string;
}
/**
* A control-plane result that may be gated behind a structured 402/Offer. The
* gated payload is the platform's OfferResponse ({@link WireOfferResponse}) —
* snake_case + {@link ../cli/backend/wire.ts WireMoney}) — surfaced as DATA
* (ADR-0002/0004), never a browser redirect and never a thrown error. Its
* `offer` prices the conversion; its `proof` is the free result already earned
* under the trial. Switch on `.gated`. Mirrors the CLI backend's `Gated<T>`.
*/
export type Gated<T> = {
readonly gated: false;
readonly value: T;
} | {
readonly gated: true;
readonly payment: WireOfferResponse;
};
/** A plan submitted to `POST /sim`. `source` is Kestrel plan text (the server
* parses it); `dataset` is a catalog artifact id (the bus/data reference). */
export interface SimInput {
readonly source: string;
readonly dataset: string;
readonly params?: {
readonly fillModel?: string;
readonly rUsd?: number;
};
/** Observe the operation's SSE events as they arrive (progress/artifact/…). */
readonly onEvent?: (event: OperationEventView) => void;
}
/** The completed sim: the operation identity + its certified artifacts. */
export interface SimResult {
/** Server-assigned control-plane identity of the run (decoded {@link Operation}). */
readonly operation: Operation;
/** The certified execution record — the WHAT HAPPENED receipt (present on success). */
readonly blotter?: Blotter;
/** The opaque domain report payload (not part of the wire protocol). */
readonly report?: unknown;
}
/** A grade submitted to `POST /grade`: the Blotter artifact ids to judge (a
* Blotter's `sessionId` is its artifact id). */
export interface GradeInput {
readonly blotters: readonly string[];
/** Observe the operation's SSE events as they arrive. */
readonly onEvent?: (event: OperationEventView) => void;
}
/** A resume of a durable Operation (`GET /operations/{id}/events`): the operation id + the
* opaque `after` cursor to replay strictly after (absent ⇒ from the beginning). No side
* effect — an existing Operation is replayed, never a new one minted. */
export interface ResumeOperationInput {
readonly operationId: string;
/** Replay strictly after this opaque cursor (absent ⇒ from the beginning). */
readonly after?: string;
/** Observe the operation's SSE events as they arrive. */
readonly onEvent?: (event: OperationEventView) => void;
}
/** The judged verdict over the graded Blotter(s): the portable {@link GradeResult}
* or, when the platform seals it, the signed {@link CertifiedGrade} receipt. */
export type GradeOutcome = GradeResult | CertifiedGrade;
/** Result of the pure `POST /validate` (no Operation): `ok` + diagnostics. */
export interface ValidateOutcome {
readonly ok: boolean;
/** Validated artifact id, when the server persists the plan. */
readonly artifactId?: string;
readonly diagnostics: readonly string[];
}
/**
* The server's diagnostic body carried through a {@link KestrelClientError} when a
* non-2xx response ships `application/problem+json` (RFC 9457) — or any problem-shaped
* JSON (a body carrying `title` / `detail` / `code`). The platform's refusals are
* exemplary here (e.g. the author-no-strategy fence: `{ title: "A customer strategy is
* required", code: "validation_failed", detail: "author-no-strategy: …ADR-0012" }`);
* this is the structured reason the CLIENT drops today. Every field is optional — a
* partial body carries what it can; an absent/unparseable body yields no `problem` at
* all (the error degrades to its bare transport message, never throws). Agents read
* these fields off the JSONL refusal envelope; a human reads `title`/`detail` in the
* message.
*/
export interface ProblemDetails {
/** The short, human-readable summary of the problem (RFC 9457 `title`). */
readonly title?: string;
/** The problem-specific explanation (RFC 9457 `detail`). */
readonly detail?: string;
/** The stable DOMAIN code the server assigned (e.g. `validation_failed`) — distinct
* from the error's transport `code` (`HTTP_422`). */
readonly code?: string;
/** Any remediation hint the server offered (`remediation` / `remedy` / `fix`). */
readonly remediation?: string;
/** The HTTP status the body accompanied. */
readonly status?: number;
}
/**
* 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 declare class KestrelClientError extends Error {
readonly name = "KestrelClientError";
readonly code: string;
readonly status?: number;
readonly problem?: ProblemDetails;
constructor(o: {
code: string;
message: string;
status?: number;
problem?: ProblemDetails;
});
}
/** The opaque domain payload accumulated from an Operation's artifact/receipt SSE
* events (the protocol pins only the envelope; the body rides in `data`). */
type StreamPayload = Record<string, unknown>;
/**
* 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 declare class KestrelClient {
private readonly base;
private readonly fetchImpl;
private cap?;
private bearer?;
constructor(opts?: ClientOptions);
/** 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. */
private ensureBearer;
/** `POST /capabilities/trial` — mint (once, then cached) an anonymous trial
* capability; its bearer token authorizes every subsequent call. Effectful →
* carries a deterministic Idempotency-Key. */
mint(): Promise<TrialCapability>;
/** `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. */
validate(source: string): Promise<ValidateOutcome>;
/** `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). */
sim(input: SimInput): Promise<Gated<SimResult>>;
/** `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). */
grade(input: GradeInput): Promise<Gated<GradeOutcome>>;
/**
* 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.
*/
resumeOperation(input: ResumeOperationInput): Promise<{
payload: StreamPayload;
}>;
private effectfulPost;
/** 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). */
private consumeStream;
private authHeader;
}
/**
* 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.ts";
/** The domain types the client returns — re-exported so consumers need only one
* import. The full protocol is `kestrel.markets/protocol`. */
export type { TrialCapability, Operation, Blotter, GradeResult, CertifiedGrade } from "../protocol/index.ts";
/** The structured 402/Offer wire shapes (the exact contract bodies surfaced as
* DATA on a {@link Gated} result). The full dialect is in `wire.ts`. */
export type { WireOfferResponse, WireOffer, WireProof, WireMoney, WireSettlementMethod, SseEventType, } from "../cli/backend/wire.ts";
//# sourceMappingURL=index.d.ts.map