@cubicecho/agent-core 1.3.0 → 2.0.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 +28 -6
- package/dist/client.d.ts +1 -1
- package/dist/client.js +5 -2
- package/dist/config.d.ts +9 -2
- package/dist/events.d.ts +17 -3
- package/dist/events.js +80 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/reset.d.ts +1 -1
- package/dist/reset.js +2 -2
- package/dist/retry.d.ts +14 -7
- package/dist/retry.js +74 -9
- package/dist/run-turn.d.ts +13 -1
- package/dist/run-turn.js +22 -3
- package/dist/schema-compat.d.ts +1 -1
- package/dist/schema-compat.js +61 -42
- package/dist/side-task.d.ts +10 -2
- package/dist/side-task.js +4 -4
- package/dist/tokens.d.ts +4 -2
- package/dist/tokens.js +4 -2
- package/dist/tool-loading.d.ts +7 -1
- package/dist/tool-loading.js +16 -3
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -27,10 +27,11 @@ only, Node >=22.
|
|
|
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. |
|
|
29
29
|
| `client` | A pooled `OpenAI` client per endpoint, plus the context-window listing and its cache. |
|
|
30
|
-
| `retry` | What to do when a request is lost, refused or too big: `isTransient`, `backoffMs`, `ContextOverflow`, `EndpointSilent`. |
|
|
30
|
+
| `retry` | What to do when a request is lost, refused or too big: `isTransient`, `backoffMs`, `ContextOverflow`, `EndpointSilent`, `requestTokens`. |
|
|
31
31
|
| `config` | The structural interfaces every function here asks for. |
|
|
32
|
-
| `run-turn` | `runTurn`: one turn with the retry loop around the negotiation around the stream. The whole loop, for a caller that wants it rather than its parts. |
|
|
32
|
+
| `run-turn` | `runTurn`: one turn with the retry loop around the negotiation around the stream. The whole loop, for a caller that wants it rather than its parts. Sizes the request against an opt-in `contextLimit`. |
|
|
33
33
|
| `reset` | `resetAll`: drops every cache and latch in one call, so a teardown cannot forget one. |
|
|
34
|
+
| `tokens` | `estimateTokens`: characters over four, deliberately low, for everything here that has to guess at a window. |
|
|
34
35
|
| `errors` | `errorMessage`: a caught `unknown` turned into something a run row can hold. |
|
|
35
36
|
| `catalog` | `CatalogServer`: the name-only shape `tool-loading` reads a connected server as. |
|
|
36
37
|
|
|
@@ -73,7 +74,29 @@ anything, and the re-send reads it — so a caller with its own retry budget pas
|
|
|
73
74
|
`idleMs` is silence, not a deadline: the timer is rearmed on every chunk, so a model that is
|
|
74
75
|
still talking is never cut off however long it takes, and one that has stopped answering raises
|
|
75
76
|
`EndpointSilent` rather than hanging the run. `timeoutMs(config)` returns `undefined` for a
|
|
76
|
-
`requestTimeoutSeconds` of zero, which waits forever — what a local model answering
|
|
77
|
+
`requestTimeoutSeconds` of zero or absent, which waits forever — what a local model answering
|
|
78
|
+
slowly needs.
|
|
79
|
+
|
|
80
|
+
## Sizing a request before sending it
|
|
81
|
+
|
|
82
|
+
`runTurn` will refuse a request that cannot fit rather than spending a round trip finding out:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { contextLimitFor, runTurn } from "@cubicecho/agent-core";
|
|
86
|
+
|
|
87
|
+
const turn = await runTurn(client, supports, build, {
|
|
88
|
+
maxRetries: 3,
|
|
89
|
+
// Opt-in: the number is the caller's to find. `contextLimitFor` asks the endpoint, and an
|
|
90
|
+
// operator's own setting overrides it — neither is network I/O a turn should be doing.
|
|
91
|
+
contextLimit: settings.contextLength || (await contextLimitFor(settings, model)),
|
|
92
|
+
onNotice: (message) => emit(runId, { kind: "notice", text: message }),
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The body is sized once, not per attempt: a downgraded request is strictly smaller than the one
|
|
97
|
+
before it and the transcript does not change between retries. A `ContextOverflow` from this is
|
|
98
|
+
neither a capability `negotiate` can answer nor something `isTransient` accepts, so it leaves
|
|
99
|
+
both loops on the first attempt.
|
|
77
100
|
|
|
78
101
|
## The config seam
|
|
79
102
|
|
|
@@ -93,9 +116,8 @@ row has no `contextLength`; `min-agent` spells it `contextLimit` and carries no
|
|
|
93
116
|
A single god interface would have forced two of them to grow columns they have no use for.
|
|
94
117
|
|
|
95
118
|
The seam is not finished. `timeoutMs` narrows to the one field it reads, but `getClient` still
|
|
96
|
-
asks for the whole of `Endpoint
|
|
97
|
-
|
|
98
|
-
breaking change and is waiting for the next major.
|
|
119
|
+
asks for the whole of `Endpoint`. `requestTimeoutSeconds` became optional in v2, so a consumer
|
|
120
|
+
with no timeout to give now leaves it out rather than inventing a `0`.
|
|
99
121
|
|
|
100
122
|
## Where the merged behaviour came from
|
|
101
123
|
|
package/dist/client.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { Endpoint } from "./config.ts";
|
|
|
6
6
|
* not — a caller must not let a local endpoint silently borrow the key meant for a paid one.
|
|
7
7
|
*/
|
|
8
8
|
export declare const NO_KEY = "agent-core";
|
|
9
|
-
/** Zero or
|
|
9
|
+
/** Zero, less, or absent means no limit, which the SDK spells as `undefined`. */
|
|
10
10
|
export declare const timeoutMs: (config: Pick<Endpoint, "requestTimeoutSeconds">) => number | undefined;
|
|
11
11
|
export declare function getClient(config: Endpoint): OpenAI;
|
|
12
12
|
/** A model an endpoint offers, and what it says the model will read. Zero means it did not say. */
|
package/dist/client.js
CHANGED
|
@@ -5,8 +5,11 @@ import OpenAI from "openai";
|
|
|
5
5
|
* not — a caller must not let a local endpoint silently borrow the key meant for a paid one.
|
|
6
6
|
*/
|
|
7
7
|
export const NO_KEY = "agent-core";
|
|
8
|
-
/** Zero or
|
|
9
|
-
export const timeoutMs = (config) =>
|
|
8
|
+
/** Zero, less, or absent means no limit, which the SDK spells as `undefined`. */
|
|
9
|
+
export const timeoutMs = (config) => {
|
|
10
|
+
const seconds = config.requestTimeoutSeconds ?? 0;
|
|
11
|
+
return seconds > 0 ? seconds * 1000 : undefined;
|
|
12
|
+
};
|
|
10
13
|
/**
|
|
11
14
|
* A client per endpoint, made once and kept.
|
|
12
15
|
*
|
package/dist/config.d.ts
CHANGED
|
@@ -17,8 +17,15 @@ export interface Endpoint {
|
|
|
17
17
|
baseUrl: string;
|
|
18
18
|
/** Empty is normal — a local server ignores it. See `getClient` for what is sent instead. */
|
|
19
19
|
apiKey: string;
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Zero, less, or absent means no limit — what a local model answering slowly needs.
|
|
22
|
+
*
|
|
23
|
+
* Optional because a consumer that has no timeout to give should not have to invent one. Two
|
|
24
|
+
* of the three servers this was extracted from carry no such field, and requiring it made
|
|
25
|
+
* them write `requestTimeoutSeconds: 0` to mean "I have no opinion", which is a made-up
|
|
26
|
+
* number standing in for an absent one.
|
|
27
|
+
*/
|
|
28
|
+
requestTimeoutSeconds?: number;
|
|
22
29
|
}
|
|
23
30
|
/** What to ask the model for. */
|
|
24
31
|
export interface ModelParams {
|
package/dist/events.d.ts
CHANGED
|
@@ -44,7 +44,15 @@ export interface RunEvent {
|
|
|
44
44
|
runId: string;
|
|
45
45
|
/** Per-run counter, from 1. Lets a client order and de-duplicate what it receives. */
|
|
46
46
|
seq: number;
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* When it happened, as epoch milliseconds.
|
|
49
|
+
*
|
|
50
|
+
* A number rather than a `Date`: these events are read over a wire, where a `Date` is an ISO
|
|
51
|
+
* string by the time anyone sees it, and `emit` runs once per streamed token — so the object
|
|
52
|
+
* it does not allocate is one per token. It also spares the `getTime()` the sweep used to do
|
|
53
|
+
* to get this same number back out.
|
|
54
|
+
*/
|
|
55
|
+
at: number;
|
|
48
56
|
kind: RunEventKind;
|
|
49
57
|
/** The delta, the arguments, the result, or the reason — whatever the kind carries. */
|
|
50
58
|
text: string;
|
|
@@ -86,8 +94,14 @@ export declare function emit(runId: string, input: RunEventInput): RunEvent;
|
|
|
86
94
|
export declare function watch(runId: string): AsyncGenerator<RunEvent>;
|
|
87
95
|
/** The backlog alone, for a caller that wants a snapshot rather than a subscription. */
|
|
88
96
|
export declare const history: (runId: string) => RunEvent[];
|
|
89
|
-
/**
|
|
90
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Test seam: forget every run, so one test's events cannot be read by the next.
|
|
99
|
+
*
|
|
100
|
+
* Named for what it forgets rather than bare `reset`, which sat in a consumer's imports beside
|
|
101
|
+
* `resetAll`, `resetClients`, `resetCapabilities` and `resetHints` saying nothing about which
|
|
102
|
+
* of the five it was — `reset.ts` had to alias it on the way in to stay readable.
|
|
103
|
+
*/
|
|
104
|
+
export declare const resetEvents: () => void;
|
|
91
105
|
/**
|
|
92
106
|
* Consecutive tokens of one kind are one thing being said, not hundreds of things.
|
|
93
107
|
*
|
package/dist/events.js
CHANGED
|
@@ -76,8 +76,10 @@ export function endRun(runId) {
|
|
|
76
76
|
/** Records one event and hands it to everyone watching that run. Never throws at the caller. */
|
|
77
77
|
export function emit(runId, input) {
|
|
78
78
|
const stream = streamFor(runId);
|
|
79
|
+
// One clock read, used for both the event and the sweep's bookkeeping.
|
|
80
|
+
const at = Date.now();
|
|
79
81
|
const event = {
|
|
80
|
-
at
|
|
82
|
+
at,
|
|
81
83
|
text: "",
|
|
82
84
|
name: "",
|
|
83
85
|
step: "",
|
|
@@ -89,7 +91,7 @@ export function emit(runId, input) {
|
|
|
89
91
|
seq: ++stream.seq,
|
|
90
92
|
};
|
|
91
93
|
stream.events.push(event);
|
|
92
|
-
stream.touched =
|
|
94
|
+
stream.touched = at;
|
|
93
95
|
if (stream.events.length > MAX_EVENTS + TRIM_SLACK) {
|
|
94
96
|
stream.events.splice(0, stream.events.length - MAX_EVENTS);
|
|
95
97
|
}
|
|
@@ -116,17 +118,59 @@ export function emit(runId, input) {
|
|
|
116
118
|
*/
|
|
117
119
|
export async function* watch(runId) {
|
|
118
120
|
const stream = streamFor(runId);
|
|
119
|
-
|
|
121
|
+
// A cursor rather than `shift()`. Draining a backlog an event at a time off the front of an
|
|
122
|
+
// array is a copy of the whole array per event, which on the ten-thousand-delta run this bus
|
|
123
|
+
// is built for is the one quadratic left in the file. The prefix behind the cursor is dropped
|
|
124
|
+
// in one `slice` per `MAX_EVENTS` instead — the same amortised trade the bus itself makes.
|
|
125
|
+
let queue = [...stream.events];
|
|
126
|
+
let head = 0;
|
|
127
|
+
let dropped = 0;
|
|
120
128
|
let wake = null;
|
|
121
129
|
const listener = (event) => {
|
|
122
130
|
queue.push(event);
|
|
131
|
+
// The bus caps its own backlog at `MAX_EVENTS`; without this the watcher downstream of it
|
|
132
|
+
// had no cap at all, so a client too slow to keep up held every delta a run ever emitted.
|
|
133
|
+
// The oldest go, which is what the backlog does, and the gap is reported once below.
|
|
134
|
+
if (queue.length - head > MAX_EVENTS + TRIM_SLACK) {
|
|
135
|
+
const cut = queue.length - head - MAX_EVENTS;
|
|
136
|
+
head += cut;
|
|
137
|
+
dropped += cut;
|
|
138
|
+
}
|
|
123
139
|
wake?.();
|
|
124
140
|
};
|
|
125
141
|
stream.listeners.add(listener);
|
|
126
142
|
try {
|
|
127
143
|
for (;;) {
|
|
128
|
-
while (queue.length
|
|
129
|
-
const event = queue
|
|
144
|
+
while (head < queue.length) {
|
|
145
|
+
const event = queue[head++];
|
|
146
|
+
// What is behind the cursor is released rather than left there. Resetting only on catch-up
|
|
147
|
+
// was not enough: a watcher that keeps pace but never quite empties the queue never
|
|
148
|
+
// reaches that branch, and the array grows by a slot per event for the length of the run.
|
|
149
|
+
if (head === queue.length) {
|
|
150
|
+
queue = [];
|
|
151
|
+
head = 0;
|
|
152
|
+
}
|
|
153
|
+
else if (head > MAX_EVENTS) {
|
|
154
|
+
queue = queue.slice(head);
|
|
155
|
+
head = 0;
|
|
156
|
+
}
|
|
157
|
+
if (dropped > 0) {
|
|
158
|
+
// Said once per gap rather than per event, and before the event that follows it, so a
|
|
159
|
+
// client reading `seq` sees why the numbers jump instead of assuming it lost its place.
|
|
160
|
+
const gap = dropped;
|
|
161
|
+
dropped = 0;
|
|
162
|
+
yield {
|
|
163
|
+
runId,
|
|
164
|
+
seq: event.seq,
|
|
165
|
+
at: event.at,
|
|
166
|
+
kind: "notice",
|
|
167
|
+
text: `${gap} event(s) dropped: this watcher fell too far behind`,
|
|
168
|
+
name: "",
|
|
169
|
+
step: event.step,
|
|
170
|
+
ok: null,
|
|
171
|
+
usage: null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
130
174
|
yield event;
|
|
131
175
|
// `done` is the last event a run will ever have, so the subscription completes rather
|
|
132
176
|
// than leaving the client holding an open stream that will never say anything again.
|
|
@@ -151,8 +195,14 @@ export async function* watch(runId) {
|
|
|
151
195
|
}
|
|
152
196
|
/** The backlog alone, for a caller that wants a snapshot rather than a subscription. */
|
|
153
197
|
export const history = (runId) => [...(streams.get(runId)?.events ?? [])];
|
|
154
|
-
/**
|
|
155
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Test seam: forget every run, so one test's events cannot be read by the next.
|
|
200
|
+
*
|
|
201
|
+
* Named for what it forgets rather than bare `reset`, which sat in a consumer's imports beside
|
|
202
|
+
* `resetAll`, `resetClients`, `resetCapabilities` and `resetHints` saying nothing about which
|
|
203
|
+
* of the five it was — `reset.ts` had to alias it on the way in to stay readable.
|
|
204
|
+
*/
|
|
205
|
+
export const resetEvents = () => {
|
|
156
206
|
streams.clear();
|
|
157
207
|
if (sweeping)
|
|
158
208
|
clearTimeout(sweeping);
|
|
@@ -168,23 +218,37 @@ export const reset = () => {
|
|
|
168
218
|
*/
|
|
169
219
|
export function fold(events) {
|
|
170
220
|
const blocks = [];
|
|
221
|
+
// The text of the block still open, accumulated rather than re-concatenated. Rebuilding the
|
|
222
|
+
// block object per delta — a spread and a join of everything so far — is the same paragraph
|
|
223
|
+
// built ten thousand times to produce it once.
|
|
224
|
+
let parts = [];
|
|
225
|
+
const close = () => {
|
|
226
|
+
if (!parts.length)
|
|
227
|
+
return;
|
|
228
|
+
const last = blocks[blocks.length - 1];
|
|
229
|
+
if (parts.length > 1)
|
|
230
|
+
last.text = parts.join("");
|
|
231
|
+
parts = [];
|
|
232
|
+
};
|
|
171
233
|
for (const event of events) {
|
|
172
234
|
const last = blocks[blocks.length - 1];
|
|
173
235
|
const mergeable = event.kind === "thinking" || event.kind === "output";
|
|
174
236
|
if (last && mergeable && last.kind === event.kind && last.step === event.step) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
at: event.at,
|
|
179
|
-
text: last.text + event.text,
|
|
180
|
-
};
|
|
237
|
+
last.seq = event.seq;
|
|
238
|
+
last.at = event.at;
|
|
239
|
+
parts.push(event.text);
|
|
181
240
|
}
|
|
182
241
|
else {
|
|
183
|
-
|
|
184
|
-
// branch
|
|
185
|
-
//
|
|
242
|
+
close();
|
|
243
|
+
// A copy, because the merged branch above writes into the block it returns and the caller
|
|
244
|
+
// cannot tell which branch its events took. Pushing the stored object let
|
|
245
|
+
// `fold(history(id))[0].text = ...` rewrite the bus, and every watcher after it read the
|
|
246
|
+
// rewrite.
|
|
186
247
|
blocks.push({ ...event });
|
|
248
|
+
if (mergeable)
|
|
249
|
+
parts.push(event.text);
|
|
187
250
|
}
|
|
188
251
|
}
|
|
252
|
+
close();
|
|
189
253
|
return blocks;
|
|
190
254
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ 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";
|
|
16
16
|
export { errorMessage } from "./errors.ts";
|
|
17
|
-
export { emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage,
|
|
17
|
+
export { emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, resetEvents, watch, } from "./events.ts";
|
|
18
18
|
export { resetAll } from "./reset.ts";
|
|
19
19
|
export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
|
|
20
20
|
export { type RunTurnOptions, runTurn } from "./run-turn.ts";
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
export { capabilitiesFor, 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
|
-
export { emit, endRun, fold, history,
|
|
15
|
+
export { emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
|
|
16
16
|
export { resetAll } from "./reset.js";
|
|
17
17
|
export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
|
|
18
18
|
export { runTurn } from "./run-turn.js";
|
package/dist/reset.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Four modules here keep state for the life of the process, each for a good reason and each
|
|
5
5
|
* with its own seam: the pooled clients and their model listings, the endpoints that turned
|
|
6
6
|
* out not to take `stream_options` or a grammar, the models that refused the no-thinking
|
|
7
|
-
* hints, and the event bus. `resetClients`, `resetCapabilities`, `resetHints` and `
|
|
7
|
+
* hints, and the event bus. `resetClients`, `resetCapabilities`, `resetHints` and `resetEvents` stay
|
|
8
8
|
* exported, because a test that means to clear one thing should say so.
|
|
9
9
|
*
|
|
10
10
|
* This is for the other case, which is every teardown. What all four hold is *latched
|
package/dist/reset.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resetCapabilities } from "./capabilities.js";
|
|
2
2
|
import { resetClients } from "./client.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resetEvents } from "./events.js";
|
|
4
4
|
import { resetHints } from "./side-task.js";
|
|
5
5
|
/**
|
|
6
6
|
* Forgets everything this package remembers between calls.
|
|
@@ -8,7 +8,7 @@ import { resetHints } from "./side-task.js";
|
|
|
8
8
|
* Four modules here keep state for the life of the process, each for a good reason and each
|
|
9
9
|
* with its own seam: the pooled clients and their model listings, the endpoints that turned
|
|
10
10
|
* out not to take `stream_options` or a grammar, the models that refused the no-thinking
|
|
11
|
-
* hints, and the event bus. `resetClients`, `resetCapabilities`, `resetHints` and `
|
|
11
|
+
* hints, and the event bus. `resetClients`, `resetCapabilities`, `resetHints` and `resetEvents` stay
|
|
12
12
|
* exported, because a test that means to clear one thing should say so.
|
|
13
13
|
*
|
|
14
14
|
* This is for the other case, which is every teardown. What all four hold is *latched
|
package/dist/retry.d.ts
CHANGED
|
@@ -23,17 +23,24 @@ export declare const compact: (tokens: number) => string;
|
|
|
23
23
|
/**
|
|
24
24
|
* What this request will cost the window, in tokens, near enough.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
26
|
+
* See `estimateTokens` for why it is characters over four and which way it is wrong on purpose.
|
|
27
|
+
*
|
|
28
|
+
* Summed by walking the body rather than by serialising it. `JSON.stringify` on the messages
|
|
29
|
+
* built the entire transcript into a string on every call and threw it away having read nothing
|
|
30
|
+
* but its `.length` — against a transcript that grows by a turn each turn, and one the SDK is
|
|
31
|
+
* about to serialise again to send. What the walk misses is JSON's own punctuation and the keys,
|
|
32
|
+
* which `ENVELOPE` puts back approximately; the difference is a rounding error against an
|
|
33
|
+
* estimate that is already characters over four.
|
|
32
34
|
*/
|
|
33
35
|
export declare const requestTokens: (body: OpenAI.ChatCompletionCreateParamsStreaming) => number;
|
|
34
36
|
export declare const isOverflow: (detail: string) => boolean;
|
|
35
37
|
/**
|
|
36
|
-
*
|
|
38
|
+
* The smallest window worth believing in, and the floor under both of its uses.
|
|
39
|
+
*
|
|
40
|
+
* `runTurn`'s `contextLimit` reads it as a sanity check on a number it was handed: below this,
|
|
41
|
+
* the limit is taken for a placeholder — an unset column, a listing that said nothing — rather
|
|
42
|
+
* than a window worth refusing a run over. `contextLimitFor` reads it as the point below which
|
|
43
|
+
* the window is nobody's business and is not asked for.
|
|
37
44
|
*
|
|
38
45
|
* Finding out what a model reads costs a listing against its endpoint, and a run whose whole
|
|
39
46
|
* request is a few thousand tokens fits anything anyone serves — spending a round trip to
|
package/dist/retry.js
CHANGED
|
@@ -21,18 +21,78 @@ export class ContextOverflow extends Error {
|
|
|
21
21
|
}
|
|
22
22
|
/** 1234 → "1.2k". The numbers in an overflow message are large and nobody reads the units digit. */
|
|
23
23
|
export const compact = (tokens) => tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
|
24
|
+
/** What `{"role":"","content":""},` costs around a message's own text, in characters. */
|
|
25
|
+
const ENVELOPE = 25;
|
|
26
|
+
/** The same for `{"id":"","type":"function","function":{"name":"","arguments":""}},` in a call. */
|
|
27
|
+
const CALL_ENVELOPE = 62;
|
|
28
|
+
/** The divisor behind `estimateTokens`, applied here to a character count rather than a string. */
|
|
29
|
+
const CHARS_PER_TOKEN = 4;
|
|
30
|
+
/** How many characters one message is worth, whichever of the shapes its content is in. */
|
|
31
|
+
function messageChars(message) {
|
|
32
|
+
let chars = message.role.length + ENVELOPE;
|
|
33
|
+
const { content } = message;
|
|
34
|
+
if (typeof content === "string")
|
|
35
|
+
chars += content.length;
|
|
36
|
+
else if (Array.isArray(content))
|
|
37
|
+
for (const part of content) {
|
|
38
|
+
// Text and refusal parts carry their own strings; an image or an audio part carries a URL
|
|
39
|
+
// or a blob, and neither is priced by its length anyway.
|
|
40
|
+
if (part.type === "text")
|
|
41
|
+
chars += part.text.length;
|
|
42
|
+
else if (part.type === "refusal")
|
|
43
|
+
chars += part.refusal.length;
|
|
44
|
+
}
|
|
45
|
+
if ("name" in message && typeof message.name === "string")
|
|
46
|
+
chars += message.name.length;
|
|
47
|
+
if ("tool_call_id" in message && typeof message.tool_call_id === "string")
|
|
48
|
+
chars += message.tool_call_id.length;
|
|
49
|
+
if ("tool_calls" in message && Array.isArray(message.tool_calls))
|
|
50
|
+
for (const call of message.tool_calls) {
|
|
51
|
+
chars += CALL_ENVELOPE + call.id.length;
|
|
52
|
+
if (call.type === "function")
|
|
53
|
+
chars += call.function.name.length + call.function.arguments.length;
|
|
54
|
+
}
|
|
55
|
+
return chars;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The tools half, cached against the array.
|
|
59
|
+
*
|
|
60
|
+
* Tool definitions are stable objects handed out by a pool, and `sanitizeTools` already caches on
|
|
61
|
+
* that same identity — so the array a turn sends is the array the last turn sent unless something
|
|
62
|
+
* reconnected. Serialising two dozen JSON schemas to measure them, on every turn, to get the same
|
|
63
|
+
* number every time, was the more expensive half of this function.
|
|
64
|
+
*/
|
|
65
|
+
const toolTokens = new WeakMap();
|
|
66
|
+
function toolsCost(tools) {
|
|
67
|
+
const hit = toolTokens.get(tools);
|
|
68
|
+
if (hit !== undefined)
|
|
69
|
+
return hit;
|
|
70
|
+
// Schemas are arbitrarily shaped, so this one really is a serialisation — but it happens once
|
|
71
|
+
// per tool array rather than once per turn.
|
|
72
|
+
const cost = estimateTokens(JSON.stringify(tools));
|
|
73
|
+
toolTokens.set(tools, cost);
|
|
74
|
+
return cost;
|
|
75
|
+
}
|
|
24
76
|
/**
|
|
25
77
|
* What this request will cost the window, in tokens, near enough.
|
|
26
78
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
79
|
+
* See `estimateTokens` for why it is characters over four and which way it is wrong on purpose.
|
|
80
|
+
*
|
|
81
|
+
* Summed by walking the body rather than by serialising it. `JSON.stringify` on the messages
|
|
82
|
+
* built the entire transcript into a string on every call and threw it away having read nothing
|
|
83
|
+
* but its `.length` — against a transcript that grows by a turn each turn, and one the SDK is
|
|
84
|
+
* about to serialise again to send. What the walk misses is JSON's own punctuation and the keys,
|
|
85
|
+
* which `ENVELOPE` puts back approximately; the difference is a rounding error against an
|
|
86
|
+
* estimate that is already characters over four.
|
|
33
87
|
*/
|
|
34
|
-
export const requestTokens = (body) =>
|
|
35
|
-
|
|
88
|
+
export const requestTokens = (body) => {
|
|
89
|
+
// Characters first and the division once at the end, rather than a rounded count per message:
|
|
90
|
+
// `Math.ceil` on every one of a few hundred messages is a few hundred tokens of pure rounding.
|
|
91
|
+
let chars = 0;
|
|
92
|
+
for (const message of body.messages)
|
|
93
|
+
chars += messageChars(message);
|
|
94
|
+
return Math.ceil(chars / CHARS_PER_TOKEN) + (body.tools?.length ? toolsCost(body.tools) : 0);
|
|
95
|
+
};
|
|
36
96
|
/**
|
|
37
97
|
* Servers refuse an over-long request in their own words; these are the ones worth reading as
|
|
38
98
|
* that rather than as a broken request. Matched loosely — every one of them is some
|
|
@@ -58,7 +118,12 @@ export const isOverflow = (detail) => !RATE_LIMITED.test(detail) &&
|
|
|
58
118
|
OVERFLOW.some((pattern) => pattern.test(detail)) &&
|
|
59
119
|
/token|context/i.test(detail);
|
|
60
120
|
/**
|
|
61
|
-
*
|
|
121
|
+
* The smallest window worth believing in, and the floor under both of its uses.
|
|
122
|
+
*
|
|
123
|
+
* `runTurn`'s `contextLimit` reads it as a sanity check on a number it was handed: below this,
|
|
124
|
+
* the limit is taken for a placeholder — an unset column, a listing that said nothing — rather
|
|
125
|
+
* than a window worth refusing a run over. `contextLimitFor` reads it as the point below which
|
|
126
|
+
* the window is nobody's business and is not asked for.
|
|
62
127
|
*
|
|
63
128
|
* Finding out what a model reads costs a listing against its endpoint, and a run whose whole
|
|
64
129
|
* request is a few thousand tokens fits anything anyone serves — spending a round trip to
|
package/dist/run-turn.d.ts
CHANGED
|
@@ -28,6 +28,18 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
|
|
|
28
28
|
* see an unexplained pause. Carries both the capability notices and the retry notices.
|
|
29
29
|
*/
|
|
30
30
|
onNotice?: (message: string) => void;
|
|
31
|
+
/**
|
|
32
|
+
* What the model will read, in tokens. Zero — the default — sends whatever it is given.
|
|
33
|
+
*
|
|
34
|
+
* With a limit, the request is sized before it is sent and a `ContextOverflow` is raised here
|
|
35
|
+
* rather than by the endpoint one round trip later. It is opt-in because the number is the
|
|
36
|
+
* caller's to find: `contextLimitFor` asks the endpoint, an operator's own setting overrides
|
|
37
|
+
* it, and neither is something a turn should be doing network I/O to discover. A limit below
|
|
38
|
+
* `SMALLEST_LIKELY_WINDOW` is not believed — a model with a window that small is rare enough
|
|
39
|
+
* that the number is far more likely a caller threading a placeholder through, and refusing a
|
|
40
|
+
* run over one would be the guard failing exactly the callers it was meant to help.
|
|
41
|
+
*/
|
|
42
|
+
contextLimit?: number;
|
|
31
43
|
}
|
|
32
44
|
/**
|
|
33
45
|
* `request` is a callback rather than a body because the body has to be rebuilt from whatever
|
|
@@ -35,4 +47,4 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
|
|
|
35
47
|
* has to apply to the schemas that were just sanitised. It is handed the same `Capabilities`
|
|
36
48
|
* object throughout, and a caller that reads those from its own closure can ignore the argument.
|
|
37
49
|
*/
|
|
38
|
-
export declare function runTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities) => OpenAI.ChatCompletionCreateParamsStreaming, { maxRetries, onNotice, ...stream }?: RunTurnOptions): Promise<Turn>;
|
|
50
|
+
export declare function runTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities) => OpenAI.ChatCompletionCreateParamsStreaming, { maxRetries, onNotice, contextLimit, ...stream }?: RunTurnOptions): Promise<Turn>;
|
package/dist/run-turn.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { negotiate } from "./capabilities.js";
|
|
2
2
|
import { errorMessage } from "./errors.js";
|
|
3
|
-
import { backoffMs, isTransient, sleep } from "./retry.js";
|
|
3
|
+
import { backoffMs, ContextOverflow, compact, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
|
|
4
4
|
import { streamTurn } from "./stream.js";
|
|
5
5
|
/**
|
|
6
6
|
* `request` is a callback rather than a body because the body has to be rebuilt from whatever
|
|
@@ -8,11 +8,30 @@ import { streamTurn } from "./stream.js";
|
|
|
8
8
|
* has to apply to the schemas that were just sanitised. It is handed the same `Capabilities`
|
|
9
9
|
* object throughout, and a caller that reads those from its own closure can ignore the argument.
|
|
10
10
|
*/
|
|
11
|
-
export async function runTurn(client, supports, request, { maxRetries = 0, onNotice, ...stream } = {}) {
|
|
11
|
+
export async function runTurn(client, supports, request, { maxRetries = 0, onNotice, contextLimit = 0, ...stream } = {}) {
|
|
12
|
+
// Sized once rather than per build. `request` is called again for every downgrade and every
|
|
13
|
+
// retry, but a downgraded body is strictly smaller than the one before it and the transcript
|
|
14
|
+
// does not change between attempts — so the first body is the one worth measuring, and
|
|
15
|
+
// measuring the rest would only spend the walk again to reach the same answer.
|
|
16
|
+
let sized = false;
|
|
17
|
+
const measured = (capabilities) => {
|
|
18
|
+
const body = request(capabilities);
|
|
19
|
+
if (!sized && contextLimit >= SMALLEST_LIKELY_WINDOW) {
|
|
20
|
+
sized = true;
|
|
21
|
+
const needed = requestTokens(body);
|
|
22
|
+
// Not retried, and deliberately not a capability: `isTransient` refuses it and none of the
|
|
23
|
+
// words below are ones `negotiate` reads as a refusal it can answer, so this leaves both
|
|
24
|
+
// loops on the first attempt instead of being sent again to be refused again.
|
|
25
|
+
if (needed > contextLimit) {
|
|
26
|
+
throw new ContextOverflow(`the request is about ${compact(needed)} tokens, over this model's ${compact(contextLimit)}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return body;
|
|
30
|
+
};
|
|
12
31
|
for (let attempt = 0;; attempt++) {
|
|
13
32
|
const produced = { any: false };
|
|
14
33
|
try {
|
|
15
|
-
return await negotiate(supports, (capabilities, box) => streamTurn(client,
|
|
34
|
+
return await negotiate(supports, (capabilities, box) => streamTurn(client, measured(capabilities), { ...stream, produced: box }), { produced, onNotice });
|
|
16
35
|
}
|
|
17
36
|
catch (error) {
|
|
18
37
|
// The abort is read before the classification, not after. A run stopped by its operator
|
package/dist/schema-compat.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare const sanitizeTools: (tools: OpenAI.ChatCompletionTool[]) => Open
|
|
|
5
5
|
* `pattern` and most `format` values, both of which only ever narrowed a string the tool
|
|
6
6
|
* re-validates anyway.
|
|
7
7
|
*/
|
|
8
|
-
export declare
|
|
8
|
+
export declare const relaxTools: (tools: OpenAI.ChatCompletionTool[]) => OpenAI.ChatCompletionTool[];
|
|
9
9
|
/**
|
|
10
10
|
* Does this failure look like the server could not build a grammar from our tool schemas?
|
|
11
11
|
*
|
package/dist/schema-compat.js
CHANGED
|
@@ -190,64 +190,83 @@ function sanitizeParameters(parameters) {
|
|
|
190
190
|
pruneRequired(out);
|
|
191
191
|
return out;
|
|
192
192
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
/** Rewrites one tool's parameters, leaving a non-function tool alone. */
|
|
194
|
+
const mapTool = (tool, fn) => tool.type === "function"
|
|
195
|
+
? {
|
|
196
|
+
...tool,
|
|
197
|
+
function: { ...tool.function, parameters: fn(tool.function.parameters) },
|
|
198
|
+
}
|
|
199
|
+
: tool;
|
|
196
200
|
/**
|
|
197
|
-
*
|
|
201
|
+
* Both rewrites are cached against the tool object rather than recomputed.
|
|
198
202
|
*
|
|
199
203
|
* The agent loop rebuilds its tool array on every iteration of every step, and normalising a
|
|
200
204
|
* couple of dozen MCP schemas is the only walk in a run that is neither a request nor a query.
|
|
201
205
|
* The pool hands out the same definition objects for the life of a connection, so identity is
|
|
202
206
|
* exactly the right key: a reconnect makes new ones and they are normalised again.
|
|
207
|
+
*
|
|
208
|
+
* Two maps rather than one, because the two answer different questions about the same tool and a
|
|
209
|
+
* relaxed schema is reached by way of a sanitised one. `relaxed` is the load-bearing half: an
|
|
210
|
+
* endpoint that has refused a grammar once has `strictSchemas` off for the life of the process
|
|
211
|
+
* (see `capabilities.ts`), so from that point every request takes this path and only this path.
|
|
212
|
+
* Caching the call that happens once per connection and not the one that happens on every request
|
|
213
|
+
* had it exactly the wrong way round.
|
|
214
|
+
*
|
|
215
|
+
* The contract both rely on is that a tool definition is not mutated in place. Nothing can evict
|
|
216
|
+
* an entry here — a caller that edits `tool.function.parameters` after the fact keeps the schema
|
|
217
|
+
* it had at first sight. Build a new definition object instead.
|
|
203
218
|
*/
|
|
204
219
|
const sanitized = new WeakMap();
|
|
205
|
-
|
|
206
|
-
|
|
220
|
+
const relaxed = new WeakMap();
|
|
221
|
+
/** Looks one up, computing and remembering it on a miss. */
|
|
222
|
+
const through = (cache, tools, fn) => tools.map((tool) => {
|
|
223
|
+
const hit = cache.get(tool);
|
|
207
224
|
if (hit)
|
|
208
225
|
return hit;
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
return
|
|
226
|
+
const built = mapTool(tool, fn);
|
|
227
|
+
cache.set(tool, built);
|
|
228
|
+
return built;
|
|
212
229
|
});
|
|
230
|
+
export const sanitizeTools = (tools) => through(sanitized, tools, sanitizeParameters);
|
|
231
|
+
/**
|
|
232
|
+
* Walked as a schema rather than as arbitrary JSON, because `pattern` and `format` are keyword
|
|
233
|
+
* names and perfectly ordinary argument names at once. Matching on the key alone deleted a
|
|
234
|
+
* *property* called `format` along with the keyword, leaving the parent's `required` naming an
|
|
235
|
+
* argument that no longer existed — which every strict validator rejects, so the retry produced
|
|
236
|
+
* the failure it was reaching for. The same distinction keeps the walk out of `default`, `enum`
|
|
237
|
+
* and `const`, whose contents are data, not schema.
|
|
238
|
+
*
|
|
239
|
+
* At module scope rather than inside `relaxTools`, so the closure is made once rather than per
|
|
240
|
+
* call — which, on the path this is on, is per request.
|
|
241
|
+
*/
|
|
242
|
+
const strip = (node) => {
|
|
243
|
+
if (Array.isArray(node))
|
|
244
|
+
return node.map(strip);
|
|
245
|
+
if (!isObject(node))
|
|
246
|
+
return node;
|
|
247
|
+
const out = {};
|
|
248
|
+
for (const [key, value] of Object.entries(node)) {
|
|
249
|
+
if (key === "pattern" || key === "format")
|
|
250
|
+
continue;
|
|
251
|
+
if (SCHEMA_KEYS.has(key))
|
|
252
|
+
out[key] = Array.isArray(value) ? value.map(strip) : strip(value);
|
|
253
|
+
else if (SCHEMA_MAPS.has(key) && isObject(value))
|
|
254
|
+
// The keys here are argument names; only the values are schemas.
|
|
255
|
+
out[key] = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, strip(sub)]));
|
|
256
|
+
else
|
|
257
|
+
out[key] = value;
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
};
|
|
213
261
|
/**
|
|
214
262
|
* The retry shape: llama.cpp's converter rejects regex escape classes (`\d`, `\w`, `\s`) in
|
|
215
263
|
* `pattern` and most `format` values, both of which only ever narrowed a string the tool
|
|
216
264
|
* re-validates anyway.
|
|
217
265
|
*/
|
|
218
|
-
export
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
* deleted a *property* called `format` along with the keyword, leaving the parent's
|
|
223
|
-
* `required` naming an argument that no longer existed — which every strict validator
|
|
224
|
-
* rejects, so the retry produced the failure it was reaching for. The same distinction keeps
|
|
225
|
-
* the walk out of `default`, `enum` and `const`, whose contents are data, not schema.
|
|
226
|
-
*/
|
|
227
|
-
const strip = (node) => {
|
|
228
|
-
if (Array.isArray(node))
|
|
229
|
-
return node.map(strip);
|
|
230
|
-
if (!isObject(node))
|
|
231
|
-
return node;
|
|
232
|
-
const out = {};
|
|
233
|
-
for (const [key, value] of Object.entries(node)) {
|
|
234
|
-
if (key === "pattern" || key === "format")
|
|
235
|
-
continue;
|
|
236
|
-
if (SCHEMA_KEYS.has(key))
|
|
237
|
-
out[key] = Array.isArray(value) ? value.map(strip) : strip(value);
|
|
238
|
-
else if (SCHEMA_MAPS.has(key) && isObject(value))
|
|
239
|
-
// The keys here are argument names; only the values are schemas.
|
|
240
|
-
out[key] = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, strip(sub)]));
|
|
241
|
-
else
|
|
242
|
-
out[key] = value;
|
|
243
|
-
}
|
|
244
|
-
return out;
|
|
245
|
-
};
|
|
246
|
-
return mapTools(tools, (parameters) => {
|
|
247
|
-
const stripped = strip(parameters);
|
|
248
|
-
return isObject(stripped) ? stripped : EMPTY_OBJECT();
|
|
249
|
-
});
|
|
250
|
-
}
|
|
266
|
+
export const relaxTools = (tools) => through(relaxed, tools, (parameters) => {
|
|
267
|
+
const stripped = strip(parameters);
|
|
268
|
+
return isObject(stripped) ? stripped : EMPTY_OBJECT();
|
|
269
|
+
});
|
|
251
270
|
/**
|
|
252
271
|
* Qwen chat templates raise this when the transcript has no user turn. Some servers wrap it
|
|
253
272
|
* in the same "unable to generate parser" wording as a real schema failure, and stripping
|
package/dist/side-task.d.ts
CHANGED
|
@@ -5,14 +5,22 @@ export interface SideTaskOptions {
|
|
|
5
5
|
maxTokens?: number;
|
|
6
6
|
temperature?: number;
|
|
7
7
|
signal?: AbortSignal;
|
|
8
|
+
/**
|
|
9
|
+
* Told what was given up on, the same way `runTurn` and `negotiate` tell a caller.
|
|
10
|
+
*
|
|
11
|
+
* There is no default, and nothing is printed without one. A library that writes to the
|
|
12
|
+
* console decides for its consumer where operator text goes — which a server embedding this
|
|
13
|
+
* cannot then route to its own logger, attach to the run it belongs to, or silence in tests.
|
|
14
|
+
*/
|
|
15
|
+
onNotice?: (message: string) => void;
|
|
8
16
|
}
|
|
9
17
|
/** Runs a side task and returns the reply text, thinking stripped. Throws like any request. */
|
|
10
|
-
export declare function ask(config: Endpoint, model: string, system: string, user: string, { maxTokens, temperature, signal }?: SideTaskOptions): Promise<string>;
|
|
18
|
+
export declare function ask(config: Endpoint, model: string, system: string, user: string, { maxTokens, temperature, signal, onNotice }?: SideTaskOptions): Promise<string>;
|
|
11
19
|
/**
|
|
12
20
|
* A side task is never worth failing the work it supports. Callers that can carry on without
|
|
13
21
|
* an answer use this and get `undefined` instead of an exception.
|
|
14
22
|
*/
|
|
15
|
-
export declare function tryAsk<T>(label: string, run: () => Promise<T>): Promise<T | undefined>;
|
|
23
|
+
export declare function tryAsk<T>(label: string, run: () => Promise<T>, { onNotice }?: Pick<SideTaskOptions, "onNotice">): Promise<T | undefined>;
|
|
16
24
|
/**
|
|
17
25
|
* Models are asked for JSON and often answer with prose around it, or a fenced block. Pull out
|
|
18
26
|
* the first array or object rather than failing the task over a wrapper.
|
package/dist/side-task.js
CHANGED
|
@@ -63,7 +63,7 @@ function rejectedTheRequest(error) {
|
|
|
63
63
|
*/
|
|
64
64
|
const stripThinking = (text) => text.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/<think>[\s\S]*$/i, "");
|
|
65
65
|
/** Runs a side task and returns the reply text, thinking stripped. Throws like any request. */
|
|
66
|
-
export async function ask(config, model, system, user, { maxTokens = 512, temperature = 0.3, signal } = {}) {
|
|
66
|
+
export async function ask(config, model, system, user, { maxTokens = 512, temperature = 0.3, signal, onNotice } = {}) {
|
|
67
67
|
const send = (hints) => getClient(config).chat.completions.create({
|
|
68
68
|
model,
|
|
69
69
|
max_tokens: maxTokens,
|
|
@@ -83,7 +83,7 @@ export async function ask(config, model, system, user, { maxTokens = 512, temper
|
|
|
83
83
|
catch (error) {
|
|
84
84
|
if (!hints || !rejectedTheRequest(error))
|
|
85
85
|
throw error;
|
|
86
|
-
|
|
86
|
+
onNotice?.("server rejected the no-thinking hints; retrying without them");
|
|
87
87
|
noHints.add(key);
|
|
88
88
|
response = await send(false);
|
|
89
89
|
}
|
|
@@ -100,7 +100,7 @@ export async function ask(config, model, system, user, { maxTokens = 512, temper
|
|
|
100
100
|
* A side task is never worth failing the work it supports. Callers that can carry on without
|
|
101
101
|
* an answer use this and get `undefined` instead of an exception.
|
|
102
102
|
*/
|
|
103
|
-
export async function tryAsk(label, run) {
|
|
103
|
+
export async function tryAsk(label, run, { onNotice } = {}) {
|
|
104
104
|
try {
|
|
105
105
|
return await run();
|
|
106
106
|
}
|
|
@@ -109,7 +109,7 @@ export async function tryAsk(label, run) {
|
|
|
109
109
|
// indistinguishable and left the cancellation with nowhere to go.
|
|
110
110
|
if (error instanceof OpenAI.APIUserAbortError)
|
|
111
111
|
throw error;
|
|
112
|
-
|
|
112
|
+
onNotice?.(`${label}: ${errorMessage(error)}`);
|
|
113
113
|
return undefined;
|
|
114
114
|
}
|
|
115
115
|
}
|
package/dist/tokens.d.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* guessing high is a run refused that would have worked, and the cost of guessing low is the
|
|
9
9
|
* endpoint's own refusal, which is where we were before the guard existed.
|
|
10
10
|
*
|
|
11
|
-
* Its own module because
|
|
12
|
-
*
|
|
11
|
+
* Its own module because it is the one number several of these agree on, and the module that
|
|
12
|
+
* owns it should not be one that also does something. It was extracted from `side-task` to
|
|
13
|
+
* break a cycle with `retry`; `side-task` no longer reads it, but `retry` and any consumer
|
|
14
|
+
* sizing its own prompt still do, and a leaf with no imports is the right home for it.
|
|
13
15
|
*/
|
|
14
16
|
export declare const estimateTokens: (text: string) => number;
|
package/dist/tokens.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* guessing high is a run refused that would have worked, and the cost of guessing low is the
|
|
9
9
|
* endpoint's own refusal, which is where we were before the guard existed.
|
|
10
10
|
*
|
|
11
|
-
* Its own module because
|
|
12
|
-
*
|
|
11
|
+
* Its own module because it is the one number several of these agree on, and the module that
|
|
12
|
+
* owns it should not be one that also does something. It was extracted from `side-task` to
|
|
13
|
+
* break a cycle with `retry`; `side-task` no longer reads it, but `retry` and any consumer
|
|
14
|
+
* sizing its own prompt still do, and a leaf with no imports is the right home for it.
|
|
13
15
|
*/
|
|
14
16
|
export const estimateTokens = (text) => Math.ceil(text.length / 4);
|
package/dist/tool-loading.d.ts
CHANGED
|
@@ -14,7 +14,13 @@ import type { CatalogServer } from "./catalog.ts";
|
|
|
14
14
|
* pays almost nothing, and a run that needs three pays for three.
|
|
15
15
|
*/
|
|
16
16
|
export declare const LOAD_TOOLS = "load_tools";
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* One object for the life of the process — the agent loop asks for it on every iteration.
|
|
19
|
+
*
|
|
20
|
+
* Frozen because it is shared: one mutable export reached by every consumer in the process
|
|
21
|
+
* means a caller that edits the description in place has edited it for all of them, in a place
|
|
22
|
+
* nobody would think to look for the change.
|
|
23
|
+
*/
|
|
18
24
|
export declare const LOAD_TOOLS_DEFINITION: OpenAI.ChatCompletionTool;
|
|
19
25
|
/**
|
|
20
26
|
* The catalogue as a plain grouped listing of names, loaded ones marked.
|
package/dist/tool-loading.js
CHANGED
|
@@ -12,8 +12,21 @@
|
|
|
12
12
|
* pays almost nothing, and a run that needs three pays for three.
|
|
13
13
|
*/
|
|
14
14
|
export const LOAD_TOOLS = "load_tools";
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/** Shallow freezing this one would leave `.function.description` — the part worth editing. */
|
|
16
|
+
function deepFreeze(value) {
|
|
17
|
+
if (value && typeof value === "object")
|
|
18
|
+
for (const held of Object.values(value))
|
|
19
|
+
deepFreeze(held);
|
|
20
|
+
return Object.freeze(value);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* One object for the life of the process — the agent loop asks for it on every iteration.
|
|
24
|
+
*
|
|
25
|
+
* Frozen because it is shared: one mutable export reached by every consumer in the process
|
|
26
|
+
* means a caller that edits the description in place has edited it for all of them, in a place
|
|
27
|
+
* nobody would think to look for the change.
|
|
28
|
+
*/
|
|
29
|
+
export const LOAD_TOOLS_DEFINITION = deepFreeze({
|
|
17
30
|
type: "function",
|
|
18
31
|
function: {
|
|
19
32
|
name: LOAD_TOOLS,
|
|
@@ -34,7 +47,7 @@ export const LOAD_TOOLS_DEFINITION = {
|
|
|
34
47
|
additionalProperties: false,
|
|
35
48
|
},
|
|
36
49
|
},
|
|
37
|
-
};
|
|
50
|
+
});
|
|
38
51
|
/**
|
|
39
52
|
* The catalogue as a plain grouped listing of names, loaded ones marked.
|
|
40
53
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cubicecho/agent-core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.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",
|
|
@@ -46,7 +46,9 @@
|
|
|
46
46
|
"test": "vitest run",
|
|
47
47
|
"test:watch": "vitest",
|
|
48
48
|
"lint": "biome check .",
|
|
49
|
-
"format": "biome check --write ."
|
|
49
|
+
"format": "biome check --write .",
|
|
50
|
+
"bench": "vitest bench --run",
|
|
51
|
+
"coverage": "vitest run --coverage"
|
|
50
52
|
},
|
|
51
53
|
"peerDependencies": {
|
|
52
54
|
"openai": ">=6"
|
|
@@ -56,6 +58,7 @@
|
|
|
56
58
|
"@semantic-release/changelog": "^7.0.0",
|
|
57
59
|
"@semantic-release/git": "^11.0.1",
|
|
58
60
|
"@types/node": "^26.4.0",
|
|
61
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
59
62
|
"openai": "^7.8.0",
|
|
60
63
|
"semantic-release": "^25.0.9",
|
|
61
64
|
"typescript": "^7.0.2",
|