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.

373 lines (342 loc) 18.8 kB
/** * # sdk/remote — the HTTP transport: the 109 client over the 5rb wire (kestrel-djm.5) * * The LIGHT transport. `remoteTransport({ baseUrl, fetch })` binds the kestrel-109 {@link KestrelClient} * (mint / validate / sim / grade over the 5rb wire dialect) and re-exposes it under the ONE typed SDK * contract. Catalog discovery / validation / grade delegate to the client verbatim; an incremental Session * is a CLIENT-SIDE REPLAY of the server-computed, catalog-pinned transcript the `POST /sim` stream carries * (the wire deliberately omits an `advance` — a face is a pure encoder over the controller, never a forked * progression). Because the server ran the SAME engine the LOCAL transport runs, the replayed pentad chain / * Blotter / grade / root are BYTE-IDENTICAL to the LOCAL run (the djm.5 headline). * * FAIL-CLOSED, PROTOCOL-UNIFORM: a 402/Offer is DATA on {@link Gated} (surfaced, never thrown / redirected); * an unknown subject is a typed {@link KestrelClientError} (the 109 client's non-2xx throw); an off-contract * SSE event is the 109 client's `SSE_PROTOCOL_VIOLATION` (fail-closed, never laundered into a face state); * a replay turn that diverges from the recorded script is a typed djm.4 {@link SessionResponse} refusal. * * DEPENDENCY BOUNDARY (djm.3 / djm.5): the ONLY value import is the 109 client (+ its re-exported wire * types) and this SDK's light core (`facade.ts` / `types.ts`). Every djm.2/djm.4 shape is a TYPE-ONLY import * (erased). NO local runtime — no djm.4 controller, no simulate/sim driver, no engine tree, no djm.8 loader * — reaches this graph. Pinned by tests/package.boundary.test.ts. * * ── DESIGN FORK LOGGED (hard rules forbid .beads writes) — the HTTP Session is a server-run REPLAY ── * A catalog Session's SUBJECT is a FIXED, server-pinned script (arm-once/pass-forever). The HTTP transport * runs it server-side ONCE at `openSession` (`POST /sim` resolves the catalog dataset to its pre-computed * record) and REPLAYS the record verb-by-verb: `start`/`advance` deliver the recorded Frames and return the * recorded pentad-chained {@link import("../protocol/session.ts").TurnEntry}s verbatim (never re-sealed — the * server's entries ARE the truth), verifying each caller turn against the recorded script (a divergent turn * is a typed `turn-conflict` refusal — fail-closed). This mirrors "HTTP is canonical; the SDK is a lossless * projection" (djm.5 design). The minimal shape consistent with djm.4's Delivery/SessionResponse. */ import { KestrelClient, KestrelClientError } from "../client/index.ts"; import type { FetchLike, TrialCapability } from "../client/index.ts"; import type { Blotter } from "../protocol/index.ts"; import { decodeCatalogPage } from "../catalog/catalog-listing.ts"; import type { SessionDiagnostic, SessionId, TurnBody, TurnEntry } from "../protocol/session.ts"; import type { AuthoredResponse, BoundResponse, Delivery, SessionFrame, SessionResponse, SessionTranscript, TurnDisposition, } from "../session/controller-types.ts"; import { SDK_VERSION, createSdk } from "./facade.ts"; import type { ArtifactResult, Gated, GradeOutcome, GradeRequest, KestrelSession, OperationRef, OperationResumption, SdkFinalized, SessionRef, Transport, ValidateOutcome, } from "./types.ts"; // Re-export the light SDK core so a remote-only consumer imports EVERYTHING it needs from this ONE light // entry: `import { createSdk, remoteTransport } from "kestrel.markets/sdk/remote"`. export { createSdk, SDK_VERSION } from "./facade.ts"; export type { KestrelSdk, KestrelSession, SessionRef, SdkFinalized, GradeRequest, ArtifactResult, OperationRef, OperationResumption, Transport, Gated, GradeOutcome, ValidateOutcome, } from "./types.ts"; /** Options for {@link remoteTransport}: the managed API base + an optional `fetch` override (tests / a * custom agent) + an optional pre-seeded trial capability. Mirrors the 109 client's `ClientOptions`. */ export interface RemoteTransportOptions { readonly baseUrl: string; readonly fetch?: FetchLike; readonly capability?: TrialCapability; } /** The server-computed Session record the `POST /sim` stream carries for a catalog subject — the truth the * HTTP transport replays. Structurally the wire form of a baked catalog-records.ts record (the wire is the * contract between server and client; the client does not import the server fixture). */ interface WireSessionRecord { readonly sessionId: string; readonly blotter: Blotter; readonly tipHash: string; readonly conformanceRoot: string; readonly artifacts: readonly string[]; readonly entries: readonly WireTurnEntry[]; readonly frames: readonly unknown[]; } interface WireTurnEntry { readonly kind: "turn"; readonly sessionId: string; readonly ordinal: number; readonly parentHash: string; readonly frameRoot: string; readonly authoredSha256: string; readonly body: TurnBody; readonly entryHash: string; } const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null; /** The disposition a replayed response earns (the djm.4 receipt label; not asserted by the parity test but * kept honest). */ function dispositionOf(r: AuthoredResponse): TurnDisposition { switch (r.kind) { case "authored": return "armed"; case "pass": return "pass"; case "stand-down": return "stood-down"; } } /** * The RECORDED body a caller response must match for a faithful replay. Returns `null` on a match, else the * typed djm.4 diagnostic for the divergence (fail-closed — the HTTP replay honors only the server-pinned * script; a divergent turn cannot be re-run against a fixed server transcript). */ function matchOrDiagnose(body: TurnBody, r: AuthoredResponse): SessionDiagnostic | null { switch (body.kind) { case "authored": // An empty authored body is a `pass` (a no-op continuation); a non-empty one is an authored document. if (body.bytes === "") return r.kind === "pass" ? null : "turn-conflict"; return r.kind === "authored" && r.document === body.bytes ? null : "turn-conflict"; case "stand-down": return r.kind === "stand-down" && r.reason === body.reason ? null : "turn-conflict"; case "failure": // A recorded host failure has no re-authorable content; only a `pass` continues past it. return r.kind === "pass" ? null : "turn-conflict"; } } /** * A HTTP Session — a client-side REPLAY of the server-computed, catalog-pinned record. Every returned object * is the server's verbatim djm.2/djm.4 protocol shape (byte-identical to the LOCAL run). */ class RemoteSession implements KestrelSession { readonly sessionId: SessionId; private cursor = 0; // index of the next uncommitted recorded turn private started = false; constructor(private readonly record: WireSessionRecord) { this.sessionId = record.sessionId as SessionId; } async start(): Promise<Delivery> { if (this.started) throw new KestrelClientError({ code: "ALREADY_STARTED", message: "start() may only be called once, first" }); this.started = true; const first = this.deliveryAt(0); if (first === null) throw new KestrelClientError({ code: "EMPTY_TRANSCRIPT", message: "the server returned an empty Session transcript" }); return first; } advance(response: AuthoredResponse): Promise<SessionResponse> { return Promise.resolve(this.commit(response)); } revise(response: AuthoredResponse): Promise<SessionResponse> { return Promise.resolve(this.commit(response)); } async submit(response: BoundResponse): Promise<SessionResponse> { const entry = this.record.entries.find((e) => e.ordinal === response.ordinal); if (entry === undefined) return { ok: false, diagnostic: "broken-chain" }; if (response.sessionId !== this.sessionId) return { ok: false, diagnostic: "wrong-session" }; if (response.parentHash !== entry.parentHash) return { ok: false, diagnostic: "broken-chain" }; if (response.frameRoot !== entry.frameRoot) return { ok: false, diagnostic: "stale-frame" }; if (!sameBody(entry.body, response.body)) return { ok: false, diagnostic: "turn-conflict" }; const receipt = { entry: entry as unknown as TurnEntry, disposition: bodyDisposition(entry.body) }; const idx = this.record.entries.indexOf(entry); const next = this.deliveryAt(idx + 1); return { ok: true, receipt, next }; } async resume(): Promise<SessionTranscript> { return { sessionId: this.sessionId, entries: this.record.entries as unknown as readonly TurnEntry[] }; } async finalize(): Promise<SdkFinalized> { return { blotter: this.record.blotter, sessionId: this.sessionId, tipHash: this.record.tipHash, conformanceRoot: this.record.conformanceRoot, artifacts: this.record.artifacts, }; } private deliveryAt(i: number): Delivery | null { const entry = this.record.entries[i]; if (entry === undefined) return null; return { sessionId: this.sessionId, ordinal: entry.ordinal, parentHash: entry.parentHash, frameRoot: entry.frameRoot, frame: this.record.frames[i] as SessionFrame, }; } private commit(response: AuthoredResponse): SessionResponse { const entry = this.record.entries[this.cursor]; if (entry === undefined) return { ok: false, diagnostic: "not-pending" }; const mismatch = matchOrDiagnose(entry.body, response); if (mismatch !== null) return { ok: false, diagnostic: mismatch }; this.cursor += 1; const receipt = { entry: entry as unknown as TurnEntry, disposition: dispositionOf(response) }; return { ok: true, receipt, next: this.deliveryAt(this.cursor) }; } } /** Structural body equality for the explicit-binding submit path (no re-seal). */ function sameBody(a: TurnBody, b: TurnBody): boolean { if (a.kind !== b.kind) return false; if (a.kind === "authored" && b.kind === "authored") return a.bytes === b.bytes; if (a.kind === "stand-down" && b.kind === "stand-down") return a.reason === b.reason; if (a.kind === "failure" && b.kind === "failure") return a.failureClass === b.failureClass; return false; } /** The disposition of a recorded body (the submit path's receipt label). */ function bodyDisposition(body: TurnBody): TurnDisposition { switch (body.kind) { case "authored": return body.bytes === "" ? "pass" : "armed"; case "stand-down": return "stood-down"; case "failure": return "failure"; } } /** Resolve a {@link SessionRef} (+ an optional caller-supplied strategy `document`) to the `POST /sim` inputs. * A catalog subject rides its `id` DIRECTLY as `dataset.artifact_id` — the BARE platform artifact handle the * server resolves against its catalog store; a raw dataset carries its own `source` + `dataset`. * * kestrel-p6hz: the `catalog:` PREFIX this used to prepend (`dataset: "catalog:" + subject.id`) is a * CLIENT-INVENTED namespace the deployed server never speaks — `POST /sim` with `dataset.artifact_id = * "catalog:art_…"` fail-closes `HTTP_404 'dataset catalog:art_… is not present in the catalog store'`, while * the BARE `dataset.artifact_id = "art_…"` opens cleanly (both verified against live prod). So a discovered * free catalog row 404'd through the agent face even though the same artifact opens over raw HTTP — the * equal-projections violation. Sending the bare handle removes the prefix defect; the SimRequest's * `artifact_id` is a bare Id (the vendored OpenAPI contract), and `catalog()` now surfaces exactly that bare * handle as each row's `id`, so `openSession({ kind:"catalog", id: row.id })` is self-consistent end-to-end. * * The optional `document` is the customer strategy the managed author-no-strategy fence requires (ADR-0012, * kestrel-88u2) — matching the sim verb's `--plans` semantics: when present it is the sim `source` (for a * catalog subject, whose `source` is structurally `""` otherwise; for a dataset subject it overrides the * ref's own source). A bare openSession (no document) keeps `source:""`: the platform's fence answers, now * legibly (Bug A). */ function simInputs(subject: SessionRef, document?: string): { source: string; dataset: string } { return subject.kind === "catalog" ? { source: document ?? "", dataset: subject.id } : { source: document ?? subject.source, dataset: subject.dataset }; } /** * Build the HTTP transport over the managed API. Wrap it with `createSdk(remoteTransport({ baseUrl, fetch }))`. * Delegates catalog / validate / grade to the 109 client; runs + replays a catalog Session's server-computed * transcript for the incremental surface. Fail-closed and protocol-uniform throughout. */ export function remoteTransport(options: RemoteTransportOptions): Transport { const base = options.baseUrl.replace(/\/+$/, ""); const fetchImpl: FetchLike = options.fetch ?? (globalThis.fetch as FetchLike); const client = new KestrelClient({ baseUrl: options.baseUrl, ...(options.fetch !== undefined ? { fetch: options.fetch } : {}), ...(options.capability !== undefined ? { capability: options.capability } : {}), }); return { kind: "http", version: `${SDK_VERSION}+http`, async catalog() { const res = await fetchImpl(`${base}/catalog`, { headers: { accept: "application/json" } }); if (!res.ok) { throw new KestrelClientError({ code: `HTTP_${res.status}`, message: `GET /catalog failed (HTTP ${res.status})`, status: res.status }); } // FAITHFUL wire decode into the canonical CatalogPage (kestrel-adge) — the byte-identical // CatalogListing[] (kestrel-dnxq; `artifact_id → id`, `frame_count → frameCount`, drop the // `content_hash` replay pin, fail-closed on an unaddressable row) PLUS the optional ex-ante `pricing` // block surfaced VERBATIM when the served `{ entries, pricing }` body carries one (absent ⇒ a bare page). return decodeCatalogPage(await res.json()); }, validate(document): Promise<ValidateOutcome> { return client.validate(document); }, async openSession(subject, document): Promise<Gated<KestrelSession>> { const { source, dataset } = simInputs(subject, document); let record: WireSessionRecord | undefined; const opened = await client.sim({ source, dataset, onEvent: (e) => { const rec = e.data?.["session"]; if (isRecord(rec)) record = rec as unknown as WireSessionRecord; }, }); if (opened.gated) return { gated: true, payment: opened.payment }; if (record === undefined) { // A 2xx sim that carried no Session record cannot back an incremental replay — fail closed, never // fabricate a Session (kestrel-markets-vuiy: the managed transport ran the subject as a BATCH // simulation but emits no incremental Session transcript to replay; managed incremental openSession // over a catalog subject is not yet a served surface — tracked cross-repo). Rather than the opaque // "not an incremental subject" (which contradicted describe's own catalog contract), the refusal now // POINTS AT THE BATCH PATH the same subject DOES run on, so a wire-first integrator is redirected // instead of stranded. The self-hosted LOCAL transport still opens the SAME catalog subject // incrementally today (`createSdk(localTransport())`). const subjectClause = subject.kind === "catalog" ? `the managed transport does not (yet) serve an incremental Session over catalog subject ${JSON.stringify(subject.id)} — the /sim stream carried no Session transcript to replay. ` : "the /sim stream carried no Session transcript to replay for this subject. "; throw new KestrelClientError({ code: "NO_SESSION_RECORD", message: subjectClause + "Run it as a BATCH simulation instead: the `sim` verb, or the SDK `client.sim({ source, dataset })` — the same drive to a Certified grade, minus the incremental start/advance/finalize surface. (The self-hosted LOCAL transport opens catalog subjects incrementally today.)", }); } return { gated: false, value: new RemoteSession(record) }; }, grade(request: GradeRequest): Promise<Gated<GradeOutcome>> { return client.grade({ blotters: request.blotters }); }, async artifact(ref: string): Promise<ArtifactResult> { const res = await fetchImpl(`${base}/artifacts/${encodeURIComponent(ref)}`, { headers: { accept: "application/json" } }); if (!res.ok) { throw new KestrelClientError({ code: `HTTP_${res.status}`, message: `artifact ${JSON.stringify(ref)} not resolvable (HTTP ${res.status})`, status: res.status }); } // The server returns the SAME { ref, kind, value } the LOCAL projection yields (src/sdk/local.ts // artifact()); the transport re-exposes it verbatim so both faces return a byte-identical ArtifactResult. const body = (await res.json()) as { kind?: unknown; value?: unknown }; return { ref, kind: typeof body.kind === "string" ? body.kind : "artifact", value: body.value }; }, async resumeOperation(ref: OperationRef): Promise<OperationResumption> { // A COMPLETED operation's OUTCOME (blotter / report / receipt + artifact refs) is carried on its // CANONICAL RESUMABLE EVENT STREAM (`GET /operations/{id}/events`) — the SAME stream `sim`/`grade` // consume — NOT on the plain `GET /operations/{id}` status envelope, which carries NO domain payload for // a completed op in production (it returns empty, so the old plain-endpoint read handed a wire-first // integrator an EMPTY payload — kestrel-o3bi: the contract mock had masked this by fabricating a payload // the real endpoint never serves). Replay the event stream (resumable via the opaque `after` cursor) and // accumulate the terminal payload, so a resumed Operation returns the SAME { blotter, grade, artifacts } // the LOCAL projection yields (src/sdk/local.ts resumeOperation) — byte-identical across transports (the // djm.5 parity headline), now over the stream production actually serves. The stream is read to its // TERMINAL event, so the resumption cursor is `null` (nothing remains to resume) — matching LOCAL. const { payload } = await client.resumeOperation({ operationId: ref.operationId, ...(ref.after?.token !== undefined ? { after: ref.after.token } : {}), }); return { operationId: ref.operationId, cursor: null, payload }; }, }; }