@cubicecho/agent-core 1.0.0 → 1.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 +61 -4
- package/dist/capabilities.d.ts +68 -0
- package/dist/capabilities.js +77 -0
- package/dist/client.d.ts +4 -2
- package/dist/client.js +21 -8
- package/dist/events.d.ts +14 -2
- package/dist/events.js +73 -16
- package/dist/index.d.ts +11 -6
- package/dist/index.js +11 -6
- package/dist/reset.d.ts +20 -0
- package/dist/reset.js +29 -0
- package/dist/retry.js +14 -2
- package/dist/schema-compat.js +97 -2
- package/dist/side-task.d.ts +2 -11
- package/dist/side-task.js +46 -24
- package/dist/stream.d.ts +63 -0
- package/dist/stream.js +104 -0
- package/dist/tokens.d.ts +14 -0
- package/dist/tokens.js +14 -0
- package/dist/tool-loading.d.ts +7 -1
- package/dist/tool-loading.js +29 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,7 +4,17 @@ The endpoint-agnostic half of an OpenAI-compatible agent loop.
|
|
|
4
4
|
|
|
5
5
|
Extracted from three servers that had each written it separately — `kanban_server`,
|
|
6
6
|
`task_server` and `min-agent` — after the copies drifted far enough that a fix in one was a bug
|
|
7
|
-
still live in another.
|
|
7
|
+
still live in another.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @cubicecho/agent-core openai
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`openai` is a peer dependency (`>=6`) so the client this hands back is the same one your code
|
|
16
|
+
already imports — one SDK version in the tree, one `instanceof` that means what it says. ESM
|
|
17
|
+
only, Node >=22.
|
|
8
18
|
|
|
9
19
|
## What is here
|
|
10
20
|
|
|
@@ -12,15 +22,58 @@ still live in another. See [`standards/extraction-backlog.md`](../standards/extr
|
|
|
12
22
|
| --- | --- |
|
|
13
23
|
| `schema-compat` | Makes an MCP tool schema something a strict or grammar-constrained server will accept. `sanitizeTools`, `relaxTools`, `isGrammarError`. |
|
|
14
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
|
+
| `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`. |
|
|
15
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. |
|
|
16
28
|
| `events` | The in-memory bus a watcher reads while a run happens. |
|
|
17
29
|
| `client` | A pooled `OpenAI` client per endpoint, plus the context-window listing and its cache. |
|
|
18
30
|
| `retry` | What to do when a request is lost, refused or too big: `isTransient`, `backoffMs`, `ContextOverflow`, `EndpointSilent`. |
|
|
19
31
|
| `config` | The structural interfaces every function here asks for. |
|
|
32
|
+
| `reset` | `resetAll`: drops every cache and latch in one call, so a teardown cannot forget one. |
|
|
33
|
+
| `errors` | `errorMessage`: a caught `unknown` turned into something a run row can hold. |
|
|
34
|
+
| `catalog` | `CatalogServer`: the name-only shape `tool-loading` reads a connected server as. |
|
|
20
35
|
|
|
21
36
|
What is **not** here is the work: orchestration, prompts, and whatever the run is about. That
|
|
22
37
|
is the caller's, and it is the part that actually differs between one server and the next.
|
|
23
38
|
|
|
39
|
+
## A turn
|
|
40
|
+
|
|
41
|
+
`negotiate` wrapping `streamTurn` is the whole of one turn against an endpoint: the request is
|
|
42
|
+
re-sent for as long as the answer is this server refusing something the request can do without,
|
|
43
|
+
and nothing is re-sent once it has started answering.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import {
|
|
47
|
+
capabilitiesFor, getClient, negotiate, relaxTools, sanitizeTools, streamTurn, timeoutMs,
|
|
48
|
+
} from "@cubicecho/agent-core";
|
|
49
|
+
|
|
50
|
+
const declared = sanitizeTools(tools);
|
|
51
|
+
const supports = capabilitiesFor(config.baseUrl);
|
|
52
|
+
|
|
53
|
+
const turn = await negotiate(supports, (supports, produced) =>
|
|
54
|
+
streamTurn(
|
|
55
|
+
getClient(config),
|
|
56
|
+
{
|
|
57
|
+
model, messages, stream: true,
|
|
58
|
+
// Rebuilt per attempt: what the endpoint has refused is latched off by the line above.
|
|
59
|
+
...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
|
|
60
|
+
tools: supports.strictSchemas ? declared : relaxTools(declared),
|
|
61
|
+
},
|
|
62
|
+
{ produced, signal, idleMs: timeoutMs(config), onOutput: (text) => emit(runId, { kind: "output", text }) },
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`send` takes a callback rather than a body because the body has to be rebuilt from the latched
|
|
68
|
+
flags. `produced` is one box per attempt — `streamTurn` sets it as soon as the server says
|
|
69
|
+
anything, and the re-send reads it — so a caller with its own retry budget passes one in
|
|
70
|
+
(`{ produced }`) and reads it afterwards to decide whether the failure is worth another attempt.
|
|
71
|
+
|
|
72
|
+
`idleMs` is silence, not a deadline: the timer is rearmed on every chunk, so a model that is
|
|
73
|
+
still talking is never cut off however long it takes, and one that has stopped answering raises
|
|
74
|
+
`EndpointSilent` rather than hanging the run. `timeoutMs(config)` returns `undefined` for a
|
|
75
|
+
`requestTimeoutSeconds` of zero, which waits forever — what a local model answering slowly needs.
|
|
76
|
+
|
|
24
77
|
## The config seam
|
|
25
78
|
|
|
26
79
|
Nothing here imports a config type from a consumer, and no function asks for a whole
|
|
@@ -35,9 +88,13 @@ const client = getClient(settings satisfies Endpoint);
|
|
|
35
88
|
```
|
|
36
89
|
|
|
37
90
|
This matters because the three consumers do not agree on the fields. `task_server`'s settings
|
|
38
|
-
row has no `contextLength`; `min-agent` spells it `contextLimit` and
|
|
39
|
-
|
|
40
|
-
|
|
91
|
+
row has no `contextLength`; `min-agent` spells it `contextLimit` and carries no retry budget.
|
|
92
|
+
A single god interface would have forced two of them to grow columns they have no use for.
|
|
93
|
+
|
|
94
|
+
The seam is not finished. `timeoutMs` narrows to the one field it reads, but `getClient` still
|
|
95
|
+
asks for the whole of `Endpoint`, and `requestTimeoutSeconds` on it is required — so a consumer
|
|
96
|
+
that has no timeout to give must invent one (`0` means "no limit"). Making it optional is a
|
|
97
|
+
breaking change and is waiting for the next major.
|
|
41
98
|
|
|
42
99
|
## Where the merged behaviour came from
|
|
43
100
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Produced } from "./stream.ts";
|
|
2
|
+
/**
|
|
3
|
+
* What an endpoint turned out not to support, and answering it when it says so.
|
|
4
|
+
*
|
|
5
|
+
* This package knew *how* to answer a refusal — `isGrammarError`, `relaxTools`, `sanitizeTools`
|
|
6
|
+
* — but not that it had already answered one, so each consumer kept its own memory of what an
|
|
7
|
+
* endpoint could not do and wrote the negotiation around it separately. Both of those are facts
|
|
8
|
+
* about the server on the other end rather than about this process, which is what makes them
|
|
9
|
+
* this package's to hold.
|
|
10
|
+
*/
|
|
11
|
+
/** What one endpoint turned out not to support. Both start optimistic and only ever latch off. */
|
|
12
|
+
export interface Capabilities {
|
|
13
|
+
/**
|
|
14
|
+
* llama.cpp-backed servers compile every tool schema into one grammar and reject keywords
|
|
15
|
+
* their converter cannot express — one bad shape from one MCP server fails the whole request.
|
|
16
|
+
* Once we have seen that, the advisory keywords stay off rather than costing every later run
|
|
17
|
+
* a failed call first. See `relaxTools`.
|
|
18
|
+
*/
|
|
19
|
+
strictSchemas: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* `stream_options` is how a streamed request asks for its token counts, and a server that has
|
|
22
|
+
* not heard of it rejects the whole request rather than the option. Dropped for good once that
|
|
23
|
+
* happens: the counts are worth one failed call to find out about, not one per run.
|
|
24
|
+
*/
|
|
25
|
+
usageInStream: boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* What this endpoint is known not to support. The same object every time, so what `negotiate`
|
|
29
|
+
* latches off stays off.
|
|
30
|
+
*/
|
|
31
|
+
export declare function capabilitiesFor(baseUrl: string): Capabilities;
|
|
32
|
+
/** Forgets every endpoint's capabilities. For tests, and for a settings change under test. */
|
|
33
|
+
export declare function resetCapabilities(): void;
|
|
34
|
+
export interface NegotiateOptions {
|
|
35
|
+
/**
|
|
36
|
+
* The flag `send` will be given, for a caller that has to read it after `negotiate` returns.
|
|
37
|
+
*
|
|
38
|
+
* An outer retry loop needs it: nothing is retried once the server has started answering, and
|
|
39
|
+
* by the time a rejected promise is in hand the turn is over. Callers without one can leave
|
|
40
|
+
* this out and take the flag from `send`'s second argument, which is the same object.
|
|
41
|
+
*/
|
|
42
|
+
produced?: Produced;
|
|
43
|
+
/** Told what was given up on, for a watcher who would otherwise see an unexplained pause. */
|
|
44
|
+
onNotice?: (message: string) => void;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Sends a request, re-sending it each time the answer is this endpoint refusing something the
|
|
48
|
+
* request can do without. Returns once the endpoint has answered, or throws if the refusal is
|
|
49
|
+
* not one of ours.
|
|
50
|
+
*
|
|
51
|
+
* A loop rather than one retry. A server that has heard of neither `stream_options` nor a
|
|
52
|
+
* grammar keyword complains about them one at a time, and answering only the first leaves the
|
|
53
|
+
* second to fail the request — so the first run against such an endpoint is spent discovering
|
|
54
|
+
* what the second one starts knowing. It terminates in at most one pass per capability, since
|
|
55
|
+
* each pass either latches one off for good or rethrows.
|
|
56
|
+
*
|
|
57
|
+
* `send` is a thunk rather than a request body because the body has to be rebuilt from the
|
|
58
|
+
* latched flags: `relaxTools` applies to the tools that were just sanitised, and
|
|
59
|
+
* `stream_options` is present or absent rather than adjusted. It is generic over what it
|
|
60
|
+
* resolves, so a caller whose request resolves a stream object before any chunk is read is the
|
|
61
|
+
* same shape as one that resolves a finished turn.
|
|
62
|
+
*
|
|
63
|
+
* It is handed the `produced` flag rather than being expected to close over one. There is only
|
|
64
|
+
* ever one flag in a turn — the same box `streamTurn` sets and the re-send below reads — and a
|
|
65
|
+
* caller that passed it to only one of the two got a turn that had already streamed tokens sent
|
|
66
|
+
* again, silently, with the watcher seeing every one of them twice.
|
|
67
|
+
*/
|
|
68
|
+
export declare function negotiate<T>(supports: Capabilities, send: (supports: Capabilities, produced: Produced) => Promise<T>, { produced, onNotice }?: NegotiateOptions): Promise<T>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { errorMessage } from "./errors.js";
|
|
2
|
+
import { isGrammarError } from "./schema-compat.js";
|
|
3
|
+
/**
|
|
4
|
+
* What each endpoint cannot do, remembered for the life of the process.
|
|
5
|
+
*
|
|
6
|
+
* Keyed by base URL, because these are facts about the server on the other end and not about
|
|
7
|
+
* this one. A llama.cpp box that cannot compile a grammar and a cloud API that can are both
|
|
8
|
+
* reachable from one settings row over its lifetime — an operator retargets it from Ollama this
|
|
9
|
+
* afternoon to OpenAI this evening — and the first one's refusal must not quietly strip
|
|
10
|
+
* pattern/format from the second one's requests, or silently cost it its token counts, for the
|
|
11
|
+
* rest of the process. Bounded by the number of endpoints ever configured, which is a settings
|
|
12
|
+
* row's worth.
|
|
13
|
+
*/
|
|
14
|
+
const capabilities = new Map();
|
|
15
|
+
/**
|
|
16
|
+
* What this endpoint is known not to support. The same object every time, so what `negotiate`
|
|
17
|
+
* latches off stays off.
|
|
18
|
+
*/
|
|
19
|
+
export function capabilitiesFor(baseUrl) {
|
|
20
|
+
let known = capabilities.get(baseUrl);
|
|
21
|
+
if (!known) {
|
|
22
|
+
known = { strictSchemas: true, usageInStream: true };
|
|
23
|
+
capabilities.set(baseUrl, known);
|
|
24
|
+
}
|
|
25
|
+
return known;
|
|
26
|
+
}
|
|
27
|
+
/** Forgets every endpoint's capabilities. For tests, and for a settings change under test. */
|
|
28
|
+
export function resetCapabilities() {
|
|
29
|
+
capabilities.clear();
|
|
30
|
+
}
|
|
31
|
+
/** `stream_options` is named in the refusal by every server that has not heard of it. */
|
|
32
|
+
const REJECTS_USAGE = /stream_options/i;
|
|
33
|
+
/**
|
|
34
|
+
* Sends a request, re-sending it each time the answer is this endpoint refusing something the
|
|
35
|
+
* request can do without. Returns once the endpoint has answered, or throws if the refusal is
|
|
36
|
+
* not one of ours.
|
|
37
|
+
*
|
|
38
|
+
* A loop rather than one retry. A server that has heard of neither `stream_options` nor a
|
|
39
|
+
* grammar keyword complains about them one at a time, and answering only the first leaves the
|
|
40
|
+
* second to fail the request — so the first run against such an endpoint is spent discovering
|
|
41
|
+
* what the second one starts knowing. It terminates in at most one pass per capability, since
|
|
42
|
+
* each pass either latches one off for good or rethrows.
|
|
43
|
+
*
|
|
44
|
+
* `send` is a thunk rather than a request body because the body has to be rebuilt from the
|
|
45
|
+
* latched flags: `relaxTools` applies to the tools that were just sanitised, and
|
|
46
|
+
* `stream_options` is present or absent rather than adjusted. It is generic over what it
|
|
47
|
+
* resolves, so a caller whose request resolves a stream object before any chunk is read is the
|
|
48
|
+
* same shape as one that resolves a finished turn.
|
|
49
|
+
*
|
|
50
|
+
* It is handed the `produced` flag rather than being expected to close over one. There is only
|
|
51
|
+
* ever one flag in a turn — the same box `streamTurn` sets and the re-send below reads — and a
|
|
52
|
+
* caller that passed it to only one of the two got a turn that had already streamed tokens sent
|
|
53
|
+
* again, silently, with the watcher seeing every one of them twice.
|
|
54
|
+
*/
|
|
55
|
+
export async function negotiate(supports, send, { produced = { any: false }, onNotice } = {}) {
|
|
56
|
+
for (;;) {
|
|
57
|
+
try {
|
|
58
|
+
return await send(supports, produced);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (produced.any)
|
|
62
|
+
throw error;
|
|
63
|
+
const detail = errorMessage(error);
|
|
64
|
+
if (supports.strictSchemas && isGrammarError(detail)) {
|
|
65
|
+
supports.strictSchemas = false;
|
|
66
|
+
onNotice?.("server could not build a grammar; retrying without pattern/format");
|
|
67
|
+
}
|
|
68
|
+
else if (supports.usageInStream && REJECTS_USAGE.test(detail)) {
|
|
69
|
+
supports.usageInStream = false;
|
|
70
|
+
onNotice?.("server rejected stream_options; token counts unavailable");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -24,8 +24,10 @@ export declare function listModels(config: Endpoint): Promise<ModelInfo[]>;
|
|
|
24
24
|
* happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
|
|
25
25
|
* the honest-looking number is a run that fails at the endpoint instead.
|
|
26
26
|
*
|
|
27
|
-
* Otherwise the listing is asked
|
|
28
|
-
*
|
|
27
|
+
* Otherwise the endpoint's listing is asked — once, and again whenever it does not name this
|
|
28
|
+
* model, since a model can arrive after the first listing was taken. A server that will not
|
|
29
|
+
* list models still has to be able to run a turn: a failure here is an unknown window, not a
|
|
30
|
+
* failed run.
|
|
29
31
|
*/
|
|
30
32
|
export declare function contextLimitFor(config: Endpoint & {
|
|
31
33
|
model: string;
|
package/dist/client.js
CHANGED
|
@@ -63,8 +63,8 @@ function contextLengthOf(model) {
|
|
|
63
63
|
*
|
|
64
64
|
* Keyed the same way the clients are, because two endpoints are two different sets of models
|
|
65
65
|
* and one of them having answered says nothing about the other. It is only ever a cache of
|
|
66
|
-
* something asked for anyway
|
|
67
|
-
*
|
|
66
|
+
* something asked for anyway, and a listing that fails leaves whatever was there rather than
|
|
67
|
+
* emptying it.
|
|
68
68
|
*/
|
|
69
69
|
const listings = new Map();
|
|
70
70
|
const endpointKey = (config) => JSON.stringify([config.baseUrl, config.apiKey || NO_KEY]);
|
|
@@ -85,15 +85,28 @@ export async function listModels(config) {
|
|
|
85
85
|
* happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
|
|
86
86
|
* the honest-looking number is a run that fails at the endpoint instead.
|
|
87
87
|
*
|
|
88
|
-
* Otherwise the listing is asked
|
|
89
|
-
*
|
|
88
|
+
* Otherwise the endpoint's listing is asked — once, and again whenever it does not name this
|
|
89
|
+
* model, since a model can arrive after the first listing was taken. A server that will not
|
|
90
|
+
* list models still has to be able to run a turn: a failure here is an unknown window, not a
|
|
91
|
+
* failed run.
|
|
90
92
|
*/
|
|
91
93
|
export async function contextLimitFor(config, declared = 0) {
|
|
92
94
|
if (declared > 0)
|
|
93
95
|
return declared;
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
96
|
+
const key = endpointKey(config);
|
|
97
|
+
// The listing is asked for again when it does not name this model, rather than only when
|
|
98
|
+
// there is no listing at all. Models arrive after a process starts — an `ollama pull` on a
|
|
99
|
+
// box that has been up a week, a worker added to a router, a name the operator has only just
|
|
100
|
+
// typed into settings — and a cache keyed on "we have asked once" answers zero for every one
|
|
101
|
+
// of them until a restart. Zero means "nobody knows", so what the operator loses is the
|
|
102
|
+
// context meter and, in a consumer that compacts on it, compaction: the session then runs at
|
|
103
|
+
// the window instead of under it and fails against the endpoint's own refusal.
|
|
104
|
+
//
|
|
105
|
+
// The cost of asking again is one listing per call while the model really is absent, which
|
|
106
|
+
// is exactly the case where the cached answer would have been wrong.
|
|
107
|
+
if (!listings.get(key)?.some((model) => model.id === config.model)) {
|
|
108
|
+
// A failure is not remembered: an endpoint that was down when the last run started is not
|
|
109
|
+
// an endpoint with no models, and a window nobody could ask about is not a failed run.
|
|
97
110
|
try {
|
|
98
111
|
await listModels(config);
|
|
99
112
|
}
|
|
@@ -101,7 +114,7 @@ export async function contextLimitFor(config, declared = 0) {
|
|
|
101
114
|
return 0;
|
|
102
115
|
}
|
|
103
116
|
}
|
|
104
|
-
const listed = listings.get(
|
|
117
|
+
const listed = listings.get(key) ?? [];
|
|
105
118
|
return listed.find((model) => model.id === config.model)?.contextLength ?? 0;
|
|
106
119
|
}
|
|
107
120
|
/** Forgets every cached client and listing. For tests, and for a settings change under test. */
|
package/dist/events.d.ts
CHANGED
|
@@ -61,8 +61,20 @@ export interface RunEvent {
|
|
|
61
61
|
/** Running totals on `usage`, otherwise null. */
|
|
62
62
|
usage: RunUsage | null;
|
|
63
63
|
}
|
|
64
|
-
/**
|
|
65
|
-
|
|
64
|
+
/**
|
|
65
|
+
* What `emit` is given: the run and the sequence are the bus's to assign.
|
|
66
|
+
*
|
|
67
|
+
* Named exclusions rather than a blanket `Partial`, which permitted both and let the spread in
|
|
68
|
+
* `emit` overwrite them — a caller could file an event under another run and hand every watcher
|
|
69
|
+
* a duplicate `seq`, which is the one thing the sequence is for.
|
|
70
|
+
*/
|
|
71
|
+
export type RunEventInput = Pick<RunEvent, "kind"> & Partial<Omit<RunEvent, "kind" | "runId" | "seq">>;
|
|
72
|
+
/**
|
|
73
|
+
* Forgets a run that will not be emitting `done` — one whose process is tearing down, or whose
|
|
74
|
+
* loop threw where it could not be caught. The sweep gets there on its own; this is for a
|
|
75
|
+
* caller that already knows.
|
|
76
|
+
*/
|
|
77
|
+
export declare function endRun(runId: string): void;
|
|
66
78
|
/** Records one event and hands it to everyone watching that run. Never throws at the caller. */
|
|
67
79
|
export declare function emit(runId: string, input: RunEventInput): RunEvent;
|
|
68
80
|
/**
|
package/dist/events.js
CHANGED
|
@@ -27,16 +27,56 @@ const streamFor = (runId) => {
|
|
|
27
27
|
const existing = streams.get(runId);
|
|
28
28
|
if (existing)
|
|
29
29
|
return existing;
|
|
30
|
-
const stream = {
|
|
30
|
+
const stream = {
|
|
31
|
+
events: [],
|
|
32
|
+
listeners: new Set(),
|
|
33
|
+
seq: 0,
|
|
34
|
+
touched: Date.now(),
|
|
35
|
+
ended: false,
|
|
36
|
+
};
|
|
31
37
|
streams.set(runId, stream);
|
|
32
38
|
return stream;
|
|
33
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* Drops the streams nobody is reading and nothing is writing to.
|
|
42
|
+
*
|
|
43
|
+
* Cleanup used to hang entirely off `done`, which assumed every run reaches it. A run killed by
|
|
44
|
+
* an uncaught throw, a signal, or a caller that simply forgets pinned its backlog for the life
|
|
45
|
+
* of the process — and in a long-lived server that map only ever grew. The `done` timer had the
|
|
46
|
+
* same shape of hole from the other end: if a watcher was still attached when it fired it
|
|
47
|
+
* deleted nothing and nothing rescheduled it, so any client slow enough to still be reading a
|
|
48
|
+
* minute after the end leaked the stream permanently.
|
|
49
|
+
*
|
|
50
|
+
* One timer for the whole map, rescheduled only while there is something in it to expire.
|
|
51
|
+
*/
|
|
52
|
+
let sweeping = null;
|
|
53
|
+
function sweep() {
|
|
54
|
+
sweeping = null;
|
|
55
|
+
const deadline = Date.now() - RETAIN_MS;
|
|
56
|
+
for (const [runId, stream] of streams) {
|
|
57
|
+
if (stream.listeners.size === 0 && stream.touched <= deadline)
|
|
58
|
+
streams.delete(runId);
|
|
59
|
+
}
|
|
60
|
+
scheduleSweep();
|
|
61
|
+
}
|
|
62
|
+
function scheduleSweep() {
|
|
63
|
+
if (sweeping || streams.size === 0)
|
|
64
|
+
return;
|
|
65
|
+
sweeping = setTimeout(sweep, RETAIN_MS);
|
|
66
|
+
sweeping.unref?.();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Forgets a run that will not be emitting `done` — one whose process is tearing down, or whose
|
|
70
|
+
* loop threw where it could not be caught. The sweep gets there on its own; this is for a
|
|
71
|
+
* caller that already knows.
|
|
72
|
+
*/
|
|
73
|
+
export function endRun(runId) {
|
|
74
|
+
streams.delete(runId);
|
|
75
|
+
}
|
|
34
76
|
/** Records one event and hands it to everyone watching that run. Never throws at the caller. */
|
|
35
77
|
export function emit(runId, input) {
|
|
36
78
|
const stream = streamFor(runId);
|
|
37
79
|
const event = {
|
|
38
|
-
runId,
|
|
39
|
-
seq: ++stream.seq,
|
|
40
80
|
at: new Date(),
|
|
41
81
|
text: "",
|
|
42
82
|
name: "",
|
|
@@ -44,20 +84,27 @@ export function emit(runId, input) {
|
|
|
44
84
|
ok: null,
|
|
45
85
|
usage: null,
|
|
46
86
|
...input,
|
|
87
|
+
// After the spread, not before: these are the bus's and a caller does not get a say.
|
|
88
|
+
runId,
|
|
89
|
+
seq: ++stream.seq,
|
|
47
90
|
};
|
|
48
91
|
stream.events.push(event);
|
|
92
|
+
stream.touched = event.at.getTime();
|
|
49
93
|
if (stream.events.length > MAX_EVENTS + TRIM_SLACK) {
|
|
50
94
|
stream.events.splice(0, stream.events.length - MAX_EVENTS);
|
|
51
95
|
}
|
|
52
|
-
for
|
|
53
|
-
|
|
54
|
-
if (event.kind === "done")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
96
|
+
// Kept for a moment so a watcher that arrives just after the end still sees how it went,
|
|
97
|
+
// then dropped: a finished run's record is the row, not this.
|
|
98
|
+
if (event.kind === "done")
|
|
99
|
+
stream.ended = true;
|
|
100
|
+
scheduleSweep();
|
|
101
|
+
for (const listener of stream.listeners) {
|
|
102
|
+
// The guarantee above is the point of this: a listener that throws must not take out the
|
|
103
|
+
// emitter, the listeners after it, or the bookkeeping already done above.
|
|
104
|
+
try {
|
|
105
|
+
listener(event);
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
61
108
|
}
|
|
62
109
|
return event;
|
|
63
110
|
}
|
|
@@ -95,15 +142,22 @@ export async function* watch(runId) {
|
|
|
95
142
|
finally {
|
|
96
143
|
stream.listeners.delete(listener);
|
|
97
144
|
// A watcher can name a run that has not started, or will never start. Nothing was recorded
|
|
98
|
-
// under it, so nothing is left behind either
|
|
99
|
-
|
|
145
|
+
// under it, so nothing is left behind either — and a run that has ended has nothing more to
|
|
146
|
+
// say to anyone, so the last watcher leaving takes the backlog with it.
|
|
147
|
+
if (stream.listeners.size === 0 && (stream.ended || stream.events.length === 0)) {
|
|
100
148
|
streams.delete(runId);
|
|
149
|
+
}
|
|
101
150
|
}
|
|
102
151
|
}
|
|
103
152
|
/** The backlog alone, for a caller that wants a snapshot rather than a subscription. */
|
|
104
153
|
export const history = (runId) => [...(streams.get(runId)?.events ?? [])];
|
|
105
154
|
/** Test seam: forget every run, so one test's events cannot be read by the next. */
|
|
106
|
-
export const reset = () =>
|
|
155
|
+
export const reset = () => {
|
|
156
|
+
streams.clear();
|
|
157
|
+
if (sweeping)
|
|
158
|
+
clearTimeout(sweeping);
|
|
159
|
+
sweeping = null;
|
|
160
|
+
};
|
|
107
161
|
/**
|
|
108
162
|
* Consecutive tokens of one kind are one thing being said, not hundreds of things.
|
|
109
163
|
*
|
|
@@ -126,7 +180,10 @@ export function fold(events) {
|
|
|
126
180
|
};
|
|
127
181
|
}
|
|
128
182
|
else {
|
|
129
|
-
|
|
183
|
+
// A copy, because the merged branch above makes one and the caller cannot tell which
|
|
184
|
+
// branch its events took. Pushing the stored object let `fold(history(id))[0].text = ...`
|
|
185
|
+
// rewrite the bus, and every watcher after it read the rewrite.
|
|
186
|
+
blocks.push({ ...event });
|
|
130
187
|
}
|
|
131
188
|
}
|
|
132
189
|
return blocks;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,17 +3,22 @@
|
|
|
3
3
|
*
|
|
4
4
|
* What is here is everything that does not know what the agent is *for*: making a tool schema
|
|
5
5
|
* a strict server will accept, getting tool definitions in front of a model without paying for
|
|
6
|
-
* all of them, one
|
|
7
|
-
*
|
|
8
|
-
* and
|
|
9
|
-
*
|
|
6
|
+
* all of them, reading one streamed turn back into a message, answering an endpoint that
|
|
7
|
+
* refuses one of those, one-shot calls that support a run, the event bus a watcher reads, a
|
|
8
|
+
* pooled client, and the rules about retrying. What is not here is the work — orchestration,
|
|
9
|
+
* prompts, and whatever the run is about — because that is the caller's, and it is the part
|
|
10
|
+
* that differs between one server and the next.
|
|
10
11
|
*/
|
|
12
|
+
export { type Capabilities, capabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
|
|
11
13
|
export type { CatalogServer } from "./catalog.ts";
|
|
12
14
|
export { contextLimitFor, getClient, listModels, type ModelInfo, NO_KEY, resetClients, timeoutMs, } from "./client.ts";
|
|
13
15
|
export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
|
|
14
16
|
export { errorMessage } from "./errors.ts";
|
|
15
|
-
export { emit, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, reset, watch, } from "./events.ts";
|
|
17
|
+
export { emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, reset, watch, } from "./events.ts";
|
|
18
|
+
export { resetAll } from "./reset.ts";
|
|
16
19
|
export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
|
|
17
20
|
export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
|
|
18
|
-
export { ask, clean,
|
|
21
|
+
export { ask, clean, listLines, parseJson, resetHints, type SideTaskOptions, tryAsk, } from "./side-task.ts";
|
|
22
|
+
export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
|
|
23
|
+
export { estimateTokens } from "./tokens.ts";
|
|
19
24
|
export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, requestedNames, } from "./tool-loading.ts";
|
package/dist/index.js
CHANGED
|
@@ -3,15 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* What is here is everything that does not know what the agent is *for*: making a tool schema
|
|
5
5
|
* a strict server will accept, getting tool definitions in front of a model without paying for
|
|
6
|
-
* all of them, one
|
|
7
|
-
*
|
|
8
|
-
* and
|
|
9
|
-
*
|
|
6
|
+
* all of them, reading one streamed turn back into a message, answering an endpoint that
|
|
7
|
+
* refuses one of those, one-shot calls that support a run, the event bus a watcher reads, a
|
|
8
|
+
* pooled client, and the rules about retrying. What is not here is the work — orchestration,
|
|
9
|
+
* prompts, and whatever the run is about — because that is the caller's, and it is the part
|
|
10
|
+
* that differs between one server and the next.
|
|
10
11
|
*/
|
|
12
|
+
export { capabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
|
|
11
13
|
export { contextLimitFor, getClient, listModels, NO_KEY, resetClients, timeoutMs, } from "./client.js";
|
|
12
14
|
export { errorMessage } from "./errors.js";
|
|
13
|
-
export { emit, fold, history, reset, watch, } from "./events.js";
|
|
15
|
+
export { emit, endRun, fold, history, reset, watch, } from "./events.js";
|
|
16
|
+
export { resetAll } from "./reset.js";
|
|
14
17
|
export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
|
|
15
18
|
export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
16
|
-
export { ask, clean,
|
|
19
|
+
export { ask, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
|
|
20
|
+
export { streamTurn, } from "./stream.js";
|
|
21
|
+
export { estimateTokens } from "./tokens.js";
|
|
17
22
|
export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, requestedNames, } from "./tool-loading.js";
|
package/dist/reset.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgets everything this package remembers between calls.
|
|
3
|
+
*
|
|
4
|
+
* Four modules here keep state for the life of the process, each for a good reason and each
|
|
5
|
+
* with its own seam: the pooled clients and their model listings, the endpoints that turned
|
|
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 `reset` stay
|
|
8
|
+
* exported, because a test that means to clear one thing should say so.
|
|
9
|
+
*
|
|
10
|
+
* This is for the other case, which is every teardown. What all four hold is *latched
|
|
11
|
+
* refusals* — a fact one test taught the process about an endpoint, still true as far as the
|
|
12
|
+
* next test can tell. Miss one and the suite becomes order-dependent in the way that passes
|
|
13
|
+
* locally and fails in CI on a different shard: the test that latched it still passes, and the
|
|
14
|
+
* one that reads the latch fails only when it happens to run second. `tests/side-task-hints.test.ts`
|
|
15
|
+
* was written that way and only passed because every case had been handed a hostname of its own.
|
|
16
|
+
*
|
|
17
|
+
* It is also the seam that does not need finding again. A fifth module with a cache is a fifth
|
|
18
|
+
* line here, rather than an edit to the teardown of three consumers who will not all notice.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resetAll(): void;
|
package/dist/reset.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { resetCapabilities } from "./capabilities.js";
|
|
2
|
+
import { resetClients } from "./client.js";
|
|
3
|
+
import { reset as resetEvents } from "./events.js";
|
|
4
|
+
import { resetHints } from "./side-task.js";
|
|
5
|
+
/**
|
|
6
|
+
* Forgets everything this package remembers between calls.
|
|
7
|
+
*
|
|
8
|
+
* Four modules here keep state for the life of the process, each for a good reason and each
|
|
9
|
+
* with its own seam: the pooled clients and their model listings, the endpoints that turned
|
|
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 `reset` stay
|
|
12
|
+
* exported, because a test that means to clear one thing should say so.
|
|
13
|
+
*
|
|
14
|
+
* This is for the other case, which is every teardown. What all four hold is *latched
|
|
15
|
+
* refusals* — a fact one test taught the process about an endpoint, still true as far as the
|
|
16
|
+
* next test can tell. Miss one and the suite becomes order-dependent in the way that passes
|
|
17
|
+
* locally and fails in CI on a different shard: the test that latched it still passes, and the
|
|
18
|
+
* one that reads the latch fails only when it happens to run second. `tests/side-task-hints.test.ts`
|
|
19
|
+
* was written that way and only passed because every case had been handed a hostname of its own.
|
|
20
|
+
*
|
|
21
|
+
* It is also the seam that does not need finding again. A fifth module with a cache is a fifth
|
|
22
|
+
* line here, rather than an edit to the teardown of three consumers who will not all notice.
|
|
23
|
+
*/
|
|
24
|
+
export function resetAll() {
|
|
25
|
+
resetClients();
|
|
26
|
+
resetCapabilities();
|
|
27
|
+
resetHints();
|
|
28
|
+
resetEvents();
|
|
29
|
+
}
|
package/dist/retry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
-
import { estimateTokens } from "./
|
|
2
|
+
import { estimateTokens } from "./tokens.js";
|
|
3
3
|
/**
|
|
4
4
|
* Everything about a request failing that is not about what the request said.
|
|
5
5
|
*
|
|
@@ -44,7 +44,19 @@ const OVERFLOW = [
|
|
|
44
44
|
/too (long|large) for/i,
|
|
45
45
|
/reduce the length/i,
|
|
46
46
|
];
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* A rate limit says the same words and means the opposite thing.
|
|
49
|
+
*
|
|
50
|
+
* OpenAI refuses a request over the per-minute token budget with "Request too large for gpt-4o
|
|
51
|
+
* ... on tokens per min (TPM)", which is "too large for" beside "tokens" — both halves of the
|
|
52
|
+
* test below. That is a 429, which `isTransient` accepts and which succeeds on the next attempt;
|
|
53
|
+
* reading it as an overflow turned it into a `ContextOverflow`, whose whole purpose is that
|
|
54
|
+
* nothing retries it. The two classifiers in this file disagreed about one error.
|
|
55
|
+
*/
|
|
56
|
+
const RATE_LIMITED = /per (min|hour|day)|rate.?limit|\b[tr]pm\b|quota/i;
|
|
57
|
+
export const isOverflow = (detail) => !RATE_LIMITED.test(detail) &&
|
|
58
|
+
OVERFLOW.some((pattern) => pattern.test(detail)) &&
|
|
59
|
+
/token|context/i.test(detail);
|
|
48
60
|
/**
|
|
49
61
|
* Below this, the window is nobody's business and is not asked for.
|
|
50
62
|
*
|
package/dist/schema-compat.js
CHANGED
|
@@ -97,16 +97,97 @@ function collapseNullableUnion(node) {
|
|
|
97
97
|
}
|
|
98
98
|
/** Combinators at the top level of a parameters schema; strict backends reject them outright. */
|
|
99
99
|
const TOP_LEVEL_COMBINATORS = ["allOf", "anyOf", "oneOf", "enum", "not"];
|
|
100
|
+
/** `#/definitions/Args` or `#/$defs/Args` — a pointer into this schema's own definitions. */
|
|
101
|
+
const LOCAL_POINTER = /^#\/(definitions|\$defs)\/([^/]+)$/;
|
|
102
|
+
/**
|
|
103
|
+
* Replaces a root-level `$ref` with what it points at.
|
|
104
|
+
*
|
|
105
|
+
* Dropping the siblings of a `$ref` is right at a nested position and wrong at this one: the
|
|
106
|
+
* siblings here are the `definitions` the pointer needs, so the reference is left dangling and
|
|
107
|
+
* `properties` is then backfilled empty below. The tool goes out advertising no arguments at
|
|
108
|
+
* all — which the model cannot detect and the server has no reason to refuse. A schema
|
|
109
|
+
* generator emits this shape whenever the argument object is a named type.
|
|
110
|
+
*/
|
|
111
|
+
function inlineRootRef(parameters) {
|
|
112
|
+
const defs = {};
|
|
113
|
+
for (const key of ["definitions", "$defs"])
|
|
114
|
+
if (isObject(parameters[key]))
|
|
115
|
+
defs[key] = parameters[key];
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
let node = parameters;
|
|
118
|
+
while (typeof node.$ref === "string") {
|
|
119
|
+
const pointer = node.$ref;
|
|
120
|
+
const target = LOCAL_POINTER.exec(pointer);
|
|
121
|
+
// A pointer at another document, or one that comes back to itself, has nothing here to
|
|
122
|
+
// resolve against. An object with no properties is at least honest about taking none.
|
|
123
|
+
if (!target || seen.has(pointer))
|
|
124
|
+
return EMPTY_OBJECT();
|
|
125
|
+
seen.add(pointer);
|
|
126
|
+
const pool = defs[target[1]];
|
|
127
|
+
const resolved = isObject(pool) ? pool[target[2]] : undefined;
|
|
128
|
+
if (!isObject(resolved))
|
|
129
|
+
return EMPTY_OBJECT();
|
|
130
|
+
node = resolved;
|
|
131
|
+
}
|
|
132
|
+
// The definitions travel with it: whatever the target refers to still lives in them.
|
|
133
|
+
return node === parameters ? parameters : { ...node, ...defs };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Folds a root `allOf` into the root itself.
|
|
137
|
+
*
|
|
138
|
+
* It is the other way a generated schema spells "the arguments are this named type", and
|
|
139
|
+
* deleting it outright below threw the arguments away while leaving the `required` that named
|
|
140
|
+
* them. Branches that are references are not something to guess at — those fall through to
|
|
141
|
+
* `pruneRequired`, which at least keeps the result self-consistent.
|
|
142
|
+
*/
|
|
143
|
+
function mergeRootAllOf(out) {
|
|
144
|
+
const branches = out.allOf;
|
|
145
|
+
if (!Array.isArray(branches))
|
|
146
|
+
return;
|
|
147
|
+
const properties = isObject(out.properties) ? { ...out.properties } : {};
|
|
148
|
+
const required = new Set(Array.isArray(out.required) ? out.required.filter((name) => typeof name === "string") : []);
|
|
149
|
+
for (const branch of branches) {
|
|
150
|
+
if (!isObject(branch) || "$ref" in branch)
|
|
151
|
+
continue;
|
|
152
|
+
if (isObject(branch.properties))
|
|
153
|
+
Object.assign(properties, branch.properties);
|
|
154
|
+
if (Array.isArray(branch.required))
|
|
155
|
+
for (const name of branch.required)
|
|
156
|
+
if (typeof name === "string")
|
|
157
|
+
required.add(name);
|
|
158
|
+
}
|
|
159
|
+
if (!Object.keys(properties).length)
|
|
160
|
+
return;
|
|
161
|
+
out.properties = properties;
|
|
162
|
+
if (required.size)
|
|
163
|
+
out.required = [...required];
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* A required argument that is not in `properties` is one no caller can supply and no strict
|
|
167
|
+
* validator will accept. Anything the rewrites above removed, `required` may still name.
|
|
168
|
+
*/
|
|
169
|
+
function pruneRequired(out) {
|
|
170
|
+
if (!Array.isArray(out.required))
|
|
171
|
+
return;
|
|
172
|
+
const properties = isObject(out.properties) ? out.properties : {};
|
|
173
|
+
const kept = out.required.filter((name) => typeof name === "string" && name in properties);
|
|
174
|
+
if (kept.length)
|
|
175
|
+
out.required = kept;
|
|
176
|
+
else
|
|
177
|
+
delete out.required;
|
|
178
|
+
}
|
|
100
179
|
function sanitizeParameters(parameters) {
|
|
101
180
|
if (!isObject(parameters))
|
|
102
181
|
return EMPTY_OBJECT();
|
|
103
|
-
const out = normalize(parameters);
|
|
182
|
+
const out = normalize(inlineRootRef(parameters));
|
|
183
|
+
mergeRootAllOf(out);
|
|
104
184
|
for (const key of TOP_LEVEL_COMBINATORS)
|
|
105
185
|
delete out[key];
|
|
106
186
|
if (out.type !== "object")
|
|
107
187
|
out.type = "object";
|
|
108
188
|
if (!isObject(out.properties))
|
|
109
189
|
out.properties = {};
|
|
190
|
+
pruneRequired(out);
|
|
110
191
|
return out;
|
|
111
192
|
}
|
|
112
193
|
const mapTools = (tools, fn) => tools.map((tool) => tool.type === "function"
|
|
@@ -135,6 +216,14 @@ export const sanitizeTools = (tools) => tools.map((tool) => {
|
|
|
135
216
|
* re-validates anyway.
|
|
136
217
|
*/
|
|
137
218
|
export function relaxTools(tools) {
|
|
219
|
+
/**
|
|
220
|
+
* Walked as a schema rather than as arbitrary JSON, because `pattern` and `format` are
|
|
221
|
+
* keyword names and perfectly ordinary argument names at once. Matching on the key alone
|
|
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
|
+
*/
|
|
138
227
|
const strip = (node) => {
|
|
139
228
|
if (Array.isArray(node))
|
|
140
229
|
return node.map(strip);
|
|
@@ -144,7 +233,13 @@ export function relaxTools(tools) {
|
|
|
144
233
|
for (const [key, value] of Object.entries(node)) {
|
|
145
234
|
if (key === "pattern" || key === "format")
|
|
146
235
|
continue;
|
|
147
|
-
|
|
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;
|
|
148
243
|
}
|
|
149
244
|
return out;
|
|
150
245
|
};
|
package/dist/side-task.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Endpoint } from "./config.ts";
|
|
2
|
+
/** Test seam, alongside `resetClients` and `reset`: forget which models refused the hints. */
|
|
3
|
+
export declare const resetHints: () => void;
|
|
2
4
|
export interface SideTaskOptions {
|
|
3
5
|
maxTokens?: number;
|
|
4
6
|
temperature?: number;
|
|
@@ -24,14 +26,3 @@ export declare const clean: (line: string) => string;
|
|
|
24
26
|
* squinted at is worse than one fewer suggestion.
|
|
25
27
|
*/
|
|
26
28
|
export declare const listLines: (text: string, max: number, maxChars: number) => string[];
|
|
27
|
-
/**
|
|
28
|
-
* Rough token count. Characters over four, because there is no tokenizer here and there is not
|
|
29
|
-
* going to be one: a server that will not say how big its window is will not lend us its
|
|
30
|
-
* vocabulary either.
|
|
31
|
-
*
|
|
32
|
-
* The estimate runs low on tool schemas — JSON packs more tokens into a character than prose
|
|
33
|
-
* does — and that is the side to be wrong on wherever it guards a window, since the cost of
|
|
34
|
-
* guessing high is a run refused that would have worked, and the cost of guessing low is the
|
|
35
|
-
* endpoint's own refusal, which is where we were before the guard existed.
|
|
36
|
-
*/
|
|
37
|
-
export declare const estimateTokens: (text: string) => number;
|
package/dist/side-task.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
2
|
import { getClient } from "./client.js";
|
|
3
|
+
import { errorMessage } from "./errors.js";
|
|
4
|
+
import { isTransient } from "./retry.js";
|
|
3
5
|
/**
|
|
4
6
|
* One-shot calls that support a run without being one: picking tools, naming a session,
|
|
5
7
|
* summarising a transcript, proposing follow-ups. They share a shape — small prompt, short
|
|
@@ -17,30 +19,49 @@ const NO_THINKING = {
|
|
|
17
19
|
chat_template_kwargs: { enable_thinking: false },
|
|
18
20
|
};
|
|
19
21
|
/**
|
|
20
|
-
* The
|
|
22
|
+
* The models that turned out not to take the hints, by endpoint and model.
|
|
21
23
|
*
|
|
22
24
|
* Keyed rather than global for the reason the client cache is keyed: a refusal is a fact about
|
|
23
|
-
*
|
|
25
|
+
* what is on the other end, not about this process. A llama.cpp box and a cloud API are both
|
|
24
26
|
* reachable from one consumer over its lifetime, and the first one's refusal must not stop the
|
|
25
27
|
* second from ever being asked.
|
|
28
|
+
*
|
|
29
|
+
* 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 `reasoning_effort` is
|
|
31
|
+
* understood is a property of the model behind the route, not of the route. Keyed on the host
|
|
32
|
+
* alone, the first model to refuse spoke for every model on it.
|
|
26
33
|
*/
|
|
27
34
|
const noHints = new Set();
|
|
35
|
+
const hintKey = (baseUrl, model) => JSON.stringify([baseUrl, model]);
|
|
36
|
+
/** Test seam, alongside `resetClients` and `reset`: forget which models refused the hints. */
|
|
37
|
+
export const resetHints = () => noHints.clear();
|
|
28
38
|
/**
|
|
29
39
|
* Whether a failure is the server complaining about the request, rather than failing to answer.
|
|
30
40
|
*
|
|
31
41
|
* The retry below used to catch everything, so an aborted first call — or a connection that
|
|
32
42
|
* never landed — latched the hints off for the life of the process and every later side task
|
|
33
|
-
* paid for it by burning a whole budget on deliberation.
|
|
34
|
-
*
|
|
43
|
+
* paid for it by burning a whole budget on deliberation. Narrowing that to any 4xx was still
|
|
44
|
+
* too wide: 401, 404 and 429 are all 4xx and none of them is about the fields. A 429 was the
|
|
45
|
+
* worst of them, because the retry then re-sent the whole request immediately — doubling the
|
|
46
|
+
* rate against a server that had just asked for less of it — and `isTransient` accepts exactly
|
|
47
|
+
* that status, so the two halves of this package disagreed about one error.
|
|
48
|
+
*
|
|
49
|
+
* 400 and 422 are what a server says when it read the body and disliked it. Everything else
|
|
50
|
+
* is left to the caller's own retry.
|
|
35
51
|
*/
|
|
36
52
|
function rejectedTheRequest(error) {
|
|
37
|
-
if (!(error instanceof OpenAI.APIError))
|
|
53
|
+
if (!(error instanceof OpenAI.APIError) || isTransient(error))
|
|
38
54
|
return false;
|
|
39
|
-
|
|
40
|
-
return status >= 400 && status < 500;
|
|
55
|
+
return error.status === 400 || error.status === 422;
|
|
41
56
|
}
|
|
42
|
-
/**
|
|
43
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Reasoning models that ignore the hints still fence their scratchpad; drop it.
|
|
59
|
+
*
|
|
60
|
+
* Including the fence that never closes. A side task answers under a small `max_tokens`, so a
|
|
61
|
+
* model that spends it deliberating is cut off mid-scratchpad and the closing tag never
|
|
62
|
+
* arrives — and the whole deliberation was then returned to the caller as the answer.
|
|
63
|
+
*/
|
|
64
|
+
const stripThinking = (text) => text.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/<think>[\s\S]*$/i, "");
|
|
44
65
|
/** Runs a side task and returns the reply text, thinking stripped. Throws like any request. */
|
|
45
66
|
export async function ask(config, model, system, user, { maxTokens = 512, temperature = 0.3, signal } = {}) {
|
|
46
67
|
const send = (hints) => getClient(config).chat.completions.create({
|
|
@@ -53,7 +74,8 @@ export async function ask(config, model, system, user, { maxTokens = 512, temper
|
|
|
53
74
|
],
|
|
54
75
|
...(hints ? NO_THINKING : {}),
|
|
55
76
|
}, { signal });
|
|
56
|
-
const
|
|
77
|
+
const key = hintKey(config.baseUrl, model);
|
|
78
|
+
const hints = !noHints.has(key);
|
|
57
79
|
let response;
|
|
58
80
|
try {
|
|
59
81
|
response = await send(hints);
|
|
@@ -62,10 +84,17 @@ export async function ask(config, model, system, user, { maxTokens = 512, temper
|
|
|
62
84
|
if (!hints || !rejectedTheRequest(error))
|
|
63
85
|
throw error;
|
|
64
86
|
console.warn("[side-task] server rejected the no-thinking hints; retrying without them");
|
|
65
|
-
noHints.add(
|
|
87
|
+
noHints.add(key);
|
|
66
88
|
response = await send(false);
|
|
67
89
|
}
|
|
68
|
-
|
|
90
|
+
const message = response.choices[0]?.message;
|
|
91
|
+
const answer = stripThinking(message?.content ?? "").trim();
|
|
92
|
+
// Nothing but scratchpad. Some servers put the deliberation in its own field and leave the
|
|
93
|
+
// content genuinely empty, in which case there is no answer to find anywhere else.
|
|
94
|
+
if (answer)
|
|
95
|
+
return answer;
|
|
96
|
+
const reasoning = message?.reasoning_content;
|
|
97
|
+
return typeof reasoning === "string" ? stripThinking(reasoning).trim() : "";
|
|
69
98
|
}
|
|
70
99
|
/**
|
|
71
100
|
* A side task is never worth failing the work it supports. Callers that can carry on without
|
|
@@ -76,7 +105,11 @@ export async function tryAsk(label, run) {
|
|
|
76
105
|
return await run();
|
|
77
106
|
}
|
|
78
107
|
catch (error) {
|
|
79
|
-
|
|
108
|
+
// A cancelled run is not a failed side task. Swallowing the abort made the two
|
|
109
|
+
// indistinguishable and left the cancellation with nowhere to go.
|
|
110
|
+
if (error instanceof OpenAI.APIUserAbortError)
|
|
111
|
+
throw error;
|
|
112
|
+
console.warn(`[side-task] ${label}:`, errorMessage(error));
|
|
80
113
|
return undefined;
|
|
81
114
|
}
|
|
82
115
|
}
|
|
@@ -116,14 +149,3 @@ export const listLines = (text, max, maxChars) => text
|
|
|
116
149
|
.map(clean)
|
|
117
150
|
.filter((line) => line.length > 0 && line.length <= maxChars)
|
|
118
151
|
.slice(0, max);
|
|
119
|
-
/**
|
|
120
|
-
* Rough token count. Characters over four, because there is no tokenizer here and there is not
|
|
121
|
-
* going to be one: a server that will not say how big its window is will not lend us its
|
|
122
|
-
* vocabulary either.
|
|
123
|
-
*
|
|
124
|
-
* The estimate runs low on tool schemas — JSON packs more tokens into a character than prose
|
|
125
|
-
* does — and that is the side to be wrong on wherever it guards a window, since the cost of
|
|
126
|
-
* guessing high is a run refused that would have worked, and the cost of guessing low is the
|
|
127
|
-
* endpoint's own refusal, which is where we were before the guard existed.
|
|
128
|
-
*/
|
|
129
|
-
export const estimateTokens = (text) => Math.ceil(text.length / 4);
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type OpenAI from "openai";
|
|
2
|
+
/**
|
|
3
|
+
* Reading one streamed turn back into a message.
|
|
4
|
+
*
|
|
5
|
+
* The rest of a request is the caller's — which model, which tools, which transcript — but the
|
|
6
|
+
* reading of the answer is the same everywhere, and it is fiddly in ways the API does not
|
|
7
|
+
* advertise: tool calls arrive in pieces, an aborted stream ends rather than throws, reasoning
|
|
8
|
+
* has two spellings and is in no published type, and an endpoint that stops answering mid-stream
|
|
9
|
+
* hangs the turn until somebody presses stop. `EndpointSilent` and `timeoutMs` were exported for
|
|
10
|
+
* this loop long before the loop itself was.
|
|
11
|
+
*/
|
|
12
|
+
/** What a turn cost. Zero throughout means the server did not say. */
|
|
13
|
+
export interface TurnUsage {
|
|
14
|
+
prompt: number;
|
|
15
|
+
completion: number;
|
|
16
|
+
total: number;
|
|
17
|
+
}
|
|
18
|
+
/** One streamed turn, put back together into the shape a loop and a transcript work with. */
|
|
19
|
+
export interface Turn {
|
|
20
|
+
content: string;
|
|
21
|
+
toolCalls: OpenAI.ChatCompletionMessageToolCall[];
|
|
22
|
+
usage: TurnUsage;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Whether the server has started answering.
|
|
26
|
+
*
|
|
27
|
+
* A box rather than a return value because it has to be readable *while* the request is in
|
|
28
|
+
* flight: the rules in `retry.ts` are built on the premise that a stream which has already
|
|
29
|
+
* emitted tokens must never be replayed, and by the time a rejected promise is in hand the turn
|
|
30
|
+
* is over. There is one of these per attempt, shared by everything that has a say in whether
|
|
31
|
+
* the attempt is repeated. See `negotiate`.
|
|
32
|
+
*/
|
|
33
|
+
export interface Produced {
|
|
34
|
+
any: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface StreamTurnOptions {
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
/**
|
|
39
|
+
* Silence allowed before the request is given up on. Zero or undefined waits forever, which
|
|
40
|
+
* is what `timeoutMs` means by a timeout of zero and what a local model answering slowly
|
|
41
|
+
* needs.
|
|
42
|
+
*/
|
|
43
|
+
idleMs?: number;
|
|
44
|
+
/** Set as soon as the server has said anything, so a failed call knows if it can be retried. */
|
|
45
|
+
produced?: Produced;
|
|
46
|
+
/** The model's scratchpad, as it arrives. */
|
|
47
|
+
onThinking?: (delta: string) => void;
|
|
48
|
+
/** The model's answer, as it arrives. */
|
|
49
|
+
onOutput?: (delta: string) => void;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Runs one turn as a stream, reporting tokens as they arrive and assembling them back into a
|
|
53
|
+
* message.
|
|
54
|
+
*
|
|
55
|
+
* Streaming buys no speed — nothing waits on the reply but the loop itself. It is what makes a
|
|
56
|
+
* run watchable: a run that stalls, loops, or reaches for the wrong tool says so while it is
|
|
57
|
+
* happening instead of only in the row it leaves behind.
|
|
58
|
+
*
|
|
59
|
+
* Two token callbacks rather than an event input, because a turn does not know which step of
|
|
60
|
+
* which run it is: `step` is the caller's flow concept, and wrapping these into an `emit` is one
|
|
61
|
+
* line at the call site.
|
|
62
|
+
*/
|
|
63
|
+
export declare function streamTurn(client: OpenAI, body: OpenAI.ChatCompletionCreateParamsStreaming, { signal, idleMs, produced, onThinking, onOutput }?: StreamTurnOptions): Promise<Turn>;
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { EndpointSilent } from "./retry.js";
|
|
2
|
+
/**
|
|
3
|
+
* Runs one turn as a stream, reporting tokens as they arrive and assembling them back into a
|
|
4
|
+
* message.
|
|
5
|
+
*
|
|
6
|
+
* Streaming buys no speed — nothing waits on the reply but the loop itself. It is what makes a
|
|
7
|
+
* run watchable: a run that stalls, loops, or reaches for the wrong tool says so while it is
|
|
8
|
+
* happening instead of only in the row it leaves behind.
|
|
9
|
+
*
|
|
10
|
+
* Two token callbacks rather than an event input, because a turn does not know which step of
|
|
11
|
+
* which run it is: `step` is the caller's flow concept, and wrapping these into an `emit` is one
|
|
12
|
+
* line at the call site.
|
|
13
|
+
*/
|
|
14
|
+
export async function streamTurn(client, body, { signal, idleMs, produced, onThinking, onOutput } = {}) {
|
|
15
|
+
// Silence, not duration: the timer is rearmed on every chunk, so a model that is still
|
|
16
|
+
// talking is never cut off however long it takes, and one that has stopped talking does not
|
|
17
|
+
// hang the run until someone notices. A request that never answers at all is the same case
|
|
18
|
+
// with no chunks in it, which is why the first arming happens before the request is sent.
|
|
19
|
+
const watchdog = new AbortController();
|
|
20
|
+
const linked = signal ? AbortSignal.any([signal, watchdog.signal]) : watchdog.signal;
|
|
21
|
+
let idle;
|
|
22
|
+
const rearm = () => {
|
|
23
|
+
if (!idleMs)
|
|
24
|
+
return;
|
|
25
|
+
clearTimeout(idle);
|
|
26
|
+
idle = setTimeout(() => watchdog.abort(), idleMs);
|
|
27
|
+
};
|
|
28
|
+
try {
|
|
29
|
+
rearm();
|
|
30
|
+
return await collect();
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// The caller's own stop has to stay distinguishable from ours: one is a run that was called
|
|
34
|
+
// off, the other is an endpoint that stopped answering and may be worth retrying. Getting
|
|
35
|
+
// this backwards records a stopped run as an endpoint fault, which nobody notices until
|
|
36
|
+
// they read the row and disbelieve it.
|
|
37
|
+
if (watchdog.signal.aborted && !signal?.aborted) {
|
|
38
|
+
throw new EndpointSilent(`the model endpoint sent nothing for ${(idleMs ?? 0) / 1000}s`);
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
clearTimeout(idle);
|
|
44
|
+
}
|
|
45
|
+
async function collect() {
|
|
46
|
+
const stream = await client.chat.completions.create(body, { signal: linked });
|
|
47
|
+
const content = [];
|
|
48
|
+
const calls = new Map();
|
|
49
|
+
const usage = { prompt: 0, completion: 0, total: 0 };
|
|
50
|
+
for await (const chunk of stream) {
|
|
51
|
+
if (produced)
|
|
52
|
+
produced.any = true;
|
|
53
|
+
rearm();
|
|
54
|
+
// Assigned rather than accumulated. `stream_options.include_usage` sends one final chunk
|
|
55
|
+
// and the two agree there, but a server that reports cumulatively per chunk makes a sum
|
|
56
|
+
// of sums out of an accumulator — and a token count wrong by a factor of the chunk count
|
|
57
|
+
// is not a number anyone would attribute to the stream reader.
|
|
58
|
+
if (chunk.usage) {
|
|
59
|
+
usage.prompt = chunk.usage.prompt_tokens ?? 0;
|
|
60
|
+
usage.completion = chunk.usage.completion_tokens ?? 0;
|
|
61
|
+
usage.total = chunk.usage.total_tokens ?? 0;
|
|
62
|
+
}
|
|
63
|
+
const delta = chunk.choices[0]?.delta;
|
|
64
|
+
if (!delta)
|
|
65
|
+
continue;
|
|
66
|
+
const thinking = delta.reasoning_content || delta.reasoning || "";
|
|
67
|
+
if (thinking)
|
|
68
|
+
onThinking?.(thinking);
|
|
69
|
+
if (delta.content) {
|
|
70
|
+
content.push(delta.content);
|
|
71
|
+
onOutput?.(delta.content);
|
|
72
|
+
}
|
|
73
|
+
// Tool calls arrive in pieces, keyed by position: the id in one chunk, the name in
|
|
74
|
+
// another, the arguments spread across the next several.
|
|
75
|
+
for (const part of delta.tool_calls ?? []) {
|
|
76
|
+
const call = calls.get(part.index) ?? { id: "", name: "", arguments: "" };
|
|
77
|
+
if (part.id)
|
|
78
|
+
call.id = part.id;
|
|
79
|
+
if (part.function?.name)
|
|
80
|
+
call.name += part.function.name;
|
|
81
|
+
if (part.function?.arguments)
|
|
82
|
+
call.arguments += part.function.arguments;
|
|
83
|
+
calls.set(part.index, call);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// An aborted stream ends its iteration rather than throwing, so without this a turn cut off
|
|
87
|
+
// halfway — by the watchdog or by someone stopping the run — comes back looking like a
|
|
88
|
+
// complete one, and a truncated answer is recorded as the output. Nothing about the API
|
|
89
|
+
// says you have to know this.
|
|
90
|
+
linked.throwIfAborted();
|
|
91
|
+
return {
|
|
92
|
+
content: content.join(""),
|
|
93
|
+
toolCalls: [...calls.entries()]
|
|
94
|
+
.sort(([a], [b]) => a - b)
|
|
95
|
+
.map(([index, call]) => ({
|
|
96
|
+
// A server that streams a call without an id still needs one for the result to answer.
|
|
97
|
+
id: call.id || `call_${index}`,
|
|
98
|
+
type: "function",
|
|
99
|
+
function: { name: call.name, arguments: call.arguments },
|
|
100
|
+
})),
|
|
101
|
+
usage,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rough token count. Characters over four, because there is no tokenizer here and there is not
|
|
3
|
+
* going to be one: a server that will not say how big its window is will not lend us its
|
|
4
|
+
* vocabulary either.
|
|
5
|
+
*
|
|
6
|
+
* The estimate runs low on tool schemas — JSON packs more tokens into a character than prose
|
|
7
|
+
* does — and that is the side to be wrong on wherever it guards a window, since the cost of
|
|
8
|
+
* guessing high is a run refused that would have worked, and the cost of guessing low is the
|
|
9
|
+
* endpoint's own refusal, which is where we were before the guard existed.
|
|
10
|
+
*
|
|
11
|
+
* Its own module because both `retry` and `side-task` need it and they now need each other:
|
|
12
|
+
* leaving it in `side-task` made the pair a cycle.
|
|
13
|
+
*/
|
|
14
|
+
export declare const estimateTokens: (text: string) => number;
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rough token count. Characters over four, because there is no tokenizer here and there is not
|
|
3
|
+
* going to be one: a server that will not say how big its window is will not lend us its
|
|
4
|
+
* vocabulary either.
|
|
5
|
+
*
|
|
6
|
+
* The estimate runs low on tool schemas — JSON packs more tokens into a character than prose
|
|
7
|
+
* does — and that is the side to be wrong on wherever it guards a window, since the cost of
|
|
8
|
+
* guessing high is a run refused that would have worked, and the cost of guessing low is the
|
|
9
|
+
* endpoint's own refusal, which is where we were before the guard existed.
|
|
10
|
+
*
|
|
11
|
+
* Its own module because both `retry` and `side-task` need it and they now need each other:
|
|
12
|
+
* leaving it in `side-task` made the pair a cycle.
|
|
13
|
+
*/
|
|
14
|
+
export const estimateTokens = (text) => Math.ceil(text.length / 4);
|
package/dist/tool-loading.d.ts
CHANGED
|
@@ -16,7 +16,13 @@ import type { CatalogServer } from "./catalog.ts";
|
|
|
16
16
|
export declare const LOAD_TOOLS = "load_tools";
|
|
17
17
|
/** One object for the life of the process — the agent loop asks for it on every iteration. */
|
|
18
18
|
export declare const LOAD_TOOLS_DEFINITION: OpenAI.ChatCompletionTool;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* The catalogue as a plain grouped listing of names, loaded ones marked.
|
|
21
|
+
*
|
|
22
|
+
* A server with no tools is dropped rather than titled: a pool hands one over whenever a
|
|
23
|
+
* server is connected but has nothing to offer, and a label with nothing under it reads as a
|
|
24
|
+
* listing that got cut off.
|
|
25
|
+
*/
|
|
20
26
|
export declare function catalogList(catalog: CatalogServer[], loaded?: ReadonlySet<string>): string;
|
|
21
27
|
/**
|
|
22
28
|
* The catalogue block appended to the system prompt. Names only — descriptions arrive on load.
|
package/dist/tool-loading.js
CHANGED
|
@@ -35,9 +35,16 @@ export const LOAD_TOOLS_DEFINITION = {
|
|
|
35
35
|
},
|
|
36
36
|
},
|
|
37
37
|
};
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The catalogue as a plain grouped listing of names, loaded ones marked.
|
|
40
|
+
*
|
|
41
|
+
* A server with no tools is dropped rather than titled: a pool hands one over whenever a
|
|
42
|
+
* server is connected but has nothing to offer, and a label with nothing under it reads as a
|
|
43
|
+
* listing that got cut off.
|
|
44
|
+
*/
|
|
39
45
|
export function catalogList(catalog, loaded) {
|
|
40
46
|
return catalog
|
|
47
|
+
.filter((server) => server.tools.length > 0)
|
|
41
48
|
.map((server) => {
|
|
42
49
|
const names = server.tools.map((tool) => ` ${tool.name}${loaded?.has(tool.name) ? " (loaded)" : ""}`);
|
|
43
50
|
return `${server.label}:\n${names.join("\n")}`;
|
|
@@ -53,7 +60,10 @@ export function catalogList(catalog, loaded) {
|
|
|
53
60
|
* the longer list instead.
|
|
54
61
|
*/
|
|
55
62
|
export function catalogPrompt(catalog, loaded) {
|
|
56
|
-
|
|
63
|
+
const list = catalogList(catalog, loaded);
|
|
64
|
+
// Not `catalog.length`: a catalogue of nothing but empty servers has no names to offer, and
|
|
65
|
+
// the preamble below would then explain a mechanism against an empty list.
|
|
66
|
+
if (!list)
|
|
57
67
|
return "";
|
|
58
68
|
return [
|
|
59
69
|
"# Tool catalogue",
|
|
@@ -63,7 +73,7 @@ export function catalogPrompt(catalog, loaded) {
|
|
|
63
73
|
"marked `(loaded)` is already in your tool list — call it directly, do not load it again. Do",
|
|
64
74
|
"not load tools the task does not need, and do not mention this mechanism in your answer.",
|
|
65
75
|
"",
|
|
66
|
-
|
|
76
|
+
list,
|
|
67
77
|
].join("\n");
|
|
68
78
|
}
|
|
69
79
|
const flatten = (catalog) => catalog.flatMap((server) => server.tools);
|
|
@@ -118,10 +128,24 @@ export function expandNames(requested, catalog) {
|
|
|
118
128
|
const name = raw.trim();
|
|
119
129
|
if (!name)
|
|
120
130
|
continue;
|
|
131
|
+
// A bare `*` is not a guess at a name, it is a refusal to choose, and its empty stem
|
|
132
|
+
// prefixes every tool in the catalogue. Answer it the way any other over-broad request is
|
|
133
|
+
// answered: with the names, so the next call can pick from them.
|
|
134
|
+
if (name === "*") {
|
|
135
|
+
overBroad.push({ name, hits: all.map((tool) => tool.name) });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
121
138
|
const hits = resolve(name);
|
|
122
|
-
if (!hits.length)
|
|
139
|
+
if (!hits.length) {
|
|
123
140
|
unknown.push(name);
|
|
124
|
-
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// The cap is what one call may load, not what one name may match: three wildcards of a
|
|
144
|
+
// dozen each cleared a per-name check and still put thirty-six definitions in front of a
|
|
145
|
+
// model that is meant to be choosing from twelve. Already-matched names are free, so a
|
|
146
|
+
// name that only repeats an earlier one never spends budget.
|
|
147
|
+
const fresh = hits.filter((hit) => !matched.has(hit));
|
|
148
|
+
if (matched.size + fresh.length > MAX_PER_LOAD)
|
|
125
149
|
overBroad.push({ name, hits });
|
|
126
150
|
else
|
|
127
151
|
for (const hit of hits)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cubicecho/agent-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.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",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"scripts": {
|
|
43
43
|
"build": "tsc -p tsconfig.build.json",
|
|
44
44
|
"prepare": "npm run build",
|
|
45
|
-
"typecheck": "tsc
|
|
45
|
+
"typecheck": "tsc -p tsconfig.tests.json",
|
|
46
46
|
"test": "vitest run",
|
|
47
47
|
"test:watch": "vitest",
|
|
48
48
|
"lint": "biome check .",
|