@cubicecho/agent-core 2.23.0 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hooks.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type OpenAI from "openai";
2
+ import { type HookEvent } from "./hook-events.ts";
2
3
  /**
3
4
  * Lifecycle hooks, from the host's side: what a session looks like to them, where their context
4
5
  * lands in a request, and what is said about each one.
@@ -9,20 +10,6 @@ import type OpenAI from "openai";
9
10
  * `runHooks` is a runner as it stands and its outcomes pass straight through, without this
10
11
  * package depending on it.
11
12
  */
12
- /**
13
- * A point in a session a hook can be bound to. Named after Claude Code's hooks of the same shape
14
- * — `sessionStart` (SessionStart), `beforeTurn` (UserPromptSubmit), `afterTurn` (Stop),
15
- * `beforeCompact` (PreCompact), `sessionEnd` (SessionEnd) — plus `sessionDelete`, for when the host
16
- * deletes a session's record.
17
- */
18
- export type HookEvent = "sessionStart" | "beforeTurn" | "afterTurn" | "beforeCompact" | "sessionEnd" | "sessionDelete";
19
- /** Every event a hook can be bound to, in the order a session meets them. */
20
- export declare const HOOK_EVENTS: readonly HookEvent[];
21
- /**
22
- * The events whose hooks run before a request, and so the only ones whose output can reach it.
23
- * Anything later runs once the model has already answered.
24
- */
25
- export declare const INJECT_EVENTS: ReadonlySet<HookEvent>;
26
13
  /**
27
14
  * One message of a session as a hook is handed it. The shape a memory server's `remember` takes,
28
15
  * so a template can pass a turn straight through.
@@ -248,6 +235,18 @@ export declare const UNTRUSTED_PREFACE: string;
248
235
  export declare function untrusted(text: string, { source }?: {
249
236
  source?: string;
250
237
  }): string;
238
+ /**
239
+ * A message's text, whether its content is a string or a list of parts.
240
+ *
241
+ * Parts are joined with nothing between them, because they are one text an API split rather than
242
+ * several: a space put in is a character the sender never wrote, and `turnMessages` hashes this
243
+ * into the uuid a memory server dedups on. Compaction reads the same function, so the summariser
244
+ * and the hook told about a fold see one message the same way. A refusal part is left out.
245
+ *
246
+ * @param content A message's `content`, in any of the shapes the API allows. One that is neither
247
+ * a string nor a list reads as empty.
248
+ */
249
+ export declare const textOf: (content: unknown) => string;
251
250
  /**
252
251
  * A stretch of a transcript as a hook reads it: what the user and the assistant said, and nothing
253
252
  * else.
package/dist/hooks.js CHANGED
@@ -1,21 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { errorMessage } from "./errors.js";
3
+ import { isPositive } from "./guards.js";
4
+ import { INJECT_EVENTS } from "./hook-events.js";
3
5
  import { scoped } from "./scope.js";
4
- import { estimateTokens } from "./tokens.js";
5
- /** Every event a hook can be bound to, in the order a session meets them. */
6
- export const HOOK_EVENTS = [
7
- "sessionStart",
8
- "beforeTurn",
9
- "afterTurn",
10
- "beforeCompact",
11
- "sessionEnd",
12
- "sessionDelete",
13
- ];
14
- /**
15
- * The events whose hooks run before a request, and so the only ones whose output can reach it.
16
- * Anything later runs once the model has already answered.
17
- */
18
- export const INJECT_EVENTS = new Set(["sessionStart", "beforeTurn"]);
6
+ import { CHARS_PER_TOKEN, estimateTokens } from "./tokens.js";
19
7
  /**
20
8
  * The most context all of a request's hooks add between them by default, in estimated tokens.
21
9
  *
@@ -55,9 +43,8 @@ const hookSettings = scoped(() => ({ ...HOOK_DEFAULTS }));
55
43
  */
56
44
  export function configureHooks(options = {}) {
57
45
  const { contextTokens, preface } = options;
58
- if (typeof contextTokens === "number" && contextTokens > 0) {
46
+ if (isPositive(contextTokens))
59
47
  hookSettings().contextTokens = contextTokens;
60
- }
61
48
  if (typeof preface === "string")
62
49
  hookSettings().preface = preface;
63
50
  return { ...hookSettings() };
@@ -75,8 +62,19 @@ export const resetHooks = () => {
75
62
  * same rule `configureHooks` applies, so a `0` threaded through for "no opinion" does not quietly
76
63
  * turn every hook's context off.
77
64
  */
78
- const budget = (given) => typeof given === "number" && given > 0 ? given : hookSettings().contextTokens;
65
+ const budget = (given) => (isPositive(given) ? given : hookSettings().contextTokens);
79
66
  const attribute = (text) => text.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
67
+ /** What every note about an outcome opens with: which hook it was, and on which event. */
68
+ const noteOf = (outcome) => ({
69
+ event: outcome.event,
70
+ source: outcome.label,
71
+ hookId: outcome.hookId,
72
+ });
73
+ /** The note a failed outcome leaves, the same whichever of the three callers reports it. */
74
+ const failureNote = (outcome) => ({
75
+ ...noteOf(outcome),
76
+ error: outcome.error ?? "failed",
77
+ });
80
78
  /**
81
79
  * Builds the context a set of outcomes adds and the notes that go with it.
82
80
  *
@@ -100,9 +98,8 @@ export function assembleContext(outcomes, maxTokens) {
100
98
  const notes = [];
101
99
  let remaining = budget(maxTokens);
102
100
  for (const outcome of outcomes) {
103
- const base = { event: outcome.event, source: outcome.label, hookId: outcome.hookId };
104
101
  if (!outcome.ok) {
105
- notes.push({ ...base, error: outcome.error ?? "failed" });
102
+ notes.push(failureNote(outcome));
106
103
  continue;
107
104
  }
108
105
  if (!outcome.inject || !INJECT_EVENTS.has(outcome.event))
@@ -114,11 +111,11 @@ export function assembleContext(outcomes, maxTokens) {
114
111
  if (cap <= 0)
115
112
  continue;
116
113
  if (estimateTokens(text) > cap)
117
- text = `${text.slice(0, cap * 4 - 1).trimEnd()}…`;
114
+ text = `${text.slice(0, cap * CHARS_PER_TOKEN - 1).trimEnd()}…`;
118
115
  const tokens = estimateTokens(text);
119
116
  remaining -= tokens;
120
117
  blocks.push(`<context source="${attribute(outcome.label)}">\n${text}\n</context>`);
121
- notes.push({ ...base, tokens, text });
118
+ notes.push({ ...noteOf(outcome), tokens, text });
122
119
  }
123
120
  return { context: blocks.join("\n\n"), notes };
124
121
  }
@@ -187,8 +184,18 @@ export function untrusted(text, { source } = {}) {
187
184
  const attrs = source === undefined ? "" : ` source="${attribute(source)}"`;
188
185
  return `<untrusted${attrs}>\n${text.replace(UNTRUSTED_TAG, "&lt;$1")}\n</untrusted>`;
189
186
  }
190
- /** A message's text, whether its content is a string or a list of parts. */
191
- const textOf = (content) => {
187
+ /**
188
+ * A message's text, whether its content is a string or a list of parts.
189
+ *
190
+ * Parts are joined with nothing between them, because they are one text an API split rather than
191
+ * several: a space put in is a character the sender never wrote, and `turnMessages` hashes this
192
+ * into the uuid a memory server dedups on. Compaction reads the same function, so the summariser
193
+ * and the hook told about a fold see one message the same way. A refusal part is left out.
194
+ *
195
+ * @param content A message's `content`, in any of the shapes the API allows. One that is neither
196
+ * a string nor a list reads as empty.
197
+ */
198
+ export const textOf = (content) => {
192
199
  if (typeof content === "string")
193
200
  return content;
194
201
  if (!Array.isArray(content))
@@ -318,9 +325,7 @@ export async function gather(run, events, context, { signal, onNote, maxTokens,
318
325
  */
319
326
  export async function notify(run, event, context, onNote) {
320
327
  const outcomes = await runSafely(run, event, context);
321
- const notes = outcomes
322
- .filter((outcome) => !outcome.ok)
323
- .map((outcome) => assembleContext([outcome]).notes[0]);
328
+ const notes = outcomes.filter((outcome) => !outcome.ok).map(failureNote);
324
329
  for (const note of notes)
325
330
  onNote?.(note);
326
331
  return notes;
@@ -345,15 +350,9 @@ export async function consult(run, event, context, onNote) {
345
350
  const notes = [];
346
351
  for (const outcome of outcomes) {
347
352
  if (!outcome.ok)
348
- notes.push(assembleContext([outcome]).notes[0]);
349
- else if (outcome.veto === true) {
350
- notes.push({
351
- event: outcome.event,
352
- source: outcome.label,
353
- hookId: outcome.hookId,
354
- veto: true,
355
- });
356
- }
353
+ notes.push(failureNote(outcome));
354
+ else if (outcome.veto === true)
355
+ notes.push({ ...noteOf(outcome), veto: true });
357
356
  }
358
357
  for (const note of notes)
359
358
  onNote?.(note);
package/dist/index.d.ts CHANGED
@@ -19,18 +19,19 @@ export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } fro
19
19
  export { type ContinueTurnOptions, continueTurn, isContinuable, } from "./continuation.ts";
20
20
  export { AgentLoopError, type AgentLoopFailure, AgentLoopOverflow, errorMessage, failedRun, ToolIterationLimit, } from "./errors.ts";
21
21
  export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunMetrics, type RunMetricsOptions, type RunUsage, resetEvents, runMetrics, type TurnReport, watch, } from "./events.ts";
22
- export { assembleContext, configureHooks, consult, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.ts";
22
+ export { HOOK_EVENTS, type HookEvent, INJECT_EVENTS } from "./hook-events.ts";
23
+ export { assembleContext, configureHooks, consult, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_PREFACE, type HookContext, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.ts";
23
24
  export { estimateFrom, type LedgerEntry, type LedgerEstimateOptions, type LedgerRequest, rebaseLedger, recordRequest, type TokenLedger, tokensBetween, } from "./ledger.ts";
24
25
  export { resetAll } from "./reset.ts";
25
- export { backoffMs, CHARS_PER_TOKEN, type ContextBreakdown, type ContextBreakdownOptions, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, type TokenEstimateOptions, toolsChars, } from "./retry.ts";
26
+ export { backoffMs, type ContextBreakdown, type ContextBreakdownOptions, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, type TokenEstimateOptions, toolsChars, } from "./retry.ts";
26
27
  export { type RunTurnOptions, runTurn } from "./run-turn.ts";
27
28
  export { createRuntime, defaultRuntime, type Runtime, type RuntimeOptions } from "./runtime.ts";
28
- export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
29
+ export { isGrammarError, relaxSchema, relaxTools, sanitizeSchema, sanitizeTools, } from "./schema-compat.ts";
29
30
  export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTask, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
30
31
  export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from "./snapshot.ts";
31
32
  export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, type AgentHook, type AgentSpec, type EndpointSpec, type ExportSpecOptions, exportSpec, type ModelSpec, type ParsedSpec, type ParseSpecOptions, type PromptPart, parseSpec, RESOLVED_DEFAULTS, type ResolvedAgent, type ResolvedTask, type RetrySpec, resolveAgentSpec, SPEC_EVENTS, type SpecBundle, type SpecServer, type TaskSpec, type ToolsSpec, } from "./spec.ts";
32
33
  export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
33
34
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, type Split, stripThinking, THINK_FENCE, } from "./thinking.ts";
34
- export { estimateTokens } from "./tokens.ts";
35
+ export { CHARS_PER_TOKEN, estimateTokens } from "./tokens.ts";
35
36
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, type ToolCall, } from "./tool-calls.ts";
36
37
  export { CALL_TOOL, carryOver, catalogList, catalogPrompt, expandNames, holdsDefinitions, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, type KeywordPreselection, type KeywordPreselectOptions, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, PROXY_TOOLS, preselectByKeywords, preselectInput, preselection, preselectSystem, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, type ToolMatch, type ToolOrder, } from "./tool-loading.ts";
package/dist/index.js CHANGED
@@ -17,18 +17,19 @@ export { applyCompaction, COMPACT_AT, compactTranscript, KEEP_RATIO, planCompact
17
17
  export { continueTurn, isContinuable, } from "./continuation.js";
18
18
  export { AgentLoopError, AgentLoopOverflow, errorMessage, failedRun, ToolIterationLimit, } from "./errors.js";
19
19
  export { configureEvents, emit, endRun, fold, history, resetEvents, runMetrics, watch, } from "./events.js";
20
- export { assembleContext, configureHooks, consult, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.js";
20
+ export { HOOK_EVENTS, INJECT_EVENTS } from "./hook-events.js";
21
+ export { assembleContext, configureHooks, consult, gather, HOOK_CONTEXT_TOKENS, HOOK_PREFACE, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.js";
21
22
  export { estimateFrom, rebaseLedger, recordRequest, tokensBetween, } from "./ledger.js";
22
23
  export { resetAll } from "./reset.js";
23
- export { backoffMs, CHARS_PER_TOKEN, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, toolsChars, } from "./retry.js";
24
+ export { backoffMs, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, toolsChars, } from "./retry.js";
24
25
  export { runTurn } from "./run-turn.js";
25
26
  export { createRuntime, defaultRuntime } from "./runtime.js";
26
- export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
27
+ export { isGrammarError, relaxSchema, relaxTools, sanitizeSchema, sanitizeTools, } from "./schema-compat.js";
27
28
  export { ask, askJson, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
28
29
  export { CAPABILITY_SNAPSHOT_VERSION, exportCapabilities, importCapabilities, } from "./snapshot.js";
29
30
  export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, exportSpec, parseSpec, RESOLVED_DEFAULTS, resolveAgentSpec, SPEC_EVENTS, } from "./spec.js";
30
31
  export { streamTurn, } from "./stream.js";
31
32
  export { ALL_FENCES, DEFAULT_FENCES, FenceSplitter, stripThinking, THINK_FENCE, } from "./thinking.js";
32
- export { estimateTokens } from "./tokens.js";
33
+ export { CHARS_PER_TOKEN, estimateTokens } from "./tokens.js";
33
34
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, } from "./tool-calls.js";
34
35
  export { CALL_TOOL, carryOver, catalogList, catalogPrompt, expandNames, holdsDefinitions, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, PROXY_TOOLS, preselectByKeywords, preselectInput, preselection, preselectSystem, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, } from "./tool-loading.js";
package/dist/retry.d.ts CHANGED
@@ -29,13 +29,6 @@ export declare class ContextOverflow extends Error {
29
29
  * @param tokens The count to render.
30
30
  */
31
31
  export declare const compact: (tokens: number) => string;
32
- /**
33
- * The divisor behind `estimateTokens`, applied here to a character count rather than a string.
34
- *
35
- * The fallback, not the rule: once a turn has come back with a reported prompt count, `runTurn`
36
- * divides by what that endpoint's model was measured at instead. See `charsPerTokenFor`.
37
- */
38
- export declare const CHARS_PER_TOKEN = 4;
39
32
  /** What the two token estimates below take besides what they measure. */
40
33
  export interface TokenEstimateOptions {
41
34
  /**
@@ -192,6 +185,19 @@ export declare const SMALLEST_LIKELY_WINDOW = 8192;
192
185
  * @param error The rejection, as caught. What is not an SDK error is not transient.
193
186
  */
194
187
  export declare function isTransient(error: unknown): boolean;
188
+ /**
189
+ * Whether a failure is the endpoint refusing the request as written, rather than losing it.
190
+ *
191
+ * 400 and 422 are what a server says when it read the body and disliked it, which makes them the
192
+ * only statuses worth answering by sending something different. Any 4xx is too wide: 401, 404 and
193
+ * 429 are 4xx and none of them is about the fields, and a 429 is one `isTransient` accepts — a
194
+ * caller that re-sent on it at once doubled the rate against a server that had just asked for less.
195
+ * Catching everything is wider still, since an abort or a connection that never landed then
196
+ * latches off whatever the re-send left out.
197
+ *
198
+ * @param error The rejection, as caught. What is not an SDK error refuses nothing.
199
+ */
200
+ export declare const refusesRequest: (error: unknown) => boolean;
195
201
  /**
196
202
  * Whether a failure is a local server still loading the model, rather than one failing to serve.
197
203
  *
package/dist/retry.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import OpenAI from "openai";
2
+ import { CHARS_PER_TOKEN } from "./tokens.js";
2
3
  /**
3
4
  * Everything about a request failing that is not about what the request said.
4
5
  *
@@ -62,13 +63,6 @@ const REFUSAL_PART = 32;
62
63
  const REASONING_KEY = 23;
63
64
  /** The same for `"reasoning":"",`, OpenRouter's spelling of it. */
64
65
  const REASONING_ALT_KEY = 15;
65
- /**
66
- * The divisor behind `estimateTokens`, applied here to a character count rather than a string.
67
- *
68
- * The fallback, not the rule: once a turn has come back with a reported prompt count, `runTurn`
69
- * divides by what that endpoint's model was measured at instead. See `charsPerTokenFor`.
70
- */
71
- export const CHARS_PER_TOKEN = 4;
72
66
  /** The divisor an option asked for, or the fallback when it asked for nothing usable. */
73
67
  const divisor = (charsPerToken) => charsPerToken !== undefined && charsPerToken > 0 ? charsPerToken : CHARS_PER_TOKEN;
74
68
  /**
@@ -349,6 +343,19 @@ export function isTransient(error) {
349
343
  const { status } = error;
350
344
  return status === 408 || status === 409 || status === 429 || (status ?? 0) >= 500;
351
345
  }
346
+ /**
347
+ * Whether a failure is the endpoint refusing the request as written, rather than losing it.
348
+ *
349
+ * 400 and 422 are what a server says when it read the body and disliked it, which makes them the
350
+ * only statuses worth answering by sending something different. Any 4xx is too wide: 401, 404 and
351
+ * 429 are 4xx and none of them is about the fields, and a 429 is one `isTransient` accepts — a
352
+ * caller that re-sent on it at once doubled the rate against a server that had just asked for less.
353
+ * Catching everything is wider still, since an abort or a connection that never landed then
354
+ * latches off whatever the re-send left out.
355
+ *
356
+ * @param error The rejection, as caught. What is not an SDK error refuses nothing.
357
+ */
358
+ export const refusesRequest = (error) => error instanceof OpenAI.APIError && (error.status === 400 || error.status === 422);
352
359
  /**
353
360
  * Whether a failure is a local server still loading the model, rather than one failing to serve.
354
361
  *
@@ -77,6 +77,13 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
77
77
  */
78
78
  loadingTimeoutMs?: number;
79
79
  }
80
+ /**
81
+ * Builds a turn's request body from what the endpoint and the model have refused so far.
82
+ *
83
+ * A function rather than a body because a downgrade changes what is sent, so it is called again
84
+ * per attempt. Its second argument is absent where no model was named.
85
+ */
86
+ export type RequestBuilder = (supports: Capabilities, model: ModelCapabilities | undefined) => OpenAI.ChatCompletionCreateParamsStreaming;
80
87
  /**
81
88
  * `request` is a callback rather than a body because the body has to be rebuilt from whatever
82
89
  * the last attempt latched off: the tools it sends depend on `strictSchemas`, and `relaxTools`
@@ -90,4 +97,4 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
90
97
  * @param options Retry budget, context limit, the model to negotiate for, notices, and the
91
98
  * stream's own callbacks.
92
99
  */
93
- export declare function runTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities, model: ModelCapabilities | undefined) => OpenAI.ChatCompletionCreateParamsStreaming, { maxRetries, onNotice, contextLimit, model, droppable, loadingTimeoutMs, ...stream }?: RunTurnOptions): Promise<Turn>;
100
+ export declare function runTurn(client: OpenAI, supports: Capabilities, request: RequestBuilder, { maxRetries, onNotice, contextLimit, model, droppable, loadingTimeoutMs, ...stream }?: RunTurnOptions): Promise<Turn>;
@@ -1,4 +1,17 @@
1
1
  import type OpenAI from "openai";
2
+ /**
3
+ * One JSON Schema as a strict server will accept it, for a schema that is not a tool's parameters.
4
+ *
5
+ * What `sanitizeTools` does to each tool, without the tool: a `response_format` schema goes through
6
+ * the same grammar converter a tool's parameters do, and a caller holding only the schema had to
7
+ * wrap it in a definition to get here. Nothing is remembered — the cache is keyed on a tool
8
+ * definition's identity, and a bare schema has no object that stands for it across requests.
9
+ *
10
+ * @param schema The schema as written. Never mutated. Its root is held to an object, as a tool's
11
+ * parameters are, and anything that is not a schema at all comes back as an object with no
12
+ * properties.
13
+ */
14
+ export declare function sanitizeSchema(schema: unknown): Record<string, unknown>;
2
15
  /**
3
16
  * Tool definitions a strict server will accept, remembered per definition object.
4
17
  *
@@ -9,6 +22,16 @@ import type OpenAI from "openai";
9
22
  * changed, a new definition is returned in its place.
10
23
  */
11
24
  export declare const sanitizeTools: (tools: OpenAI.ChatCompletionTool[]) => OpenAI.ChatCompletionTool[];
25
+ /**
26
+ * One schema without its `pattern` and `format` keywords, for a schema that is not a tool's.
27
+ *
28
+ * The retry `relaxTools` makes, on a bare schema. Only the keywords go: a property that happens to
29
+ * be called `format` is an argument name and stays.
30
+ *
31
+ * @param schema Already sanitised. Relaxing is the retry, not a substitute for `sanitizeSchema`.
32
+ * Never mutated; anything that is not a schema comes back as an object with no properties.
33
+ */
34
+ export declare function relaxSchema(schema: unknown): Record<string, unknown>;
12
35
  /**
13
36
  * The retry shape: llama.cpp's converter rejects regex escape classes (`\d`, `\w`, `\s`) in
14
37
  * `pattern` and most `format` values, both of which only ever narrowed a string the tool
@@ -1,4 +1,4 @@
1
- const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1
+ import { isRecord } from "./guards.js";
2
2
  /** Lookahead and lookbehind: `(?=`, `(?!`, `(?<=`, `(?<!`. */
3
3
  const LOOKAROUND = /\(\?<?[=!]/;
4
4
  const PRIMITIVES = new Set(["object", "string", "number", "integer", "boolean", "array", "null"]);
@@ -20,6 +20,24 @@ const SCHEMA_KEYS = new Set([
20
20
  ]);
21
21
  /** Keys whose value is a name -> schema map. */
22
22
  const SCHEMA_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions"]);
23
+ /** The two spellings of a schema's own pool of named definitions. */
24
+ const POOL_KEYS = ["definitions", "$defs"];
25
+ /**
26
+ * One keyword's value with `fn` applied wherever it holds a schema, and untouched where it holds
27
+ * data — which is what keeps a walk out of `default`, `enum` and `const`.
28
+ */
29
+ const mapChildren = (key, value, fn) => {
30
+ if (SCHEMA_KEYS.has(key))
31
+ return Array.isArray(value) ? value.map((sub) => fn(sub)) : fn(value);
32
+ // The keys here are argument names; only the values are schemas.
33
+ if (SCHEMA_MAPS.has(key) && isRecord(value))
34
+ return Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, fn(sub)]));
35
+ return value;
36
+ };
37
+ /** The names a schema's `required` lists, without whatever in it is not a name. */
38
+ const requiredOf = (node) => Array.isArray(node.required)
39
+ ? node.required.filter((name) => typeof name === "string")
40
+ : [];
23
41
  /**
24
42
  * Coerces one schema position. Malformed MCP output sometimes puts a bare type name where a
25
43
  * whole schema belongs, which the grammar converter reports as `Unrecognized schema: "object"`.
@@ -29,7 +47,7 @@ function asSchema(node) {
29
47
  return PRIMITIVES.has(node) && node !== "object" ? { type: node } : EMPTY_OBJECT();
30
48
  if (typeof node === "boolean")
31
49
  return node;
32
- if (!isObject(node))
50
+ if (!isRecord(node))
33
51
  return EMPTY_OBJECT();
34
52
  return normalize(node);
35
53
  }
@@ -50,14 +68,8 @@ function normalize(node) {
50
68
  else
51
69
  out.type = "null";
52
70
  }
53
- else if (SCHEMA_KEYS.has(key)) {
54
- out[key] = Array.isArray(value) ? value.map(asSchema) : asSchema(value);
55
- }
56
- else if (SCHEMA_MAPS.has(key) && isObject(value)) {
57
- out[key] = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, asSchema(sub)]));
58
- }
59
71
  else {
60
- out[key] = value;
72
+ out[key] = mapChildren(key, value, asSchema);
61
73
  }
62
74
  }
63
75
  collapseNullableUnion(out);
@@ -66,7 +78,7 @@ function normalize(node) {
66
78
  if (typeof out.pattern === "string" && LOOKAROUND.test(out.pattern))
67
79
  delete out.pattern;
68
80
  // `{"type": "object"}` with no properties produces invalid GBNF.
69
- if (out.type === "object" && !isObject(out.properties))
81
+ if (out.type === "object" && !isRecord(out.properties))
70
82
  out.properties = {};
71
83
  // Strict validators reject any sibling of `$ref`, and draft-07 ignores them, so a reference
72
84
  // stands alone or not at all. This is not hypothetical tidying: collapsing `anyOf: [{$ref},
@@ -88,11 +100,11 @@ function collapseNullableUnion(node) {
88
100
  const variants = node[key];
89
101
  if (!Array.isArray(variants))
90
102
  continue;
91
- const concrete = variants.filter((item) => !(isObject(item) && item.type === "null"));
103
+ const concrete = variants.filter((item) => !(isRecord(item) && item.type === "null"));
92
104
  if (concrete.length !== 1 || concrete.length === variants.length)
93
105
  continue;
94
106
  delete node[key];
95
- Object.assign(node, { nullable: true, ...(isObject(concrete[0]) ? concrete[0] : {}) });
107
+ Object.assign(node, { nullable: true, ...(isRecord(concrete[0]) ? concrete[0] : {}) });
96
108
  }
97
109
  }
98
110
  /** Combinators at the top level of a parameters schema; strict backends reject them outright. */
@@ -102,8 +114,8 @@ const LOCAL_POINTER = /^#\/(definitions|\$defs)\/([^/]+)$/;
102
114
  /** The `definitions` and `$defs` that a local pointer in this schema resolves against. */
103
115
  function poolsOf(parameters) {
104
116
  const defs = {};
105
- for (const key of ["definitions", "$defs"])
106
- if (isObject(parameters[key]))
117
+ for (const key of POOL_KEYS)
118
+ if (isRecord(parameters[key]))
107
119
  defs[key] = parameters[key];
108
120
  return defs;
109
121
  }
@@ -119,16 +131,16 @@ function poolsOf(parameters) {
119
131
  function resolveRef(node, defs) {
120
132
  const seen = new Set();
121
133
  let current = node;
122
- while (isObject(current) && typeof current.$ref === "string") {
134
+ while (isRecord(current) && typeof current.$ref === "string") {
123
135
  const pointer = current.$ref;
124
136
  const target = LOCAL_POINTER.exec(pointer);
125
137
  if (!target || seen.has(pointer))
126
138
  return undefined;
127
139
  seen.add(pointer);
128
140
  const pool = defs[target[1]];
129
- current = isObject(pool) ? pool[target[2]] : undefined;
141
+ current = isRecord(pool) ? pool[target[2]] : undefined;
130
142
  }
131
- return isObject(current) ? current : undefined;
143
+ return isRecord(current) ? current : undefined;
132
144
  }
133
145
  /**
134
146
  * Replaces a root-level `$ref` with what it points at.
@@ -164,18 +176,16 @@ function mergeRootAllOf(out) {
164
176
  if (!Array.isArray(branches))
165
177
  return;
166
178
  const defs = poolsOf(out);
167
- const properties = isObject(out.properties) ? { ...out.properties } : {};
168
- const required = new Set(Array.isArray(out.required) ? out.required.filter((name) => typeof name === "string") : []);
179
+ const properties = isRecord(out.properties) ? { ...out.properties } : {};
180
+ const required = new Set(requiredOf(out));
169
181
  for (const raw of branches) {
170
182
  const branch = resolveRef(raw, defs);
171
183
  if (!branch)
172
184
  continue;
173
- if (isObject(branch.properties))
185
+ if (isRecord(branch.properties))
174
186
  Object.assign(properties, branch.properties);
175
- if (Array.isArray(branch.required))
176
- for (const name of branch.required)
177
- if (typeof name === "string")
178
- required.add(name);
187
+ for (const name of requiredOf(branch))
188
+ required.add(name);
179
189
  }
180
190
  if (!Object.keys(properties).length)
181
191
  return;
@@ -214,22 +224,16 @@ function mergeRootUnion(out) {
214
224
  shared = new Set();
215
225
  continue;
216
226
  }
217
- if (isObject(branch.properties))
227
+ if (isRecord(branch.properties))
218
228
  Object.assign(properties, branch.properties);
219
- const names = Array.isArray(branch.required)
220
- ? branch.required.filter((name) => typeof name === "string")
221
- : [];
229
+ const names = requiredOf(branch);
222
230
  shared = previous === null ? new Set(names) : new Set(names.filter((n) => previous.has(n)));
223
231
  }
224
232
  if (!Object.keys(properties).length)
225
233
  continue;
226
- out.properties = { ...(isObject(out.properties) ? out.properties : {}), ...properties };
227
- if (shared?.size) {
228
- const already = Array.isArray(out.required)
229
- ? out.required.filter((name) => typeof name === "string")
230
- : [];
231
- out.required = [...new Set([...already, ...shared])];
232
- }
234
+ out.properties = { ...(isRecord(out.properties) ? out.properties : {}), ...properties };
235
+ if (shared?.size)
236
+ out.required = [...new Set([...requiredOf(out), ...shared])];
233
237
  }
234
238
  }
235
239
  /**
@@ -246,7 +250,7 @@ function collectRefs(node, into) {
246
250
  collectRefs(item, into);
247
251
  return;
248
252
  }
249
- if (!isObject(node))
253
+ if (!isRecord(node))
250
254
  return;
251
255
  for (const [key, value] of Object.entries(node)) {
252
256
  if (key === "$ref" && typeof value === "string")
@@ -269,7 +273,7 @@ function collectRefs(node, into) {
269
273
  * outside it still refers in.
270
274
  */
271
275
  function pruneDefs(out) {
272
- const pools = ["definitions", "$defs"].filter((key) => isObject(out[key]));
276
+ const pools = POOL_KEYS.filter((key) => isRecord(out[key]));
273
277
  if (!pools.length)
274
278
  return;
275
279
  const live = {};
@@ -282,7 +286,7 @@ function pruneDefs(out) {
282
286
  continue;
283
287
  const [, poolKey, name] = target;
284
288
  const pool = out[poolKey];
285
- if (!isObject(pool) || !(name in pool))
289
+ if (!isRecord(pool) || !(name in pool))
286
290
  continue;
287
291
  live[poolKey] ??= new Set();
288
292
  const names = live[poolKey];
@@ -317,24 +321,36 @@ function pruneDefs(out) {
317
321
  function pruneRequired(out) {
318
322
  if (!Array.isArray(out.required))
319
323
  return;
320
- const properties = isObject(out.properties) ? out.properties : {};
324
+ const properties = isRecord(out.properties) ? out.properties : {};
321
325
  const kept = out.required.filter((name) => typeof name === "string" && name in properties);
322
326
  if (kept.length)
323
327
  out.required = kept;
324
328
  else
325
329
  delete out.required;
326
330
  }
327
- function sanitizeParameters(parameters) {
328
- if (!isObject(parameters))
331
+ /**
332
+ * One JSON Schema as a strict server will accept it, for a schema that is not a tool's parameters.
333
+ *
334
+ * What `sanitizeTools` does to each tool, without the tool: a `response_format` schema goes through
335
+ * the same grammar converter a tool's parameters do, and a caller holding only the schema had to
336
+ * wrap it in a definition to get here. Nothing is remembered — the cache is keyed on a tool
337
+ * definition's identity, and a bare schema has no object that stands for it across requests.
338
+ *
339
+ * @param schema The schema as written. Never mutated. Its root is held to an object, as a tool's
340
+ * parameters are, and anything that is not a schema at all comes back as an object with no
341
+ * properties.
342
+ */
343
+ export function sanitizeSchema(schema) {
344
+ if (!isRecord(schema))
329
345
  return EMPTY_OBJECT();
330
- const out = normalize(inlineRootRef(parameters));
346
+ const out = normalize(inlineRootRef(schema));
331
347
  mergeRootAllOf(out);
332
348
  mergeRootUnion(out);
333
349
  for (const key of TOP_LEVEL_COMBINATORS)
334
350
  delete out[key];
335
351
  if (out.type !== "object")
336
352
  out.type = "object";
337
- if (!isObject(out.properties))
353
+ if (!isRecord(out.properties))
338
354
  out.properties = {};
339
355
  pruneRequired(out);
340
356
  pruneDefs(out);
@@ -386,7 +402,7 @@ const through = (cache, tools, fn) => tools.map((tool) => {
386
402
  * @param tools The definitions as the pool hands them over. Never mutated — where a schema
387
403
  * changed, a new definition is returned in its place.
388
404
  */
389
- export const sanitizeTools = (tools) => through(sanitized, tools, sanitizeParameters);
405
+ export const sanitizeTools = (tools) => through(sanitized, tools, sanitizeSchema);
390
406
  /**
391
407
  * Walked as a schema rather than as arbitrary JSON, because `pattern` and `format` are keyword
392
408
  * names and perfectly ordinary argument names at once. Matching on the key alone deleted a
@@ -394,29 +410,33 @@ export const sanitizeTools = (tools) => through(sanitized, tools, sanitizeParame
394
410
  * argument that no longer existed — which every strict validator rejects, so the retry produced
395
411
  * the failure it was reaching for. The same distinction keeps the walk out of `default`, `enum`
396
412
  * and `const`, whose contents are data, not schema.
397
- *
398
- * At module scope rather than inside `relaxTools`, so the closure is made once rather than per
399
- * call — which, on the path this is on, is per request.
400
413
  */
401
414
  const strip = (node) => {
402
415
  if (Array.isArray(node))
403
416
  return node.map(strip);
404
- if (!isObject(node))
417
+ if (!isRecord(node))
405
418
  return node;
406
419
  const out = {};
407
420
  for (const [key, value] of Object.entries(node)) {
408
421
  if (key === "pattern" || key === "format")
409
422
  continue;
410
- if (SCHEMA_KEYS.has(key))
411
- out[key] = Array.isArray(value) ? value.map(strip) : strip(value);
412
- else if (SCHEMA_MAPS.has(key) && isObject(value))
413
- // The keys here are argument names; only the values are schemas.
414
- out[key] = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, strip(sub)]));
415
- else
416
- out[key] = value;
423
+ out[key] = mapChildren(key, value, strip);
417
424
  }
418
425
  return out;
419
426
  };
427
+ /**
428
+ * One schema without its `pattern` and `format` keywords, for a schema that is not a tool's.
429
+ *
430
+ * The retry `relaxTools` makes, on a bare schema. Only the keywords go: a property that happens to
431
+ * be called `format` is an argument name and stays.
432
+ *
433
+ * @param schema Already sanitised. Relaxing is the retry, not a substitute for `sanitizeSchema`.
434
+ * Never mutated; anything that is not a schema comes back as an object with no properties.
435
+ */
436
+ export function relaxSchema(schema) {
437
+ const stripped = strip(schema);
438
+ return isRecord(stripped) ? stripped : EMPTY_OBJECT();
439
+ }
420
440
  /**
421
441
  * The retry shape: llama.cpp's converter rejects regex escape classes (`\d`, `\w`, `\s`) in
422
442
  * `pattern` and most `format` values, both of which only ever narrowed a string the tool
@@ -424,10 +444,7 @@ const strip = (node) => {
424
444
  *
425
445
  * @param tools Already sanitised. Relaxing is the retry, not a substitute for `sanitizeTools`.
426
446
  */
427
- export const relaxTools = (tools) => through(relaxed, tools, (parameters) => {
428
- const stripped = strip(parameters);
429
- return isObject(stripped) ? stripped : EMPTY_OBJECT();
430
- });
447
+ export const relaxTools = (tools) => through(relaxed, tools, relaxSchema);
431
448
  /**
432
449
  * Qwen chat templates raise this when the transcript has no user turn. Some servers wrap it
433
450
  * in the same "unable to generate parser" wording as a real schema failure, and stripping