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.
196 lines (180 loc) • 10.8 kB
text/typescript
/**
* # session/harness/decoding — CONSTRAINED DECODING per lane (bead cfg-earn-retest)
*
* The watcher's whole benchmark claim rests on separating "the model has no edge" from "the model could
* not SPELL the turn" (bead kestrel-y31). `artifacts/grammar/watcher.gbnf` removes the second failure
* mode wherever the serving stack can take a CFG — llama.cpp, vLLM, Fireworks. But the two lanes the
* #39 re-test most needs a frontier reference from, **OpenAI and Azure OpenAI, cannot take a GBNF at
* all**: their only constrained-decoding lever is STRUCTURED OUTPUTS (a strict JSON Schema).
*
* So the surface ships in two machine encodings, generated from ONE table by `scripts/gen-grammar.ts`,
* and this module is the router that hands each lane the encoding it can actually honor:
*
* | lane | encoding | wire field |
* |----------------------------|---------------------------------|---------------------------------------------------|
* | `fireworks` | GBNF (`watcher.gbnf`) | `response_format: {type:"grammar", grammar}` |
* | `openai` / `azure` | strict JSON Schema | `response_format: {type:"json_schema", json_schema}` |
* | `local` | GBNF, served-side | `--grammar-file` on the server (NOT a request field) |
* | `bedrock`/`anthropic`/… | none | — (no constrained-decoding lever on these lanes) |
*
* ## The schema lane is the LOOSER lane — say so, don't paper over it
* `watcher.schema.json` is NOT the GBNF's equal (key order, whitespace, numeric bounds, and `price` as a
* regex rather than a CFG — the artifact's own `looserThanGbnf` list is the authority). A turn that slips
* past the schema still meets `parseTurn`/`validateAction`, which fail CLOSED and score it as an
* `invalidTurn`. So a residual invalid-rate on the OpenAI/Azure lanes is EXPECTED and is a real datum;
* on `fireworks` under GBNF it should be ~0, and a nonzero rate there is a bug worth chasing.
*
* ## Artifacts are read LAZILY and FAIL CLOSED
* The grammar/schema are read off disk only when a lane actually asks for them (so importing the harness
* barrel from a packaged `dist/` — which ships no `artifacts/` — never touches the filesystem). When a
* lane DOES ask and the artifact is missing or stale, this throws: silently sending an unconstrained
* request on a lane the caller believes is constrained would quietly reintroduce exactly the emission
* failure the grammar exists to remove, and the run would look like a JUDGMENT result.
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/** The constrained-decoding lever a lane can honor. */
export type DecodingConstraint =
/** A GBNF context-free grammar (llama.cpp / vLLM / Fireworks). The TIGHT encoding. */
| { readonly mode: "gbnf"; readonly grammar: string }
/** A strict JSON Schema (OpenAI / Azure structured outputs). The LOOSER encoding — see the module doc. */
| { readonly mode: "json-schema"; readonly name: string; readonly schema: Record<string, unknown> };
/** Where the generated artifacts live, relative to this module (repo root `artifacts/grammar/`). */
const GRAMMAR_DIR = new URL("../../../artifacts/grammar/", import.meta.url);
function readArtifact(file: string): string {
const path = fileURLToPath(new URL(file, GRAMMAR_DIR));
try {
return readFileSync(path, "utf8");
} catch (e) {
throw new Error(
`constrained decoding: cannot read ${file} at ${path} — ${e instanceof Error ? e.message : String(e)}.\n` +
`Run \`bun run gen-grammar\` to emit it. Refusing to send an UNCONSTRAINED request on a lane the ` +
`caller asked to constrain: that would silently reintroduce the emission failures the grammar ` +
`exists to remove (bead kestrel-y31), and the run would be misread as a judgment result.`,
);
}
}
let gbnfCache: string | undefined;
let schemaCache: { readonly name: string; readonly schema: Record<string, unknown> } | undefined;
/** The committed GBNF grammar text (`artifacts/grammar/watcher.gbnf`). Memoized; throws if absent. */
export function watcherGbnf(): string {
if (gbnfCache === undefined) gbnfCache = readArtifact("watcher.gbnf");
return gbnfCache;
}
/** The committed strict JSON Schema + its `json_schema.name`, unwrapped from the artifact's envelope
* (`artifacts/grammar/watcher.schema.json`). Only `.schema` goes on the wire — the envelope's provenance
* fields (`vocabHash`, `looserThanGbnf`, …) are for readers, and a strict-mode validator would reject
* them as unknown keywords. Memoized; throws if absent or malformed. */
export function watcherJsonSchema(): { readonly name: string; readonly schema: Record<string, unknown> } {
if (schemaCache === undefined) {
const raw = readArtifact("watcher.schema.json");
const env = JSON.parse(raw) as { name?: unknown; schema?: unknown };
if (typeof env.name !== "string" || typeof env.schema !== "object" || env.schema === null) {
throw new Error(`constrained decoding: watcher.schema.json is malformed (expected {name, schema}). Run \`bun run gen-grammar\`.`);
}
schemaCache = { name: env.name, schema: env.schema as Record<string, unknown> };
}
return schemaCache;
}
/** TEST SEAM ONLY: drop the memoized artifacts so a test can re-read them. Never called in a live run. */
export function resetDecodingCache(): void {
gbnfCache = undefined;
schemaCache = undefined;
}
/**
* The constraint a lane should carry by DEFAULT — the whole point of routing this centrally is that a
* runner (`eval-watcher.ts`) does not have to know, per provider, which encoding the wire speaks.
*
* `local` is deliberately ABSENT: an mlx_lm/vLLM/llama.cpp server takes its grammar at SERVE time
* (`--grammar-file artifacts/grammar/watcher.gbnf`, see `scripts/serve/watcher-llama-server.sh`), not as
* a request field, and sending one in the body would be ignored at best. Bedrock / Anthropic / Google
* expose no constrained-decoding lever at all, so they get none.
*/
export function defaultDecodingFor(provider: string): DecodingConstraint | undefined {
switch (provider) {
case "fireworks":
return { mode: "gbnf", grammar: watcherGbnf() };
case "openai":
case "azure": {
const { name, schema } = watcherJsonSchema();
return { mode: "json-schema", name, schema };
}
default:
return undefined;
}
}
/** The AI-SDK `providerOptions` key an OpenAI-COMPATIBLE provider reads its passthrough options from.
* `createOpenAICompatible({ name })` sets it, and any key it does not recognize is spread verbatim into
* the request body — which is exactly how a non-standard field like Fireworks' grammar `response_format`
* reaches the wire through a provider package that has never heard of it. */
export const FIREWORKS_PROVIDER_NAME = "fireworks";
/** The shape of an AI-SDK language-model call's params, narrowed to the two fields this module sets. */
export interface DecodingParams {
readonly responseFormat?: { readonly type: "json"; readonly schema: Record<string, unknown>; readonly name: string };
readonly providerOptions?: Record<string, Record<string, unknown>>;
}
/**
* The exact params patch a constraint becomes on an AI-SDK model call. PURE — this is the function the
* offline tests assert against, and the same function the live middleware applies, so "the schema is
* attached to the outbound request" is proven on the code path that actually runs.
*
* - **json-schema** → `responseFormat: {type:"json", schema, name}`. Both `@ai-sdk/openai` and
* `@ai-sdk/azure` translate that into `response_format: {type:"json_schema", json_schema:{name, strict,
* schema}}`, with `strict` defaulting to TRUE — which is the mode we want (it is what makes the
* sampler honor `enum`/`required`/`additionalProperties:false` rather than merely validate afterwards).
*
* - **gbnf** → `providerOptions.fireworks.response_format = {type:"grammar", grammar}`. Fireworks' OpenAI-
* compatible endpoint accepts a GBNF grammar in `response_format`; `@ai-sdk/openai-compatible` has no
* named setting for it, but it SPREADS unrecognized `providerOptions[name]` keys into the request body
* (and the spread lands after its own `response_format`, so this overrides it). That is the supported
* extra-body escape hatch, not a hack around the SDK.
*/
export function decodingParams(c: DecodingConstraint): DecodingParams {
if (c.mode === "json-schema") {
return { responseFormat: { type: "json", schema: c.schema, name: c.name } };
}
return {
providerOptions: {
[FIREWORKS_PROVIDER_NAME]: { response_format: { type: "grammar", grammar: c.grammar } },
},
};
}
/** A minimal structural view of the AI-SDK call params this middleware rewrites. */
interface CallParams {
readonly responseFormat?: unknown;
readonly providerOptions?: Record<string, Record<string, unknown>>;
readonly [k: string]: unknown;
}
/**
* Apply a constraint to a model call's params. MERGES `providerOptions` (never clobbers a caller's
* prompt-cache breakpoint or reasoning options) and sets `responseFormat` outright (the harness never
* sets one itself — the watcher's reply is read as text and parsed by `parseTurn`).
*
* Exported so the offline test can drive the EXACT transform the live middleware performs.
*/
export function applyDecoding(params: CallParams, c: DecodingConstraint): CallParams {
const patch = decodingParams(c);
const out: Record<string, unknown> = { ...params };
if (patch.responseFormat !== undefined) out.responseFormat = patch.responseFormat;
if (patch.providerOptions !== undefined) {
const merged: Record<string, Record<string, unknown>> = { ...(params.providerOptions ?? {}) };
for (const [provider, opts] of Object.entries(patch.providerOptions)) {
merged[provider] = { ...(merged[provider] ?? {}), ...opts };
}
out.providerOptions = merged;
}
return out as CallParams;
}
/**
* The constraint as an AI-SDK `LanguageModelMiddleware` — wrapped around the model in
* `ai-sdk-client.ts#buildModel`, so the constraint rides EVERY call that model makes and cannot be
* forgotten at a call site. Typed structurally (not against the SDK's middleware type) to keep this
* module free of the provider packages; `ai-sdk-client.ts` is the one file allowed to import them.
*/
export function decodingMiddleware(c: DecodingConstraint): {
readonly transformParams: (opts: { readonly params: CallParams }) => Promise<CallParams>;
} {
return {
transformParams: async ({ params }) => applyDecoding(params, c),
};
}