@cubicecho/agent-core 2.0.7 → 2.1.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/README.md CHANGED
@@ -23,7 +23,7 @@ only, Node >=22.
23
23
  | `schema-compat` | Makes an MCP tool schema something a strict or grammar-constrained server will accept. `sanitizeTools`, `relaxTools`, `isGrammarError`. |
24
24
  | `tool-loading` | On-demand tool discovery: a name-only catalogue plus a `load_tools` meta-tool, so a run pays for the schemas it asks for instead of all of them. |
25
25
  | `stream` | Reads one streamed turn back into a message: token callbacks, tool-call reassembly, and the idle watchdog that turns a silent endpoint into `EndpointSilent`. |
26
- | `capabilities` | What an endpoint turned out not to support, per endpoint, and the loop that answers it when it says so. `capabilitiesFor`, `negotiate`. |
26
+ | `capabilities` | What an endpoint turned out not to support — and, under it, what one model on that endpoint did not — plus the loop that answers either when it says so. `capabilitiesFor`, `modelCapabilitiesFor`, `negotiate`. |
27
27
  | `side-task` | One-shot calls that support a run without being one — small prompt, short answer, no tools, never worth failing the run over. |
28
28
  | `events` | The in-memory bus a watcher reads while a run happens: `emit`, `watch`, `history`, `fold`. A watcher's backlog is capped and reports its own gaps. |
29
29
  | `client` | A pooled `OpenAI` client per endpoint, plus the context-window listing and its cache. |
@@ -70,6 +70,41 @@ const turn = await negotiate(supports, (supports, produced) =>
70
70
  );
71
71
  ```
72
72
 
73
+ ## What the model refuses, rather than the server
74
+
75
+ `strictSchemas` and `usageInStream` are facts about a server. Three more arrive through the same
76
+ channel — an error string on a chat completion — and are facts about a *model*: a
77
+ `reasoning_effort` it does not take, a ceiling it spells `max_completion_tokens`, a temperature
78
+ that is not ours to pick. They cannot latch on the endpoint, because one API key reaches every
79
+ model a provider offers: the first turn on `gpt-4o` would stop `gpt-5` ever being asked to reason
80
+ again, with the setting still reading `high` and nothing anywhere saying it had stopped.
81
+
82
+ So they hang off the endpoint under the name the endpoint knows the model by. Pass `model` and
83
+ the same loop answers both levels; leave it out and nothing changes.
84
+
85
+ ```ts
86
+ const turn = await negotiate(supports, (supports, produced, model) =>
87
+ streamTurn(
88
+ getClient(config),
89
+ {
90
+ model: name, messages, stream: true,
91
+ // Each rebuilt per attempt from what this model has already refused.
92
+ ...(model?.reasoningEffort ? { reasoning_effort: effort } : {}),
93
+ ...(model?.legacyTokenLimit ? { max_tokens: limit } : { max_completion_tokens: limit }),
94
+ ...(model?.chosenTemperature ? { temperature } : {}),
95
+ tools: supports.strictSchemas ? declared : relaxTools(declared),
96
+ },
97
+ { produced, signal },
98
+ ),
99
+ { model: name },
100
+ );
101
+ ```
102
+
103
+ `runTurn` takes the same option and hands `request` the same second argument. Keying on
104
+ `(endpoint, model)` rather than the model name alone is the part worth keeping: `gpt-4o` at
105
+ OpenAI and `gpt-4o` behind a proxy need not be the same weights, and one that refused a reasoning
106
+ effort must not speak for the other.
107
+
73
108
  `send` takes a callback rather than a body because the body has to be rebuilt from the latched
74
109
  flags. `produced` is one box per attempt — `streamTurn` sets it as soon as the server says
75
110
  anything, and the re-send reads it — so a caller with its own retry budget passes one in
@@ -8,7 +8,10 @@ import type { Produced } from "./stream.ts";
8
8
  * about the server on the other end rather than about this process, which is what makes them
9
9
  * this package's to hold.
10
10
  */
11
- /** What one endpoint turned out not to support. Both start optimistic and only ever latch off. */
11
+ /**
12
+ * What one endpoint turned out not to support. Both flags start optimistic and only ever latch
13
+ * off; what is about the model rather than the server hangs off `models`.
14
+ */
12
15
  export interface Capabilities {
13
16
  /**
14
17
  * llama.cpp-backed servers compile every tool schema into one grammar and reject keywords
@@ -23,14 +26,63 @@ export interface Capabilities {
23
26
  * happens: the counts are worth one failed call to find out about, not one per run.
24
27
  */
25
28
  usageInStream: boolean;
29
+ /**
30
+ * What each model reached through this endpoint turned out not to support, by the name the
31
+ * endpoint knows it as. A second level rather than two more flags beside these, because one
32
+ * API key reaches every model a provider offers: a flag here would let the first turn on a
33
+ * model that cannot reason latch "no reasoning" for every later turn on one that can, which
34
+ * stops asking for it with the setting still reading `high` and nothing anywhere saying it
35
+ * stopped. Empty until `modelCapabilitiesFor` is asked about a model.
36
+ */
37
+ models: Map<string, ModelCapabilities>;
38
+ }
39
+ /**
40
+ * What one model on that endpoint turned out not to support. All start optimistic and only ever
41
+ * latch off, the same as the endpoint's own.
42
+ *
43
+ * These arrive through the same channel as the endpoint's — an error string on a chat
44
+ * completion — which is why they are negotiated by the same loop rather than a second one. What
45
+ * makes them the model's is that the answer differs between two models the same key reaches.
46
+ */
47
+ export interface ModelCapabilities {
48
+ /**
49
+ * Takes a `reasoning_effort` at all. A model that cannot reason refuses the field rather than
50
+ * ignoring it, so the whole request fails over a setting that means nothing to it.
51
+ */
52
+ reasoningEffort: boolean;
53
+ /**
54
+ * Spells its ceiling `max_tokens`. The reasoning models want `max_completion_tokens` instead,
55
+ * and they are exactly the models anyone sets an effort on.
56
+ */
57
+ legacyTokenLimit: boolean;
58
+ /**
59
+ * Takes a temperature we picked, rather than only the one it was built with. A reasoning model
60
+ * refuses any other value, including the one a settings row has been showing all along.
61
+ */
62
+ chosenTemperature: boolean;
26
63
  }
27
64
  /**
28
65
  * What this endpoint is known not to support. The same object every time, so what `negotiate`
29
66
  * latches off stays off.
30
67
  *
31
- * @param baseUrl Identifies the endpoint. These are per-server, not per-model.
68
+ * @param baseUrl Identifies the endpoint. The two flags on it are per-server; what is
69
+ * per-model hangs off `models`, which `modelCapabilitiesFor` reads.
32
70
  */
33
71
  export declare function capabilitiesFor(baseUrl: string): Capabilities;
72
+ /**
73
+ * What this model on this endpoint is known not to support. The same object every time, so what
74
+ * `negotiate` latches off stays off.
75
+ *
76
+ * Nested under the endpoint rather than keyed by name alone, because `gpt-4o` at OpenAI and
77
+ * `gpt-4o` behind a proxy need not be the same weights — and a proxy is free to answer to a name
78
+ * it does not really serve. One that refused a reasoning effort must not speak for the other.
79
+ * Bounded by the models actually asked for on that endpoint, which is what a dropdown holds.
80
+ *
81
+ * @param supports The endpoint's own, as `capabilitiesFor` hands it over.
82
+ * @param model The name the endpoint knows the model as — whatever goes in the request body,
83
+ * since that is the only name the refusal is about.
84
+ */
85
+ export declare function modelCapabilitiesFor(supports: Capabilities, model: string): ModelCapabilities;
34
86
  /** Forgets every endpoint's capabilities. For tests, and for a settings change under test. */
35
87
  export declare function resetCapabilities(): void;
36
88
  /** What `negotiate` takes besides the request. Both optional, both about telling someone. */
@@ -45,6 +97,15 @@ export interface NegotiateOptions {
45
97
  produced?: Produced;
46
98
  /** Told what was given up on, for a watcher who would otherwise see an unexplained pause. */
47
99
  onNotice?: (message: string) => void;
100
+ /**
101
+ * Which model this request is for, by the name the endpoint knows it as.
102
+ *
103
+ * Given one, the refusals that are about the model rather than the server are answered too,
104
+ * and `send` is handed what that model has already refused. Left out, nothing changes — which
105
+ * is the point of it being here rather than a third positional argument: a caller with one
106
+ * model per endpoint, or one that only ever meets the endpoint's own refusals, needs no edit.
107
+ */
108
+ model?: string;
48
109
  }
49
110
  /**
50
111
  * Sends a request, re-sending it each time the answer is this endpoint refusing something the
@@ -57,6 +118,11 @@ export interface NegotiateOptions {
57
118
  * what the second one starts knowing. It terminates in at most one pass per capability, since
58
119
  * each pass either latches one off for good or rethrows.
59
120
  *
121
+ * One loop over both levels, because a refusal arrives the same way whichever it is about and
122
+ * one request can meet both. An OpenAI reasoning model has two waiting on its own — the ceiling
123
+ * is spelled the other way, and then the temperature is not ours to pick — so a loop that
124
+ * stopped after the first answer would hand the caller the second.
125
+ *
60
126
  * `send` is a thunk rather than a request body because the body has to be rebuilt from the
61
127
  * latched flags: `relaxTools` applies to the tools that were just sanitised, and
62
128
  * `stream_options` is present or absent rather than adjusted. It is generic over what it
@@ -70,7 +136,9 @@ export interface NegotiateOptions {
70
136
  *
71
137
  * @param supports What this endpoint has already refused. Latched off further as it refuses more.
72
138
  * @param send Builds and sends the request. Called again per downgrade, never once tokens
73
- * have arrived.
74
- * @param options `produced` for a caller with its own retry budget, `onNotice` for a watcher.
139
+ * have arrived. Its third argument is what the named model has refused, absent when no model
140
+ * was named.
141
+ * @param options `produced` for a caller with its own retry budget, `onNotice` for a watcher,
142
+ * `model` to negotiate the model's refusals alongside the endpoint's.
75
143
  */
76
- export declare function negotiate<T>(supports: Capabilities, send: (supports: Capabilities, produced: Produced) => Promise<T>, { produced, onNotice }?: NegotiateOptions): Promise<T>;
144
+ export declare function negotiate<T>(supports: Capabilities, send: (supports: Capabilities, produced: Produced, model: ModelCapabilities | undefined) => Promise<T>, { produced, onNotice, model: name }?: NegotiateOptions): Promise<T>;
@@ -16,24 +16,74 @@ const capabilities = new Map();
16
16
  * What this endpoint is known not to support. The same object every time, so what `negotiate`
17
17
  * latches off stays off.
18
18
  *
19
- * @param baseUrl Identifies the endpoint. These are per-server, not per-model.
19
+ * @param baseUrl Identifies the endpoint. The two flags on it are per-server; what is
20
+ * per-model hangs off `models`, which `modelCapabilitiesFor` reads.
20
21
  */
21
22
  export function capabilitiesFor(baseUrl) {
22
23
  let known = capabilities.get(baseUrl);
23
24
  if (!known) {
24
- known = { strictSchemas: true, usageInStream: true };
25
+ known = { strictSchemas: true, usageInStream: true, models: new Map() };
25
26
  capabilities.set(baseUrl, known);
26
27
  }
27
28
  return known;
28
29
  }
30
+ /**
31
+ * What this model on this endpoint is known not to support. The same object every time, so what
32
+ * `negotiate` latches off stays off.
33
+ *
34
+ * Nested under the endpoint rather than keyed by name alone, because `gpt-4o` at OpenAI and
35
+ * `gpt-4o` behind a proxy need not be the same weights — and a proxy is free to answer to a name
36
+ * it does not really serve. One that refused a reasoning effort must not speak for the other.
37
+ * Bounded by the models actually asked for on that endpoint, which is what a dropdown holds.
38
+ *
39
+ * @param supports The endpoint's own, as `capabilitiesFor` hands it over.
40
+ * @param model The name the endpoint knows the model as — whatever goes in the request body,
41
+ * since that is the only name the refusal is about.
42
+ */
43
+ export function modelCapabilitiesFor(supports, model) {
44
+ let known = supports.models.get(model);
45
+ if (!known) {
46
+ known = { reasoningEffort: true, legacyTokenLimit: true, chosenTemperature: true };
47
+ supports.models.set(model, known);
48
+ }
49
+ return known;
50
+ }
29
51
  /** Forgets every endpoint's capabilities. For tests, and for a settings change under test. */
30
52
  export function resetCapabilities() {
31
53
  capabilities.clear();
32
54
  }
33
- /** Every flag on a `Capabilities`, typed, so a third one is compared without an edit here. */
34
- const keys = (of) => Object.keys(of);
55
+ /**
56
+ * Every latching flag in play on one attempt, endpoint and model together, in a stable order.
57
+ * Read positionally and only against another reading of the same two objects: what it answers is
58
+ * whether anything moved while the request was out, and a flag added to either interface later is
59
+ * compared without an edit here. `models` is not one of them — it is the second level, not a
60
+ * flag, and the map is the same object throughout.
61
+ */
62
+ const flagsOf = (supports, model) => [
63
+ ...Object.values(supports).filter((value) => typeof value === "boolean"),
64
+ ...(model ? Object.values(model) : []),
65
+ ];
35
66
  /** `stream_options` is named in the refusal by every server that has not heard of it. */
36
67
  const REJECTS_USAGE = /stream_options/i;
68
+ /** A model that cannot reason refuses the field by name. */
69
+ const rejectsEffort = (detail) => /reasoning_effort/i.test(detail);
70
+ /**
71
+ * Read only alongside the name it is asking for: `'max_tokens' is not supported with this model.
72
+ * Use 'max_completion_tokens' instead.`
73
+ *
74
+ * A bare `max_tokens` complaint is also how a server says the *number* was too large —
75
+ * `max_tokens is too large: 200000. This model supports at most 16384.` — and the answer to that
76
+ * is not to send the same number under a different name. It is to let the error out, where
77
+ * whoever typed the number can see it.
78
+ */
79
+ const wantsCompletionLimit = (detail) => /max_tokens/i.test(detail) && /max_completion_tokens/i.test(detail);
80
+ /**
81
+ * `'temperature' does not support 0.7 with this model. Only the default (1) is supported.`
82
+ *
83
+ * The qualifier is load-bearing. A temperature out of range is the caller's mistake to see
84
+ * rather than ours to work around, and dropping the field would hide it.
85
+ */
86
+ const refusesChosenTemperature = (detail) => /temperature/i.test(detail) && /only the default|does not support/i.test(detail);
37
87
  /**
38
88
  * Sends a request, re-sending it each time the answer is this endpoint refusing something the
39
89
  * request can do without. Returns once the endpoint has answered, or throws if the refusal is
@@ -45,6 +95,11 @@ const REJECTS_USAGE = /stream_options/i;
45
95
  * what the second one starts knowing. It terminates in at most one pass per capability, since
46
96
  * each pass either latches one off for good or rethrows.
47
97
  *
98
+ * One loop over both levels, because a refusal arrives the same way whichever it is about and
99
+ * one request can meet both. An OpenAI reasoning model has two waiting on its own — the ceiling
100
+ * is spelled the other way, and then the temperature is not ours to pick — so a loop that
101
+ * stopped after the first answer would hand the caller the second.
102
+ *
48
103
  * `send` is a thunk rather than a request body because the body has to be rebuilt from the
49
104
  * latched flags: `relaxTools` applies to the tools that were just sanitised, and
50
105
  * `stream_options` is present or absent rather than adjusted. It is generic over what it
@@ -58,17 +113,21 @@ const REJECTS_USAGE = /stream_options/i;
58
113
  *
59
114
  * @param supports What this endpoint has already refused. Latched off further as it refuses more.
60
115
  * @param send Builds and sends the request. Called again per downgrade, never once tokens
61
- * have arrived.
62
- * @param options `produced` for a caller with its own retry budget, `onNotice` for a watcher.
116
+ * have arrived. Its third argument is what the named model has refused, absent when no model
117
+ * was named.
118
+ * @param options `produced` for a caller with its own retry budget, `onNotice` for a watcher,
119
+ * `model` to negotiate the model's refusals alongside the endpoint's.
63
120
  */
64
- export async function negotiate(supports, send, { produced = { any: false }, onNotice } = {}) {
121
+ export async function negotiate(supports, send, { produced = { any: false }, onNotice, model: name } = {}) {
122
+ const model = name === undefined ? undefined : modelCapabilitiesFor(supports, name);
65
123
  for (;;) {
66
- // What this attempt was built with. `capabilitiesFor` hands one object per endpoint to
67
- // everyone on it, so a run starting alongside this one may latch a flag off while this call
68
- // is in flight — and the branches below are guarded on the flag still being set.
69
- const sent = { ...supports };
124
+ // What this attempt was built with. `capabilitiesFor` and `modelCapabilitiesFor` hand one
125
+ // object per endpoint and per model to everyone on them, so a run starting alongside this
126
+ // one may latch a flag off while this call is in flight — and the branches below are guarded
127
+ // on the flag still being set.
128
+ const sent = flagsOf(supports, model);
70
129
  try {
71
- return await send(supports, produced);
130
+ return await send(supports, produced, model);
72
131
  }
73
132
  catch (error) {
74
133
  if (produced.any)
@@ -82,7 +141,19 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
82
141
  supports.usageInStream = false;
83
142
  onNotice?.("server rejected stream_options; token counts unavailable");
84
143
  }
85
- else if (keys(sent).every((flag) => sent[flag] === supports[flag])) {
144
+ else if (model?.reasoningEffort && rejectsEffort(detail)) {
145
+ model.reasoningEffort = false;
146
+ onNotice?.("model does not take a reasoning effort; retrying without one");
147
+ }
148
+ else if (model?.legacyTokenLimit && wantsCompletionLimit(detail)) {
149
+ model.legacyTokenLimit = false;
150
+ onNotice?.("model wants max_completion_tokens; retrying with the limit spelled that way");
151
+ }
152
+ else if (model?.chosenTemperature && refusesChosenTemperature(detail)) {
153
+ model.chosenTemperature = false;
154
+ onNotice?.("model takes only its own temperature; retrying without ours");
155
+ }
156
+ else if (flagsOf(supports, model).every((flag, index) => flag === sent[index])) {
86
157
  throw error;
87
158
  }
88
159
  // Otherwise the refusal was answered by whoever got there first, and this attempt was
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
12
- export { type Capabilities, capabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
12
+ export { type Capabilities, capabilitiesFor, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
13
13
  export type { CatalogServer } from "./catalog.ts";
14
14
  export { contextLimitFor, getClient, listModels, type ModelInfo, NO_KEY, resetClients, timeoutMs, } from "./client.ts";
15
15
  export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
12
- export { capabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
12
+ export { capabilitiesFor, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
13
13
  export { contextLimitFor, getClient, listModels, NO_KEY, resetClients, timeoutMs, } from "./client.js";
14
14
  export { errorMessage } from "./errors.js";
15
15
  export { emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
@@ -1,13 +1,14 @@
1
1
  import type OpenAI from "openai";
2
- import { type Capabilities } from "./capabilities.ts";
2
+ import { type Capabilities, type ModelCapabilities } from "./capabilities.ts";
3
3
  import { type StreamTurnOptions, type Turn } from "./stream.ts";
4
4
  /**
5
5
  * One turn, given as many attempts as the caller allows.
6
6
  *
7
7
  * Two different things are being recovered from here, and they nest. The inner one is a
8
- * capability the endpoint turns out not to have — `stream_options`, a grammar keyword — which
9
- * is a refusal: it is answered by sending a lesser request, and it latches against that
10
- * endpoint for the life of the process, so it costs one failed call rather than one a run.
8
+ * capability the endpoint turns out not to have — `stream_options`, a grammar keyword — or that
9
+ * the model does not, given `model` below. Either is a refusal: it is answered by sending a
10
+ * lesser request, and it latches for the life of the process against the endpoint or against
11
+ * that one model on it, so it costs one failed call rather than one a run.
11
12
  * The outer one is the endpoint being unreachable, busy or silent, which is not about this
12
13
  * request at all and is worth simply waiting out.
13
14
  *
@@ -40,6 +41,15 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
40
41
  * run over one would be the guard failing exactly the callers it was meant to help.
41
42
  */
42
43
  contextLimit?: number;
44
+ /**
45
+ * Which model the body names, so the refusals that are about the model rather than the server
46
+ * are negotiated too — a reasoning effort it does not take, a token ceiling it spells the
47
+ * other way, a temperature that is not ours to pick. Left out, only the endpoint's own are.
48
+ *
49
+ * It is given here rather than read off the body because the body is built from the answer:
50
+ * `request` has to know what this model refused before it can build one that avoids it.
51
+ */
52
+ model?: string;
43
53
  }
44
54
  /**
45
55
  * `request` is a callback rather than a body because the body has to be rebuilt from whatever
@@ -49,7 +59,9 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
49
59
  *
50
60
  * @param client The pooled client for this endpoint.
51
61
  * @param supports What the endpoint has already refused, threaded through the negotiation.
52
- * @param request Builds the body. Called again per attempt, since a downgrade changes it.
53
- * @param options Retry budget, context limit, notices, and the stream's own callbacks.
62
+ * @param request Builds the body. Called again per attempt, since a downgrade changes it. Its
63
+ * second argument is what the model named in `options.model` has refused, absent when none was.
64
+ * @param options Retry budget, context limit, the model to negotiate for, notices, and the
65
+ * stream's own callbacks.
54
66
  */
55
- export declare function runTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities) => OpenAI.ChatCompletionCreateParamsStreaming, { maxRetries, onNotice, contextLimit, ...stream }?: RunTurnOptions): Promise<Turn>;
67
+ export declare function runTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities, model: ModelCapabilities | undefined) => OpenAI.ChatCompletionCreateParamsStreaming, { maxRetries, onNotice, contextLimit, model, ...stream }?: RunTurnOptions): Promise<Turn>;
package/dist/run-turn.js CHANGED
@@ -10,17 +10,19 @@ import { streamTurn } from "./stream.js";
10
10
  *
11
11
  * @param client The pooled client for this endpoint.
12
12
  * @param supports What the endpoint has already refused, threaded through the negotiation.
13
- * @param request Builds the body. Called again per attempt, since a downgrade changes it.
14
- * @param options Retry budget, context limit, notices, and the stream's own callbacks.
13
+ * @param request Builds the body. Called again per attempt, since a downgrade changes it. Its
14
+ * second argument is what the model named in `options.model` has refused, absent when none was.
15
+ * @param options Retry budget, context limit, the model to negotiate for, notices, and the
16
+ * stream's own callbacks.
15
17
  */
16
- export async function runTurn(client, supports, request, { maxRetries = 0, onNotice, contextLimit = 0, ...stream } = {}) {
18
+ export async function runTurn(client, supports, request, { maxRetries = 0, onNotice, contextLimit = 0, model, ...stream } = {}) {
17
19
  // Sized once rather than per build. `request` is called again for every downgrade and every
18
20
  // retry, but a downgraded body is strictly smaller than the one before it and the transcript
19
21
  // does not change between attempts — so the first body is the one worth measuring, and
20
22
  // measuring the rest would only spend the walk again to reach the same answer.
21
23
  let sized = false;
22
- const measured = (capabilities) => {
23
- const body = request(capabilities);
24
+ const measured = (capabilities, forModel) => {
25
+ const body = request(capabilities, forModel);
24
26
  if (!sized && contextLimit >= SMALLEST_LIKELY_WINDOW) {
25
27
  sized = true;
26
28
  const needed = requestTokens(body);
@@ -36,7 +38,7 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
36
38
  for (let attempt = 0;; attempt++) {
37
39
  const produced = { any: false };
38
40
  try {
39
- return await negotiate(supports, (capabilities, box) => streamTurn(client, measured(capabilities), { ...stream, produced: box }), { produced, onNotice });
41
+ return await negotiate(supports, (capabilities, box, forModel) => streamTurn(client, measured(capabilities, forModel), { ...stream, produced: box }), { produced, onNotice, model });
40
42
  }
41
43
  catch (error) {
42
44
  // The abort is read before the classification, not after. A run stopped by its operator
@@ -232,6 +232,84 @@ function mergeRootUnion(out) {
232
232
  }
233
233
  }
234
234
  }
235
+ /**
236
+ * Every `$ref` string anywhere under a node, walked as arbitrary JSON rather than as a schema.
237
+ *
238
+ * The keyword-aware walk `strip` does is the wrong way round for this one. Missing a pointer
239
+ * here means deleting a definition that something still refers to, which breaks the schema;
240
+ * finding one that was really a string sitting in a `default` or an `enum` costs a definition
241
+ * that outlives its last real reference. So this errs the cheap way and reads every position.
242
+ */
243
+ function collectRefs(node, into) {
244
+ if (Array.isArray(node)) {
245
+ for (const item of node)
246
+ collectRefs(item, into);
247
+ return;
248
+ }
249
+ if (!isObject(node))
250
+ return;
251
+ for (const [key, value] of Object.entries(node)) {
252
+ if (key === "$ref" && typeof value === "string")
253
+ into.add(value);
254
+ else
255
+ collectRefs(value, into);
256
+ }
257
+ }
258
+ /**
259
+ * Drops the `definitions` and `$defs` entries that nothing points at any more.
260
+ *
261
+ * The rewrites above delete whole subtrees — a root combinator once its branches are folded in,
262
+ * every sibling of a `$ref`, the branch of a union that was only ever `null` — and the pointers
263
+ * go with them while the pools they named stay behind. On a real Gmail or filesystem schema
264
+ * those pools are most of the parameter bytes, re-sent for every tool on every turn of every
265
+ * run, describing shapes the request no longer mentions anywhere.
266
+ *
267
+ * Reachability rather than a single pass, because a definition that is still pointed at can
268
+ * name another; a cycle among them terminates on the `has` check, whether or not anything
269
+ * outside it still refers in.
270
+ */
271
+ function pruneDefs(out) {
272
+ const pools = ["definitions", "$defs"].filter((key) => isObject(out[key]));
273
+ if (!pools.length)
274
+ return;
275
+ const live = {};
276
+ const visit = (node) => {
277
+ const pointers = new Set();
278
+ collectRefs(node, pointers);
279
+ for (const pointer of pointers) {
280
+ const target = LOCAL_POINTER.exec(pointer);
281
+ if (!target)
282
+ continue;
283
+ const [, poolKey, name] = target;
284
+ const pool = out[poolKey];
285
+ if (!isObject(pool) || !(name in pool))
286
+ continue;
287
+ live[poolKey] ??= new Set();
288
+ const names = live[poolKey];
289
+ if (names.has(name))
290
+ continue;
291
+ names.add(name);
292
+ visit(pool[name]);
293
+ }
294
+ };
295
+ // The pools themselves are not roots: a definition is reached from the schema body, or by
296
+ // another definition that was, or not at all.
297
+ const body = { ...out };
298
+ for (const key of pools)
299
+ delete body[key];
300
+ visit(body);
301
+ for (const key of pools) {
302
+ const names = live[key];
303
+ if (!names?.size) {
304
+ delete out[key];
305
+ continue;
306
+ }
307
+ const pool = out[key];
308
+ if (names.size === Object.keys(pool).length)
309
+ continue;
310
+ out[key] = Object.fromEntries(Object.entries(pool).filter(([name]) => names.has(name)));
311
+ }
312
+ }
235
313
  /**
236
314
  * A required argument that is not in `properties` is one no caller can supply and no strict
237
315
  * validator will accept. Anything the rewrites above removed, `required` may still name.
@@ -259,6 +337,7 @@ function sanitizeParameters(parameters) {
259
337
  if (!isObject(out.properties))
260
338
  out.properties = {};
261
339
  pruneRequired(out);
340
+ pruneDefs(out);
262
341
  return out;
263
342
  }
264
343
  /** Rewrites one tool's parameters, leaving a non-function tool alone. */
package/llms.txt CHANGED
@@ -15,6 +15,8 @@ What an endpoint turned out not to support, and answering it when it says so.
15
15
 
16
16
  - `Capabilities` (type) — What one endpoint turned out not to support.
17
17
  - `capabilitiesFor` — What this endpoint is known not to support.
18
+ - `ModelCapabilities` (type) — What one model on that endpoint turned out not to support.
19
+ - `modelCapabilitiesFor` — What this model on this endpoint is known not to support.
18
20
  - `NegotiateOptions` (type) — What `negotiate` takes besides the request.
19
21
  - `negotiate` — Sends a request, re-sending it each time the answer is this endpoint refusing something the request can do without.
20
22
  - `resetCapabilities` — Forgets every endpoint's capabilities.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.0.7",
3
+ "version": "2.1.0",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",