@cubicecho/agent-core 2.2.4 → 2.4.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 +138 -3
- package/dist/capabilities.d.ts +7 -3
- package/dist/capabilities.js +22 -9
- package/dist/client.d.ts +28 -0
- package/dist/client.js +94 -9
- package/dist/events.d.ts +71 -1
- package/dist/events.js +70 -35
- package/dist/hooks.d.ts +271 -0
- package/dist/hooks.js +256 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/reset.d.ts +6 -5
- package/dist/reset.js +8 -5
- package/dist/retry.d.ts +5 -0
- package/dist/retry.js +5 -0
- package/dist/run-turn.d.ts +5 -0
- package/dist/run-turn.js +14 -1
- package/dist/side-task.js +8 -8
- package/dist/stream.d.ts +19 -1
- package/dist/stream.js +11 -1
- package/dist/tool-loading.d.ts +30 -10
- package/dist/tool-loading.js +45 -18
- package/llms.txt +30 -2
- package/package.json +1 -1
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { errorMessage } from "./errors.js";
|
|
3
|
+
import { estimateTokens } from "./tokens.js";
|
|
4
|
+
/** Every event a hook can be bound to, in the order a session meets them. */
|
|
5
|
+
export const HOOK_EVENTS = [
|
|
6
|
+
"sessionStart",
|
|
7
|
+
"beforeTurn",
|
|
8
|
+
"afterTurn",
|
|
9
|
+
"beforeCompact",
|
|
10
|
+
"sessionEnd",
|
|
11
|
+
"sessionDelete",
|
|
12
|
+
];
|
|
13
|
+
/**
|
|
14
|
+
* The events whose hooks run before a request, and so the only ones whose output can reach it.
|
|
15
|
+
* Anything later runs once the model has already answered.
|
|
16
|
+
*/
|
|
17
|
+
export const INJECT_EVENTS = new Set(["sessionStart", "beforeTurn"]);
|
|
18
|
+
/**
|
|
19
|
+
* The most context all of a request's hooks add between them by default, in estimated tokens.
|
|
20
|
+
*
|
|
21
|
+
* Enough for a handful of recalled memories, and small against any window worth running an agent
|
|
22
|
+
* in. The point is that a generous hook cannot crowd out the conversation it was meant to inform.
|
|
23
|
+
* `configureHooks` moves it for a process, and `gather` and `assembleContext` for one request.
|
|
24
|
+
*/
|
|
25
|
+
export const HOOK_CONTEXT_TOKENS = 2000;
|
|
26
|
+
/** The numbers this module was written with. */
|
|
27
|
+
const HOOK_DEFAULTS = { contextTokens: HOOK_CONTEXT_TOKENS };
|
|
28
|
+
/** What is in force now. Read where it is used, so a change applies from the next request. */
|
|
29
|
+
let hookLimits = { ...HOOK_DEFAULTS };
|
|
30
|
+
/**
|
|
31
|
+
* Changes what hooks are held to, for a process whose windows are not the size these defaults
|
|
32
|
+
* were chosen for.
|
|
33
|
+
*
|
|
34
|
+
* Module-level for the same reason `configureEvents` is: a budget is a deployment's setting, said
|
|
35
|
+
* once at startup. A caller that sizes it per model or per agent — a 128k window can afford more
|
|
36
|
+
* recall than an 8k one — passes `maxTokens` to `gather` instead, which wins over this.
|
|
37
|
+
*
|
|
38
|
+
* @param options The limits to change. A field left out — or given anything that is not a number
|
|
39
|
+
* above zero — keeps what it has, so a half-built config narrows nothing. `Infinity` is a number
|
|
40
|
+
* above zero, and lifts the shared budget entirely.
|
|
41
|
+
* @returns Everything in force afterwards, including what this call did not change.
|
|
42
|
+
*/
|
|
43
|
+
export function configureHooks(options = {}) {
|
|
44
|
+
for (const [name, value] of Object.entries(options)) {
|
|
45
|
+
if (typeof value === "number" && value > 0)
|
|
46
|
+
hookLimits[name] = value;
|
|
47
|
+
}
|
|
48
|
+
return { ...hookLimits };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Test seam: puts `configureHooks` back to the defaults, so one test's budget is not the next's.
|
|
52
|
+
* `resetAll` calls it.
|
|
53
|
+
*/
|
|
54
|
+
export const resetHooks = () => {
|
|
55
|
+
hookLimits = { ...HOOK_DEFAULTS };
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* The budget a call is held to: its own when it gave a usable one, the process's otherwise. The
|
|
59
|
+
* same rule `configureHooks` applies, so a `0` threaded through for "no opinion" does not quietly
|
|
60
|
+
* turn every hook's context off.
|
|
61
|
+
*/
|
|
62
|
+
const budget = (given) => typeof given === "number" && given > 0 ? given : hookLimits.contextTokens;
|
|
63
|
+
/**
|
|
64
|
+
* Said once, above the blocks, so the model reads them as background rather than instructions.
|
|
65
|
+
* Names no host; `withContext` takes another for one that wants to.
|
|
66
|
+
*/
|
|
67
|
+
export const HOOK_PREFACE = "The <context> blocks below were added for this message by the host's hooks. They are " +
|
|
68
|
+
"background the user did not write and may not be relevant. The user's message follows them.";
|
|
69
|
+
const attribute = (text) => text.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
70
|
+
/**
|
|
71
|
+
* Builds the context a set of outcomes adds and the notes that go with it.
|
|
72
|
+
*
|
|
73
|
+
* Each injected outcome is wrapped in `<context source="…">` naming its label, so a model reading
|
|
74
|
+
* a recalled line can tell it is a memory rather than something the user said. Each is held to
|
|
75
|
+
* its own `maxTokens` and the whole to `maxTokens` here, and a block past the total is dropped
|
|
76
|
+
* whole rather than cut to a stub. For any hooks the pool's `validateHooks` accepts, the text is
|
|
77
|
+
* what its `contextBlocks` builds from the same outcomes, character for character, so a host
|
|
78
|
+
* moving between the two sends the same request.
|
|
79
|
+
*
|
|
80
|
+
* The note keeps each hook's text as it was cut, so a host can show exactly what the model was
|
|
81
|
+
* given without re-deriving the caps or parsing the wrapper back off.
|
|
82
|
+
*
|
|
83
|
+
* @param outcomes What the runners returned. An injecting outcome on an event that cannot inject
|
|
84
|
+
* adds nothing; a failed one is noted wherever it falls, including past the budget.
|
|
85
|
+
* @param maxTokens The budget every block shares. Absent, or not a number above zero, is what
|
|
86
|
+
* `configureHooks` last set — `HOOK_CONTEXT_TOKENS` unless something moved it.
|
|
87
|
+
*/
|
|
88
|
+
export function assembleContext(outcomes, maxTokens) {
|
|
89
|
+
const blocks = [];
|
|
90
|
+
const notes = [];
|
|
91
|
+
let remaining = budget(maxTokens);
|
|
92
|
+
for (const outcome of outcomes) {
|
|
93
|
+
const base = { event: outcome.event, source: outcome.label, hookId: outcome.hookId };
|
|
94
|
+
if (!outcome.ok) {
|
|
95
|
+
notes.push({ ...base, error: outcome.error ?? "failed" });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (!outcome.inject || !INJECT_EVENTS.has(outcome.event))
|
|
99
|
+
continue;
|
|
100
|
+
let text = outcome.text?.trim();
|
|
101
|
+
if (!text)
|
|
102
|
+
continue;
|
|
103
|
+
const cap = Math.min(outcome.maxTokens, remaining);
|
|
104
|
+
if (cap <= 0)
|
|
105
|
+
continue;
|
|
106
|
+
if (estimateTokens(text) > cap)
|
|
107
|
+
text = `${text.slice(0, cap * 4 - 1).trimEnd()}…`;
|
|
108
|
+
const tokens = estimateTokens(text);
|
|
109
|
+
remaining -= tokens;
|
|
110
|
+
blocks.push(`<context source="${attribute(outcome.label)}">\n${text}\n</context>`);
|
|
111
|
+
notes.push({ ...base, tokens, text });
|
|
112
|
+
}
|
|
113
|
+
return { context: blocks.join("\n\n"), notes };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The request, with the hooks' context added to this turn's question.
|
|
117
|
+
*
|
|
118
|
+
* It goes on the question and not in the system prompt, because it is about the question — and a
|
|
119
|
+
* system prompt that changed every turn would miss the prompt cache every turn. Nothing is written
|
|
120
|
+
* back: a host that stores what the user typed never remembers the context as something they said.
|
|
121
|
+
*
|
|
122
|
+
* @param history The request's messages. Neither the array nor any message in it is changed.
|
|
123
|
+
* @param index Where this turn's question sits in `history` — which is not where it sits in the
|
|
124
|
+
* session once a compaction has folded the head into a summary. Anything but a user message there
|
|
125
|
+
* leaves the request as it was.
|
|
126
|
+
* @param context What `assembleContext` built. Empty returns `history` itself.
|
|
127
|
+
* @param preface Said above the blocks. Defaults to `HOOK_PREFACE`.
|
|
128
|
+
* @returns `history` when there was nothing to add or nowhere to add it, otherwise a new array.
|
|
129
|
+
*/
|
|
130
|
+
export function withContext(history, index, context, preface = HOOK_PREFACE) {
|
|
131
|
+
const message = history[index];
|
|
132
|
+
if (!context || message?.role !== "user")
|
|
133
|
+
return history;
|
|
134
|
+
const lead = `${preface}\n\n${context}\n\n`;
|
|
135
|
+
const content = typeof message.content === "string"
|
|
136
|
+
? `${lead}${message.content}`
|
|
137
|
+
: [{ type: "text", text: lead }, ...message.content];
|
|
138
|
+
return history.map((item, at) => (at === index ? { ...message, content } : item));
|
|
139
|
+
}
|
|
140
|
+
/** A message's text, whether its content is a string or a list of parts. */
|
|
141
|
+
const textOf = (content) => {
|
|
142
|
+
if (typeof content === "string")
|
|
143
|
+
return content;
|
|
144
|
+
if (!Array.isArray(content))
|
|
145
|
+
return "";
|
|
146
|
+
return content
|
|
147
|
+
.map((part) => (typeof part?.text === "string" && part.type !== "refusal" ? part.text : ""))
|
|
148
|
+
.join("");
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* A stretch of a transcript as a hook reads it: what the user and the assistant said, and nothing
|
|
152
|
+
* else.
|
|
153
|
+
*
|
|
154
|
+
* Tool calls and their results are left out. They are the model's working rather than the
|
|
155
|
+
* conversation, and most of a transcript's characters; a memory server that filed them would
|
|
156
|
+
* recall a directory listing ahead of the decision it led to.
|
|
157
|
+
*
|
|
158
|
+
* The uuid is the session, the position and a digest of what was said. Position alone is not
|
|
159
|
+
* stable — a retry cuts the transcript back and writes a new answer at the same index, and a
|
|
160
|
+
* server deduping on it would keep the answer that was thrown away — and text alone would make
|
|
161
|
+
* two identical "ok"s one memory. The same message sent twice, after its turn and again when it
|
|
162
|
+
* is compacted, is one.
|
|
163
|
+
*
|
|
164
|
+
* @param sessionId Prefixes every uuid, so two sessions never share one.
|
|
165
|
+
* @param messages The transcript, in whatever shape the host stores it, so long as each message
|
|
166
|
+
* has an OpenAI-style `role` and `content`.
|
|
167
|
+
* @param from The first index, inclusive. Below zero reads from the start.
|
|
168
|
+
* @param to The end, exclusive. Absent, or past the end, reads to the end.
|
|
169
|
+
*/
|
|
170
|
+
export function turnMessages(sessionId, messages, from, to) {
|
|
171
|
+
const end = Math.min(to ?? messages.length, messages.length);
|
|
172
|
+
const out = [];
|
|
173
|
+
for (let at = Math.max(0, from); at < end; at++) {
|
|
174
|
+
const message = messages[at];
|
|
175
|
+
if (message.role !== "user" && message.role !== "assistant")
|
|
176
|
+
continue;
|
|
177
|
+
const text = textOf(message.content).trim();
|
|
178
|
+
if (!text)
|
|
179
|
+
continue;
|
|
180
|
+
const digest = createHash("sha256").update(`${message.role}\0${text}`).digest("hex");
|
|
181
|
+
out.push({ speaker: message.role, text, uuid: `${sessionId}:${at}:${digest.slice(0, 12)}` });
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Which turn of a session begins at a point, from 0: the user messages ahead of it.
|
|
187
|
+
*
|
|
188
|
+
* @param messages The transcript.
|
|
189
|
+
* @param before Where the turn begins. Absent is the end, which is the index of a turn whose
|
|
190
|
+
* question has not been appended yet.
|
|
191
|
+
*/
|
|
192
|
+
export const turnIndex = (messages, before = messages.length) => messages.slice(0, before).filter((message) => message.role === "user").length;
|
|
193
|
+
/** A runner that rejected, as the one outcome its event can still be noted by. */
|
|
194
|
+
const rejected = (event, error) => ({
|
|
195
|
+
serverId: "",
|
|
196
|
+
label: "",
|
|
197
|
+
hookId: "",
|
|
198
|
+
event,
|
|
199
|
+
ok: false,
|
|
200
|
+
error: errorMessage(error),
|
|
201
|
+
ms: 0,
|
|
202
|
+
inject: false,
|
|
203
|
+
maxTokens: 0,
|
|
204
|
+
});
|
|
205
|
+
const runSafely = (run, event, context, signal) => Promise.resolve()
|
|
206
|
+
.then(() => run(event, context, { signal }))
|
|
207
|
+
.catch((error) => [rejected(event, error)]);
|
|
208
|
+
/**
|
|
209
|
+
* Runs the hooks ahead of a request and builds what they add to it.
|
|
210
|
+
*
|
|
211
|
+
* This is on the path of the first token, so the events run together rather than one after the
|
|
212
|
+
* other, and a hook that fails costs the turn its context and never the turn — a runner that
|
|
213
|
+
* rejects outright is noted once for its event, with an empty `hookId` and `source`, and the rest
|
|
214
|
+
* go ahead. Bounding each hook's time is the runner's job; `signal` is how the turn ends all of
|
|
215
|
+
* them.
|
|
216
|
+
*
|
|
217
|
+
* @param run Runs one event's hooks.
|
|
218
|
+
* @param events Which to run: `["beforeTurn"]` ordinarily, and `sessionStart` ahead of it on a
|
|
219
|
+
* session's first turn. The outcomes are assembled in this order, so it is also the order the
|
|
220
|
+
* budget is spent in.
|
|
221
|
+
* @param context What the hooks are told.
|
|
222
|
+
* @param options `signal` is handed to the runner, and should be the turn's own: a user who
|
|
223
|
+
* stopped the turn stopped its recall. `onNote` hears each note as the whole is assembled.
|
|
224
|
+
* `maxTokens` is the shared budget for this request, read as `assembleContext` reads it: absent
|
|
225
|
+
* or unusable is the process's, from `configureHooks`.
|
|
226
|
+
*/
|
|
227
|
+
export async function gather(run, events, context, { signal, onNote, maxTokens, } = {}) {
|
|
228
|
+
const outcomes = await Promise.all(events.map((event) => runSafely(run, event, context, signal)));
|
|
229
|
+
const gathered = assembleContext(outcomes.flat(), maxTokens);
|
|
230
|
+
for (const note of gathered.notes)
|
|
231
|
+
onNote?.(note);
|
|
232
|
+
return gathered;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Runs the hooks for an event that reads what happened and adds nothing to a request. Never
|
|
236
|
+
* rejects, so a host can fire it without awaiting it.
|
|
237
|
+
*
|
|
238
|
+
* No signal: these run once the turn has been answered, and a reader who stops listening at that
|
|
239
|
+
* point has not asked for the turn not to be remembered.
|
|
240
|
+
*
|
|
241
|
+
* @param run Runs the event's hooks.
|
|
242
|
+
* @param event `afterTurn`, `beforeCompact`, `sessionEnd` or `sessionDelete`. An injecting event
|
|
243
|
+
* works too, but what its hooks return is dropped, since there is no request here to add it to.
|
|
244
|
+
* @param context What the hooks are told.
|
|
245
|
+
* @param onNote Hears each note — which, with nothing injected, is only ever a failure.
|
|
246
|
+
* @returns The same notes.
|
|
247
|
+
*/
|
|
248
|
+
export async function notify(run, event, context, onNote) {
|
|
249
|
+
const outcomes = await runSafely(run, event, context);
|
|
250
|
+
const notes = outcomes
|
|
251
|
+
.filter((outcome) => !outcome.ok)
|
|
252
|
+
.map((outcome) => assembleContext([outcome]).notes[0]);
|
|
253
|
+
for (const note of notes)
|
|
254
|
+
onNote?.(note);
|
|
255
|
+
return notes;
|
|
256
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
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
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
|
|
8
|
-
* pooled client, and the rules about retrying. What is not here is the work — orchestration,
|
|
7
|
+
* refuses one of those, one-shot calls that support a run, the host's side of lifecycle hooks,
|
|
8
|
+
* the event bus a watcher reads, a pooled client, and the rules about retrying. What is not here is the work — orchestration,
|
|
9
9
|
* prompts, and whatever the run is about — because that is the caller's, and it is the part
|
|
10
10
|
* that differs between one server and the next.
|
|
11
11
|
*/
|
|
@@ -14,7 +14,8 @@ export type { CatalogServer } from "./catalog.ts";
|
|
|
14
14
|
export { contextLimitFor, getClient, listModels, type ModelInfo, NO_KEY, resetClients, timeoutMs, } from "./client.ts";
|
|
15
15
|
export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
|
|
16
16
|
export { errorMessage } from "./errors.ts";
|
|
17
|
-
export { emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, resetEvents, watch, } from "./events.ts";
|
|
17
|
+
export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, resetEvents, watch, } from "./events.ts";
|
|
18
|
+
export { assembleContext, configureHooks, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, withContext, } from "./hooks.ts";
|
|
18
19
|
export { resetAll } from "./reset.ts";
|
|
19
20
|
export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
|
|
20
21
|
export { type RunTurnOptions, runTurn } from "./run-turn.ts";
|
|
@@ -22,4 +23,4 @@ export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
|
|
|
22
23
|
export { ask, clean, listLines, parseJson, resetHints, type SideTaskOptions, tryAsk, } from "./side-task.ts";
|
|
23
24
|
export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
|
|
24
25
|
export { estimateTokens } from "./tokens.ts";
|
|
25
|
-
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";
|
|
26
|
+
export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.ts";
|
package/dist/index.js
CHANGED
|
@@ -4,15 +4,16 @@
|
|
|
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
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
|
|
8
|
-
* pooled client, and the rules about retrying. What is not here is the work — orchestration,
|
|
7
|
+
* refuses one of those, one-shot calls that support a run, the host's side of lifecycle hooks,
|
|
8
|
+
* the event bus a watcher reads, a pooled client, and the rules about retrying. What is not here is the work — orchestration,
|
|
9
9
|
* prompts, and whatever the run is about — because that is the caller's, and it is the part
|
|
10
10
|
* that differs between one server and the next.
|
|
11
11
|
*/
|
|
12
12
|
export { capabilitiesFor, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
|
|
13
13
|
export { contextLimitFor, getClient, listModels, NO_KEY, resetClients, timeoutMs, } from "./client.js";
|
|
14
14
|
export { errorMessage } from "./errors.js";
|
|
15
|
-
export { emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
|
|
15
|
+
export { configureEvents, emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
|
|
16
|
+
export { assembleContext, configureHooks, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, withContext, } from "./hooks.js";
|
|
16
17
|
export { resetAll } from "./reset.js";
|
|
17
18
|
export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
|
|
18
19
|
export { runTurn } from "./run-turn.js";
|
|
@@ -20,4 +21,4 @@ export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
|
20
21
|
export { ask, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
|
|
21
22
|
export { streamTurn, } from "./stream.js";
|
|
22
23
|
export { estimateTokens } from "./tokens.js";
|
|
23
|
-
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";
|
|
24
|
+
export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
|
package/dist/reset.d.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Forgets everything this package remembers between calls.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Five modules here keep state for the life of the process, each for a good reason and each
|
|
5
5
|
* with its own seam: the pooled clients and their model listings, the endpoints that turned
|
|
6
6
|
* out not to take `stream_options` or a grammar, the models that refused the no-thinking
|
|
7
|
-
* hints, and the
|
|
8
|
-
* exported, because a test that means to clear
|
|
7
|
+
* hints, the event bus, and the hooks' configured budget. `resetClients`, `resetCapabilities`,
|
|
8
|
+
* `resetHints`, `resetEvents` and `resetHooks` stay exported, because a test that means to clear
|
|
9
|
+
* one thing should say so.
|
|
9
10
|
*
|
|
10
|
-
* This is for the other case, which is every teardown. What
|
|
11
|
+
* This is for the other case, which is every teardown. What they hold is *latched
|
|
11
12
|
* refusals* — a fact one test taught the process about an endpoint, still true as far as the
|
|
12
13
|
* next test can tell. Miss one and the suite becomes order-dependent in the way that passes
|
|
13
14
|
* locally and fails in CI on a different shard: the test that latched it still passes, and the
|
|
14
15
|
* one that reads the latch fails only when it happens to run second. `tests/side-task-hints.test.ts`
|
|
15
16
|
* was written that way and only passed because every case had been handed a hostname of its own.
|
|
16
17
|
*
|
|
17
|
-
* It is also the seam that does not need finding again. A
|
|
18
|
+
* It is also the seam that does not need finding again. A sixth module with a cache is a sixth
|
|
18
19
|
* line here, rather than an edit to the teardown of three consumers who will not all notice.
|
|
19
20
|
*/
|
|
20
21
|
export declare function resetAll(): void;
|
package/dist/reset.js
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
import { resetCapabilities } from "./capabilities.js";
|
|
2
2
|
import { resetClients } from "./client.js";
|
|
3
3
|
import { resetEvents } from "./events.js";
|
|
4
|
+
import { resetHooks } from "./hooks.js";
|
|
4
5
|
import { resetHints } from "./side-task.js";
|
|
5
6
|
/**
|
|
6
7
|
* Forgets everything this package remembers between calls.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
+
* Five modules here keep state for the life of the process, each for a good reason and each
|
|
9
10
|
* with its own seam: the pooled clients and their model listings, the endpoints that turned
|
|
10
11
|
* out not to take `stream_options` or a grammar, the models that refused the no-thinking
|
|
11
|
-
* hints, and the
|
|
12
|
-
* exported, because a test that means to clear
|
|
12
|
+
* hints, the event bus, and the hooks' configured budget. `resetClients`, `resetCapabilities`,
|
|
13
|
+
* `resetHints`, `resetEvents` and `resetHooks` stay exported, because a test that means to clear
|
|
14
|
+
* one thing should say so.
|
|
13
15
|
*
|
|
14
|
-
* This is for the other case, which is every teardown. What
|
|
16
|
+
* This is for the other case, which is every teardown. What they hold is *latched
|
|
15
17
|
* refusals* — a fact one test taught the process about an endpoint, still true as far as the
|
|
16
18
|
* next test can tell. Miss one and the suite becomes order-dependent in the way that passes
|
|
17
19
|
* locally and fails in CI on a different shard: the test that latched it still passes, and the
|
|
18
20
|
* one that reads the latch fails only when it happens to run second. `tests/side-task-hints.test.ts`
|
|
19
21
|
* was written that way and only passed because every case had been handed a hostname of its own.
|
|
20
22
|
*
|
|
21
|
-
* It is also the seam that does not need finding again. A
|
|
23
|
+
* It is also the seam that does not need finding again. A sixth module with a cache is a sixth
|
|
22
24
|
* line here, rather than an edit to the teardown of three consumers who will not all notice.
|
|
23
25
|
*/
|
|
24
26
|
export function resetAll() {
|
|
@@ -26,4 +28,5 @@ export function resetAll() {
|
|
|
26
28
|
resetCapabilities();
|
|
27
29
|
resetHints();
|
|
28
30
|
resetEvents();
|
|
31
|
+
resetHooks();
|
|
29
32
|
}
|
package/dist/retry.d.ts
CHANGED
|
@@ -14,6 +14,11 @@ export declare class EndpointSilent extends Error {
|
|
|
14
14
|
/**
|
|
15
15
|
* The request was bigger than the model will read. Its own class so nothing retries it: sending
|
|
16
16
|
* the same too-large request again is the same refusal, one round trip later.
|
|
17
|
+
*
|
|
18
|
+
* `runTurn` raises it from either side of the round trip — its own pre-flight guard, or the
|
|
19
|
+
* endpoint's refusal read back through `isOverflow` — so a caller has one thing to catch whether
|
|
20
|
+
* or not it gave a `contextLimit`. The second carries the endpoint's own message, and the error
|
|
21
|
+
* it was built from as `cause`.
|
|
17
22
|
*/
|
|
18
23
|
export declare class ContextOverflow extends Error {
|
|
19
24
|
readonly name = "ContextOverflow";
|
package/dist/retry.js
CHANGED
|
@@ -15,6 +15,11 @@ export class EndpointSilent extends Error {
|
|
|
15
15
|
/**
|
|
16
16
|
* The request was bigger than the model will read. Its own class so nothing retries it: sending
|
|
17
17
|
* the same too-large request again is the same refusal, one round trip later.
|
|
18
|
+
*
|
|
19
|
+
* `runTurn` raises it from either side of the round trip — its own pre-flight guard, or the
|
|
20
|
+
* endpoint's refusal read back through `isOverflow` — so a caller has one thing to catch whether
|
|
21
|
+
* or not it gave a `contextLimit`. The second carries the endpoint's own message, and the error
|
|
22
|
+
* it was built from as `cause`.
|
|
18
23
|
*/
|
|
19
24
|
export class ContextOverflow extends Error {
|
|
20
25
|
name = "ContextOverflow";
|
package/dist/run-turn.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ import { type StreamTurnOptions, type Turn } from "./stream.ts";
|
|
|
12
12
|
* The outer one is the endpoint being unreachable, busy or silent, which is not about this
|
|
13
13
|
* request at all and is worth simply waiting out.
|
|
14
14
|
*
|
|
15
|
+
* A request too big for the window is neither, and comes out of here as `ContextOverflow`
|
|
16
|
+
* however it was found out about — by the `contextLimit` guard below before a round trip was
|
|
17
|
+
* spent, or by the endpoint's own refusal after one. The option decides how early the caller
|
|
18
|
+
* hears, not what it hears.
|
|
19
|
+
*
|
|
15
20
|
* Both are bounded by the same rule: nothing is sent again once the model has started
|
|
16
21
|
* answering. The tokens are already out and on their way to whoever is watching, and a second
|
|
17
22
|
* attempt would say everything twice. That is what `produced` is, one box per attempt — set by
|
package/dist/run-turn.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { negotiate } from "./capabilities.js";
|
|
2
2
|
import { errorMessage } from "./errors.js";
|
|
3
|
-
import { backoffMs, ContextOverflow, compact, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
|
|
3
|
+
import { backoffMs, ContextOverflow, compact, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
|
|
4
4
|
import { streamTurn } from "./stream.js";
|
|
5
5
|
/**
|
|
6
6
|
* `request` is a callback rather than a body because the body has to be rebuilt from whatever
|
|
@@ -46,6 +46,19 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
|
|
|
46
46
|
// rules in `retry.ts` — so classifying first brings a cancelled run back from the dead.
|
|
47
47
|
if (produced.any || stream.signal?.aborted)
|
|
48
48
|
throw error;
|
|
49
|
+
// The endpoint's own refusal, classified here rather than left to the caller. One failure
|
|
50
|
+
// had two error types depending on an option about something else: a caller that gave a
|
|
51
|
+
// `contextLimit` got `ContextOverflow` from the guard above, and one that did not — the
|
|
52
|
+
// default — got a raw SDK error and had to know to run `isOverflow` over its message
|
|
53
|
+
// itself. `runTurn` is offered as the whole loop, so the classification this package
|
|
54
|
+
// already knows how to do belongs inside it.
|
|
55
|
+
//
|
|
56
|
+
// The original is kept as `cause`, because the endpoint's wording is the half that names
|
|
57
|
+
// the number. A rate limit borrows the same words and is not one of these — `isOverflow`
|
|
58
|
+
// rules it out, and it goes on to be retried below as the 429 it is.
|
|
59
|
+
if (!(error instanceof ContextOverflow) && isOverflow(errorMessage(error))) {
|
|
60
|
+
throw new ContextOverflow(errorMessage(error), { cause: error });
|
|
61
|
+
}
|
|
49
62
|
if (attempt >= maxRetries || !isTransient(error))
|
|
50
63
|
throw error;
|
|
51
64
|
const wait = backoffMs(attempt);
|
package/dist/side-task.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
2
|
import { capabilitiesFor, negotiate } from "./capabilities.js";
|
|
3
|
-
import { getClient } from "./client.js";
|
|
3
|
+
import { endpointKey, getClient } from "./client.js";
|
|
4
4
|
import { errorMessage } from "./errors.js";
|
|
5
5
|
import { isTransient } from "./retry.js";
|
|
6
6
|
/**
|
|
@@ -24,10 +24,10 @@ const NO_THINKING = { chat_template_kwargs: { enable_thinking: false } };
|
|
|
24
24
|
/**
|
|
25
25
|
* The models that turned out not to take the hints, by endpoint and model.
|
|
26
26
|
*
|
|
27
|
-
* Keyed rather than global for the reason the client cache is keyed
|
|
28
|
-
* what is on the other end, not about this
|
|
29
|
-
*
|
|
30
|
-
* second from ever being asked.
|
|
27
|
+
* Keyed rather than global for the reason the client cache is keyed, and on the same
|
|
28
|
+
* `endpointKey` it is: a refusal is a fact about what is on the other end, not about this
|
|
29
|
+
* process. A llama.cpp box and a cloud API are both reachable from one consumer over its
|
|
30
|
+
* lifetime, and the first one's refusal must not stop the second from ever being asked.
|
|
31
31
|
*
|
|
32
32
|
* The model belongs in the key for the same reason. One base URL is routinely many models —
|
|
33
33
|
* OpenRouter, LiteLLM, vLLM serving several at once — and whether `chat_template_kwargs` reaches
|
|
@@ -38,7 +38,7 @@ const NO_THINKING = { chat_template_kwargs: { enable_thinking: false } };
|
|
|
38
38
|
* the same (endpoint, model) pair, where a run on that model can read it too.
|
|
39
39
|
*/
|
|
40
40
|
const noHints = new Set();
|
|
41
|
-
const hintKey = (
|
|
41
|
+
const hintKey = (config, model) => JSON.stringify([endpointKey(config), model]);
|
|
42
42
|
/** Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints. */
|
|
43
43
|
export const resetHints = () => noHints.clear();
|
|
44
44
|
/**
|
|
@@ -108,12 +108,12 @@ export async function ask(config, model, system, user, { maxTokens = 512, temper
|
|
|
108
108
|
// whether a run or a side task found it out, and the point of latching it is that only one of
|
|
109
109
|
// them has to pay for it. Nothing here sends tools or `stream_options`, so the two
|
|
110
110
|
// endpoint-level flags are not in play — the model's three are the whole of what this meets.
|
|
111
|
-
const supports = capabilitiesFor(config.baseUrl);
|
|
111
|
+
const supports = capabilitiesFor(config.baseUrl, config.apiKey);
|
|
112
112
|
const attempt = (hints) => negotiate(supports, (_supports, _produced, refused) => send(hints, refused), {
|
|
113
113
|
model,
|
|
114
114
|
onNotice,
|
|
115
115
|
});
|
|
116
|
-
const key = hintKey(config
|
|
116
|
+
const key = hintKey(config, model);
|
|
117
117
|
const hints = !noHints.has(key);
|
|
118
118
|
let response;
|
|
119
119
|
try {
|
package/dist/stream.d.ts
CHANGED
|
@@ -18,8 +18,26 @@ export interface TurnUsage {
|
|
|
18
18
|
/** One streamed turn, put back together into the shape a loop and a transcript work with. */
|
|
19
19
|
export interface Turn {
|
|
20
20
|
content: string;
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The narrower of the SDK's two tool-call shapes, because it is the only one built here — a
|
|
23
|
+
* streamed `tool_calls` delta carries a function and nothing else. Typed as the union it
|
|
24
|
+
* belongs to, every caller had to narrow before it could read `.function`, to rule out a
|
|
25
|
+
* custom call that this loop cannot produce. Still assignable wherever the union is wanted.
|
|
26
|
+
*/
|
|
27
|
+
toolCalls: OpenAI.ChatCompletionMessageFunctionToolCall[];
|
|
22
28
|
usage: TurnUsage;
|
|
29
|
+
/**
|
|
30
|
+
* Why the model stopped, in the endpoint's own words — `stop`, `length`, `tool_calls`, or `""`
|
|
31
|
+
* where it never said.
|
|
32
|
+
*
|
|
33
|
+
* Reported because `length` is otherwise invisible. A turn cut off at the token ceiling comes
|
|
34
|
+
* back as a well-formed `Turn` with truncated `content`, or with a tool call whose `arguments`
|
|
35
|
+
* stop mid-JSON — so the caller meets a parse failure with nothing to attribute it to. Being
|
|
36
|
+
* cut off looking whole is the same trap `throwIfAborted` below answers for the abort; this
|
|
37
|
+
* half is not an error, because the tokens are real and a caller may still want them, so it is
|
|
38
|
+
* handed over rather than raised.
|
|
39
|
+
*/
|
|
40
|
+
finishReason: string;
|
|
23
41
|
}
|
|
24
42
|
/**
|
|
25
43
|
* Whether the model has said anything a second attempt would say twice.
|
package/dist/stream.js
CHANGED
|
@@ -51,6 +51,7 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
|
|
|
51
51
|
const content = [];
|
|
52
52
|
const calls = new Map();
|
|
53
53
|
const usage = { prompt: 0, completion: 0, total: 0 };
|
|
54
|
+
let finishReason = "";
|
|
54
55
|
for await (const chunk of stream) {
|
|
55
56
|
// Rearmed on every chunk, latched below on only some: a priming chunk is the endpoint
|
|
56
57
|
// being alive, which is all the watchdog is asking about.
|
|
@@ -64,7 +65,15 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
|
|
|
64
65
|
usage.completion = chunk.usage.completion_tokens ?? 0;
|
|
65
66
|
usage.total = chunk.usage.total_tokens ?? 0;
|
|
66
67
|
}
|
|
67
|
-
|
|
68
|
+
// One choice, because that is what an agent loop asks for. A body with `n` above one
|
|
69
|
+
// keeps only the first; nothing here is built to reassemble several at once.
|
|
70
|
+
const choice = chunk.choices[0];
|
|
71
|
+
// Read before the delta guard rather than beside the content. The chunk that carries the
|
|
72
|
+
// reason usually carries an empty delta, and some servers send it with no delta at all —
|
|
73
|
+
// either of which the guard below skips, taking the reason with it.
|
|
74
|
+
if (choice?.finish_reason)
|
|
75
|
+
finishReason = choice.finish_reason;
|
|
76
|
+
const delta = choice?.delta;
|
|
68
77
|
if (!delta)
|
|
69
78
|
continue;
|
|
70
79
|
const thinking = delta.reasoning_content || delta.reasoning || "";
|
|
@@ -112,6 +121,7 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
|
|
|
112
121
|
function: { name: call.name, arguments: call.arguments },
|
|
113
122
|
})),
|
|
114
123
|
usage,
|
|
124
|
+
finishReason,
|
|
115
125
|
};
|
|
116
126
|
}
|
|
117
127
|
}
|