@cubicecho/agent-core 2.1.2 → 2.2.1

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
@@ -90,7 +90,9 @@ const turn = await negotiate(supports, (supports, produced, model) =>
90
90
  model: name, messages, stream: true,
91
91
  // Each rebuilt per attempt from what this model has already refused.
92
92
  ...(model?.reasoningEffort ? { reasoning_effort: effort } : {}),
93
- ...(model?.legacyTokenLimit ? { max_tokens: limit } : { max_completion_tokens: limit }),
93
+ ...(model?.legacyTokenLimit === false
94
+ ? { max_completion_tokens: limit }
95
+ : { max_tokens: limit }),
94
96
  ...(model?.chosenTemperature ? { temperature } : {}),
95
97
  tools: supports.strictSchemas ? declared : relaxTools(declared),
96
98
  },
@@ -100,6 +102,14 @@ const turn = await negotiate(supports, (supports, produced, model) =>
100
102
  );
101
103
  ```
102
104
 
105
+ The ceiling is the one of the three guarded on `=== false` rather than on truthiness, and it is
106
+ the only one that has to be. The other two *omit* a field where the flag is absent, so a caller
107
+ that leaves `model` out sends a smaller request and nothing else; both branches of this one are a
108
+ field, so truthiness picks the newer spelling for a caller who was told nothing would change —
109
+ and the newer spelling is exactly the one an older model or a llama.cpp-shaped endpoint rejects.
110
+ `modelCapabilitiesFor` starts a model at `legacyTokenLimit: true`, and an absent model has to read
111
+ the same way it does.
112
+
103
113
  `runTurn` takes the same option and hands `request` the same second argument. Keying on
104
114
  `(endpoint, model)` rather than the model name alone is the part worth keeping: `gpt-4o` at
105
115
  OpenAI and `gpt-4o` behind a proxy need not be the same weights, and one that refused a reasoning
@@ -53,6 +53,11 @@ export interface ModelCapabilities {
53
53
  /**
54
54
  * Spells its ceiling `max_tokens`. The reasoning models want `max_completion_tokens` instead,
55
55
  * and they are exactly the models anyone sets an effort on.
56
+ *
57
+ * Read it as `=== false` rather than for truthiness. This is the one flag whose two answers are
58
+ * both a field to send, so a caller that named no model — and was told that changes nothing —
59
+ * gets `undefined` here and, on a truthiness test, sends `max_completion_tokens` to a server
60
+ * that never refused anything. The flag starts `true`; absent has to mean the same.
56
61
  */
57
62
  legacyTokenLimit: boolean;
58
63
  /**
@@ -95,7 +100,17 @@ export interface NegotiateOptions {
95
100
  * this out and take the flag from `send`'s second argument, which is the same object.
96
101
  */
97
102
  produced?: Produced;
98
- /** Told what was given up on, for a watcher who would otherwise see an unexplained pause. */
103
+ /**
104
+ * Told what was given up on, for a watcher who would otherwise see an unexplained pause.
105
+ *
106
+ * Each message opens with the thing that refused, so a reader tells the two levels apart
107
+ * without parsing: `server` for the endpoint's own, and the model's own name — as `model` was
108
+ * given it — for the three that are the model's. That matters because these latch for the
109
+ * life of the process and this line is the only announcement that one did: on a consumer
110
+ * where the model is a per-turn setting, "model does not take a reasoning effort" is the same
111
+ * line for every model on the endpoint, and the operator who later asks why the setting still
112
+ * reads `high` has no way to tell which one it was about.
113
+ */
99
114
  onNotice?: (message: string) => void;
100
115
  /**
101
116
  * Which model this request is for, by the name the endpoint knows it as.
@@ -136,7 +136,11 @@ const refusesChosenTemperature = (detail) => /temperature/i.test(detail) && /onl
136
136
  * `model` to negotiate the model's refusals alongside the endpoint's.
137
137
  */
138
138
  export async function negotiate(supports, send, { produced = { any: false }, onNotice, model: name } = {}) {
139
- const model = name === undefined ? undefined : modelCapabilitiesFor(supports, name);
139
+ // The name and what it has refused, bound together because the notices below need both. They
140
+ // exist or are absent as one — the second is resolved from the first — but that is a fact
141
+ // about two locals, and narrowing one of those tells TypeScript nothing about the other.
142
+ const named = name === undefined ? undefined : { name, refused: modelCapabilitiesFor(supports, name) };
143
+ const model = named?.refused;
140
144
  for (;;) {
141
145
  // What this attempt was built with. `capabilitiesFor` and `modelCapabilitiesFor` hand one
142
146
  // object per endpoint and per model to everyone on them, so a run starting alongside this
@@ -158,17 +162,17 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
158
162
  supports.usageInStream = false;
159
163
  onNotice?.("server rejected stream_options; token counts unavailable");
160
164
  }
161
- else if (model?.reasoningEffort && rejectsEffort(detail)) {
162
- model.reasoningEffort = false;
163
- onNotice?.("model does not take a reasoning effort; retrying without one");
165
+ else if (named?.refused.reasoningEffort && rejectsEffort(detail)) {
166
+ named.refused.reasoningEffort = false;
167
+ onNotice?.(`${named.name} does not take a reasoning effort; retrying without one`);
164
168
  }
165
- else if (model?.legacyTokenLimit && wantsCompletionLimit(detail)) {
166
- model.legacyTokenLimit = false;
167
- onNotice?.("model wants max_completion_tokens; retrying with the limit spelled that way");
169
+ else if (named?.refused.legacyTokenLimit && wantsCompletionLimit(detail)) {
170
+ named.refused.legacyTokenLimit = false;
171
+ onNotice?.(`${named.name} wants max_completion_tokens; retrying with the limit spelled that way`);
168
172
  }
169
- else if (model?.chosenTemperature && refusesChosenTemperature(detail)) {
170
- model.chosenTemperature = false;
171
- onNotice?.("model takes only its own temperature; retrying without ours");
173
+ else if (named?.refused.chosenTemperature && refusesChosenTemperature(detail)) {
174
+ named.refused.chosenTemperature = false;
175
+ onNotice?.(`${named.name} takes only its own temperature; retrying without ours`);
172
176
  }
173
177
  else if (flagsOf(supports, model).every((flag, index) => flag === sent[index])) {
174
178
  throw error;
package/dist/client.js CHANGED
@@ -76,10 +76,16 @@ function contextLengthOf(model) {
76
76
  /**
77
77
  * The last listing from each endpoint, so a run can size its window without a round trip.
78
78
  *
79
- * Keyed the same way the clients are, because two endpoints are two different sets of models
80
- * and one of them having answered says nothing about the other. It is only ever a cache of
81
- * something asked for anyway, and a listing that fails leaves whatever was there rather than
82
- * emptying it.
79
+ * Keyed by base URL and key, because two endpoints are two different sets of models and one of
80
+ * them having answered says nothing about the other and because a key can be the difference
81
+ * between what a router will show one caller and another.
82
+ *
83
+ * The timeout is deliberately not in it, which is where this key parts company with the
84
+ * clients'. Which models a server offers has nothing to do with how long we are willing to wait
85
+ * for it, so two settings rows differing only there ask once between them rather than twice.
86
+ *
87
+ * It is only ever a cache of something asked for anyway, and a listing that fails leaves
88
+ * whatever was there rather than emptying it.
83
89
  */
84
90
  const listings = new Map();
85
91
  const endpointKey = (config) => JSON.stringify([config.baseUrl, config.apiKey || NO_KEY]);
package/dist/retry.d.ts CHANGED
@@ -33,8 +33,10 @@ export declare const compact: (tokens: number) => string;
33
33
  * built the entire transcript into a string on every call and threw it away having read nothing
34
34
  * but its `.length` — against a transcript that grows by a turn each turn, and one the SDK is
35
35
  * about to serialise again to send. What the walk misses is JSON's own punctuation and the keys,
36
- * which `ENVELOPE` puts back approximately; the difference is a rounding error against an
37
- * estimate that is already characters over four.
36
+ * which the envelope constants put back one per key rather than one per message, since the
37
+ * three keys only some shapes carry are most of what a tool-using transcript is made of. What
38
+ * is left is a message's escaping, which is not a constant and is small against an estimate
39
+ * that is already characters over four.
38
40
  *
39
41
  * @param body The request as it will be sent, tools included.
40
42
  */
@@ -49,18 +51,19 @@ export declare const requestTokens: (body: OpenAI.ChatCompletionCreateParamsStre
49
51
  */
50
52
  export declare const isOverflow: (detail: string) => boolean;
51
53
  /**
52
- * The smallest window worth believing in, and the floor under both of its uses.
54
+ * The smallest window worth believing in, and the floor under `runTurn`'s guard.
53
55
  *
54
56
  * `runTurn`'s `contextLimit` reads it as a sanity check on a number it was handed: below this,
55
57
  * the limit is taken for a placeholder — an unset column, a listing that said nothing — rather
56
- * than a window worth refusing a run over. `contextLimitFor` reads it as the point below which
57
- * the window is nobody's business and is not asked for.
58
+ * than a window worth refusing a run over. That is the whole of what reads it; `contextLimitFor`
59
+ * asks the endpoint whatever the number, and `client.ts` does not import this file.
58
60
  *
59
- * Finding out what a model reads costs a listing against its endpoint, and a run whose whole
60
- * request is a few thousand tokens fits anything anyone serves spending a round trip to
61
- * confirm that, on every run of every card, would be the cost of the guard falling on the
62
- * runs that never needed it. A model in a window smaller than this exists, and a request that
63
- * overruns one is left to the endpoint's own complaint, which reads properly now either way.
61
+ * A model in a window smaller than this exists, and the reason not to refuse a run over one is
62
+ * that the number far more often came from a caller threading a placeholder through than from
63
+ * such a model. A request that really does overrun a tiny window is left to the endpoint's own
64
+ * complaint, which `isOverflow` reads properly either way so what the floor costs is a round
65
+ * trip on the runs it declines to guard, and what it saves is refusing the ones it would have
66
+ * guarded wrongly.
64
67
  */
65
68
  export declare const SMALLEST_LIKELY_WINDOW = 8192;
66
69
  /**
package/dist/retry.js CHANGED
@@ -28,10 +28,21 @@ export const compact = (tokens) => tokens >= 1000 ? `${(tokens / 1000).toFixed(1
28
28
  /** What `{"role":"","content":""},` costs around a message's own text, in characters. */
29
29
  const ENVELOPE = 25;
30
30
  /** The same for `{"id":"","type":"function","function":{"name":"","arguments":""}},` in a call. */
31
- const CALL_ENVELOPE = 62;
31
+ const CALL_ENVELOPE = 66;
32
+ // `ENVELOPE` is the two keys every message has. These are the three that only some do, and each
33
+ // is the key with its punctuation and the comma after the value it holds — the value's own
34
+ // length is counted where the value is read. Applying `ENVELOPE` alone to these shapes left the
35
+ // keys out, which cost 4.5 tokens on every tool result: the message a tool-using run has most
36
+ // of, and short in the direction that lets an overflow through the guard meant to catch it.
37
+ /** What `"name":"",` costs around a message's name. */
38
+ const NAME_KEY = 10;
39
+ /** The same for `"tool_call_id":"",` around a tool result's call id. */
40
+ const TOOL_CALL_ID_KEY = 18;
41
+ /** The same for `"tool_calls":[]` around the calls; each call's own comma is in `CALL_ENVELOPE`. */
42
+ const TOOL_CALLS_KEY = 15;
32
43
  /** The divisor behind `estimateTokens`, applied here to a character count rather than a string. */
33
44
  const CHARS_PER_TOKEN = 4;
34
- /** How many characters one message is worth, whichever of the shapes its content is in. */
45
+ /** How many characters one message is worth: its keys, and its content in whichever shape. */
35
46
  function messageChars(message) {
36
47
  let chars = message.role.length + ENVELOPE;
37
48
  const { content } = message;
@@ -47,15 +58,17 @@ function messageChars(message) {
47
58
  chars += part.refusal.length;
48
59
  }
49
60
  if ("name" in message && typeof message.name === "string")
50
- chars += message.name.length;
61
+ chars += NAME_KEY + message.name.length;
51
62
  if ("tool_call_id" in message && typeof message.tool_call_id === "string")
52
- chars += message.tool_call_id.length;
53
- if ("tool_calls" in message && Array.isArray(message.tool_calls))
63
+ chars += TOOL_CALL_ID_KEY + message.tool_call_id.length;
64
+ if ("tool_calls" in message && Array.isArray(message.tool_calls)) {
65
+ chars += TOOL_CALLS_KEY;
54
66
  for (const call of message.tool_calls) {
55
67
  chars += CALL_ENVELOPE + call.id.length;
56
68
  if (call.type === "function")
57
69
  chars += call.function.name.length + call.function.arguments.length;
58
70
  }
71
+ }
59
72
  return chars;
60
73
  }
61
74
  /**
@@ -92,8 +105,10 @@ function toolsCost(tools) {
92
105
  * built the entire transcript into a string on every call and threw it away having read nothing
93
106
  * but its `.length` — against a transcript that grows by a turn each turn, and one the SDK is
94
107
  * about to serialise again to send. What the walk misses is JSON's own punctuation and the keys,
95
- * which `ENVELOPE` puts back approximately; the difference is a rounding error against an
96
- * estimate that is already characters over four.
108
+ * which the envelope constants put back one per key rather than one per message, since the
109
+ * three keys only some shapes carry are most of what a tool-using transcript is made of. What
110
+ * is left is a message's escaping, which is not a constant and is small against an estimate
111
+ * that is already characters over four.
97
112
  *
98
113
  * @param body The request as it will be sent, tools included.
99
114
  */
@@ -138,18 +153,19 @@ export const isOverflow = (detail) => !RATE_LIMITED.test(detail) &&
138
153
  OVERFLOW.some((pattern) => pattern.test(detail)) &&
139
154
  /token|context/i.test(detail);
140
155
  /**
141
- * The smallest window worth believing in, and the floor under both of its uses.
156
+ * The smallest window worth believing in, and the floor under `runTurn`'s guard.
142
157
  *
143
158
  * `runTurn`'s `contextLimit` reads it as a sanity check on a number it was handed: below this,
144
159
  * the limit is taken for a placeholder — an unset column, a listing that said nothing — rather
145
- * than a window worth refusing a run over. `contextLimitFor` reads it as the point below which
146
- * the window is nobody's business and is not asked for.
147
- *
148
- * Finding out what a model reads costs a listing against its endpoint, and a run whose whole
149
- * request is a few thousand tokens fits anything anyone serves spending a round trip to
150
- * confirm that, on every run of every card, would be the cost of the guard falling on the
151
- * runs that never needed it. A model in a window smaller than this exists, and a request that
152
- * overruns one is left to the endpoint's own complaint, which reads properly now either way.
160
+ * than a window worth refusing a run over. That is the whole of what reads it; `contextLimitFor`
161
+ * asks the endpoint whatever the number, and `client.ts` does not import this file.
162
+ *
163
+ * A model in a window smaller than this exists, and the reason not to refuse a run over one is
164
+ * that the number far more often came from a caller threading a placeholder through than from
165
+ * such a model. A request that really does overrun a tiny window is left to the endpoint's own
166
+ * complaint, which `isOverflow` reads properly either way so what the floor costs is a round
167
+ * trip on the runs it declines to guard, and what it saves is refusing the ones it would have
168
+ * guarded wrongly.
153
169
  */
154
170
  export const SMALLEST_LIKELY_WINDOW = 8192;
155
171
  /**
@@ -12,6 +12,11 @@ export interface SideTaskOptions {
12
12
  /**
13
13
  * Told what was given up on, the same way `runTurn` and `negotiate` tell a caller.
14
14
  *
15
+ * The one notice `ask` raises itself opens with the model's name, as `negotiate`'s do for the
16
+ * refusals that are the model's: what it announces is latched on the (endpoint, model) pair,
17
+ * so on a consumer reaching several models through one base URL the name is the only thing
18
+ * separating one announcement from the next.
19
+ *
15
20
  * There is no default, and nothing is printed without one. A library that writes to the
16
21
  * console decides for its consumer where operator text goes — which a server embedding this
17
22
  * cannot then route to its own logger, attach to the run it belongs to, or silence in tests.
package/dist/side-task.js CHANGED
@@ -125,7 +125,7 @@ export async function ask(config, model, system, user, { maxTokens = 512, temper
125
125
  // does not offer as `none`.
126
126
  if (!hints || !rejectedTheRequest(error))
127
127
  throw error;
128
- onNotice?.("server rejected the no-thinking hints; retrying without them");
128
+ onNotice?.(`${model} rejected the no-thinking hints; retrying without them`);
129
129
  noHints.add(key);
130
130
  response = await attempt(false);
131
131
  }
package/llms.txt CHANGED
@@ -81,7 +81,7 @@ Everything about a request failing that is not about what the request said.
81
81
  - `isOverflow` — Whether a refusal means the request was too big, rather than merely refused.
82
82
  - `isTransient` — Whether a failed request is worth trying again.
83
83
  - `requestTokens` — What this request will cost the window, in tokens, near enough.
84
- - `SMALLEST_LIKELY_WINDOW` — The smallest window worth believing in, and the floor under both of its uses.
84
+ - `SMALLEST_LIKELY_WINDOW` — The smallest window worth believing in, and the floor under `runTurn`'s guard.
85
85
  - `sleep` — A delay an abort cuts short, rejecting rather than resolving early.
86
86
 
87
87
  ### run-turn
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.1.2",
3
+ "version": "2.2.1",
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",