@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 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. See [`standards/extraction-backlog.md`](../standards/extraction-backlog.md).
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 has no timeout or retry
39
- budget at all. A single god interface would have forced two of them to grow columns they have
40
- no use for.
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, once per endpoint. A server that will not list models still
28
- * has to be able to run a turn: a failure here is an unknown window, not a failed run.
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: nothing here refreshes it, and a listing that fails leaves
67
- * whatever was there rather than emptying it.
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, once per endpoint. A server that will not list models still
89
- * has to be able to run a turn: a failure here is an unknown window, not a failed run.
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
- // A failure is not remembered: an endpoint that was down when the last run started is not an
95
- // endpoint with no models, and the one listing this costs is nothing beside the run itself.
96
- if (!listings.has(endpointKey(config))) {
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(endpointKey(config)) ?? [];
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
- /** What `emit` is given: the run and the sequence are the bus's to assign. */
65
- export type RunEventInput = Pick<RunEvent, "kind"> & Partial<Omit<RunEvent, "kind">>;
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 = { events: [], listeners: new Set(), seq: 0 };
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 (const listener of stream.listeners)
53
- listener(event);
54
- if (event.kind === "done") {
55
- // Kept for a moment so a watcher that arrives just after the end still sees how it went,
56
- // then dropped: a finished run's record is the row, not this.
57
- setTimeout(() => {
58
- if (streams.get(runId) === stream && stream.listeners.size === 0)
59
- streams.delete(runId);
60
- }, RETAIN_MS).unref?.();
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
- if (stream.listeners.size === 0 && stream.events.length === 0)
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 = () => streams.clear();
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
- blocks.push(event);
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-shot calls that support a run, the event bus a watcher reads, a pooled
7
- * client, and the rules about retrying. What is not here is the work orchestration, prompts,
8
- * and whatever the run is about because that is the caller's, and it is the part that differs
9
- * between one server and the next.
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, estimateTokens, listLines, parseJson, type SideTaskOptions, tryAsk, } from "./side-task.ts";
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-shot calls that support a run, the event bus a watcher reads, a pooled
7
- * client, and the rules about retrying. What is not here is the work orchestration, prompts,
8
- * and whatever the run is about because that is the caller's, and it is the part that differs
9
- * between one server and the next.
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, estimateTokens, listLines, parseJson, tryAsk, } from "./side-task.js";
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";
@@ -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 "./side-task.js";
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
- export const isOverflow = (detail) => OVERFLOW.some((pattern) => pattern.test(detail)) && /token|context/i.test(detail);
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
  *
@@ -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
- out[key] = strip(value);
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
  };
@@ -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 endpoints that turned out not to take the hints, by base URL.
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
- * the server on the other end, not about this process. A llama.cpp box and a cloud API are both
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. Only a 4xx says the fields were the
34
- * problem; a timeout, a refused connection or a 500 say nothing about them at all.
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
- const status = error.status ?? 0;
40
- return status >= 400 && status < 500;
55
+ return error.status === 400 || error.status === 422;
41
56
  }
42
- /** Reasoning models that ignore the hints still fence their scratchpad; drop it. */
43
- const stripThinking = (text) => text.replace(/<think>[\s\S]*?<\/think>/gi, "");
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 hints = !noHints.has(config.baseUrl);
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(config.baseUrl);
87
+ noHints.add(key);
66
88
  response = await send(false);
67
89
  }
68
- return stripThinking(response.choices[0]?.message?.content ?? "").trim();
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
- console.warn(`[side-task] ${label}:`, error.message);
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);
@@ -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
+ }
@@ -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);
@@ -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
- /** The catalogue as a plain grouped listing of names, loaded ones marked. */
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.
@@ -35,9 +35,16 @@ export const LOAD_TOOLS_DEFINITION = {
35
35
  },
36
36
  },
37
37
  };
38
- /** The catalogue as a plain grouped listing of names, loaded ones marked. */
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
- if (!catalog.length)
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
- catalogList(catalog, loaded),
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
- else if (hits.length > MAX_PER_LOAD)
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.0.0",
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 --noEmit",
45
+ "typecheck": "tsc -p tsconfig.tests.json",
46
46
  "test": "vitest run",
47
47
  "test:watch": "vitest",
48
48
  "lint": "biome check .",