@cubicecho/agent-core 2.21.0 → 2.22.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 +63 -11
- package/dist/agent-loop.d.ts +89 -3
- package/dist/agent-loop.js +123 -6
- package/dist/client.d.ts +28 -10
- package/dist/client.js +109 -47
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/side-task.d.ts +35 -2
- package/dist/side-task.js +20 -9
- package/dist/spec.d.ts +13 -1
- package/llms.txt +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ only, Node >=22.
|
|
|
36
36
|
| `continuation` | `continueTurn`: carries on an answer the token ceiling cut off, by prefilling it as a trailing assistant message. |
|
|
37
37
|
| `config` | The structural interfaces every function here asks for. |
|
|
38
38
|
| `run-turn` | `runTurn`: one turn with the retry loop around the negotiation around the stream. The whole loop, for a caller that wants it rather than its parts. Sizes the request against an opt-in `contextLimit`. |
|
|
39
|
-
| `agent-loop` | `runAgentLoop`: the loop above a turn — `runTurn` per step, the tools between, `load_tools` and preselection handled, until the model stops asking. Plus the parts it is made of: `buildBody`, `preselect`, `preview`,
|
|
39
|
+
| `agent-loop` | `runAgentLoop`: the loop above a turn — `runTurn` per step, the tools between, `load_tools` and preselection handled, until the model stops asking. Plus the parts it is made of: `buildBody`, `preselect`, `preview`, `resolveApiKey` for a caller deciding which key an endpoint gets, and `taskCall` for running a side task on the settings an agent spec gives it. |
|
|
40
40
|
| `tool-calls` | Reading what a model meant by a tool call it did not write cleanly: `parseToolArguments` repairs almost-JSON arguments and says when they were cut off, `recoverToolCalls` finds calls written into the reply as text. |
|
|
41
41
|
| `compaction` | Keeping a long run inside its window: `pruneToolResults` clears stale tool results, `planCompaction` and `compactTranscript` fold the oldest stretch into a summary. |
|
|
42
42
|
| `ledger` | What stretches of a transcript cost, read off the prompt counts the server reported rather than estimated: `recordRequest`, `tokensBetween`, `estimateFrom`, `rebaseLedger`. |
|
|
@@ -279,15 +279,24 @@ const turn = await runTurn(client, supports, build, {
|
|
|
279
279
|
});
|
|
280
280
|
```
|
|
281
281
|
|
|
282
|
-
`contextLimitFor` answers the operator's number when there is one. Otherwise it
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
`
|
|
289
|
-
|
|
290
|
-
|
|
282
|
+
`contextLimitFor` answers the operator's number when there is one. Otherwise it reads the
|
|
283
|
+
`/v1/models` listing first. A window at the top of the model's entry (`context_length`,
|
|
284
|
+
`max_context_window`, `max_model_len`, `context_window` or `n_ctx`: vLLM's `max_model_len`,
|
|
285
|
+
OpenRouter's `context_length`) is taken as the one being served, and nothing else is asked.
|
|
286
|
+
|
|
287
|
+
Where the listing does not settle it, the server's own API is asked (`servedWindow`): llama.cpp's
|
|
288
|
+
`/props` (`default_generation_settings.n_ctx`), then LM Studio's `/api/v0/models`
|
|
289
|
+
(`loaded_context_length`). That covers a model listed with no window, which is LM Studio, and one
|
|
290
|
+
listed with only `meta.n_ctx_train`, which is llama.cpp. The trained window differs from the served
|
|
291
|
+
one in the case the guard exists for, a 256k model started at `-c 16384`, so it is used only when
|
|
292
|
+
neither native route gives a number. A server with neither route is latched and not asked again.
|
|
293
|
+
`/api/v0/models` is also dropped on its own: for the life of the process once it answers 404, 405
|
|
294
|
+
or 501, and for `listingMissMs` after any other error.
|
|
295
|
+
|
|
296
|
+
A top-level listing key is trusted without a second opinion, so on a server that puts a trained
|
|
297
|
+
window there, pass `contextLength`. Ollama reports no window on any route this reads, and truncates
|
|
298
|
+
an over-long prompt rather than refusing it, so on Ollama pass `contextLength` or there is no guard
|
|
299
|
+
at all.
|
|
291
300
|
|
|
292
301
|
What is weighed is the prompt plus the reply ceiling the body carries, under whichever spelling
|
|
293
302
|
was chosen, because that is what the endpoint weighs: a 30k prompt into a 32k window with
|
|
@@ -555,7 +564,9 @@ have to score well below the ones it kept — a hit just under the line scoring
|
|
|
555
564
|
one just over it means the ranking chose arbitrarily, which is the case a model is worth spending
|
|
556
565
|
on. Matching nothing is not confident either, since the words cannot tell a request that needs no
|
|
557
566
|
tools from one whose words are not in the catalogue. An empty `toolSelectModel` still means no
|
|
558
|
-
preselection at all, words included.
|
|
567
|
+
preselection at all, words included. `preselect` also takes a `temperature` and a
|
|
568
|
+
`reasoningEffort`, read as `ask` reads them, and `runAgentLoop({ preselect: true })` makes the call
|
|
569
|
+
itself — see "Its side tasks" below.
|
|
559
570
|
|
|
560
571
|
A turn cut off at `maxTokens` is said so as a notice, and with `maxContinuations` above zero it is
|
|
561
572
|
continued first. `continueTurn` is the same thing for a caller with its own loop:
|
|
@@ -1141,6 +1152,47 @@ a form can load it. Nothing in `src/` imports it — the same direction the conf
|
|
|
1141
1152
|
what lets a document travel between hosts without the loop growing an opinion about where an agent
|
|
1142
1153
|
comes from.
|
|
1143
1154
|
|
|
1155
|
+
### Its side tasks
|
|
1156
|
+
|
|
1157
|
+
`tasks.<key>` configures a side task apart from the main model — its own model, endpoint, ceiling,
|
|
1158
|
+
temperature and reasoning effort — and resolution hands each back as a `ResolvedTask` holding only
|
|
1159
|
+
what a layer stated. `taskCall` turns one into the arguments `ask`, `askJson`, `summariser` and
|
|
1160
|
+
`preselect` already take, so a setting added to a task later arrives without the host's call
|
|
1161
|
+
changing:
|
|
1162
|
+
|
|
1163
|
+
```ts
|
|
1164
|
+
const title = taskCall(config.tasks.title, { signal, onNotice }, config);
|
|
1165
|
+
const name = await ask(title.endpoint, title.model, "Name this chat.", firstMessage, title.options);
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
**An absent setting is the side task's own default, never the agent's.** A titler under an agent
|
|
1169
|
+
running at temperature 1.0 is still sent 0.3, and its ceiling is still `ask`'s 512, `summariser`'s
|
|
1170
|
+
1024 or `preselect`'s 256. What the task does state wins over the same field in the options beside
|
|
1171
|
+
it, so the options are where a host puts its own default for a task that names none. `maxTokens: 0`
|
|
1172
|
+
sends no ceiling, as it does on the main model.
|
|
1173
|
+
|
|
1174
|
+
`reasoningEffort` is the one that reads differently from the main model's. A side task is sent the
|
|
1175
|
+
no-thinking hints by default, and absent or `"off"` keeps them; a level — `"low"`, `"high"` — is
|
|
1176
|
+
sent as `reasoning_effort` in their place, for a compaction model an operator pointed at a model
|
|
1177
|
+
that should deliberate.
|
|
1178
|
+
|
|
1179
|
+
The third argument is the endpoint the main turn uses. Given it, a task on the same endpoint is
|
|
1180
|
+
sent the agent's spelling of the URL and, having no key of its own, the agent's key, so the two
|
|
1181
|
+
share one entry in `capabilitiesFor` and a refusal learned by either is known to both; a task on
|
|
1182
|
+
another endpoint is sent only a key of its own, as `resolveApiKey` has it.
|
|
1183
|
+
|
|
1184
|
+
The loop reads two of them when asked to. `preselect: true` has `runAgentLoop` make the
|
|
1185
|
+
preselection itself with `tasks.toolSelect` — or with `toolSelectModel` on the agent's endpoint,
|
|
1186
|
+
which stays the flattened spelling for a host that is not on the spec — and `compact: true` has it
|
|
1187
|
+
fold the transcript before a step, once `planCompaction` says a `contextLength` window is filling,
|
|
1188
|
+
with `tasks.compaction` writing the summary and no falling back to the main model without it. Both
|
|
1189
|
+
are off by default: a host that already calls `preselect` or compacts in `beforeStep` is not sent
|
|
1190
|
+
a second request.
|
|
1191
|
+
|
|
1192
|
+
```ts
|
|
1193
|
+
const result = await runAgentLoop({ config, preselect: { keywords: true }, compact: true, ... });
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1144
1196
|
## What is kept for the life of the process
|
|
1145
1197
|
|
|
1146
1198
|
Four caches outlive any one run: the `OpenAI` clients, the model listings, the latched
|
package/dist/agent-loop.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type OpenAI from "openai";
|
|
2
2
|
import { type Capabilities, type ModelCapabilities } from "./capabilities.ts";
|
|
3
3
|
import type { CatalogServer } from "./catalog.ts";
|
|
4
|
+
import { type CompactionOptions } from "./compaction.ts";
|
|
4
5
|
import type { Endpoint, ModelParams, RetryPolicy, ToolPolicy } from "./config.ts";
|
|
5
6
|
import { type RunEventInput, type RunMetrics } from "./events.ts";
|
|
6
7
|
import { type HookContext, type HookEvent, type HookNote, type HookRunner } from "./hooks.ts";
|
|
7
8
|
import { type TokenLedger } from "./ledger.ts";
|
|
9
|
+
import { type SideTask, type SideTaskOptions } from "./side-task.ts";
|
|
8
10
|
import type { Turn, TurnUsage } from "./stream.ts";
|
|
9
11
|
import { type KeywordPreselectOptions, type ToolOrder } from "./tool-loading.ts";
|
|
10
12
|
/**
|
|
@@ -64,6 +66,38 @@ export declare function resolveApiKey(own: {
|
|
|
64
66
|
baseUrl: string;
|
|
65
67
|
apiKey?: string;
|
|
66
68
|
}, env?: Record<string, string | undefined>): string;
|
|
69
|
+
/**
|
|
70
|
+
* A side task's settings as the call that honours them: its endpoint, its model, and the options
|
|
71
|
+
* `ask`, `askJson`, `summariser` and `preselect` take last.
|
|
72
|
+
*
|
|
73
|
+
* A helper rather than a second signature on each of the four, so a setting added to a task later
|
|
74
|
+
* arrives through `options` without a host's call changing. What the task states wins over the
|
|
75
|
+
* same field in `options`: the host's code is written once and the task is what an operator
|
|
76
|
+
* configured over it, so a host passes its own ceiling as the value for a task that names none.
|
|
77
|
+
* A setting the task leaves out and `options` does too is absent from the result, which is what
|
|
78
|
+
* leaves it to the entry point's own default — 0.3, not the agent's temperature.
|
|
79
|
+
*
|
|
80
|
+
* A resolved spec carries no key, so a task on the agent's own endpoint would otherwise go out
|
|
81
|
+
* unkeyed, and be remembered under a second capability entry beside the agent's. Given `agent`,
|
|
82
|
+
* such a task is sent the agent's `baseUrl` as the agent spells it and, having no key of its own,
|
|
83
|
+
* the agent's key, so `capabilitiesFor` answers both with one object; a task on another endpoint
|
|
84
|
+
* is sent only a key of its own, as `resolveApiKey` has it. The environment is not read: the
|
|
85
|
+
* agent's key is whatever the main turn is sent.
|
|
86
|
+
*
|
|
87
|
+
* @param task The task, ordinarily `resolved.tasks.<key>`.
|
|
88
|
+
* @param options What the host adds — cancellation, notices, `keywords` — and its own defaults
|
|
89
|
+
* for the settings a task may leave out.
|
|
90
|
+
* @param agent The endpoint the main turn uses, key included. Absent sends the task's endpoint
|
|
91
|
+
* as it stands.
|
|
92
|
+
*/
|
|
93
|
+
export declare function taskCall<Options extends SideTaskOptions = SideTaskOptions>(task: SideTask, options?: Options, agent?: {
|
|
94
|
+
baseUrl: string;
|
|
95
|
+
apiKey?: string;
|
|
96
|
+
}): {
|
|
97
|
+
endpoint: Endpoint;
|
|
98
|
+
model: string;
|
|
99
|
+
options: Options;
|
|
100
|
+
};
|
|
67
101
|
/**
|
|
68
102
|
* The tools a request is likely to need, picked by a small model before the run starts, or none.
|
|
69
103
|
*
|
|
@@ -83,13 +117,18 @@ export declare function resolveApiKey(own: {
|
|
|
83
117
|
* means by empty.
|
|
84
118
|
* @param catalog The servers to choose from.
|
|
85
119
|
* @param prompt The request being planned for. Only its head is read; see `preselectInput`.
|
|
86
|
-
* @param options Cancellation, notices, the reply ceiling (256), the
|
|
120
|
+
* @param options Cancellation, notices, the reply ceiling (256), the temperature and reasoning
|
|
121
|
+
* effort as `ask` reads them (0.3 and none when absent), the cap the choice is held to
|
|
87
122
|
* (`MAX_PER_LOAD`), and whether to try the words first.
|
|
88
123
|
*/
|
|
89
|
-
export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, maxPerLoad, keywords, }?: {
|
|
124
|
+
export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, temperature, reasoningEffort, maxPerLoad, keywords, }?: {
|
|
90
125
|
signal?: AbortSignal;
|
|
91
126
|
onNotice?: (message: string) => void;
|
|
92
127
|
maxTokens?: number;
|
|
128
|
+
/** As `SideTaskOptions.temperature`: absent is 0.3. */
|
|
129
|
+
temperature?: number;
|
|
130
|
+
/** As `SideTaskOptions.reasoningEffort`: absent keeps the no-thinking hints. */
|
|
131
|
+
reasoningEffort?: string;
|
|
93
132
|
maxPerLoad?: number;
|
|
94
133
|
/**
|
|
95
134
|
* Try `preselectByKeywords` first and spend the model only on what it cannot settle. `true`
|
|
@@ -202,9 +241,14 @@ export interface AgentLoopOptions {
|
|
|
202
241
|
* `toolDiscovery` absent is eager, `maxRetries` absent is none, and `contextLength` is handed
|
|
203
242
|
* to `runTurn` as `contextLimit`, which sizes each request against the window before sending.
|
|
204
243
|
* `"proxy"` is on-demand loading behind a tool array that never changes; see `PROXY_TOOLS`.
|
|
244
|
+
*
|
|
245
|
+
* `tasks` is a resolved agent's, and is read only for the side tasks the loop was asked to run:
|
|
246
|
+
* `tasks.toolSelect` under `preselect` and `tasks.compaction` under `compact`. `toolSelectModel`
|
|
247
|
+
* is the flattened spelling of the first, for a host that is not on the spec.
|
|
205
248
|
*/
|
|
206
|
-
config: Endpoint & ModelParams & Pick<ToolPolicy, "maxToolIterations"> & Partial<Pick<ToolPolicy, "toolDiscovery">> & Partial<RetryPolicy> & {
|
|
249
|
+
config: Endpoint & ModelParams & Pick<ToolPolicy, "maxToolIterations"> & Partial<Pick<ToolPolicy, "toolDiscovery" | "toolSelectModel">> & Partial<RetryPolicy> & {
|
|
207
250
|
contextLength?: number;
|
|
251
|
+
tasks?: Readonly<Record<string, SideTask>>;
|
|
208
252
|
};
|
|
209
253
|
/**
|
|
210
254
|
* The standing instruction, sent as the first message. On-demand and proxied modes append the
|
|
@@ -260,6 +304,43 @@ export interface AgentLoopOptions {
|
|
|
260
304
|
* mode, or proxied, where no step is routed either way.
|
|
261
305
|
*/
|
|
262
306
|
preselectRouting?: "exclusive" | "append";
|
|
307
|
+
/**
|
|
308
|
+
* Has the loop make the preselection itself, before its first request, with the preselector
|
|
309
|
+
* the config names. Off by default: a host that passes a resolved agent and calls `preselect`
|
|
310
|
+
* on its own is not sent a second one.
|
|
311
|
+
*
|
|
312
|
+
* The preselector is `config.tasks.toolSelect` with everything it states — its endpoint, model,
|
|
313
|
+
* ceiling, temperature and reasoning effort, keyed as `taskCall` keys a task against `config` —
|
|
314
|
+
* and otherwise `config.toolSelectModel` on the config's own endpoint at `preselect`'s defaults.
|
|
315
|
+
* What it is asked about is the text of the last user message. Nothing is asked in eager mode,
|
|
316
|
+
* with neither preselector named, with no user message, or when `preselected` is given — an
|
|
317
|
+
* empty one included, which is how a host says it has already decided.
|
|
318
|
+
*
|
|
319
|
+
* `true` takes `preselect`'s defaults; an object passes `keywords` and `maxPerLoad` through.
|
|
320
|
+
* Its notices arrive as `notice` events, and a preselection that fails picks nothing rather
|
|
321
|
+
* than ending the run. It runs before the hooks gather, not beside them.
|
|
322
|
+
*/
|
|
323
|
+
preselect?: boolean | {
|
|
324
|
+
keywords?: boolean | KeywordPreselectOptions;
|
|
325
|
+
maxPerLoad?: number;
|
|
326
|
+
};
|
|
327
|
+
/**
|
|
328
|
+
* Has the loop fold the transcript itself before a step, once `planCompaction` says the window
|
|
329
|
+
* is filling, with the summary written by `config.tasks.compaction` on its own settings. Off by
|
|
330
|
+
* default, and nothing is folded without that task — there is no falling back to the main model
|
|
331
|
+
* — or without a `config.contextLength` to plan against.
|
|
332
|
+
*
|
|
333
|
+
* It runs after `beforeStep`, on what that returned, and is `compactTranscript` and nothing
|
|
334
|
+
* more: the plan is made from the last request's reported prompt, or an estimate before the
|
|
335
|
+
* first step and after a `beforeStep` rewrite; `hooks`, when given, are told `beforeCompact`
|
|
336
|
+
* beside the summary; and a fold is said as a `notice` event. A summary that fails is a notice
|
|
337
|
+
* too and folds nothing. Tool results are not pruned — `beforeStep` is where a host does that.
|
|
338
|
+
*
|
|
339
|
+
* The result's `messages` then open with the summary in place of what was folded, so a host
|
|
340
|
+
* that keeps its transcript append-only wants `runCompaction` and a stored fold instead. `true`
|
|
341
|
+
* plans by `planCompaction`'s defaults; an object moves its thresholds.
|
|
342
|
+
*/
|
|
343
|
+
compact?: boolean | Pick<CompactionOptions, "compactAt" | "keepRatio" | "target">;
|
|
263
344
|
/**
|
|
264
345
|
* Tools already loaded, carried from an earlier question. See `carryOver`. Not read in proxied
|
|
265
346
|
* mode, where a tool is loaded only while its definition is in the history.
|
|
@@ -469,6 +550,11 @@ export interface AgentLoopResult {
|
|
|
469
550
|
* the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
|
|
470
551
|
* a session switched out of proxied mode still has it in its history, and the model copies it.
|
|
471
552
|
*
|
|
553
|
+
* Neither side task is run unless asked for. `preselect` has the loop make the preselection and
|
|
554
|
+
* `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
|
|
555
|
+
* that task's own settings — a setting the task leaves out is the side task's default, never the
|
|
556
|
+
* run's `temperature` or `maxTokens`.
|
|
557
|
+
*
|
|
472
558
|
* @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
|
|
473
559
|
* and cancellation. See `AgentLoopOptions`.
|
|
474
560
|
*/
|
package/dist/agent-loop.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { charsPerTokenFor } from "./calibration.js";
|
|
2
2
|
import { capabilitiesFor, effortFor, modelCapabilitiesFor, } from "./capabilities.js";
|
|
3
3
|
import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
|
|
4
|
+
import { compactTranscript, planCompaction, summariser, } from "./compaction.js";
|
|
4
5
|
import { continueTurn } from "./continuation.js";
|
|
5
6
|
import { AgentLoopError, AgentLoopOverflow, errorMessage, ToolIterationLimit, } from "./errors.js";
|
|
6
7
|
import { runMetrics } from "./events.js";
|
|
@@ -106,6 +107,52 @@ export function resolveApiKey(own, inherited, env = process.env) {
|
|
|
106
107
|
return NO_KEY;
|
|
107
108
|
return inherited?.apiKey || env.OPENAI_API_KEY || NO_KEY;
|
|
108
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* A side task's settings as the call that honours them: its endpoint, its model, and the options
|
|
112
|
+
* `ask`, `askJson`, `summariser` and `preselect` take last.
|
|
113
|
+
*
|
|
114
|
+
* A helper rather than a second signature on each of the four, so a setting added to a task later
|
|
115
|
+
* arrives through `options` without a host's call changing. What the task states wins over the
|
|
116
|
+
* same field in `options`: the host's code is written once and the task is what an operator
|
|
117
|
+
* configured over it, so a host passes its own ceiling as the value for a task that names none.
|
|
118
|
+
* A setting the task leaves out and `options` does too is absent from the result, which is what
|
|
119
|
+
* leaves it to the entry point's own default — 0.3, not the agent's temperature.
|
|
120
|
+
*
|
|
121
|
+
* A resolved spec carries no key, so a task on the agent's own endpoint would otherwise go out
|
|
122
|
+
* unkeyed, and be remembered under a second capability entry beside the agent's. Given `agent`,
|
|
123
|
+
* such a task is sent the agent's `baseUrl` as the agent spells it and, having no key of its own,
|
|
124
|
+
* the agent's key, so `capabilitiesFor` answers both with one object; a task on another endpoint
|
|
125
|
+
* is sent only a key of its own, as `resolveApiKey` has it. The environment is not read: the
|
|
126
|
+
* agent's key is whatever the main turn is sent.
|
|
127
|
+
*
|
|
128
|
+
* @param task The task, ordinarily `resolved.tasks.<key>`.
|
|
129
|
+
* @param options What the host adds — cancellation, notices, `keywords` — and its own defaults
|
|
130
|
+
* for the settings a task may leave out.
|
|
131
|
+
* @param agent The endpoint the main turn uses, key included. Absent sends the task's endpoint
|
|
132
|
+
* as it stands.
|
|
133
|
+
*/
|
|
134
|
+
export function taskCall(task, options, agent) {
|
|
135
|
+
const own = task.endpoint;
|
|
136
|
+
const shared = agent && (!own.baseUrl.trim() || sameUrl(own.baseUrl, agent.baseUrl));
|
|
137
|
+
const stated = {};
|
|
138
|
+
if (task.maxTokens !== undefined)
|
|
139
|
+
stated.maxTokens = task.maxTokens;
|
|
140
|
+
if (task.temperature !== undefined)
|
|
141
|
+
stated.temperature = task.temperature;
|
|
142
|
+
if (task.reasoningEffort !== undefined)
|
|
143
|
+
stated.reasoningEffort = task.reasoningEffort;
|
|
144
|
+
return {
|
|
145
|
+
endpoint: agent
|
|
146
|
+
? {
|
|
147
|
+
...own,
|
|
148
|
+
...(shared ? { baseUrl: agent.baseUrl } : {}),
|
|
149
|
+
apiKey: resolveApiKey(own, agent, {}),
|
|
150
|
+
}
|
|
151
|
+
: own,
|
|
152
|
+
model: task.model,
|
|
153
|
+
options: { ...options, ...stated },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
109
156
|
/**
|
|
110
157
|
* The tools a request is likely to need, picked by a small model before the run starts, or none.
|
|
111
158
|
*
|
|
@@ -125,10 +172,11 @@ export function resolveApiKey(own, inherited, env = process.env) {
|
|
|
125
172
|
* means by empty.
|
|
126
173
|
* @param catalog The servers to choose from.
|
|
127
174
|
* @param prompt The request being planned for. Only its head is read; see `preselectInput`.
|
|
128
|
-
* @param options Cancellation, notices, the reply ceiling (256), the
|
|
175
|
+
* @param options Cancellation, notices, the reply ceiling (256), the temperature and reasoning
|
|
176
|
+
* effort as `ask` reads them (0.3 and none when absent), the cap the choice is held to
|
|
129
177
|
* (`MAX_PER_LOAD`), and whether to try the words first.
|
|
130
178
|
*/
|
|
131
|
-
export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
|
|
179
|
+
export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, temperature, reasoningEffort, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
|
|
132
180
|
if (!model || !catalog.some((server) => server.tools.length > 0))
|
|
133
181
|
return [];
|
|
134
182
|
if (keywords) {
|
|
@@ -141,7 +189,7 @@ export async function preselect(config, model, catalog, prompt, { signal, onNoti
|
|
|
141
189
|
return guess.names;
|
|
142
190
|
}
|
|
143
191
|
}
|
|
144
|
-
const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, signal, onNotice }), { onNotice });
|
|
192
|
+
const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, temperature, reasoningEffort, signal, onNotice }), { onNotice });
|
|
145
193
|
return preselection(reply, catalog, maxPerLoad);
|
|
146
194
|
}
|
|
147
195
|
/**
|
|
@@ -240,6 +288,11 @@ const UNRUN_CALL = "Not run: the run stopped first.";
|
|
|
240
288
|
* the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
|
|
241
289
|
* a session switched out of proxied mode still has it in its history, and the model copies it.
|
|
242
290
|
*
|
|
291
|
+
* Neither side task is run unless asked for. `preselect` has the loop make the preselection and
|
|
292
|
+
* `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
|
|
293
|
+
* that task's own settings — a setting the task leaves out is the side task's default, never the
|
|
294
|
+
* run's `temperature` or `maxTokens`.
|
|
295
|
+
*
|
|
243
296
|
* @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
|
|
244
297
|
* and cancellation. See `AgentLoopOptions`.
|
|
245
298
|
*/
|
|
@@ -270,6 +323,15 @@ export async function runAgentLoop(options) {
|
|
|
270
323
|
return result;
|
|
271
324
|
throw new ToolIterationLimit(`Stopped after ${options.config.maxToolIterations} tool iterations.`, standing.read());
|
|
272
325
|
}
|
|
326
|
+
/** A message's words, as the preselector is asked about them: its string, or its text parts. */
|
|
327
|
+
const userText = (message) => {
|
|
328
|
+
const content = message?.content;
|
|
329
|
+
if (typeof content === "string")
|
|
330
|
+
return content;
|
|
331
|
+
if (!Array.isArray(content))
|
|
332
|
+
return "";
|
|
333
|
+
return content.flatMap((part) => (part.type === "text" ? part.text : [])).join("\n");
|
|
334
|
+
};
|
|
273
335
|
/**
|
|
274
336
|
* The steps of `runAgentLoop`, throwing what they caught as it was caught. Resolves to nothing
|
|
275
337
|
* when `maxToolIterations` is spent, and tells `standing` how to read the run either way.
|
|
@@ -311,10 +373,44 @@ async function runSteps(options, standing) {
|
|
|
311
373
|
const onDemand = proxied || (config.toolDiscovery === "ondemand" && catalog.length > 0);
|
|
312
374
|
// Proxied, `loaded` is what this run has put a definition in the history for, and starts empty.
|
|
313
375
|
const loaded = new Set(onDemand && !proxied ? (options.loaded ?? []) : []);
|
|
314
|
-
|
|
376
|
+
// The loop's own preselection, where it was asked for and the host has not already decided: by
|
|
377
|
+
// the task where the config carries one, and by the flattened name on its own endpoint where
|
|
378
|
+
// it does not. `preselect` answers an empty model with nothing, so neither named asks nothing.
|
|
379
|
+
const choose = async () => {
|
|
380
|
+
const prompt = userText(options.messages.findLast((message) => message.role === "user"));
|
|
381
|
+
if (!prompt.trim())
|
|
382
|
+
return [];
|
|
383
|
+
const asked = {
|
|
384
|
+
...(typeof options.preselect === "object" ? options.preselect : {}),
|
|
385
|
+
signal,
|
|
386
|
+
onNotice: notice,
|
|
387
|
+
};
|
|
388
|
+
const selector = config.tasks?.toolSelect;
|
|
389
|
+
if (!selector?.model) {
|
|
390
|
+
return preselect(config, config.toolSelectModel ?? "", catalog, prompt, asked);
|
|
391
|
+
}
|
|
392
|
+
const call = taskCall(selector, asked, config);
|
|
393
|
+
return preselect(call.endpoint, call.model, catalog, prompt, call.options);
|
|
394
|
+
};
|
|
395
|
+
const preselected = !onDemand
|
|
396
|
+
? []
|
|
397
|
+
: options.preselect && options.preselected === undefined
|
|
398
|
+
? await choose()
|
|
399
|
+
: [...(options.preselected ?? [])];
|
|
315
400
|
if (!proxied)
|
|
316
401
|
for (const name of preselected)
|
|
317
402
|
loaded.add(name);
|
|
403
|
+
// The loop's own compaction, which is the task's or nothing: a summary the main model writes is
|
|
404
|
+
// a second full-price request the operator did not ask for.
|
|
405
|
+
const compactor = options.compact ? config.tasks?.compaction : undefined;
|
|
406
|
+
const summarise = (() => {
|
|
407
|
+
if (!compactor?.model)
|
|
408
|
+
return undefined;
|
|
409
|
+
const call = taskCall(compactor, { signal, onNotice: notice }, config);
|
|
410
|
+
const write = summariser(call.endpoint, call.model, call.options);
|
|
411
|
+
// A summary that fails folds nothing, and the step goes out as it was.
|
|
412
|
+
return async (text) => (await tryAsk("compaction", () => write(text), { onNotice: notice })) ?? "";
|
|
413
|
+
})();
|
|
318
414
|
const used = new Set();
|
|
319
415
|
const definitions = new Map();
|
|
320
416
|
for (const tool of tools) {
|
|
@@ -418,11 +514,32 @@ async function runSteps(options, standing) {
|
|
|
418
514
|
limit: config.contextLength ?? 0,
|
|
419
515
|
ledger,
|
|
420
516
|
});
|
|
517
|
+
let next = rewritten ?? messages;
|
|
518
|
+
if (summarise) {
|
|
519
|
+
const plan = planCompaction(next, {
|
|
520
|
+
...(typeof options.compact === "object" ? options.compact : {}),
|
|
521
|
+
limit: config.contextLength ?? 0,
|
|
522
|
+
// The last request's prompt describes this transcript only while nothing has rewritten it.
|
|
523
|
+
...(next === messages && previous && previous.prompt > 0 ? { used: previous.prompt } : {}),
|
|
524
|
+
charsPerToken: charsPerTokenFor(supports, config.model),
|
|
525
|
+
...((compactor?.maxTokens ?? 0) > 0 ? { summaryTokens: compactor?.maxTokens } : {}),
|
|
526
|
+
});
|
|
527
|
+
if (plan) {
|
|
528
|
+
const folded = await compactTranscript(next, plan, summarise, {
|
|
529
|
+
...(hooks
|
|
530
|
+
? { hooks: { run: hooks.run, context: hooks.context, onNote: hooks.onNote } }
|
|
531
|
+
: {}),
|
|
532
|
+
});
|
|
533
|
+
if (folded !== next)
|
|
534
|
+
notice(`compacted ${plan.cut - plan.from} messages into a summary`);
|
|
535
|
+
next = folded;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
421
538
|
// A rewrite may have folded a definition away, and a load answered "already loaded" would
|
|
422
539
|
// then point at nothing. Forgetting costs a definition sent twice at worst.
|
|
423
|
-
if (proxied &&
|
|
540
|
+
if (proxied && next !== messages)
|
|
424
541
|
loaded.clear();
|
|
425
|
-
messages =
|
|
542
|
+
messages = next;
|
|
426
543
|
ledger = rebaseLedger(ledger, before, messages);
|
|
427
544
|
onEvent({ kind: "turn", text: `turn ${step + 1}` });
|
|
428
545
|
// Appended, the shortlist is already in `loaded` — after what was carried, and once — so the
|
package/dist/client.d.ts
CHANGED
|
@@ -18,7 +18,8 @@ export interface ClientPoolOptions {
|
|
|
18
18
|
maxClients?: number;
|
|
19
19
|
/**
|
|
20
20
|
* How long, in milliseconds, a model an endpoint did not list — or a server that answered
|
|
21
|
-
* without a served window
|
|
21
|
+
* without a served window, or whose `/api/v0/models` answered with an error other than a
|
|
22
|
+
* missing route — is taken at its word before the endpoint is asked again.
|
|
22
23
|
*/
|
|
23
24
|
listingMissMs?: number;
|
|
24
25
|
}
|
|
@@ -66,7 +67,12 @@ export declare const firstTokenMs: (config: Pick<Endpoint, "requestTimeoutSecond
|
|
|
66
67
|
* @param config Where to send requests and how long to wait. An absent `apiKey` becomes `NO_KEY`.
|
|
67
68
|
*/
|
|
68
69
|
export declare function getClient(config: Endpoint): OpenAI;
|
|
69
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* A model an endpoint offers, and what it says the model will read. Zero means it did not say.
|
|
72
|
+
*
|
|
73
|
+
* The number is whatever the listing carried, which from llama.cpp is the trained window rather
|
|
74
|
+
* than the served one; `contextLimitFor` is the lookup that knows the difference.
|
|
75
|
+
*/
|
|
70
76
|
export interface ModelInfo {
|
|
71
77
|
id: string;
|
|
72
78
|
contextLength: number;
|
|
@@ -109,7 +115,8 @@ export declare const endpointId: (config: {
|
|
|
109
115
|
apiKey?: string;
|
|
110
116
|
}) => string;
|
|
111
117
|
/**
|
|
112
|
-
* The window a local server is
|
|
118
|
+
* The window a local server's own API says it is serving a model in, for the servers whose
|
|
119
|
+
* listing does not say.
|
|
113
120
|
*
|
|
114
121
|
* llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
|
|
115
122
|
* Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
|
|
@@ -117,8 +124,12 @@ export declare const endpointId: (config: {
|
|
|
117
124
|
* built for, and reading the trained one lets an overflow through the guard meant to catch it.
|
|
118
125
|
* Ollama reports nothing on either; an operator there has to declare the window.
|
|
119
126
|
*
|
|
120
|
-
* Zero where no answer was found
|
|
121
|
-
* latched and not asked again
|
|
127
|
+
* Zero where no answer was found, which is remembered for `listingMissMs`. A server without
|
|
128
|
+
* either route is latched and not asked again. `/api/v0/models` is also dropped on its own: until
|
|
129
|
+
* `resetClients` once it answers as a route that is not there, and for `listingMissMs` after any
|
|
130
|
+
* other error. A server that could not be reached is not remembered at all.
|
|
131
|
+
*
|
|
132
|
+
* The listing is not read here; `contextLimitFor` reads it first and comes here after.
|
|
122
133
|
*
|
|
123
134
|
* @param config The endpoint, plus the model whose window is wanted.
|
|
124
135
|
*/
|
|
@@ -139,11 +150,18 @@ export declare function listModels(config: Endpoint): Promise<ModelInfo[]>;
|
|
|
139
150
|
* happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
|
|
140
151
|
* the honest-looking number is a run that fails at the endpoint instead.
|
|
141
152
|
*
|
|
142
|
-
* Otherwise the
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
153
|
+
* Otherwise the endpoint's listing — asked once, and again whenever it does not name this model,
|
|
154
|
+
* since a model can arrive after the first listing was taken. A window stated at the top of the
|
|
155
|
+
* model's entry is taken as the one being served, and nothing else is asked. Where the listing
|
|
156
|
+
* did not settle it — no window, only llama.cpp's trained one, no entry, no listing — the
|
|
157
|
+
* server's own API is asked (`servedWindow`), and what it says wins; the trained window is what
|
|
158
|
+
* is left when it says nothing. A server that will not list models still has to be able to run a
|
|
159
|
+
* turn: a failure here is an unknown window, not a failed run.
|
|
160
|
+
*
|
|
161
|
+
* The listing goes first because it is the one route every server here has, and the native ones
|
|
162
|
+
* are two more requests that a server stating its window in the listing never needed. Asked
|
|
163
|
+
* first, they were also asked again every `listingMissMs` for the life of the process on a server
|
|
164
|
+
* whose `/props` answers without a window, by any host that sizes the window per turn.
|
|
147
165
|
*
|
|
148
166
|
* @param config The endpoint, plus the model whose window is wanted.
|
|
149
167
|
* @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
|
package/dist/client.js
CHANGED
|
@@ -162,18 +162,26 @@ const CONTEXT_KEYS = [
|
|
|
162
162
|
"n_ctx",
|
|
163
163
|
];
|
|
164
164
|
const positive = (value) => (typeof value === "number" && value > 0 ? value : 0);
|
|
165
|
+
/**
|
|
166
|
+
* A listing entry's window, and whether it is only the one the model was trained with.
|
|
167
|
+
*
|
|
168
|
+
* The two are told apart because they are trusted differently: a top-level key is taken as the
|
|
169
|
+
* window being served and settles the lookup, and a trained one is a ceiling the server may be
|
|
170
|
+
* running well under, so `contextLimitFor` still asks the native routes before settling for it.
|
|
171
|
+
*/
|
|
165
172
|
function contextLengthOf(model) {
|
|
166
173
|
const record = model;
|
|
167
174
|
for (const key of CONTEXT_KEYS) {
|
|
168
175
|
const value = positive(record[key]);
|
|
169
176
|
if (value)
|
|
170
|
-
return value;
|
|
177
|
+
return { contextLength: value, trained: false };
|
|
171
178
|
}
|
|
172
179
|
// llama.cpp's, and the window the model was trained with rather than the one it is served in:
|
|
173
180
|
// a 256k model started at `-c 16384` lists 262144 here. Better than nothing, and why the
|
|
174
|
-
// served window is asked for
|
|
181
|
+
// served window is still asked for after it. `meta` is `null` while the model loads.
|
|
175
182
|
const meta = record.meta;
|
|
176
|
-
|
|
183
|
+
const contextLength = positive(meta?.n_ctx_train);
|
|
184
|
+
return { contextLength, trained: contextLength > 0 };
|
|
177
185
|
}
|
|
178
186
|
/**
|
|
179
187
|
* The last listing from each endpoint, so a run can size its window without a round trip.
|
|
@@ -190,6 +198,13 @@ function contextLengthOf(model) {
|
|
|
190
198
|
* whatever was there rather than emptying it.
|
|
191
199
|
*/
|
|
192
200
|
const listings = new Map();
|
|
201
|
+
/**
|
|
202
|
+
* The models in each endpoint's last listing whose window is only the trained one, by id.
|
|
203
|
+
*
|
|
204
|
+
* Kept beside `listings` rather than on `ModelInfo`, so what `listModels` hands back is the shape
|
|
205
|
+
* it has always been. Replaced with each listing that lands, and left alone by one that fails.
|
|
206
|
+
*/
|
|
207
|
+
const trainedOnly = new Map();
|
|
193
208
|
/**
|
|
194
209
|
* When an endpoint was last asked about a model it did not name, keyed on the two together.
|
|
195
210
|
*
|
|
@@ -243,8 +258,25 @@ export const endpointId = (config) => createHash("sha256").update(endpointKey(co
|
|
|
243
258
|
* is asked again after `listingMissMs`, like a listing that did not name the model.
|
|
244
259
|
*/
|
|
245
260
|
const served = new Map();
|
|
246
|
-
/** Endpoints
|
|
261
|
+
/** Endpoints with neither native route, which are not asked for either again. */
|
|
247
262
|
const unserved = new Set();
|
|
263
|
+
/**
|
|
264
|
+
* Endpoints whose `/api/v0/models` answered with an error, and when.
|
|
265
|
+
*
|
|
266
|
+
* `gone` is a route the server does not have (`NOT_THERE`), which is a fact about the server and
|
|
267
|
+
* stands until `resetClients`. Any other error stands for `listingMissMs` only, because it may be
|
|
268
|
+
* one the server gets over, and LM Studio latched for good on a passing 5xx would have no window
|
|
269
|
+
* at all until the process restarted, its listing carrying none.
|
|
270
|
+
*
|
|
271
|
+
* Per endpoint and on its own, whatever `/props` said: a server whose `/props` answers without a
|
|
272
|
+
* window is never `unserved`, and without this its missing route was asked again with every miss.
|
|
273
|
+
*/
|
|
274
|
+
const refusals = new Map();
|
|
275
|
+
/** Whether `/api/v0/models` is to be left alone on this endpoint for now. */
|
|
276
|
+
const refused = (endpoint) => {
|
|
277
|
+
const refusal = refusals.get(endpoint);
|
|
278
|
+
return (refusal !== undefined && (refusal.gone || Date.now() - refusal.at < poolLimits.listingMissMs));
|
|
279
|
+
};
|
|
248
280
|
/** How long a probe may take before the window is taken from the listing instead. */
|
|
249
281
|
const PROBE_TIMEOUT_MS = 10_000;
|
|
250
282
|
/** The server root behind an OpenAI-compatible base URL, which is where the native APIs live. */
|
|
@@ -252,8 +284,9 @@ const rootOf = (baseUrl) => baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
|
252
284
|
/** A route this server does not have, rather than one that failed to answer. */
|
|
253
285
|
const NOT_THERE = new Set([404, 405, 501]);
|
|
254
286
|
/**
|
|
255
|
-
* Asks one native endpoint. `
|
|
256
|
-
*
|
|
287
|
+
* Asks one native endpoint. `failed` is any answer that was not a 2xx and `missing` the ones among
|
|
288
|
+
* them that mean a server without the route; `body` is absent for both, and for a 2xx that was not
|
|
289
|
+
* JSON. A server that could not be reached throws.
|
|
257
290
|
*/
|
|
258
291
|
async function probe(config, path) {
|
|
259
292
|
const apiKey = config.apiKey || undefined;
|
|
@@ -262,16 +295,17 @@ async function probe(config, path) {
|
|
|
262
295
|
signal: AbortSignal.timeout(timeoutMs(config) ?? PROBE_TIMEOUT_MS),
|
|
263
296
|
});
|
|
264
297
|
if (!response.ok)
|
|
265
|
-
return { missing: NOT_THERE.has(response.status) };
|
|
298
|
+
return { failed: true, missing: NOT_THERE.has(response.status) };
|
|
266
299
|
try {
|
|
267
|
-
return { missing: false, body: await response.json() };
|
|
300
|
+
return { failed: false, missing: false, body: await response.json() };
|
|
268
301
|
}
|
|
269
302
|
catch {
|
|
270
|
-
return { missing: false };
|
|
303
|
+
return { failed: false, missing: false };
|
|
271
304
|
}
|
|
272
305
|
}
|
|
273
306
|
/**
|
|
274
|
-
* The window a local server is
|
|
307
|
+
* The window a local server's own API says it is serving a model in, for the servers whose
|
|
308
|
+
* listing does not say.
|
|
275
309
|
*
|
|
276
310
|
* llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
|
|
277
311
|
* Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
|
|
@@ -279,8 +313,12 @@ async function probe(config, path) {
|
|
|
279
313
|
* built for, and reading the trained one lets an overflow through the guard meant to catch it.
|
|
280
314
|
* Ollama reports nothing on either; an operator there has to declare the window.
|
|
281
315
|
*
|
|
282
|
-
* Zero where no answer was found
|
|
283
|
-
* latched and not asked again
|
|
316
|
+
* Zero where no answer was found, which is remembered for `listingMissMs`. A server without
|
|
317
|
+
* either route is latched and not asked again. `/api/v0/models` is also dropped on its own: until
|
|
318
|
+
* `resetClients` once it answers as a route that is not there, and for `listingMissMs` after any
|
|
319
|
+
* other error. A server that could not be reached is not remembered at all.
|
|
320
|
+
*
|
|
321
|
+
* The listing is not read here; `contextLimitFor` reads it first and comes here after.
|
|
284
322
|
*
|
|
285
323
|
* @param config The endpoint, plus the model whose window is wanted.
|
|
286
324
|
*/
|
|
@@ -300,13 +338,19 @@ export async function servedWindow(config) {
|
|
|
300
338
|
?.default_generation_settings;
|
|
301
339
|
window = positive(settings?.n_ctx);
|
|
302
340
|
if (!window) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
341
|
+
if (!refused(endpoint)) {
|
|
342
|
+
const lmstudio = await probe(config, "/api/v0/models");
|
|
343
|
+
if (lmstudio.failed)
|
|
344
|
+
refusals.set(endpoint, { at: Date.now(), gone: lmstudio.missing });
|
|
345
|
+
else
|
|
346
|
+
refusals.delete(endpoint);
|
|
347
|
+
const { data } = (lmstudio.body ?? {});
|
|
348
|
+
const entry = Array.isArray(data)
|
|
349
|
+
? data.find((model) => model?.id === config.model)
|
|
350
|
+
: undefined;
|
|
351
|
+
window = positive(entry?.loaded_context_length);
|
|
352
|
+
}
|
|
353
|
+
if (props.missing && refusals.get(endpoint)?.gone) {
|
|
310
354
|
unserved.add(endpoint);
|
|
311
355
|
return 0;
|
|
312
356
|
}
|
|
@@ -325,10 +369,18 @@ export async function servedWindow(config) {
|
|
|
325
369
|
*/
|
|
326
370
|
export async function listModels(config) {
|
|
327
371
|
const { data } = await getClient(config).models.list();
|
|
372
|
+
const trained = new Set();
|
|
328
373
|
const models = data
|
|
329
|
-
.map((model) =>
|
|
374
|
+
.map((model) => {
|
|
375
|
+
const window = contextLengthOf(model);
|
|
376
|
+
if (window.trained)
|
|
377
|
+
trained.add(model.id);
|
|
378
|
+
return { id: model.id, contextLength: window.contextLength };
|
|
379
|
+
})
|
|
330
380
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
331
|
-
|
|
381
|
+
const key = endpointKey(config);
|
|
382
|
+
listings.set(key, models);
|
|
383
|
+
trainedOnly.set(key, trained);
|
|
332
384
|
return models;
|
|
333
385
|
}
|
|
334
386
|
/**
|
|
@@ -339,11 +391,18 @@ export async function listModels(config) {
|
|
|
339
391
|
* happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
|
|
340
392
|
* the honest-looking number is a run that fails at the endpoint instead.
|
|
341
393
|
*
|
|
342
|
-
* Otherwise the
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
394
|
+
* Otherwise the endpoint's listing — asked once, and again whenever it does not name this model,
|
|
395
|
+
* since a model can arrive after the first listing was taken. A window stated at the top of the
|
|
396
|
+
* model's entry is taken as the one being served, and nothing else is asked. Where the listing
|
|
397
|
+
* did not settle it — no window, only llama.cpp's trained one, no entry, no listing — the
|
|
398
|
+
* server's own API is asked (`servedWindow`), and what it says wins; the trained window is what
|
|
399
|
+
* is left when it says nothing. A server that will not list models still has to be able to run a
|
|
400
|
+
* turn: a failure here is an unknown window, not a failed run.
|
|
401
|
+
*
|
|
402
|
+
* The listing goes first because it is the one route every server here has, and the native ones
|
|
403
|
+
* are two more requests that a server stating its window in the listing never needed. Asked
|
|
404
|
+
* first, they were also asked again every `listingMissMs` for the life of the process on a server
|
|
405
|
+
* whose `/props` answers without a window, by any host that sizes the window per turn.
|
|
347
406
|
*
|
|
348
407
|
* @param config The endpoint, plus the model whose window is wanted.
|
|
349
408
|
* @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
|
|
@@ -351,9 +410,6 @@ export async function listModels(config) {
|
|
|
351
410
|
export async function contextLimitFor(config, declared = 0) {
|
|
352
411
|
if (declared > 0)
|
|
353
412
|
return declared;
|
|
354
|
-
const window = await servedWindow(config);
|
|
355
|
-
if (window > 0)
|
|
356
|
-
return window;
|
|
357
413
|
const key = endpointKey(config);
|
|
358
414
|
const listed = () => listings.get(key)?.find((model) => model.id === config.model);
|
|
359
415
|
// The listing is asked for again when it does not name this model, rather than only when
|
|
@@ -369,26 +425,30 @@ export async function contextLimitFor(config, declared = 0) {
|
|
|
369
425
|
// Asked again, but not on every call. A model that is never coming answers the same zero
|
|
370
426
|
// however often the endpoint is asked, and a caller sizing a window per turn pays a round
|
|
371
427
|
// trip for each of them; `listingMissMs` is how long that answer is allowed to stand.
|
|
372
|
-
if (asked
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
428
|
+
if (asked === undefined || Date.now() - asked >= poolLimits.listingMissMs) {
|
|
429
|
+
// A failure is not remembered: an endpoint that was down when the last run started is not
|
|
430
|
+
// an endpoint with no models, and a window nobody could ask about is not a failed run.
|
|
431
|
+
const answered = await listModels(config).then(() => true, () => false);
|
|
432
|
+
// Recorded where the endpoint was actually asked, and only there. Stamping it on the calls
|
|
433
|
+
// that skipped the request would push the deadline out ahead of any caller polling faster
|
|
434
|
+
// than the interval, which is a memory that never expires rather than one that expires in
|
|
435
|
+
// half a minute.
|
|
436
|
+
if (answered) {
|
|
437
|
+
if (listed())
|
|
438
|
+
misses.delete(missKey);
|
|
439
|
+
else
|
|
440
|
+
misses.set(missKey, Date.now());
|
|
441
|
+
}
|
|
381
442
|
}
|
|
382
|
-
// Recorded where the endpoint was actually asked, and only there. Stamping it on the calls
|
|
383
|
-
// that skipped the request would push the deadline out ahead of any caller polling faster
|
|
384
|
-
// than the interval, which is a memory that never expires rather than one that expires in
|
|
385
|
-
// half a minute.
|
|
386
|
-
if (listed())
|
|
387
|
-
misses.delete(missKey);
|
|
388
|
-
else
|
|
389
|
-
misses.set(missKey, Date.now());
|
|
390
443
|
}
|
|
391
|
-
|
|
444
|
+
const entry = listed();
|
|
445
|
+
const fromListing = entry?.contextLength ?? 0;
|
|
446
|
+
// A top-level key settles it. A trained window does not: llama.cpp lists the one the model was
|
|
447
|
+
// built with, and taking it over what `/props` says lets an overflow through the guard.
|
|
448
|
+
if (fromListing > 0 && !trainedOnly.get(key)?.has(config.model))
|
|
449
|
+
return fromListing;
|
|
450
|
+
const window = await servedWindow(config);
|
|
451
|
+
return window > 0 ? window : fromListing;
|
|
392
452
|
}
|
|
393
453
|
/**
|
|
394
454
|
* Forgets every cached client and listing, and puts `configureClients` back to the defaults. For
|
|
@@ -399,6 +459,8 @@ export function resetClients() {
|
|
|
399
459
|
clients.clear();
|
|
400
460
|
listings.clear();
|
|
401
461
|
misses.clear();
|
|
462
|
+
trainedOnly.clear();
|
|
402
463
|
served.clear();
|
|
403
464
|
unserved.clear();
|
|
465
|
+
refusals.clear();
|
|
404
466
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
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
|
-
export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type StepWindow, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult, } from "./agent-loop.ts";
|
|
12
|
+
export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type StepWindow, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult, taskCall, } from "./agent-loop.ts";
|
|
13
13
|
export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
|
|
14
14
|
export { type Capabilities, capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
|
|
15
15
|
export type { CatalogServer } from "./catalog.ts";
|
|
@@ -25,7 +25,7 @@ export { resetAll } from "./reset.ts";
|
|
|
25
25
|
export { backoffMs, CHARS_PER_TOKEN, type ContextBreakdown, type ContextBreakdownOptions, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, type TokenEstimateOptions, toolsChars, } from "./retry.ts";
|
|
26
26
|
export { type RunTurnOptions, runTurn } from "./run-turn.ts";
|
|
27
27
|
export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
|
|
28
|
-
export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
|
|
28
|
+
export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTask, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
|
|
29
29
|
export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from "./snapshot.ts";
|
|
30
30
|
export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, type AgentHook, type AgentSpec, type EndpointSpec, type ExportSpecOptions, exportSpec, type ModelSpec, type ParsedSpec, type ParseSpecOptions, type PromptPart, parseSpec, RESOLVED_DEFAULTS, type ResolvedAgent, type ResolvedTask, type RetrySpec, resolveAgentSpec, SPEC_EVENTS, type SpecBundle, type SpecServer, type TaskSpec, type ToolsSpec, } from "./spec.ts";
|
|
31
31
|
export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
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
|
-
export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, } from "./agent-loop.js";
|
|
12
|
+
export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, taskCall, } from "./agent-loop.js";
|
|
13
13
|
export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.js";
|
|
14
14
|
export { capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
|
|
15
15
|
export { configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.js";
|
package/dist/side-task.d.ts
CHANGED
|
@@ -20,10 +20,22 @@ export declare const resetHints: () => void;
|
|
|
20
20
|
export type SideTaskInput = string | OpenAI.ChatCompletionContentPart[];
|
|
21
21
|
/** What a side task may be given. All optional — one given none of them still runs. */
|
|
22
22
|
export interface SideTaskOptions {
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Ceiling on the reply, default 512. These answers are meant to be short. Zero or less sends
|
|
25
|
+
* none and leaves it to the server, which is what `maxTokens: 0` means on the main model too.
|
|
26
|
+
*/
|
|
24
27
|
maxTokens?: number;
|
|
25
28
|
/** Sampling temperature, default 0.3. Naming and classifying want the same answer twice. */
|
|
26
29
|
temperature?: number;
|
|
30
|
+
/**
|
|
31
|
+
* A reasoning effort to ask for in place of the no-thinking hints, for a side task an operator
|
|
32
|
+
* has pointed at a model that should deliberate — a compaction summary is the usual one.
|
|
33
|
+
*
|
|
34
|
+
* Absent, `""` and `"off"` all keep the hints, because off is what they already ask for. Any
|
|
35
|
+
* other value is sent as `reasoning_effort`, stepped up by `effortFor` where the model has
|
|
36
|
+
* refused it by value, and `chat_template_kwargs` is left out of the request altogether.
|
|
37
|
+
*/
|
|
38
|
+
reasoningEffort?: string;
|
|
27
39
|
/** Abandons the call, usually because the run it supports has gone away. */
|
|
28
40
|
signal?: AbortSignal;
|
|
29
41
|
/**
|
|
@@ -40,6 +52,27 @@ export interface SideTaskOptions {
|
|
|
40
52
|
*/
|
|
41
53
|
onNotice?: (message: string) => void;
|
|
42
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* One side task's own settings, as a resolved agent spec carries them under `tasks.<key>`.
|
|
57
|
+
*
|
|
58
|
+
* Declared here rather than imported from `spec.ts`, whose `ResolvedTask` it matches field for
|
|
59
|
+
* field: the spec module is one nothing else in `src/` imports, and a host that is not on the spec
|
|
60
|
+
* can write one of these by hand. An absent setting is the side task's own default — 0.3, the
|
|
61
|
+
* entry point's ceiling, thinking off — and never the main model's, so raising the agent's
|
|
62
|
+
* temperature for the chat does not make its titler creative. See `taskCall`.
|
|
63
|
+
*/
|
|
64
|
+
export interface SideTask {
|
|
65
|
+
/** The model that does the task. */
|
|
66
|
+
model: string;
|
|
67
|
+
/** Where that model is reached, which need not be where the agent's is. */
|
|
68
|
+
endpoint: Endpoint;
|
|
69
|
+
/** The reply's ceiling. Absent is the entry point's default; zero sends none. */
|
|
70
|
+
maxTokens?: number;
|
|
71
|
+
/** Absent is 0.3, whatever the agent's own temperature is. Zero means zero. */
|
|
72
|
+
temperature?: number;
|
|
73
|
+
/** As `SideTaskOptions.reasoningEffort`: a level replaces the no-thinking hints. */
|
|
74
|
+
reasoningEffort?: string;
|
|
75
|
+
}
|
|
43
76
|
/**
|
|
44
77
|
* Runs a side task and returns the reply text, thinking stripped. Throws like any request.
|
|
45
78
|
*
|
|
@@ -47,7 +80,7 @@ export interface SideTaskOptions {
|
|
|
47
80
|
* @param model The model to ask, usually smaller than the one running the work.
|
|
48
81
|
* @param system The instruction.
|
|
49
82
|
* @param user The input it applies to. Content parts where the model is being shown an image.
|
|
50
|
-
* @param options Reply ceiling, temperature, cancellation, notices.
|
|
83
|
+
* @param options Reply ceiling, temperature, a reasoning effort, cancellation, notices.
|
|
51
84
|
*/
|
|
52
85
|
export declare function ask(config: Endpoint, model: string, system: string, user: SideTaskInput, options?: SideTaskOptions): Promise<string>;
|
|
53
86
|
/** What `askJson` takes besides a side task's options. */
|
package/dist/side-task.js
CHANGED
|
@@ -72,7 +72,7 @@ function rejectedTheRequest(error) {
|
|
|
72
72
|
* @param model The model to ask, usually smaller than the one running the work.
|
|
73
73
|
* @param system The instruction.
|
|
74
74
|
* @param user The input it applies to. Content parts where the model is being shown an image.
|
|
75
|
-
* @param options Reply ceiling, temperature, cancellation, notices.
|
|
75
|
+
* @param options Reply ceiling, temperature, a reasoning effort, cancellation, notices.
|
|
76
76
|
*/
|
|
77
77
|
export function ask(config, model, system, user, options = {}) {
|
|
78
78
|
return complete(config, model, system, user, options);
|
|
@@ -81,22 +81,29 @@ export function ask(config, model, system, user, options = {}) {
|
|
|
81
81
|
* The request `ask` and `askJson` share, with `format` deciding the extra body fields from what
|
|
82
82
|
* the model and the endpoint have refused, rebuilt on every re-send.
|
|
83
83
|
*/
|
|
84
|
-
async function complete(config, model, system, user, { maxTokens = 512, temperature = 0.3, signal, onNotice }, format) {
|
|
84
|
+
async function complete(config, model, system, user, { maxTokens = 512, temperature = 0.3, reasoningEffort, signal, onNotice }, format) {
|
|
85
|
+
// The effort the caller chose, or empty where it chose none: `""` and `"off"` read as absent,
|
|
86
|
+
// since off is what the hints below already ask for.
|
|
87
|
+
const level = effortFor(undefined, reasoningEffort);
|
|
85
88
|
// Whether the last request carried an effort, which `negotiate` decides and not this function.
|
|
86
89
|
let sentEffort = false;
|
|
87
90
|
const send = (hints, effort, supports, refused) => {
|
|
88
91
|
// `none` is what a side task wants and not always what the model offers: OpenAI's reasoning
|
|
89
92
|
// models refuse it by value and list `minimal` as their floor. `effortFor` answers with the
|
|
90
93
|
// cheapest rung this one takes, which `negotiate` has been stepping up as it was refused.
|
|
91
|
-
|
|
94
|
+
// A level the caller chose goes through the same ladder, which only ever steps up from it.
|
|
95
|
+
const asked = effort ? effortFor(refused, level || "none") : "";
|
|
92
96
|
sentEffort = asked !== "";
|
|
93
97
|
return getClient(config).chat.completions.create({
|
|
94
98
|
model,
|
|
95
99
|
// The reasoning models want the ceiling spelled the other way, and they are exactly the
|
|
96
|
-
// models a side task most wants to stop deliberating.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
// models a side task most wants to stop deliberating. Zero is no ceiling, as it is on the
|
|
101
|
+
// main model: a spec's `maxTokens: 0` sent as `max_tokens: 0` asks for an empty reply.
|
|
102
|
+
...(maxTokens > 0
|
|
103
|
+
? refused && !refused.legacyTokenLimit
|
|
104
|
+
? { max_completion_tokens: maxTokens }
|
|
105
|
+
: { max_tokens: maxTokens }
|
|
106
|
+
: {}),
|
|
100
107
|
// One that will only run at the temperature it was built with is sent none: a side task
|
|
101
108
|
// wants the same answer twice, and 1.0 from that model is as close as it gets.
|
|
102
109
|
...(refused && !refused.chosenTemperature ? {} : { temperature }),
|
|
@@ -121,7 +128,9 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
|
|
|
121
128
|
onNotice,
|
|
122
129
|
});
|
|
123
130
|
const key = hintKey(endpointId(config), model);
|
|
124
|
-
|
|
131
|
+
// A chosen effort replaces the template hint rather than riding beside it: `enable_thinking:
|
|
132
|
+
// false` next to `reasoning_effort: "high"` is a request that contradicts itself.
|
|
133
|
+
const hints = !level && !noHints.has(key);
|
|
125
134
|
let response;
|
|
126
135
|
try {
|
|
127
136
|
response = await attempt(hints, true);
|
|
@@ -134,7 +143,9 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
|
|
|
134
143
|
const effort = sentEffort;
|
|
135
144
|
if (!(hints || effort) || !rejectedTheRequest(error))
|
|
136
145
|
throw error;
|
|
137
|
-
onNotice?.(
|
|
146
|
+
onNotice?.(level
|
|
147
|
+
? `${model} rejected a request carrying a reasoning effort; retrying without it`
|
|
148
|
+
: `${model} rejected a request carrying the no-thinking hints; retrying without them`);
|
|
138
149
|
response = await attempt(false, false);
|
|
139
150
|
// Latched on the finding, not the hypothesis: a context overflow is a 400 `negotiate` does
|
|
140
151
|
// not recognise too, and it fails the retry the same way, leaving the next call to try the
|
package/dist/spec.d.ts
CHANGED
|
@@ -132,8 +132,14 @@ export interface TaskSpec {
|
|
|
132
132
|
model?: string;
|
|
133
133
|
/** Where that model lives, when it is not on the agent's endpoint. */
|
|
134
134
|
endpoint?: EndpointSpec;
|
|
135
|
+
/** The reply's ceiling. Absent is the side task's own default, not the agent's `maxTokens`. */
|
|
135
136
|
maxTokens?: number;
|
|
137
|
+
/** Absent is the side task's own default, 0.3, whatever the agent's temperature is. */
|
|
136
138
|
temperature?: number;
|
|
139
|
+
/**
|
|
140
|
+
* A level has the task deliberate. Absent and `"off"` leave it with the no-thinking hints a
|
|
141
|
+
* side task is sent by default; see `SideTaskOptions.reasoningEffort`.
|
|
142
|
+
*/
|
|
137
143
|
reasoningEffort?: string;
|
|
138
144
|
}
|
|
139
145
|
/** A tool call bound to a lifecycle event, run by the host rather than the model. */
|
|
@@ -178,7 +184,13 @@ export interface SpecBundle {
|
|
|
178
184
|
*/
|
|
179
185
|
mcpServers?: SpecServer[];
|
|
180
186
|
}
|
|
181
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* One side task, resolved: a model, and the endpoint it is reached through.
|
|
189
|
+
*
|
|
190
|
+
* The three settings are present only where a layer stated them, and nothing is filled in from
|
|
191
|
+
* the agent's own model — `taskCall` turns one into a call, and what is absent there is left to
|
|
192
|
+
* the side task's default.
|
|
193
|
+
*/
|
|
182
194
|
export interface ResolvedTask {
|
|
183
195
|
model: string;
|
|
184
196
|
/** This task's endpoint, which is the agent's unless a layer named another. */
|
package/llms.txt
CHANGED
|
@@ -26,6 +26,7 @@ The loop above a turn: send, run the tools the model asked for, send again, unti
|
|
|
26
26
|
- `ToolCallOutcome` (type) — What one tool call did, in the order the model asked.
|
|
27
27
|
- `ToolCallRequest` (type) — One call the model made, as `dispatch` and `onToolCall` are handed it.
|
|
28
28
|
- `ToolCallResult` (type) — One call's whole answer, as `onToolResult` is handed it.
|
|
29
|
+
- `taskCall` — A side task's settings as the call that honours them: its endpoint, its model, and the options `ask`, `askJson`, `summariser` and `preselect` take last.
|
|
29
30
|
|
|
30
31
|
### calibration
|
|
31
32
|
|
|
@@ -70,7 +71,7 @@ The contract between whatever holds the tools and the loop that offers them to a
|
|
|
70
71
|
- `ModelInfo` (type) — A model an endpoint offers, and what it says the model will read.
|
|
71
72
|
- `NO_KEY` — The SDK insists on a non-empty key even where the server will not look at it.
|
|
72
73
|
- `resetClients` — Forgets every cached client and listing, and puts `configureClients` back to the defaults.
|
|
73
|
-
- `servedWindow` — The window a local server is
|
|
74
|
+
- `servedWindow` — The window a local server's own API says it is serving a model in, for the servers whose listing does not say.
|
|
74
75
|
- `timeoutMs` — Zero, less, or absent means no limit, which the SDK spells as `undefined`.
|
|
75
76
|
|
|
76
77
|
### compaction
|
|
@@ -240,6 +241,7 @@ One-shot calls that support a run without being one: picking tools, naming a ses
|
|
|
240
241
|
- `listLines` — A list-shaped reply, one item per line, cleaned of the bullets and quotes models decorate them with.
|
|
241
242
|
- `parseJson` — Models are asked for JSON and often answer with prose around it, or a fenced block.
|
|
242
243
|
- `resetHints` — Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints.
|
|
244
|
+
- `SideTask` (type) — One side task's own settings, as a resolved agent spec carries them under `tasks.<key>`.
|
|
243
245
|
- `SideTaskInput` (type) — The input a side task applies its instruction to: text, or the content parts a vision model reads.
|
|
244
246
|
- `SideTaskOptions` (type) — What a side task may be given.
|
|
245
247
|
- `tryAsk` — A side task is never worth failing the work it supports.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cubicecho/agent-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.22.0",
|
|
4
4
|
"description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openai",
|