@cubicecho/agent-core 2.1.1 → 2.2.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 +11 -1
- package/dist/capabilities.d.ts +16 -1
- package/dist/capabilities.js +14 -10
- package/dist/client.js +10 -4
- package/dist/retry.d.ts +9 -8
- package/dist/retry.js +11 -10
- package/dist/side-task.js +37 -12
- package/llms.txt +1 -1
- package/package.json +1 -1
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
|
|
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
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
package/dist/capabilities.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
162
|
-
|
|
163
|
-
onNotice?.(
|
|
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 (
|
|
166
|
-
|
|
167
|
-
onNotice?.(
|
|
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 (
|
|
170
|
-
|
|
171
|
-
onNotice?.(
|
|
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
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
@@ -49,18 +49,19 @@ export declare const requestTokens: (body: OpenAI.ChatCompletionCreateParamsStre
|
|
|
49
49
|
*/
|
|
50
50
|
export declare const isOverflow: (detail: string) => boolean;
|
|
51
51
|
/**
|
|
52
|
-
* The smallest window worth believing in, and the floor under
|
|
52
|
+
* The smallest window worth believing in, and the floor under `runTurn`'s guard.
|
|
53
53
|
*
|
|
54
54
|
* `runTurn`'s `contextLimit` reads it as a sanity check on a number it was handed: below this,
|
|
55
55
|
* 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.
|
|
57
|
-
* the
|
|
56
|
+
* than a window worth refusing a run over. That is the whole of what reads it; `contextLimitFor`
|
|
57
|
+
* asks the endpoint whatever the number, and `client.ts` does not import this file.
|
|
58
58
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
59
|
+
* A model in a window smaller than this exists, and the reason not to refuse a run over one is
|
|
60
|
+
* that the number far more often came from a caller threading a placeholder through than from
|
|
61
|
+
* such a model. A request that really does overrun a tiny window is left to the endpoint's own
|
|
62
|
+
* complaint, which `isOverflow` reads properly either way — so what the floor costs is a round
|
|
63
|
+
* trip on the runs it declines to guard, and what it saves is refusing the ones it would have
|
|
64
|
+
* guarded wrongly.
|
|
64
65
|
*/
|
|
65
66
|
export declare const SMALLEST_LIKELY_WINDOW = 8192;
|
|
66
67
|
/**
|
package/dist/retry.js
CHANGED
|
@@ -28,7 +28,7 @@ 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 =
|
|
31
|
+
const CALL_ENVELOPE = 66;
|
|
32
32
|
/** The divisor behind `estimateTokens`, applied here to a character count rather than a string. */
|
|
33
33
|
const CHARS_PER_TOKEN = 4;
|
|
34
34
|
/** How many characters one message is worth, whichever of the shapes its content is in. */
|
|
@@ -138,18 +138,19 @@ export const isOverflow = (detail) => !RATE_LIMITED.test(detail) &&
|
|
|
138
138
|
OVERFLOW.some((pattern) => pattern.test(detail)) &&
|
|
139
139
|
/token|context/i.test(detail);
|
|
140
140
|
/**
|
|
141
|
-
* The smallest window worth believing in, and the floor under
|
|
141
|
+
* The smallest window worth believing in, and the floor under `runTurn`'s guard.
|
|
142
142
|
*
|
|
143
143
|
* `runTurn`'s `contextLimit` reads it as a sanity check on a number it was handed: below this,
|
|
144
144
|
* 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.
|
|
146
|
-
* the
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
145
|
+
* than a window worth refusing a run over. That is the whole of what reads it; `contextLimitFor`
|
|
146
|
+
* asks the endpoint whatever the number, and `client.ts` does not import this file.
|
|
147
|
+
*
|
|
148
|
+
* A model in a window smaller than this exists, and the reason not to refuse a run over one is
|
|
149
|
+
* that the number far more often came from a caller threading a placeholder through than from
|
|
150
|
+
* such a model. A request that really does overrun a tiny window is left to the endpoint's own
|
|
151
|
+
* complaint, which `isOverflow` reads properly either way — so what the floor costs is a round
|
|
152
|
+
* trip on the runs it declines to guard, and what it saves is refusing the ones it would have
|
|
153
|
+
* guarded wrongly.
|
|
153
154
|
*/
|
|
154
155
|
export const SMALLEST_LIKELY_WINDOW = 8192;
|
|
155
156
|
/**
|
package/dist/side-task.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
+
import { capabilitiesFor, negotiate } from "./capabilities.js";
|
|
2
3
|
import { getClient } from "./client.js";
|
|
3
4
|
import { errorMessage } from "./errors.js";
|
|
4
5
|
import { isTransient } from "./retry.js";
|
|
@@ -13,11 +14,13 @@ import { isTransient } from "./retry.js";
|
|
|
13
14
|
* the OpenAI-compatible spelling and `chat_template_kwargs` the llama.cpp/vLLM one; servers
|
|
14
15
|
* disagree about which they take, so send both. One that rejects the unknown fields gets a
|
|
15
16
|
* single retry without them, and is not offered them again.
|
|
17
|
+
*
|
|
18
|
+
* Only the second half is latched here. `reasoning_effort` is a field `negotiate` already knows
|
|
19
|
+
* how to be refused, so it is sent under `ModelCapabilities.reasoningEffort` instead — which
|
|
20
|
+
* both narrows the fallback below to the field it is really about, and shares the answer with
|
|
21
|
+
* the runs on that model rather than keeping a second opinion about it.
|
|
16
22
|
*/
|
|
17
|
-
const NO_THINKING = {
|
|
18
|
-
reasoning_effort: "none",
|
|
19
|
-
chat_template_kwargs: { enable_thinking: false },
|
|
20
|
-
};
|
|
23
|
+
const NO_THINKING = { chat_template_kwargs: { enable_thinking: false } };
|
|
21
24
|
/**
|
|
22
25
|
* The models that turned out not to take the hints, by endpoint and model.
|
|
23
26
|
*
|
|
@@ -27,9 +30,12 @@ const NO_THINKING = {
|
|
|
27
30
|
* second from ever being asked.
|
|
28
31
|
*
|
|
29
32
|
* The model belongs in the key for the same reason. One base URL is routinely many models —
|
|
30
|
-
* OpenRouter, LiteLLM, vLLM serving several at once — and whether `
|
|
31
|
-
*
|
|
32
|
-
* alone, the first model to refuse spoke for every model on it.
|
|
33
|
+
* OpenRouter, LiteLLM, vLLM serving several at once — and whether `chat_template_kwargs` reaches
|
|
34
|
+
* a chat template that reads it is a property of the model behind the route, not of the route.
|
|
35
|
+
* Keyed on the host alone, the first model to refuse spoke for every model on it.
|
|
36
|
+
*
|
|
37
|
+
* Only the `chat_template_kwargs` half is here. `reasoning_effort` is `negotiate`'s to latch, on
|
|
38
|
+
* the same (endpoint, model) pair, where a run on that model can read it too.
|
|
33
39
|
*/
|
|
34
40
|
const noHints = new Set();
|
|
35
41
|
const hintKey = (baseUrl, model) => JSON.stringify([baseUrl, model]);
|
|
@@ -81,28 +87,47 @@ const stripThinking = (text) => text
|
|
|
81
87
|
* @param options Reply ceiling, temperature, cancellation, notices.
|
|
82
88
|
*/
|
|
83
89
|
export async function ask(config, model, system, user, { maxTokens = 512, temperature = 0.3, signal, onNotice } = {}) {
|
|
84
|
-
const send = (hints) => getClient(config).chat.completions.create({
|
|
90
|
+
const send = (hints, refused) => getClient(config).chat.completions.create({
|
|
85
91
|
model,
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
// The reasoning models want the ceiling spelled the other way, and they are exactly the
|
|
93
|
+
// models a side task most wants to stop deliberating.
|
|
94
|
+
...(refused && !refused.legacyTokenLimit
|
|
95
|
+
? { max_completion_tokens: maxTokens }
|
|
96
|
+
: { max_tokens: maxTokens }),
|
|
97
|
+
// One that will only run at the temperature it was built with is sent none: a side task
|
|
98
|
+
// wants the same answer twice, and 1.0 from that model is as close as it gets.
|
|
99
|
+
...(refused && !refused.chosenTemperature ? {} : { temperature }),
|
|
88
100
|
messages: [
|
|
89
101
|
{ role: "system", content: system },
|
|
90
102
|
{ role: "user", content: user },
|
|
91
103
|
],
|
|
92
104
|
...(hints ? NO_THINKING : {}),
|
|
105
|
+
...(hints && refused?.reasoningEffort !== false ? { reasoning_effort: "none" } : {}),
|
|
93
106
|
}, { signal });
|
|
107
|
+
// The endpoint's own object, not one of this module's: what a model refuses is the same fact
|
|
108
|
+
// whether a run or a side task found it out, and the point of latching it is that only one of
|
|
109
|
+
// them has to pay for it. Nothing here sends tools or `stream_options`, so the two
|
|
110
|
+
// endpoint-level flags are not in play — the model's three are the whole of what this meets.
|
|
111
|
+
const supports = capabilitiesFor(config.baseUrl);
|
|
112
|
+
const attempt = (hints) => negotiate(supports, (_supports, _produced, refused) => send(hints, refused), {
|
|
113
|
+
model,
|
|
114
|
+
onNotice,
|
|
115
|
+
});
|
|
94
116
|
const key = hintKey(config.baseUrl, model);
|
|
95
117
|
const hints = !noHints.has(key);
|
|
96
118
|
let response;
|
|
97
119
|
try {
|
|
98
|
-
response = await
|
|
120
|
+
response = await attempt(hints);
|
|
99
121
|
}
|
|
100
122
|
catch (error) {
|
|
123
|
+
// Whatever is left after `negotiate` has answered everything it knows: on this path that is
|
|
124
|
+
// the hints it does not, which is `chat_template_kwargs` and an effort the model has but
|
|
125
|
+
// does not offer as `none`.
|
|
101
126
|
if (!hints || !rejectedTheRequest(error))
|
|
102
127
|
throw error;
|
|
103
128
|
onNotice?.("server rejected the no-thinking hints; retrying without them");
|
|
104
129
|
noHints.add(key);
|
|
105
|
-
response = await
|
|
130
|
+
response = await attempt(false);
|
|
106
131
|
}
|
|
107
132
|
const message = response.choices[0]?.message;
|
|
108
133
|
const answer = stripThinking(message?.content ?? "").trim();
|
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
|
|
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.
|
|
3
|
+
"version": "2.2.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",
|