@cubicecho/agent-core 2.7.0 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -7
- package/dist/agent-loop.d.ts +4 -3
- package/dist/agent-loop.js +20 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/retry.js +15 -0
- package/dist/side-task.js +4 -17
- package/dist/stream.d.ts +28 -2
- package/dist/stream.js +68 -18
- package/dist/thinking.d.ts +85 -0
- package/dist/thinking.js +182 -0
- package/dist/tool-loading.d.ts +34 -8
- package/dist/tool-loading.js +59 -14
- package/llms.txt +15 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,8 +22,9 @@ only, Node >=22.
|
|
|
22
22
|
| --- | --- |
|
|
23
23
|
| `schema-compat` | Makes an MCP tool schema something a strict or grammar-constrained server will accept. `sanitizeTools`, `relaxTools`, `isGrammarError`. |
|
|
24
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`. |
|
|
25
|
+
| `stream` | Reads one streamed turn back into a message: token callbacks, tool-call reassembly, fenced reasoning taken out of the answer, and the idle watchdog that turns a silent endpoint into `EndpointSilent`. |
|
|
26
26
|
| `capabilities` | What an endpoint turned out not to support — and, under it, what one model on that endpoint did not — plus the loop that answers either when it says so. `capabilitiesFor`, `modelCapabilitiesFor`, `negotiate`. |
|
|
27
|
+
| `thinking` | Tells a scratchpad fenced inside `content` from the answer: `FenceSplitter` for a stream, `stripThinking` for a whole reply, and the fence tables both read. |
|
|
27
28
|
| `side-task` | One-shot calls that support a run without being one — small prompt, short answer, no tools, never worth failing the run over. `askJson` holds the answer to a schema where the server can. |
|
|
28
29
|
| `hooks` | The host's side of lifecycle hooks: `gather` before a request and `notify` after, the shared context budget, `withContext` to put what they add on the turn's question, `untrusted` to fence text nobody vouched for, and `turnMessages` to hand them a transcript. Running a hook is a runner the caller passes. |
|
|
29
30
|
| `events` | The in-memory bus a watcher reads while a run happens: `emit`, `watch`, `history`, `fold`. A watcher's backlog is capped and reports its own gaps. |
|
|
@@ -80,11 +81,28 @@ for: a router is free to send two keys to two different backends, and then what
|
|
|
80
81
|
refused is not a fact about the other. Absent and empty read the same, so a local server with no
|
|
81
82
|
key is one entry however its caller spells it.
|
|
82
83
|
|
|
83
|
-
`Turn` is `content`, `toolCalls`, `usage` and `
|
|
84
|
-
cut off at the token ceiling comes back looking exactly like a finished one, with
|
|
85
|
-
or — the case that bites — a tool call whose `arguments` stop mid-JSON, so the
|
|
86
|
-
parse failure with nothing to attribute it to. `finishReason` is `"length"` there,
|
|
87
|
-
endpoint never said.
|
|
84
|
+
`Turn` is `content`, `toolCalls`, `usage`, `finishReason` and `reasoning`. The fourth is worth
|
|
85
|
+
reading: a turn cut off at the token ceiling comes back looking exactly like a finished one, with
|
|
86
|
+
truncated prose or — the case that bites — a tool call whose `arguments` stop mid-JSON, so the
|
|
87
|
+
caller meets a parse failure with nothing to attribute it to. `finishReason` is `"length"` there,
|
|
88
|
+
`""` where the endpoint never said.
|
|
89
|
+
|
|
90
|
+
`reasoning` is the scratchpad `onThinking` was told, kept because two common families want it
|
|
91
|
+
back. gpt-oss and DeepSeek in thinking mode read the analysis behind a tool call off the assistant
|
|
92
|
+
message on the next request: store it as `reasoning_content` on that message while it ends in a
|
|
93
|
+
tool call, and drop it once the model has answered. Any other model is better off without it,
|
|
94
|
+
since it is context paid for on every turn. `requestTokens` counts it either way.
|
|
95
|
+
|
|
96
|
+
A server without a reasoning parser leaves the scratchpad in `content`, fenced, and then it is shown
|
|
97
|
+
as output, stored and sent back. `streamTurn` routes text inside a fence to `onThinking` and
|
|
98
|
+
`reasoning` instead, holding back the tail of a chunk that could be half a tag. `DEFAULT_FENCES` is
|
|
99
|
+
`<think>`, gpt-oss harmony's analysis channel served raw, and Kimi's `◁think▷`, none of which a
|
|
100
|
+
model writes as an answer; `ALL_FENCES` adds `<thinking>` and `<reasoning>`, which it can be
|
|
101
|
+
quoting, and is what the side tasks use. Pass `fences: []` to read `content` as all answer. A reply
|
|
102
|
+
cut off inside a fence has an empty `content`, not the deliberation promoted to one. A template that
|
|
103
|
+
opens `<think>` in the prompt leaves only the closing tag, so everything before it is moved to
|
|
104
|
+
`reasoning` when it arrives; `startInReasoning: true` says so up front, so `onOutput` is never told
|
|
105
|
+
it at all.
|
|
88
106
|
|
|
89
107
|
## What the model refuses, rather than the server
|
|
90
108
|
|
|
@@ -289,7 +307,10 @@ Found calls are run as `call_recovered_0` onward, the text is what is left, `onT
|
|
|
289
307
|
result see the turn that way, and a notice says so, since the real fix is the server's parser.
|
|
290
308
|
|
|
291
309
|
With `toolDiscovery: "ondemand"` and a catalogue, the request declares `load_tools` and what has
|
|
292
|
-
been loaded, and the catalogue rides on the system prompt
|
|
310
|
+
been loaded, appended in the order it was loaded, and the catalogue rides on the system prompt
|
|
311
|
+
unmarked, the same text on every step. Marking loads there rewrote the head of the prompt and lost
|
|
312
|
+
the prompt cache for the whole transcript on each one; a model that loads a tool twice is told in
|
|
313
|
+
the `load_tools` result that it already has it. A model that calls
|
|
293
314
|
a catalogued tool without loading it first is right about what it wants, and gets it loaded and
|
|
294
315
|
run. A preselection shapes the first step alone: those tools, no catalogue, no `load_tools` —
|
|
295
316
|
a model with the menu still in front of it shops, reloading what it has or picking a sibling —
|
package/dist/agent-loop.d.ts
CHANGED
|
@@ -192,9 +192,10 @@ export interface AgentLoopResult {
|
|
|
192
192
|
* whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
|
|
193
193
|
*
|
|
194
194
|
* On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
|
|
195
|
-
* prompt
|
|
196
|
-
*
|
|
197
|
-
* `maxTokens` is said so as a notice,
|
|
195
|
+
* prompt unchanged from step to step, loaded tools are appended to the tool array in load order,
|
|
196
|
+
* a catalogued tool called without being loaded is loaded and run rather than refused, and a
|
|
197
|
+
* preselection shapes the first step. A turn cut off at `maxTokens` is said so as a notice,
|
|
198
|
+
* because it otherwise reads exactly like a finished one.
|
|
198
199
|
*
|
|
199
200
|
* @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
|
|
200
201
|
* and cancellation. See `AgentLoopOptions`.
|
package/dist/agent-loop.js
CHANGED
|
@@ -6,7 +6,7 @@ import { runTurn } from "./run-turn.js";
|
|
|
6
6
|
import { relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
7
7
|
import { askJson, tryAsk } from "./side-task.js";
|
|
8
8
|
import { parseToolArguments, recoverToolCalls } from "./tool-calls.js";
|
|
9
|
-
import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_PER_LOAD, PRESELECT_SCHEMA, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
|
|
9
|
+
import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, PRESELECT_SCHEMA, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
|
|
10
10
|
/**
|
|
11
11
|
* The loop above a turn: send, run the tools the model asked for, send again, until it stops
|
|
12
12
|
* asking.
|
|
@@ -130,9 +130,10 @@ const accumulate = (total, turn) => {
|
|
|
130
130
|
* whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
|
|
131
131
|
*
|
|
132
132
|
* On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
|
|
133
|
-
* prompt
|
|
134
|
-
*
|
|
135
|
-
* `maxTokens` is said so as a notice,
|
|
133
|
+
* prompt unchanged from step to step, loaded tools are appended to the tool array in load order,
|
|
134
|
+
* a catalogued tool called without being loaded is loaded and run rather than refused, and a
|
|
135
|
+
* preselection shapes the first step. A turn cut off at `maxTokens` is said so as a notice,
|
|
136
|
+
* because it otherwise reads exactly like a finished one.
|
|
136
137
|
*
|
|
137
138
|
* @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
|
|
138
139
|
* and cancellation. See `AgentLoopOptions`.
|
|
@@ -150,7 +151,15 @@ export async function runAgentLoop(options) {
|
|
|
150
151
|
for (const name of preselected)
|
|
151
152
|
loaded.add(name);
|
|
152
153
|
const used = new Set();
|
|
153
|
-
const
|
|
154
|
+
const definitions = new Map();
|
|
155
|
+
for (const tool of tools) {
|
|
156
|
+
if (tool.type === "function" && !definitions.has(tool.function.name)) {
|
|
157
|
+
definitions.set(tool.function.name, tool);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// In the order the names are given, not the order of `tools`: `loaded` is a set, which iterates
|
|
161
|
+
// in the order things were added, so a load appends and never reshuffles what went before.
|
|
162
|
+
const byName = (names) => [...names].flatMap((name) => definitions.get(name) ?? []);
|
|
154
163
|
let messages = [...options.messages];
|
|
155
164
|
// Held by reference rather than by index, so a `beforeStep` that folds the head into a summary
|
|
156
165
|
// moves the question without losing it — and one that summarises the question away takes the
|
|
@@ -176,9 +185,12 @@ export async function runAgentLoop(options) {
|
|
|
176
185
|
const declared = routed
|
|
177
186
|
? byName(new Set(preselected))
|
|
178
187
|
: onDemand
|
|
179
|
-
? [LOAD_TOOLS_DEFINITION,
|
|
188
|
+
? loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded))
|
|
180
189
|
: tools;
|
|
181
|
-
|
|
190
|
+
// Unmarked, so the system prompt is the same text on every step and a load does not throw
|
|
191
|
+
// away the cache for the whole transcript. What is loaded is said in `declared` and in the
|
|
192
|
+
// `load_tools` result instead. The preselected first step is the one exception, by design.
|
|
193
|
+
const prompt = onDemand && !routed ? `${system}\n\n${catalogPrompt(catalog)}`.trim() : system;
|
|
182
194
|
const request = [
|
|
183
195
|
...(prompt ? [{ role: "system", content: prompt }] : []),
|
|
184
196
|
...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context, hooks?.preface),
|
|
@@ -293,9 +305,9 @@ export async function runAgentLoop(options) {
|
|
|
293
305
|
throw unreadable;
|
|
294
306
|
if (onDemand && name === LOAD_TOOLS) {
|
|
295
307
|
const resolved = expandNames(requestedNames(args), catalog);
|
|
308
|
+
content = loadResult(resolved, catalog, loaded);
|
|
296
309
|
for (const hit of resolved.matched)
|
|
297
310
|
loaded.add(hit);
|
|
298
|
-
content = loadResult(resolved, catalog);
|
|
299
311
|
ok = resolved.matched.length > 0;
|
|
300
312
|
}
|
|
301
313
|
else {
|
package/dist/index.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
|
|
|
25
25
|
export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTaskOptions, tryAsk, } from "./side-task.ts";
|
|
26
26
|
export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from "./snapshot.ts";
|
|
27
27
|
export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
|
|
28
|
+
export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, type Split, stripThinking, THINK_FENCE, } from "./thinking.ts";
|
|
28
29
|
export { estimateTokens } from "./tokens.ts";
|
|
29
30
|
export { parseToolArguments, recoverToolCalls, ToolArgumentsError, type ToolCall, } from "./tool-calls.ts";
|
|
30
|
-
export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.ts";
|
|
31
|
+
export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.ts";
|
package/dist/index.js
CHANGED
|
@@ -23,6 +23,7 @@ export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
|
23
23
|
export { ask, askJson, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
|
|
24
24
|
export { CAPABILITY_SNAPSHOT_VERSION, exportCapabilities, importCapabilities, } from "./snapshot.js";
|
|
25
25
|
export { streamTurn, } from "./stream.js";
|
|
26
|
+
export { ALL_FENCES, DEFAULT_FENCES, FenceSplitter, stripThinking, THINK_FENCE, } from "./thinking.js";
|
|
26
27
|
export { estimateTokens } from "./tokens.js";
|
|
27
28
|
export { parseToolArguments, recoverToolCalls, ToolArgumentsError, } from "./tool-calls.js";
|
|
28
|
-
export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
|
|
29
|
+
export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
|
package/dist/retry.js
CHANGED
|
@@ -53,6 +53,16 @@ const TOOL_CALLS_KEY = 15;
|
|
|
53
53
|
const TEXT_PART = 26;
|
|
54
54
|
/** The same for `{"type":"refusal","refusal":""},` around a refusal part. */
|
|
55
55
|
const REFUSAL_PART = 32;
|
|
56
|
+
/**
|
|
57
|
+
* The same for `"reasoning_content":"",` around an assistant message's scratchpad.
|
|
58
|
+
*
|
|
59
|
+
* Not in the SDK's types, and passed back by the caller who keeps it: gpt-oss and DeepSeek in
|
|
60
|
+
* thinking mode want the analysis behind a tool call on the next request. Left uncounted, the
|
|
61
|
+
* guard came up short by the whole scratchpad on exactly the runs that follow that rule.
|
|
62
|
+
*/
|
|
63
|
+
const REASONING_KEY = 23;
|
|
64
|
+
/** The same for `"reasoning":"",`, OpenRouter's spelling of it. */
|
|
65
|
+
const REASONING_ALT_KEY = 15;
|
|
56
66
|
/** The divisor behind `estimateTokens`, applied here to a character count rather than a string. */
|
|
57
67
|
const CHARS_PER_TOKEN = 4;
|
|
58
68
|
/** How many characters one message is worth: its keys, and its content in whichever shape. */
|
|
@@ -76,6 +86,11 @@ function messageChars(message) {
|
|
|
76
86
|
chars += NAME_KEY + message.name.length;
|
|
77
87
|
if ("tool_call_id" in message && typeof message.tool_call_id === "string")
|
|
78
88
|
chars += TOOL_CALL_ID_KEY + message.tool_call_id.length;
|
|
89
|
+
const { reasoning_content: reasoning, reasoning: alternate } = message;
|
|
90
|
+
if (typeof reasoning === "string")
|
|
91
|
+
chars += REASONING_KEY + reasoning.length;
|
|
92
|
+
if (typeof alternate === "string")
|
|
93
|
+
chars += REASONING_ALT_KEY + alternate.length;
|
|
79
94
|
if ("tool_calls" in message && Array.isArray(message.tool_calls)) {
|
|
80
95
|
chars += TOOL_CALLS_KEY;
|
|
81
96
|
for (const call of message.tool_calls) {
|
package/dist/side-task.js
CHANGED
|
@@ -4,6 +4,7 @@ import { endpointId, getClient } from "./client.js";
|
|
|
4
4
|
import { errorMessage } from "./errors.js";
|
|
5
5
|
import { isTransient } from "./retry.js";
|
|
6
6
|
import { relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
7
|
+
import { stripThinking } from "./thinking.js";
|
|
7
8
|
/**
|
|
8
9
|
* One-shot calls that support a run without being one: picking tools, naming a session,
|
|
9
10
|
* summarising a transcript, proposing follow-ups. They share a shape — small prompt, short
|
|
@@ -64,23 +65,6 @@ function rejectedTheRequest(error) {
|
|
|
64
65
|
return false;
|
|
65
66
|
return error.status === 400 || error.status === 422;
|
|
66
67
|
}
|
|
67
|
-
/**
|
|
68
|
-
* Reasoning models that ignore the hints still fence their scratchpad; drop it.
|
|
69
|
-
*
|
|
70
|
-
* Including the fence that never closes. A side task answers under a small `max_tokens`, so a
|
|
71
|
-
* model that spends it deliberating is cut off mid-scratchpad and the closing tag never
|
|
72
|
-
* arrives — and the whole deliberation was then returned to the caller as the answer.
|
|
73
|
-
*/
|
|
74
|
-
const stripThinking = (text) => text
|
|
75
|
-
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
|
76
|
-
.replace(/<think>[\s\S]*$/i, "")
|
|
77
|
-
// And the fence that never opens. Several chat templates put the opening tag at the end of
|
|
78
|
-
// the prompt rather than leaving the model to write it, so what comes back is deliberation
|
|
79
|
-
// first and only the closing tag to mark where it stops. Neither pattern above matches that,
|
|
80
|
-
// and the whole scratchpad went to the caller as the answer — a session title, a tool
|
|
81
|
-
// preselection, a suggestion list. Every real `<think>` is gone by this point, so a `</think>`
|
|
82
|
-
// still here opened in the prompt; the first one is taken, which keeps the most text.
|
|
83
|
-
.replace(/^[\s\S]*?<\/think>/i, "");
|
|
84
68
|
/**
|
|
85
69
|
* Runs a side task and returns the reply text, thinking stripped. Throws like any request.
|
|
86
70
|
*
|
|
@@ -159,6 +143,9 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
|
|
|
159
143
|
modelCapabilitiesFor(supports, model).reasoningEffort = false;
|
|
160
144
|
}
|
|
161
145
|
const message = response.choices[0]?.message;
|
|
146
|
+
// Reasoning models that ignore the hints still fence their scratchpad. A side task answers
|
|
147
|
+
// under a small ceiling, so the fence often never closes, and a template that opened it in
|
|
148
|
+
// the prompt leaves only the close; either way the deliberation used to come back as the answer.
|
|
162
149
|
const answer = stripThinking(message?.content ?? "").trim();
|
|
163
150
|
// Nothing but scratchpad. Some servers put the deliberation in its own field and leave the
|
|
164
151
|
// content genuinely empty, in which case there is no answer to find anywhere else.
|
package/dist/stream.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type OpenAI from "openai";
|
|
2
|
+
import { type Fence } from "./thinking.ts";
|
|
2
3
|
/**
|
|
3
4
|
* Reading one streamed turn back into a message.
|
|
4
5
|
*
|
|
@@ -46,6 +47,17 @@ export interface Turn {
|
|
|
46
47
|
* handed over rather than raised.
|
|
47
48
|
*/
|
|
48
49
|
finishReason: string;
|
|
50
|
+
/**
|
|
51
|
+
* The model's scratchpad, as `onThinking` was told it, `""` where it deliberated in silence or
|
|
52
|
+
* not at all.
|
|
53
|
+
*
|
|
54
|
+
* Kept because some models want it back. gpt-oss and DeepSeek in thinking mode read the
|
|
55
|
+
* analysis behind a tool call off the assistant message on the next request, so a caller
|
|
56
|
+
* talking to one stores it as `reasoning_content` on that message for as long as the message
|
|
57
|
+
* ends in a tool call, and drops it once the model has given a final answer. A model that
|
|
58
|
+
* does not ask for it is better off without it: it is context, paid for on every turn.
|
|
59
|
+
*/
|
|
60
|
+
reasoning: string;
|
|
49
61
|
}
|
|
50
62
|
/**
|
|
51
63
|
* Whether the model has said anything a second attempt would say twice.
|
|
@@ -84,7 +96,21 @@ export interface StreamTurnOptions {
|
|
|
84
96
|
firstChunkMs?: number;
|
|
85
97
|
/** Set by the first chunk that carries anything, so a failed call knows if it can be retried. */
|
|
86
98
|
produced?: Produced;
|
|
87
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* The fences that mark a scratchpad written into `content`, `DEFAULT_FENCES` unless given.
|
|
101
|
+
*
|
|
102
|
+
* Text inside one goes to `onThinking` and `reasoning` rather than `onOutput` and `content`.
|
|
103
|
+
* `ALL_FENCES` adds `<thinking>` and `<reasoning>`, which a model can also be quoting; an
|
|
104
|
+
* empty list reads `content` as all answer.
|
|
105
|
+
*/
|
|
106
|
+
fences?: readonly Fence[];
|
|
107
|
+
/**
|
|
108
|
+
* The chat template opened the first fence in the prompt, so the reply starts inside it.
|
|
109
|
+
* Without this the scratchpad is still moved out of `content` once the closing tag arrives,
|
|
110
|
+
* but `onOutput` will have been told it first.
|
|
111
|
+
*/
|
|
112
|
+
startInReasoning?: boolean;
|
|
113
|
+
/** The model's scratchpad, as it arrives, from its own field or from a fence in `content`. */
|
|
88
114
|
onThinking?: (delta: string) => void;
|
|
89
115
|
/** The model's answer, as it arrives. */
|
|
90
116
|
onOutput?: (delta: string) => void;
|
|
@@ -105,4 +131,4 @@ export interface StreamTurnOptions {
|
|
|
105
131
|
* @param body The request, which must set `stream: true`.
|
|
106
132
|
* @param options Cancellation, the idle watchdog, and the token callbacks.
|
|
107
133
|
*/
|
|
108
|
-
export declare function streamTurn(client: OpenAI, body: OpenAI.ChatCompletionCreateParamsStreaming, { signal, idleMs, firstChunkMs, produced, onThinking, onOutput }?: StreamTurnOptions): Promise<Turn>;
|
|
134
|
+
export declare function streamTurn(client: OpenAI, body: OpenAI.ChatCompletionCreateParamsStreaming, { signal, idleMs, firstChunkMs, produced, fences, startInReasoning, onThinking, onOutput, }?: StreamTurnOptions): Promise<Turn>;
|
package/dist/stream.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EndpointSilent } from "./retry.js";
|
|
2
|
+
import { DEFAULT_FENCES, FenceSplitter } from "./thinking.js";
|
|
2
3
|
/** The largest delay a timer takes, which is as close to none as the SDK's timeout option goes. */
|
|
3
4
|
const NO_SDK_TIMEOUT = 2 ** 31 - 1;
|
|
4
5
|
/**
|
|
@@ -17,7 +18,7 @@ const NO_SDK_TIMEOUT = 2 ** 31 - 1;
|
|
|
17
18
|
* @param body The request, which must set `stream: true`.
|
|
18
19
|
* @param options Cancellation, the idle watchdog, and the token callbacks.
|
|
19
20
|
*/
|
|
20
|
-
export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, produced, onThinking, onOutput } = {}) {
|
|
21
|
+
export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, produced, fences = DEFAULT_FENCES, startInReasoning, onThinking, onOutput, } = {}) {
|
|
21
22
|
// Silence, not duration: the timer is rearmed on every chunk, so a model that is still
|
|
22
23
|
// talking is never cut off however long it takes, and one that has stopped talking does not
|
|
23
24
|
// hang the run until someone notices. A request that never answers at all is the same case
|
|
@@ -65,8 +66,17 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
|
|
|
65
66
|
signal: linked,
|
|
66
67
|
...(watched ? { timeout: NO_SDK_TIMEOUT } : {}),
|
|
67
68
|
});
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
// The field's reasoning here, the fenced kind in the splitter, which can still move text
|
|
70
|
+
// already read as answer into reasoning when a closing tag turns up with no opening one.
|
|
71
|
+
const reasoning = [];
|
|
72
|
+
const splitter = new FenceSplitter(fences, { startInside: startInReasoning });
|
|
73
|
+
const report = (parts) => {
|
|
74
|
+
for (const part of parts)
|
|
75
|
+
(part.kind === "reasoning" ? onThinking : onOutput)?.(part.text);
|
|
76
|
+
};
|
|
77
|
+
// In arrival order, sorted by index at the end; a call from a server that sent none keeps
|
|
78
|
+
// its place in the order they arrived.
|
|
79
|
+
const calls = [];
|
|
70
80
|
const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
|
|
71
81
|
let finishReason = "";
|
|
72
82
|
for await (const chunk of stream) {
|
|
@@ -110,42 +120,82 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
|
|
|
110
120
|
rearm(true);
|
|
111
121
|
if (produced && carried)
|
|
112
122
|
produced.any = true;
|
|
113
|
-
if (thinking)
|
|
123
|
+
if (thinking) {
|
|
124
|
+
reasoning.push(thinking);
|
|
114
125
|
onThinking?.(thinking);
|
|
115
|
-
if (delta.content) {
|
|
116
|
-
content.push(delta.content);
|
|
117
|
-
onOutput?.(delta.content);
|
|
118
126
|
}
|
|
127
|
+
if (delta.content)
|
|
128
|
+
report(splitter.push(delta.content));
|
|
119
129
|
// Tool calls arrive in pieces, keyed by position: the id in one chunk, the name in
|
|
120
130
|
// another, the arguments spread across the next several.
|
|
121
131
|
for (const part of delta.tool_calls ?? []) {
|
|
122
|
-
const call =
|
|
132
|
+
const call = fragmentOf(part);
|
|
123
133
|
if (part.id)
|
|
124
134
|
call.id = part.id;
|
|
125
135
|
if (part.function?.name)
|
|
126
136
|
call.name += part.function.name;
|
|
127
137
|
if (part.function?.arguments)
|
|
128
138
|
call.arguments += part.function.arguments;
|
|
129
|
-
calls.set(part.index, call);
|
|
130
139
|
}
|
|
131
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* The call a fragment belongs to.
|
|
143
|
+
*
|
|
144
|
+
* By `index` where the server sent a number, which the SDK types as required and servers have
|
|
145
|
+
* nonetheless left out: keyed on `undefined`, every call joined into one whose name and
|
|
146
|
+
* arguments were all of theirs run together. Without one, by `id`; failing that, a fragment
|
|
147
|
+
* naming a function opens a call and a bare run of arguments continues the latest. A server
|
|
148
|
+
* that sends whole calls one per chunk at index `0` makes the same mistake the other way, so
|
|
149
|
+
* a different id, or a name after arguments have begun, opens a new call under that index.
|
|
150
|
+
*/
|
|
151
|
+
function fragmentOf(part) {
|
|
152
|
+
const index = typeof part.index === "number" ? part.index : undefined;
|
|
153
|
+
const name = part.function?.name;
|
|
154
|
+
const known = index !== undefined
|
|
155
|
+
? calls.findLast((call) => call.index === index)
|
|
156
|
+
: part.id
|
|
157
|
+
? calls.find((call) => call.id === part.id)
|
|
158
|
+
: name
|
|
159
|
+
? undefined
|
|
160
|
+
: calls.at(-1);
|
|
161
|
+
const another = known && ((part.id && known.id && part.id !== known.id) || (name && known.arguments));
|
|
162
|
+
if (known && !another)
|
|
163
|
+
return known;
|
|
164
|
+
const call = { index, id: "", name: "", arguments: "" };
|
|
165
|
+
calls.push(call);
|
|
166
|
+
return call;
|
|
167
|
+
}
|
|
132
168
|
// An aborted stream ends its iteration rather than throwing, so without this a turn cut off
|
|
133
169
|
// halfway — by the watchdog or by someone stopping the run — comes back looking like a
|
|
134
170
|
// complete one, and a truncated answer is recorded as the output. Nothing about the API
|
|
135
171
|
// says you have to know this.
|
|
136
172
|
linked.throwIfAborted();
|
|
173
|
+
// What was held back as a possible tag. A reply that ended inside a fence stays reasoning:
|
|
174
|
+
// cut off at the ceiling mid-scratchpad, it has no answer, and promoting the deliberation to
|
|
175
|
+
// one is how a truncated turn gets recorded as output.
|
|
176
|
+
report(splitter.finish());
|
|
177
|
+
const minted = new Set();
|
|
137
178
|
return {
|
|
138
|
-
content:
|
|
139
|
-
toolCalls:
|
|
140
|
-
.
|
|
141
|
-
.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
179
|
+
content: splitter.output,
|
|
180
|
+
toolCalls: calls
|
|
181
|
+
.map((call, order) => ({ call, order }))
|
|
182
|
+
.sort((a, b) => (a.call.index ?? a.order) - (b.call.index ?? b.order) || a.order - b.order)
|
|
183
|
+
.map(({ call }, position) => {
|
|
184
|
+
// A server that streams a call without an id still needs one for the result to answer,
|
|
185
|
+
// and two calls it put under one index must not be answered as one.
|
|
186
|
+
let id = call.id || `call_${call.index ?? position}`;
|
|
187
|
+
if (minted.has(id))
|
|
188
|
+
id = `call_${position}_${minted.size}`;
|
|
189
|
+
minted.add(id);
|
|
190
|
+
return {
|
|
191
|
+
id,
|
|
192
|
+
type: "function",
|
|
193
|
+
function: { name: call.name, arguments: call.arguments },
|
|
194
|
+
};
|
|
195
|
+
}),
|
|
147
196
|
usage,
|
|
148
197
|
finishReason,
|
|
198
|
+
reasoning: reasoning.join("") + splitter.reasoning,
|
|
149
199
|
};
|
|
150
200
|
}
|
|
151
201
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telling a model's scratchpad from its answer when both arrive in `content`.
|
|
3
|
+
*
|
|
4
|
+
* A server with a reasoning parser puts the deliberation in a field of its own. One without — a
|
|
5
|
+
* llama.cpp started with `--reasoning-format none`, a model whose template has no parser, most
|
|
6
|
+
* distills on any server — leaves it fenced inline, and then it is shown as output, stored in
|
|
7
|
+
* the transcript and sent back on the next turn, where it costs context and teaches the model to
|
|
8
|
+
* keep doing it. This is the one table of fences both the stream and the side tasks read.
|
|
9
|
+
*/
|
|
10
|
+
/** One way a model marks off its scratchpad inside `content`. */
|
|
11
|
+
export interface Fence {
|
|
12
|
+
open: string;
|
|
13
|
+
close: string;
|
|
14
|
+
}
|
|
15
|
+
/** DeepSeek, Qwen3, QwQ and most distills. */
|
|
16
|
+
export declare const THINK_FENCE: Fence;
|
|
17
|
+
/**
|
|
18
|
+
* The fences nobody writes by accident, and so the ones `streamTurn` reads by default.
|
|
19
|
+
*
|
|
20
|
+
* `<think>` opening a reply is never meant as output, and the other two are made of tokens that
|
|
21
|
+
* only a model's template produces: gpt-oss's harmony analysis channel, served raw, and Kimi's.
|
|
22
|
+
*/
|
|
23
|
+
export declare const DEFAULT_FENCES: readonly Fence[];
|
|
24
|
+
/**
|
|
25
|
+
* Every fence known, including the two plain-word ones some fine-tunes and prompt-instructed
|
|
26
|
+
* models use. Those can also be text the model is quoting, so they are opt-in for a stream,
|
|
27
|
+
* and on for a side task, whose answer is too short to be quoting anything.
|
|
28
|
+
*/
|
|
29
|
+
export declare const ALL_FENCES: readonly Fence[];
|
|
30
|
+
/** A piece of `content`, said to be one or the other. */
|
|
31
|
+
export interface Split {
|
|
32
|
+
kind: "reasoning" | "output";
|
|
33
|
+
text: string;
|
|
34
|
+
}
|
|
35
|
+
/** What `FenceSplitter` takes besides its fences. */
|
|
36
|
+
export interface FenceSplitterOptions {
|
|
37
|
+
/**
|
|
38
|
+
* The template already opened the first fence in the prompt, so the reply starts inside it.
|
|
39
|
+
*
|
|
40
|
+
* Several chat templates end the prompt with `<think>` rather than leaving the model to write
|
|
41
|
+
* it. Without this the splitter still catches it once `</think>` arrives, and moves what came
|
|
42
|
+
* before into `reasoning`, but a watcher will have been shown it as output by then.
|
|
43
|
+
*/
|
|
44
|
+
startInside?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A state machine over a stream of `content` that routes fenced text to reasoning.
|
|
48
|
+
*
|
|
49
|
+
* The reference shape is Vercel's `extractReasoningMiddleware`. A tag can be split across chunks,
|
|
50
|
+
* so the tail of each push that could be the start of one is held until the next push settles
|
|
51
|
+
* it; `finish` releases it. A reply cut off mid-scratchpad ends with the fence still open and its
|
|
52
|
+
* text still reasoning, rather than promoted to the answer.
|
|
53
|
+
*
|
|
54
|
+
* A closing tag with no opening one is the template having opened it in the prompt. When no
|
|
55
|
+
* fence has been seen yet, everything before it becomes reasoning retroactively in `output` and
|
|
56
|
+
* `reasoning`, though what was already handed out as output cannot be taken back.
|
|
57
|
+
*/
|
|
58
|
+
export declare class FenceSplitter {
|
|
59
|
+
#private;
|
|
60
|
+
/** The answer so far, with every fence taken out. */
|
|
61
|
+
output: string;
|
|
62
|
+
/** Everything that was inside a fence so far. */
|
|
63
|
+
reasoning: string;
|
|
64
|
+
/**
|
|
65
|
+
* @param fences The fences to read, `DEFAULT_FENCES` unless given; an empty list passes
|
|
66
|
+
* everything through as output.
|
|
67
|
+
* @param options Whether the reply starts inside the first fence.
|
|
68
|
+
*/
|
|
69
|
+
constructor(fences?: readonly Fence[], { startInside }?: FenceSplitterOptions);
|
|
70
|
+
/**
|
|
71
|
+
* Reads one more piece of content, returning what it settled, in order.
|
|
72
|
+
*
|
|
73
|
+
* @param text The next delta.
|
|
74
|
+
*/
|
|
75
|
+
push(text: string): Split[];
|
|
76
|
+
/** Releases whatever was held back as a possible tag, now that no more is coming. */
|
|
77
|
+
finish(): Split[];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* What is left of a complete reply once every scratchpad is taken out of it.
|
|
81
|
+
*
|
|
82
|
+
* @param text The whole reply.
|
|
83
|
+
* @param fences The fences to read, every known one unless given.
|
|
84
|
+
*/
|
|
85
|
+
export declare function stripThinking(text: string, fences?: readonly Fence[]): string;
|
package/dist/thinking.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telling a model's scratchpad from its answer when both arrive in `content`.
|
|
3
|
+
*
|
|
4
|
+
* A server with a reasoning parser puts the deliberation in a field of its own. One without — a
|
|
5
|
+
* llama.cpp started with `--reasoning-format none`, a model whose template has no parser, most
|
|
6
|
+
* distills on any server — leaves it fenced inline, and then it is shown as output, stored in
|
|
7
|
+
* the transcript and sent back on the next turn, where it costs context and teaches the model to
|
|
8
|
+
* keep doing it. This is the one table of fences both the stream and the side tasks read.
|
|
9
|
+
*/
|
|
10
|
+
/** DeepSeek, Qwen3, QwQ and most distills. */
|
|
11
|
+
export const THINK_FENCE = { open: "<think>", close: "</think>" };
|
|
12
|
+
/**
|
|
13
|
+
* The fences nobody writes by accident, and so the ones `streamTurn` reads by default.
|
|
14
|
+
*
|
|
15
|
+
* `<think>` opening a reply is never meant as output, and the other two are made of tokens that
|
|
16
|
+
* only a model's template produces: gpt-oss's harmony analysis channel, served raw, and Kimi's.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_FENCES = [
|
|
19
|
+
THINK_FENCE,
|
|
20
|
+
{ open: "<|channel|>analysis<|message|>", close: "<|end|>" },
|
|
21
|
+
{ open: "◁think▷", close: "◁/think▷" },
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Every fence known, including the two plain-word ones some fine-tunes and prompt-instructed
|
|
25
|
+
* models use. Those can also be text the model is quoting, so they are opt-in for a stream,
|
|
26
|
+
* and on for a side task, whose answer is too short to be quoting anything.
|
|
27
|
+
*/
|
|
28
|
+
export const ALL_FENCES = [
|
|
29
|
+
...DEFAULT_FENCES,
|
|
30
|
+
{ open: "<thinking>", close: "</thinking>" },
|
|
31
|
+
{ open: "<reasoning>", close: "</reasoning>" },
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Framing that is neither scratchpad nor answer, dropped wherever it turns up outside a fence.
|
|
35
|
+
*
|
|
36
|
+
* Only harmony has any: after the analysis channel closes, raw gpt-oss output announces the final
|
|
37
|
+
* channel before the answer and ends with a return token.
|
|
38
|
+
*/
|
|
39
|
+
const FRAMING = {
|
|
40
|
+
"<|channel|>analysis<|message|>": [
|
|
41
|
+
"<|start|>assistant",
|
|
42
|
+
"<|channel|>final<|message|>",
|
|
43
|
+
"<|return|>",
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* A state machine over a stream of `content` that routes fenced text to reasoning.
|
|
48
|
+
*
|
|
49
|
+
* The reference shape is Vercel's `extractReasoningMiddleware`. A tag can be split across chunks,
|
|
50
|
+
* so the tail of each push that could be the start of one is held until the next push settles
|
|
51
|
+
* it; `finish` releases it. A reply cut off mid-scratchpad ends with the fence still open and its
|
|
52
|
+
* text still reasoning, rather than promoted to the answer.
|
|
53
|
+
*
|
|
54
|
+
* A closing tag with no opening one is the template having opened it in the prompt. When no
|
|
55
|
+
* fence has been seen yet, everything before it becomes reasoning retroactively in `output` and
|
|
56
|
+
* `reasoning`, though what was already handed out as output cannot be taken back.
|
|
57
|
+
*/
|
|
58
|
+
export class FenceSplitter {
|
|
59
|
+
/** The answer so far, with every fence taken out. */
|
|
60
|
+
output = "";
|
|
61
|
+
/** Everything that was inside a fence so far. */
|
|
62
|
+
reasoning = "";
|
|
63
|
+
#fences;
|
|
64
|
+
#markers;
|
|
65
|
+
#inside;
|
|
66
|
+
#seenFence;
|
|
67
|
+
#held = "";
|
|
68
|
+
/**
|
|
69
|
+
* @param fences The fences to read, `DEFAULT_FENCES` unless given; an empty list passes
|
|
70
|
+
* everything through as output.
|
|
71
|
+
* @param options Whether the reply starts inside the first fence.
|
|
72
|
+
*/
|
|
73
|
+
constructor(fences = DEFAULT_FENCES, { startInside } = {}) {
|
|
74
|
+
this.#fences = fences;
|
|
75
|
+
this.#markers = fences.flatMap((fence) => [
|
|
76
|
+
fence.open,
|
|
77
|
+
fence.close,
|
|
78
|
+
...(FRAMING[fence.open] ?? []),
|
|
79
|
+
]);
|
|
80
|
+
this.#inside = startInside ? fences[0] : undefined;
|
|
81
|
+
this.#seenFence = this.#inside !== undefined;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reads one more piece of content, returning what it settled, in order.
|
|
85
|
+
*
|
|
86
|
+
* @param text The next delta.
|
|
87
|
+
*/
|
|
88
|
+
push(text) {
|
|
89
|
+
const parts = [];
|
|
90
|
+
let rest = this.#held + text;
|
|
91
|
+
this.#held = "";
|
|
92
|
+
while (rest) {
|
|
93
|
+
const found = this.#next(rest);
|
|
94
|
+
if (!found) {
|
|
95
|
+
const keep = this.#partialTail(rest);
|
|
96
|
+
this.#emit(parts, rest.slice(0, rest.length - keep));
|
|
97
|
+
this.#held = rest.slice(rest.length - keep);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
this.#emit(parts, rest.slice(0, found.at));
|
|
101
|
+
rest = rest.slice(found.at + found.marker.length);
|
|
102
|
+
this.#take(found.marker);
|
|
103
|
+
}
|
|
104
|
+
return parts;
|
|
105
|
+
}
|
|
106
|
+
/** Releases whatever was held back as a possible tag, now that no more is coming. */
|
|
107
|
+
finish() {
|
|
108
|
+
const parts = [];
|
|
109
|
+
this.#emit(parts, this.#held);
|
|
110
|
+
this.#held = "";
|
|
111
|
+
return parts;
|
|
112
|
+
}
|
|
113
|
+
/** The earliest marker that means something in the current state. */
|
|
114
|
+
#next(text) {
|
|
115
|
+
const candidates = this.#inside ? [this.#inside.close] : this.#markers;
|
|
116
|
+
let best;
|
|
117
|
+
for (const marker of candidates) {
|
|
118
|
+
const at = text.indexOf(marker);
|
|
119
|
+
if (at === -1)
|
|
120
|
+
continue;
|
|
121
|
+
if (!best || at < best.at || (at === best.at && marker.length > best.marker.length))
|
|
122
|
+
best = { at, marker };
|
|
123
|
+
}
|
|
124
|
+
return best;
|
|
125
|
+
}
|
|
126
|
+
/** How much of the end of `text` could be the beginning of a marker. */
|
|
127
|
+
#partialTail(text) {
|
|
128
|
+
const candidates = this.#inside ? [this.#inside.close] : this.#markers;
|
|
129
|
+
let keep = 0;
|
|
130
|
+
for (const marker of candidates)
|
|
131
|
+
for (let length = Math.min(marker.length - 1, text.length); length > keep; length--)
|
|
132
|
+
if (text.endsWith(marker.slice(0, length))) {
|
|
133
|
+
keep = length;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
return keep;
|
|
137
|
+
}
|
|
138
|
+
#take(marker) {
|
|
139
|
+
if (this.#inside) {
|
|
140
|
+
this.#inside = undefined;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const opened = this.#fences.find((fence) => fence.open === marker);
|
|
144
|
+
if (opened) {
|
|
145
|
+
this.#inside = opened;
|
|
146
|
+
this.#seenFence = true;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// A close with no open. The first time, the template opened it in the prompt; after a fence
|
|
150
|
+
// has been read, it is a stray tag and only dropped.
|
|
151
|
+
const closes = this.#fences.some((fence) => fence.close === marker);
|
|
152
|
+
if (closes && !this.#seenFence) {
|
|
153
|
+
this.reasoning += this.output;
|
|
154
|
+
this.output = "";
|
|
155
|
+
}
|
|
156
|
+
if (closes)
|
|
157
|
+
this.#seenFence = true;
|
|
158
|
+
}
|
|
159
|
+
#emit(parts, text) {
|
|
160
|
+
if (!text)
|
|
161
|
+
return;
|
|
162
|
+
const kind = this.#inside ? "reasoning" : "output";
|
|
163
|
+
this[kind] += text;
|
|
164
|
+
const last = parts.at(-1);
|
|
165
|
+
if (last?.kind === kind)
|
|
166
|
+
last.text += text;
|
|
167
|
+
else
|
|
168
|
+
parts.push({ kind, text });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* What is left of a complete reply once every scratchpad is taken out of it.
|
|
173
|
+
*
|
|
174
|
+
* @param text The whole reply.
|
|
175
|
+
* @param fences The fences to read, every known one unless given.
|
|
176
|
+
*/
|
|
177
|
+
export function stripThinking(text, fences = ALL_FENCES) {
|
|
178
|
+
const splitter = new FenceSplitter(fences);
|
|
179
|
+
splitter.push(text);
|
|
180
|
+
splitter.finish();
|
|
181
|
+
return splitter.output;
|
|
182
|
+
}
|
package/dist/tool-loading.d.ts
CHANGED
|
@@ -23,28 +23,49 @@ export declare const LOAD_TOOLS = "load_tools";
|
|
|
23
23
|
*/
|
|
24
24
|
export declare const LOAD_TOOLS_DEFINITION: OpenAI.ChatCompletionTool;
|
|
25
25
|
/**
|
|
26
|
-
* The catalogue as a plain grouped listing of names, loaded ones marked.
|
|
26
|
+
* The catalogue as a plain grouped listing of names, loaded ones marked if asked.
|
|
27
27
|
*
|
|
28
28
|
* A server with no tools is dropped rather than titled: a pool hands one over whenever a
|
|
29
29
|
* server is connected but has nothing to offer, and a label with nothing under it reads as a
|
|
30
30
|
* listing that got cut off.
|
|
31
31
|
*
|
|
32
32
|
* @param catalog The connected servers. Ones with no tools are dropped.
|
|
33
|
-
* @param loaded Names
|
|
33
|
+
* @param loaded Names to mark `(loaded)` rather than remove. Absent marks nothing, which keeps the
|
|
34
|
+
* listing the same text for the whole run.
|
|
34
35
|
*/
|
|
35
36
|
export declare function catalogList(catalog: CatalogServer[], loaded?: ReadonlySet<string>): string;
|
|
36
37
|
/**
|
|
37
38
|
* The catalogue block appended to the system prompt. Names only — descriptions arrive on load.
|
|
38
39
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* the
|
|
40
|
+
* `runAgentLoop` passes no `loaded`, so the block is the same text on every step. The system
|
|
41
|
+
* prompt is the head of the request, and marking each load there threw away the prompt cache for
|
|
42
|
+
* the whole transcript on every `load_tools` call. What is loaded is said where it does not move
|
|
43
|
+
* the prefix instead: in the tool array, appended in load order (`loadedTools`), and in the
|
|
44
|
+
* `load_tools` result, which answers a repeat load with "already loaded" (`loadResult`).
|
|
45
|
+
*
|
|
46
|
+
* Loaded tools are never removed from the list. That reads as the tool having vanished the moment
|
|
47
|
+
* it was loaded, and the model loads again to get it back; hoisting them into a separate "already
|
|
48
|
+
* loaded" section splits a server's tools apart, and the model picks a sibling from the longer
|
|
49
|
+
* list instead.
|
|
43
50
|
*
|
|
44
51
|
* @param catalog The connected servers. A catalogue with no tools in it produces an empty string.
|
|
45
|
-
* @param loaded Names
|
|
52
|
+
* @param loaded Names to mark `(loaded)`, for a caller that rebuilds its prompt per load and does
|
|
53
|
+
* not mind the cache. Absent marks nothing.
|
|
46
54
|
*/
|
|
47
55
|
export declare function catalogPrompt(catalog: CatalogServer[], loaded?: ReadonlySet<string>): string;
|
|
56
|
+
/**
|
|
57
|
+
* A tool array with newly loaded definitions appended, in the order they were loaded.
|
|
58
|
+
*
|
|
59
|
+
* Never re-sorted and never rebuilt from a set. A template renders the tool array into the
|
|
60
|
+
* prompt near its head, and a load that moved an earlier definition moved everything after it,
|
|
61
|
+
* so the cache was lost from there on every load; appended, the definitions already sent stay
|
|
62
|
+
* a prefix of the new array.
|
|
63
|
+
*
|
|
64
|
+
* @param previous What the last request declared, `load_tools` included. Not written to.
|
|
65
|
+
* @param matched The definitions to add. Ones whose name is already declared, here or earlier in
|
|
66
|
+
* this list, are skipped rather than moved.
|
|
67
|
+
*/
|
|
68
|
+
export declare function loadedTools(previous: readonly OpenAI.ChatCompletionTool[], matched: readonly OpenAI.ChatCompletionTool[]): OpenAI.ChatCompletionTool[];
|
|
48
69
|
/**
|
|
49
70
|
* The most a single `load_tools` call may pull in.
|
|
50
71
|
*
|
|
@@ -103,10 +124,15 @@ export declare function expandNames(requested: string[], catalog: CatalogServer[
|
|
|
103
124
|
/**
|
|
104
125
|
* What `load_tools` reports back: the descriptions, now that they are worth their tokens.
|
|
105
126
|
*
|
|
127
|
+
* A name that was loaded before this call is reported as already loaded rather than loaded
|
|
128
|
+
* again. The catalogue no longer marks what is loaded — see `catalogPrompt` — so this is where a
|
|
129
|
+
* model that asks twice finds out it need not have, and is told to call the tool instead.
|
|
130
|
+
*
|
|
106
131
|
* @param expanded What `expandNames` resolved: the matches, the misses, and the over-broad asks.
|
|
107
132
|
* @param catalog The servers, read for the descriptions now worth their tokens.
|
|
133
|
+
* @param loaded What was loaded before this call. Absent reports every match as newly loaded.
|
|
108
134
|
*/
|
|
109
|
-
export declare function loadResult({ matched, unknown, overBroad, deferred, maxPerLoad }: ReturnType<typeof expandNames>, catalog: CatalogServer[]): string;
|
|
135
|
+
export declare function loadResult({ matched, unknown, overBroad, deferred, maxPerLoad }: ReturnType<typeof expandNames>, catalog: CatalogServer[], loaded?: ReadonlySet<string>): string;
|
|
110
136
|
/**
|
|
111
137
|
* Whether the catalogue holds a tool by this name.
|
|
112
138
|
*
|
package/dist/tool-loading.js
CHANGED
|
@@ -49,14 +49,15 @@ export const LOAD_TOOLS_DEFINITION = deepFreeze({
|
|
|
49
49
|
},
|
|
50
50
|
});
|
|
51
51
|
/**
|
|
52
|
-
* The catalogue as a plain grouped listing of names, loaded ones marked.
|
|
52
|
+
* The catalogue as a plain grouped listing of names, loaded ones marked if asked.
|
|
53
53
|
*
|
|
54
54
|
* A server with no tools is dropped rather than titled: a pool hands one over whenever a
|
|
55
55
|
* server is connected but has nothing to offer, and a label with nothing under it reads as a
|
|
56
56
|
* listing that got cut off.
|
|
57
57
|
*
|
|
58
58
|
* @param catalog The connected servers. Ones with no tools are dropped.
|
|
59
|
-
* @param loaded Names
|
|
59
|
+
* @param loaded Names to mark `(loaded)` rather than remove. Absent marks nothing, which keeps the
|
|
60
|
+
* listing the same text for the whole run.
|
|
60
61
|
*/
|
|
61
62
|
export function catalogList(catalog, loaded) {
|
|
62
63
|
return catalog
|
|
@@ -70,13 +71,20 @@ export function catalogList(catalog, loaded) {
|
|
|
70
71
|
/**
|
|
71
72
|
* The catalogue block appended to the system prompt. Names only — descriptions arrive on load.
|
|
72
73
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* the
|
|
74
|
+
* `runAgentLoop` passes no `loaded`, so the block is the same text on every step. The system
|
|
75
|
+
* prompt is the head of the request, and marking each load there threw away the prompt cache for
|
|
76
|
+
* the whole transcript on every `load_tools` call. What is loaded is said where it does not move
|
|
77
|
+
* the prefix instead: in the tool array, appended in load order (`loadedTools`), and in the
|
|
78
|
+
* `load_tools` result, which answers a repeat load with "already loaded" (`loadResult`).
|
|
79
|
+
*
|
|
80
|
+
* Loaded tools are never removed from the list. That reads as the tool having vanished the moment
|
|
81
|
+
* it was loaded, and the model loads again to get it back; hoisting them into a separate "already
|
|
82
|
+
* loaded" section splits a server's tools apart, and the model picks a sibling from the longer
|
|
83
|
+
* list instead.
|
|
77
84
|
*
|
|
78
85
|
* @param catalog The connected servers. A catalogue with no tools in it produces an empty string.
|
|
79
|
-
* @param loaded Names
|
|
86
|
+
* @param loaded Names to mark `(loaded)`, for a caller that rebuilds its prompt per load and does
|
|
87
|
+
* not mind the cache. Absent marks nothing.
|
|
80
88
|
*/
|
|
81
89
|
export function catalogPrompt(catalog, loaded) {
|
|
82
90
|
const list = catalogList(catalog, loaded);
|
|
@@ -88,14 +96,39 @@ export function catalogPrompt(catalog, loaded) {
|
|
|
88
96
|
"# Tool catalogue",
|
|
89
97
|
"",
|
|
90
98
|
"These tools exist but are not loaded. Call `load_tools` with the names you need, then call",
|
|
91
|
-
"them on the step after. Names are descriptive; load a tool to see its parameters. A
|
|
92
|
-
"
|
|
93
|
-
"
|
|
99
|
+
"them on the step after. Names are descriptive; load a tool to see its parameters. A tool",
|
|
100
|
+
"already in your tool list is loaded — call it directly, do not load it again. Do not load",
|
|
101
|
+
"tools the task does not need, and do not mention this mechanism in your answer.",
|
|
94
102
|
"",
|
|
95
103
|
list,
|
|
96
104
|
].join("\n");
|
|
97
105
|
}
|
|
98
106
|
const flatten = (catalog) => catalog.flatMap((server) => server.tools);
|
|
107
|
+
/**
|
|
108
|
+
* A tool array with newly loaded definitions appended, in the order they were loaded.
|
|
109
|
+
*
|
|
110
|
+
* Never re-sorted and never rebuilt from a set. A template renders the tool array into the
|
|
111
|
+
* prompt near its head, and a load that moved an earlier definition moved everything after it,
|
|
112
|
+
* so the cache was lost from there on every load; appended, the definitions already sent stay
|
|
113
|
+
* a prefix of the new array.
|
|
114
|
+
*
|
|
115
|
+
* @param previous What the last request declared, `load_tools` included. Not written to.
|
|
116
|
+
* @param matched The definitions to add. Ones whose name is already declared, here or earlier in
|
|
117
|
+
* this list, are skipped rather than moved.
|
|
118
|
+
*/
|
|
119
|
+
export function loadedTools(previous, matched) {
|
|
120
|
+
const nameOf = (tool) => tool.type === "function" ? tool.function.name : undefined;
|
|
121
|
+
const declared = new Set(previous.map(nameOf));
|
|
122
|
+
const tools = [...previous];
|
|
123
|
+
for (const tool of matched) {
|
|
124
|
+
const name = nameOf(tool);
|
|
125
|
+
if (name !== undefined && declared.has(name))
|
|
126
|
+
continue;
|
|
127
|
+
declared.add(name);
|
|
128
|
+
tools.push(tool);
|
|
129
|
+
}
|
|
130
|
+
return tools;
|
|
131
|
+
}
|
|
99
132
|
/**
|
|
100
133
|
* The most a single `load_tools` call may pull in.
|
|
101
134
|
*
|
|
@@ -201,17 +234,29 @@ export function expandNames(requested, catalog, maxPerLoad = MAX_PER_LOAD) {
|
|
|
201
234
|
/**
|
|
202
235
|
* What `load_tools` reports back: the descriptions, now that they are worth their tokens.
|
|
203
236
|
*
|
|
237
|
+
* A name that was loaded before this call is reported as already loaded rather than loaded
|
|
238
|
+
* again. The catalogue no longer marks what is loaded — see `catalogPrompt` — so this is where a
|
|
239
|
+
* model that asks twice finds out it need not have, and is told to call the tool instead.
|
|
240
|
+
*
|
|
204
241
|
* @param expanded What `expandNames` resolved: the matches, the misses, and the over-broad asks.
|
|
205
242
|
* @param catalog The servers, read for the descriptions now worth their tokens.
|
|
243
|
+
* @param loaded What was loaded before this call. Absent reports every match as newly loaded.
|
|
206
244
|
*/
|
|
207
|
-
export function loadResult({ matched, unknown, overBroad, deferred, maxPerLoad }, catalog) {
|
|
245
|
+
export function loadResult({ matched, unknown, overBroad, deferred, maxPerLoad }, catalog, loaded) {
|
|
208
246
|
const byName = new Map(flatten(catalog).map((tool) => [tool.name, tool.description]));
|
|
209
247
|
const lines = [];
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
248
|
+
const fresh = matched.filter((name) => !loaded?.has(name));
|
|
249
|
+
const again = matched.filter((name) => loaded?.has(name));
|
|
250
|
+
if (fresh.length) {
|
|
251
|
+
lines.push(`Loaded ${fresh.length} tool(s); they are callable on your next step.`, "");
|
|
252
|
+
for (const name of fresh)
|
|
213
253
|
lines.push(`${name}: ${byName.get(name) ?? ""}`.trim());
|
|
214
254
|
}
|
|
255
|
+
if (again.length) {
|
|
256
|
+
if (lines.length)
|
|
257
|
+
lines.push("");
|
|
258
|
+
lines.push(`Already loaded and in your tool list: ${again.join(", ")}. Call them directly; do not load them again.`);
|
|
259
|
+
}
|
|
215
260
|
for (const { name, hits } of overBroad) {
|
|
216
261
|
if (lines.length)
|
|
217
262
|
lines.push("");
|
package/llms.txt
CHANGED
|
@@ -202,6 +202,19 @@ Reading one streamed turn back into a message.
|
|
|
202
202
|
- `Turn` (type) — One streamed turn, put back together into the shape a loop and a transcript work with.
|
|
203
203
|
- `TurnUsage` (type) — What a turn cost.
|
|
204
204
|
|
|
205
|
+
### thinking
|
|
206
|
+
|
|
207
|
+
Telling a model's scratchpad from its answer when both arrive in `content`.
|
|
208
|
+
|
|
209
|
+
- `ALL_FENCES` — Every fence known, including the two plain-word ones some fine-tunes and prompt-instructed models use.
|
|
210
|
+
- `DEFAULT_FENCES` — The fences nobody writes by accident, and so the ones `streamTurn` reads by default.
|
|
211
|
+
- `Fence` (type) — One way a model marks off its scratchpad inside `content`.
|
|
212
|
+
- `FenceSplitter` — A state machine over a stream of `content` that routes fenced text to reasoning.
|
|
213
|
+
- `FenceSplitterOptions` (type) — What `FenceSplitter` takes besides its fences.
|
|
214
|
+
- `Split` (type) — A piece of `content`, said to be one or the other.
|
|
215
|
+
- `stripThinking` — What is left of a complete reply once every scratchpad is taken out of it.
|
|
216
|
+
- `THINK_FENCE` — DeepSeek, Qwen3, QwQ and most distills.
|
|
217
|
+
|
|
205
218
|
### tokens
|
|
206
219
|
|
|
207
220
|
- `estimateTokens` — Rough token count.
|
|
@@ -218,12 +231,13 @@ Reading what a model meant by a tool call when it did not write one cleanly.
|
|
|
218
231
|
### tool-loading
|
|
219
232
|
|
|
220
233
|
- `carryOver` — The tools to start the next turn with: recently used, newest last, capped.
|
|
221
|
-
- `catalogList` — The catalogue as a plain grouped listing of names, loaded ones marked.
|
|
234
|
+
- `catalogList` — The catalogue as a plain grouped listing of names, loaded ones marked if asked.
|
|
222
235
|
- `catalogPrompt` — The catalogue block appended to the system prompt.
|
|
223
236
|
- `expandNames` — Resolves requested names against the catalogue, expanding trailing `*` wildcards.
|
|
224
237
|
- `inCatalog` — Whether the catalogue holds a tool by this name.
|
|
225
238
|
- `LOAD_TOOLS` — On-demand tool loading.
|
|
226
239
|
- `LOAD_TOOLS_DEFINITION` — One object for the life of the process — the agent loop asks for it on every iteration.
|
|
240
|
+
- `loadedTools` — A tool array with newly loaded definitions appended, in the order they were loaded.
|
|
227
241
|
- `loadResult` — What `load_tools` reports back: the descriptions, now that they are worth their tokens.
|
|
228
242
|
- `MAX_CARRIED` — The most a conversation carries between turns.
|
|
229
243
|
- `MAX_PER_LOAD` — The most a single `load_tools` call may pull in.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cubicecho/agent-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.1",
|
|
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",
|