@cubicecho/agent-core 2.3.0 → 2.5.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 +205 -1
- package/dist/agent-loop.d.ts +202 -0
- package/dist/agent-loop.js +355 -0
- package/dist/capabilities.d.ts +36 -2
- package/dist/capabilities.js +73 -19
- package/dist/client.d.ts +13 -0
- package/dist/client.js +11 -0
- package/dist/compaction.d.ts +133 -0
- package/dist/compaction.js +173 -0
- package/dist/config.d.ts +20 -0
- package/dist/events.d.ts +5 -0
- package/dist/hooks.d.ts +33 -3
- package/dist/hooks.js +45 -5
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -4
- package/dist/reset.d.ts +6 -5
- package/dist/reset.js +8 -5
- package/dist/retry.d.ts +9 -0
- package/dist/retry.js +9 -0
- package/dist/run-turn.d.ts +9 -3
- package/dist/run-turn.js +11 -4
- package/dist/side-task.d.ts +35 -1
- package/dist/side-task.js +101 -32
- package/dist/snapshot.d.ts +57 -0
- package/dist/snapshot.js +123 -0
- package/dist/stream.d.ts +8 -0
- package/dist/stream.js +4 -1
- package/dist/tool-calls.d.ts +67 -0
- package/dist/tool-calls.js +346 -0
- package/dist/tool-loading.d.ts +21 -1
- package/dist/tool-loading.js +23 -6
- package/llms.txt +60 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,13 +24,17 @@ only, Node >=22.
|
|
|
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
25
|
| `stream` | Reads one streamed turn back into a message: token callbacks, tool-call reassembly, and the idle watchdog that turns a silent endpoint into `EndpointSilent`. |
|
|
26
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
|
-
| `side-task` | One-shot calls that support a run without being one — small prompt, short answer, no tools, never worth failing the run over. |
|
|
27
|
+
| `side-task` | One-shot calls that support a run without being one — small prompt, short answer, no tools, never worth failing the run over. `askJson` holds the answer to a schema where the server can. |
|
|
28
28
|
| `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, and `turnMessages` to hand them a transcript. Running a hook is a runner the caller passes. |
|
|
29
29
|
| `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. |
|
|
30
30
|
| `client` | A pooled `OpenAI` client per endpoint, plus the context-window listing and its cache. |
|
|
31
31
|
| `retry` | What to do when a request is lost, refused or too big: `isTransient`, `backoffMs`, `ContextOverflow`, `EndpointSilent`, `requestTokens`. |
|
|
32
32
|
| `config` | The structural interfaces every function here asks for. |
|
|
33
33
|
| `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`. |
|
|
34
|
+
| `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`, and `resolveApiKey` for a caller deciding which key an endpoint gets. |
|
|
35
|
+
| `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. |
|
|
36
|
+
| `compaction` | Keeping a long run inside its window: `pruneToolResults` clears stale tool results, `planCompaction` and `compactTranscript` fold the oldest stretch into a summary. |
|
|
37
|
+
| `snapshot` | `exportCapabilities` and `importCapabilities`: the latched refusals as a JSON blob a consumer stores, so a restart need not learn them again. |
|
|
34
38
|
| `reset` | `resetAll`: drops every cache and latch in one call, so a teardown cannot forget one. |
|
|
35
39
|
| `tokens` | `estimateTokens`: characters over four, deliberately low, for everything here that has to guess at a window. |
|
|
36
40
|
| `errors` | `errorMessage`: a caught `unknown` turned into something a run row can hold. |
|
|
@@ -147,6 +151,29 @@ still talking is never cut off however long it takes, and one that has stopped a
|
|
|
147
151
|
`requestTimeoutSeconds` of zero or absent, which waits forever — what a local model answering
|
|
148
152
|
slowly needs.
|
|
149
153
|
|
|
154
|
+
## Structured side tasks
|
|
155
|
+
|
|
156
|
+
`askJson` is `ask` for an answer with a shape. It sends the schema as `response_format` of type
|
|
157
|
+
`json_schema`, which llama.cpp compiles into a grammar and vLLM, LM Studio, Ollama and OpenAI each
|
|
158
|
+
hold the reply to, so a small model that wraps JSON in prose on its own cannot do so here. The
|
|
159
|
+
schema is normalised the way a tool's parameters are, and relaxed where the endpoint could not
|
|
160
|
+
build a grammar, because llama.cpp reads both with the same converter. It also rides on the system
|
|
161
|
+
prompt, and the reply goes through `parseJson` either way.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
const picked = await askJson<{ tools: string[] }>(config, small, system, request, PRESELECT_SCHEMA, {
|
|
165
|
+
name: "preselection",
|
|
166
|
+
onNotice,
|
|
167
|
+
}); // undefined when no JSON came back
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
A model that refuses the field latches `structuredOutput` off, per `(endpoint, model)` like the
|
|
171
|
+
other refusals here, and is asked in words from then on. A server that finds the *schema* invalid
|
|
172
|
+
is not latched: that error is the caller's to see. `strict` defaults to true, which OpenAI takes to
|
|
173
|
+
mean every property required and `additionalProperties: false`; a looser schema wants it off there.
|
|
174
|
+
`preselect` is its first user, answering `{ tools: [...] }`, and `preselection` still takes the
|
|
175
|
+
bare array an older prompt produced.
|
|
176
|
+
|
|
150
177
|
## Sizing a request before sending it
|
|
151
178
|
|
|
152
179
|
`runTurn` will refuse a request that cannot fit rather than spending a round trip finding out:
|
|
@@ -164,6 +191,13 @@ const turn = await runTurn(client, supports, build, {
|
|
|
164
191
|
});
|
|
165
192
|
```
|
|
166
193
|
|
|
194
|
+
What is weighed is the prompt plus the reply ceiling the body carries, under whichever spelling
|
|
195
|
+
was chosen, because that is what the endpoint weighs: a 30k prompt into a 32k window with
|
|
196
|
+
`max_tokens: 4096` is refused there, so it is refused here. A body with no ceiling reserves
|
|
197
|
+
nothing, and the server gives the reply whatever the prompt leaves. A consumer that subtracted
|
|
198
|
+
`maxTokens` from the limit itself before calling in no longer needs to, and doing both reserves
|
|
199
|
+
the ceiling twice.
|
|
200
|
+
|
|
167
201
|
The body is sized once, not per attempt: a downgraded request is strictly smaller than the one
|
|
168
202
|
before it and the transcript does not change between retries. A `ContextOverflow` from this is
|
|
169
203
|
neither a capability `negotiate` can answer nor something `isTransient` accepts, so it leaves
|
|
@@ -175,6 +209,138 @@ refuses instead — one round trip later — and `runTurn` reads that refusal ba
|
|
|
175
209
|
original error as `cause`. A rate limit borrows those words and means the opposite ("Request too
|
|
176
210
|
large for gpt-4o ... on tokens per min"); that is ruled out and waited through as the 429 it is.
|
|
177
211
|
|
|
212
|
+
## The loop
|
|
213
|
+
|
|
214
|
+
`runAgentLoop` is the part of an agent that three servers had each written, and that had drifted
|
|
215
|
+
the way the turn had before `runTurn`: one noticed a turn cut off at the ceiling and two did not,
|
|
216
|
+
one tested the ceiling's spelling the other way round, one sent a reasoning effort and two never
|
|
217
|
+
did. What it does not know is what the run is for — the prompt, the tools, and what a tool call
|
|
218
|
+
*does* are the caller's.
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import { runAgentLoop, emit } from "@cubicecho/agent-core";
|
|
222
|
+
|
|
223
|
+
const { turn, messages, usage, loaded } = await runAgentLoop({
|
|
224
|
+
config, // Endpoint & ModelParams & { maxToolIterations, toolDiscovery?, maxRetries?, contextLength? }
|
|
225
|
+
system, // sent as the first message; on-demand mode appends the catalogue
|
|
226
|
+
messages: history, // ending in the question; not written to
|
|
227
|
+
tools, // every tool the run may reach
|
|
228
|
+
catalog, // the same, name-only, for on-demand loading
|
|
229
|
+
preselected, // from `preselect`, if a small model chose
|
|
230
|
+
dispatch: ({ name, args }, signal) => pool.call(name, args, signal),
|
|
231
|
+
hooks: { run, context: { session: { id } } },
|
|
232
|
+
signal,
|
|
233
|
+
onEvent: (event) => emit(runId, event),
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Each step is one `runTurn` with the body from `buildBody`, so everything `negotiate` answers is
|
|
238
|
+
answered here too, and a request that is too big throws `ContextOverflow` whichever side found
|
|
239
|
+
out. Between steps the loop runs the calls: sequentially by default, or together with
|
|
240
|
+
`parallel: true`, which also makes an identical call — the same name and arguments, byte for byte
|
|
241
|
+
— once for the run. A call that threw is forgotten rather than cached, so asking again is a real
|
|
242
|
+
retry. What a tool throws is what the model reads, and so are arguments that did not parse.
|
|
243
|
+
|
|
244
|
+
Arguments go through `parseToolArguments`, which is lenient where the model's meaning is plain:
|
|
245
|
+
JSON held in a string is opened, and the almost-JSON local models write — single quotes, Python's
|
|
246
|
+
`True` and `None`, bare keys, a trailing comma — is repaired, without touching what is inside a
|
|
247
|
+
string. What still is not an object throws a `ToolArgumentsError` whose `kind` is `truncated` when
|
|
248
|
+
the turn stopped at the ceiling, with a message telling the model so, and `malformed` otherwise.
|
|
249
|
+
The repaired JSON is what the transcript keeps, and an unreadable call is replayed as `{}`, because
|
|
250
|
+
a server that parses replayed arguments refuses the originals on every later request. `dispatch`
|
|
251
|
+
is still handed the model's own text as `raw`, and the parallel dedupe compares repaired arguments,
|
|
252
|
+
so `{'a': 1}` and `{"a": 1}` are one call.
|
|
253
|
+
|
|
254
|
+
A server whose tool-call parser was written for another template streams the model's call as
|
|
255
|
+
plain text, and the run ends on a reply that is nothing but a call nobody made. Unless
|
|
256
|
+
`recoverToolCalls: false`, a turn with no calls, some text, and tools to call is passed through
|
|
257
|
+
`recoverToolCalls`, which finds `<tool_call>` blocks (Hermes, Qwen, Qwen3-Coder's markup),
|
|
258
|
+
`[TOOL_CALLS]` (Mistral, both spellings) and `<|python_tag|>` (Llama 3) after the last `</think>`,
|
|
259
|
+
and — naming only tools that exist — a reply that is only a JSON call or holds one fenced one.
|
|
260
|
+
Found calls are run as `call_recovered_0` onward, the text is what is left, `onTurn` and the
|
|
261
|
+
result see the turn that way, and a notice says so, since the real fix is the server's parser.
|
|
262
|
+
|
|
263
|
+
With `toolDiscovery: "ondemand"` and a catalogue, the request declares `load_tools` and what has
|
|
264
|
+
been loaded, and the catalogue rides on the system prompt marked with what is. A model that calls
|
|
265
|
+
a catalogued tool without loading it first is right about what it wants, and gets it loaded and
|
|
266
|
+
run. A preselection shapes the first step alone: those tools, no catalogue, no `load_tools` —
|
|
267
|
+
a model with the menu still in front of it shops, reloading what it has or picking a sibling —
|
|
268
|
+
and everything is back from the second step on.
|
|
269
|
+
|
|
270
|
+
`beforeStep` is handed the transcript before each request and may return a replacement, which is
|
|
271
|
+
where compaction goes (below). Hooks are gathered once, onto the question, and never written into
|
|
272
|
+
the transcript that comes back; `afterTurn` is told the reply without the run waiting on it.
|
|
273
|
+
|
|
274
|
+
`resolveApiKey` is exported and not applied, because which key an endpoint gets is a rule a
|
|
275
|
+
consumer states and a library guessing it could send one where it was not meant to go. The rule
|
|
276
|
+
it encodes is the conservative one: an endpoint's own key wins; one that names a base URL of its
|
|
277
|
+
own, different from the settings it inherits from, gets `NO_KEY` rather than the operator's key or
|
|
278
|
+
`$OPENAI_API_KEY`; anything else inherits.
|
|
279
|
+
|
|
280
|
+
## Fields this interface cannot spell
|
|
281
|
+
|
|
282
|
+
`ModelParams` has `temperature`, `maxTokens` and `reasoningEffort`. Everything else a model card or
|
|
283
|
+
a server asks for — `top_k`, `min_p`, `repeat_penalty`, llama.cpp's `id_slot`, `cache_prompt` and
|
|
284
|
+
`reasoning_budget` — goes in `extraBody`, which `buildBody` merges in last. It can override
|
|
285
|
+
`temperature` but not `model`, `messages`, `stream` or `tools`, which are the loop's.
|
|
286
|
+
|
|
287
|
+
A local server ignores a field it does not know. OpenAI refuses it — `Unrecognized request
|
|
288
|
+
argument supplied: min_p` — and so do some proxies, as `Unknown parameter: 'min_p'`. Given the
|
|
289
|
+
names it may drop as `droppable` (`runTurn` takes the same option, and `runAgentLoop` passes the
|
|
290
|
+
`extraBody` keys),
|
|
291
|
+
`negotiate` reads either wording, latches the name off for that model on that endpoint, and sends
|
|
292
|
+
again without it, with a notice naming it. A nested name is dropped at its top-level field. A
|
|
293
|
+
refused field nobody said was droppable is passed on, since dropping it would change the request
|
|
294
|
+
behind the caller's back.
|
|
295
|
+
|
|
296
|
+
On a llama.cpp server started with `--parallel`, pin each session to a slot with
|
|
297
|
+
`extraBody: { id_slot: n }`. The slot keeps that session's KV cache warm, which is the difference
|
|
298
|
+
between a cached prefill on every turn and a full one — but only while the prefix stays the same
|
|
299
|
+
from turn to turn; see #63, and the caution on compaction below.
|
|
300
|
+
|
|
301
|
+
Ollama's `options` object is not read on its OpenAI-compatible `/v1` route, so sampling set there
|
|
302
|
+
does nothing; send the fields at the top level.
|
|
303
|
+
|
|
304
|
+
## Keeping a long run inside its window
|
|
305
|
+
|
|
306
|
+
Two ways to make a transcript smaller, cheap first.
|
|
307
|
+
|
|
308
|
+
`pruneToolResults(messages, { keepLast: 5, maxChars: 256 })` replaces every tool result but the
|
|
309
|
+
latest five with a stub — `[result cleared, 10,412 chars]`. A 40k-character `read_file` is 10k
|
|
310
|
+
tokens on every turn after it, and by then the model has usually taken what it wanted; the stub
|
|
311
|
+
keeps the call answered and says how much was there.
|
|
312
|
+
|
|
313
|
+
`planCompaction(messages, { limit, used })` says where to fold the oldest stretch into a summary,
|
|
314
|
+
once `used` (the last turn's prompt tokens, or the estimate) is past three quarters of the window.
|
|
315
|
+
The kept tail fills at most 35% of it and starts on a user message, since a transcript resuming
|
|
316
|
+
mid-exchange is one servers refuse; leading system prompts are never folded, and an earlier
|
|
317
|
+
summary is continued rather than summarised. `compactTranscript` writes the summary and tells
|
|
318
|
+
`beforeCompact` hooks what is going while it does. A hook cannot veto it. Because the cut lands on
|
|
319
|
+
a user message, one long tool run under a single question has nothing to fold — pruning is what
|
|
320
|
+
keeps that one going.
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
beforeStep: async (messages, step) => {
|
|
324
|
+
const plan = planCompaction(messages, { limit: config.contextLength ?? 0, used: lastPromptTokens });
|
|
325
|
+
if (!plan) return;
|
|
326
|
+
return compactTranscript(
|
|
327
|
+
pruneToolResults(messages),
|
|
328
|
+
plan,
|
|
329
|
+
summariser(config, config.model, { signal }),
|
|
330
|
+
{ hooks: { run, context } },
|
|
331
|
+
);
|
|
332
|
+
},
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
**Both rewrite the prefix.** A prompt cache matches from the first token, so a transcript whose
|
|
336
|
+
early messages change is re-processed whole — on a local server that is the entire prefill, every
|
|
337
|
+
time. Run them rarely and together, at the point `planCompaction` says the window is filling, so
|
|
338
|
+
the cache is lost once rather than a little on every turn. Pruning on every step is the expensive
|
|
339
|
+
way to save tokens.
|
|
340
|
+
|
|
341
|
+
`pruneToolResults` keeps the transcript's indexes, so a plan made before pruning still applies to
|
|
342
|
+
what it returns, as above.
|
|
343
|
+
|
|
178
344
|
## Watching a run
|
|
179
345
|
|
|
180
346
|
`watch` replays what the run has already emitted, then yields what happens next until `done`.
|
|
@@ -273,6 +439,22 @@ host that stores what the user typed never stores the context as something they
|
|
|
273
439
|
injecting hook shares `HOOK_CONTEXT_TOKENS` (2000) by default, each held to its own `maxTokens`
|
|
274
440
|
inside that, so a generous hook cannot crowd out the conversation it was meant to inform.
|
|
275
441
|
|
|
442
|
+
The budget is the caller's to move, at either level. `configureHooks` sets it for the process, and
|
|
443
|
+
`maxTokens` on `gather` (or the second argument to `assembleContext`) sets it for one request and
|
|
444
|
+
wins over it — the one to reach for when the budget follows the model, since a 128k window can
|
|
445
|
+
afford more recall than an 8k one:
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
import { configureHooks, gather } from "@cubicecho/agent-core";
|
|
449
|
+
|
|
450
|
+
configureHooks({ contextTokens: 4000 });
|
|
451
|
+
const gathered = await gather(run, ["beforeTurn"], context, { maxTokens: contextLimit / 20 });
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
Either one given something that is not a number above zero keeps what was there, as
|
|
455
|
+
`configureEvents` does, so a `0` threaded through for "no opinion" does not switch recall off.
|
|
456
|
+
`resetHooks` (and `resetAll`) puts the default back.
|
|
457
|
+
|
|
276
458
|
Neither function rejects. A hook failing is an outcome, and a runner that throws outright is
|
|
277
459
|
noted once for its event and costs only that event's context. `notify` takes no signal: a reader
|
|
278
460
|
who leaves once the turn is answered has not asked for it not to be remembered.
|
|
@@ -324,6 +506,22 @@ up a week is still picked up without a restart.
|
|
|
324
506
|
|
|
325
507
|
`resetAll` drops all four, and `reset.ts` names each seam separately for a test that wants one.
|
|
326
508
|
|
|
509
|
+
The latches can outlive the process as well, because otherwise every restart spends one refused
|
|
510
|
+
request per endpoint and model learning the same facts again. `exportCapabilities` returns every
|
|
511
|
+
refusal as a JSON-safe `CapabilitySnapshot`, and `importCapabilities` takes one back:
|
|
512
|
+
|
|
513
|
+
```ts
|
|
514
|
+
importCapabilities(settings.capabilities); // on boot; false if the version moved on
|
|
515
|
+
// ...
|
|
516
|
+
settings.capabilities = exportCapabilities(); // on shutdown, or after a notice
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
A snapshot names endpoints by `endpointId`, a SHA-256 digest of the URL and key, so it can be
|
|
520
|
+
written to a settings row or a file without a credential going with it. Importing merges and only
|
|
521
|
+
latches off, the same as a refusal does. A snapshot of another `version` is ignored. How old is too
|
|
522
|
+
old is left to the consumer, who can read `savedAt` first: a server upgraded between boots may
|
|
523
|
+
accept what it used to refuse, and nothing latched ever unlatches without a reset.
|
|
524
|
+
|
|
327
525
|
## Where the merged behaviour came from
|
|
328
526
|
|
|
329
527
|
- `schema-compat` — `kanban_server`/`task_server`'s version, which strips **every** sibling of a
|
|
@@ -336,3 +534,9 @@ up a week is still picked up without a restart.
|
|
|
336
534
|
its `fold` fix: two blocks in different steps are not one block.
|
|
337
535
|
- `retry` — `kanban_server`'s, which is the only one of the three with the `ContextOverflow`
|
|
338
536
|
guard. `min-agent` had no retry layer at all.
|
|
537
|
+
- `agent-loop` — `task_server`'s `length` notice and abort checks, `min-agent`'s parallel dispatch
|
|
538
|
+
with its dedupe (as an option) and its handling of nameless call fragments and empty stored
|
|
539
|
+
arguments, and the ceiling test `task_server` and `kanban_server` agreed on rather than
|
|
540
|
+
`min-agent`'s inverted one.
|
|
541
|
+
- `compaction` — `min-agent`'s arithmetic and `SUMMARY_PROMPT`, the only implementation of the
|
|
542
|
+
three. Pruning had none.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type OpenAI from "openai";
|
|
2
|
+
import { type Capabilities, type ModelCapabilities } from "./capabilities.ts";
|
|
3
|
+
import type { CatalogServer } from "./catalog.ts";
|
|
4
|
+
import type { Endpoint, ModelParams, RetryPolicy, ToolPolicy } from "./config.ts";
|
|
5
|
+
import type { RunEventInput } from "./events.ts";
|
|
6
|
+
import { type HookContext, type HookEvent, type HookNote, type HookRunner } from "./hooks.ts";
|
|
7
|
+
import type { Turn, TurnUsage } from "./stream.ts";
|
|
8
|
+
/**
|
|
9
|
+
* The one place a streamed request's body is decided from a config and what the endpoint and
|
|
10
|
+
* the model have refused.
|
|
11
|
+
*
|
|
12
|
+
* Every field that negotiates lives here: the ceiling's two spellings, a temperature only a
|
|
13
|
+
* model that takes ours is sent, a reasoning effort only one that takes it is, `stream_options`
|
|
14
|
+
* only where the server has heard of it, relaxed schemas only where it could not build a grammar,
|
|
15
|
+
* and `extraBody` last, less whatever the model refused by name. The ceiling is tested
|
|
16
|
+
* `=== false` — `modelCapabilitiesFor` starts a model at `legacyTokenLimit: true` and an absent
|
|
17
|
+
* one has to read the same — which is the test one of the three copies had inverted.
|
|
18
|
+
*
|
|
19
|
+
* @param config What to ask for. `maxTokens` of zero or less sends no ceiling; `reasoningEffort`
|
|
20
|
+
* absent or `"off"` sends no effort.
|
|
21
|
+
* @param supports What the endpoint has refused, as `negotiate` hands it to `send`.
|
|
22
|
+
* @param refused What the model has refused, as `negotiate` hands it over. Absent is a model
|
|
23
|
+
* that has refused nothing.
|
|
24
|
+
* @param messages The request's messages, system prompt included, sent as they are.
|
|
25
|
+
* @param tools The tool definitions. Sanitised here — a lookup for a definition seen before —
|
|
26
|
+
* and relaxed where the endpoint needs it. Empty sends no `tools` field at all.
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildBody(config: ModelParams, supports: Capabilities, refused: ModelCapabilities | undefined, messages: OpenAI.ChatCompletionMessageParam[], tools?: OpenAI.ChatCompletionTool[]): OpenAI.ChatCompletionCreateParamsStreaming;
|
|
29
|
+
/**
|
|
30
|
+
* A long tool argument or result cut to what a watcher needs, with the full length said.
|
|
31
|
+
*
|
|
32
|
+
* For events, never for the transcript: the model reads the whole of what a tool returned.
|
|
33
|
+
*
|
|
34
|
+
* @param text What to show.
|
|
35
|
+
* @param limit Characters kept, 2000 by default. Text at or under it comes back as it was.
|
|
36
|
+
*/
|
|
37
|
+
export declare const preview: (text: string, limit?: number) => string;
|
|
38
|
+
/**
|
|
39
|
+
* The key to send, where an endpoint may inherit one from the settings it overrides.
|
|
40
|
+
*
|
|
41
|
+
* A credential issued for one endpoint has no business being posted to another. A profile that
|
|
42
|
+
* names its own `baseUrl` and no key of its own is sent `NO_KEY` — not the operator's key, and
|
|
43
|
+
* not `$OPENAI_API_KEY` — because "I pointed an agent at a friend's server and it sent my OpenAI
|
|
44
|
+
* key" is not a mistake worth being able to make, and a local server wants no key anyway. One on
|
|
45
|
+
* the same endpoint inherits the key as it inherits everything else, and the environment is the
|
|
46
|
+
* last word on the endpoint that was configured rather than overridden.
|
|
47
|
+
*
|
|
48
|
+
* @param own The endpoint as the agent or profile states it. Its own key always wins. An empty or
|
|
49
|
+
* absent `baseUrl` is one that inherits the endpoint too.
|
|
50
|
+
* @param inherited The settings it overrides. Absent treats `own` as the configured endpoint, so
|
|
51
|
+
* only its key and the environment's are in play.
|
|
52
|
+
* @param env Where `OPENAI_API_KEY` is read from, `process.env` by default.
|
|
53
|
+
*/
|
|
54
|
+
export declare function resolveApiKey(own: {
|
|
55
|
+
baseUrl?: string;
|
|
56
|
+
apiKey?: string;
|
|
57
|
+
}, inherited?: {
|
|
58
|
+
baseUrl: string;
|
|
59
|
+
apiKey?: string;
|
|
60
|
+
}, env?: Record<string, string | undefined>): string;
|
|
61
|
+
/**
|
|
62
|
+
* The tools a request is likely to need, picked by a small model before the run starts, or none.
|
|
63
|
+
*
|
|
64
|
+
* On-demand loading otherwise spends a round trip on reading the catalogue and calling
|
|
65
|
+
* `load_tools`; a small model reading the same catalogue usually names the right tools, and the
|
|
66
|
+
* task model opens with them in hand. A wrong guess costs a few hundred tokens for one run, and
|
|
67
|
+
* a failed one costs nothing — it is reported through `onNotice` and answered with an empty list,
|
|
68
|
+
* since a side task is never worth failing the run. A stop still throws.
|
|
69
|
+
*
|
|
70
|
+
* @param config The endpoint the preselector is reached through.
|
|
71
|
+
* @param model The preselector. An empty name picks nothing, which is what `toolSelectModel`
|
|
72
|
+
* means by empty.
|
|
73
|
+
* @param catalog The servers to choose from.
|
|
74
|
+
* @param prompt The request being planned for. Only its head is read; see `preselectInput`.
|
|
75
|
+
* @param options Cancellation, notices, the reply ceiling (256) and the cap the choice is held to
|
|
76
|
+
* (`MAX_PER_LOAD`).
|
|
77
|
+
*/
|
|
78
|
+
export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, maxPerLoad, }?: {
|
|
79
|
+
signal?: AbortSignal;
|
|
80
|
+
onNotice?: (message: string) => void;
|
|
81
|
+
maxTokens?: number;
|
|
82
|
+
maxPerLoad?: number;
|
|
83
|
+
}): Promise<string[]>;
|
|
84
|
+
/** One call the model made, as `dispatch` is handed it. */
|
|
85
|
+
export interface ToolCallRequest {
|
|
86
|
+
id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
/** Parsed by `parseToolArguments`, repairs and all. */
|
|
89
|
+
args: Record<string, unknown>;
|
|
90
|
+
/** The arguments as the model wrote them, before any repair. */
|
|
91
|
+
raw: string;
|
|
92
|
+
}
|
|
93
|
+
/** What one tool call did, in the order the model asked. */
|
|
94
|
+
export interface ToolCallOutcome {
|
|
95
|
+
name: string;
|
|
96
|
+
/** False when the arguments did not parse, the tool threw, or `load_tools` loaded nothing. */
|
|
97
|
+
ok: boolean;
|
|
98
|
+
}
|
|
99
|
+
/** The hooks a loop runs around one question. See `hooks.ts`. */
|
|
100
|
+
export interface AgentLoopHooks {
|
|
101
|
+
run: HookRunner;
|
|
102
|
+
/** What the hooks are told. `reply` and `turn` are filled in for `afterTurn`. */
|
|
103
|
+
context: HookContext;
|
|
104
|
+
/** Run before the first request, `["beforeTurn"]` by default. */
|
|
105
|
+
events?: readonly HookEvent[];
|
|
106
|
+
/** The shared context budget. Absent is `configureHooks`'s. */
|
|
107
|
+
maxTokens?: number;
|
|
108
|
+
/** Said above the context blocks. Absent is `HOOK_PREFACE`. */
|
|
109
|
+
preface?: string;
|
|
110
|
+
/** Hears each note, from before the request and from `afterTurn`. */
|
|
111
|
+
onNote?: (note: HookNote) => void;
|
|
112
|
+
}
|
|
113
|
+
/** What `runAgentLoop` takes. */
|
|
114
|
+
export interface AgentLoopOptions {
|
|
115
|
+
/**
|
|
116
|
+
* The endpoint, what to ask the model for, and how long it may keep calling tools.
|
|
117
|
+
* `toolDiscovery` absent is eager, `maxRetries` absent is none, and `contextLength` is handed
|
|
118
|
+
* to `runTurn` as `contextLimit`, which sizes each request against the window before sending.
|
|
119
|
+
*/
|
|
120
|
+
config: Endpoint & ModelParams & Pick<ToolPolicy, "maxToolIterations"> & Partial<Pick<ToolPolicy, "toolDiscovery">> & Partial<RetryPolicy> & {
|
|
121
|
+
contextLength?: number;
|
|
122
|
+
};
|
|
123
|
+
/** The standing instruction, sent as the first message. On-demand mode appends the catalogue. */
|
|
124
|
+
system?: string;
|
|
125
|
+
/** The transcript so far, ending in the question. Not written to; see the result's `messages`. */
|
|
126
|
+
messages: OpenAI.ChatCompletionMessageParam[];
|
|
127
|
+
/**
|
|
128
|
+
* Every tool this run may reach. Eager mode sends all of them; on-demand mode sends the ones
|
|
129
|
+
* loaded so far, by name.
|
|
130
|
+
*/
|
|
131
|
+
tools?: OpenAI.ChatCompletionTool[];
|
|
132
|
+
/** The same tools as a name-only catalogue. On-demand mode needs it, and is eager without it. */
|
|
133
|
+
catalog?: CatalogServer[];
|
|
134
|
+
/**
|
|
135
|
+
* What `preselect` picked. The first step is sent these and nothing else — no catalogue, no
|
|
136
|
+
* `load_tools` — because a model with the menu still in front of it shops: it reloads what it
|
|
137
|
+
* has or picks a sibling. Everything comes back on the step after.
|
|
138
|
+
*/
|
|
139
|
+
preselected?: readonly string[];
|
|
140
|
+
/** Tools already loaded, carried from an earlier question. See `carryOver`. */
|
|
141
|
+
loaded?: Iterable<string>;
|
|
142
|
+
/** Runs one tool call and returns what the model reads. What it throws, the model reads too. */
|
|
143
|
+
dispatch: (call: ToolCallRequest, signal?: AbortSignal) => Promise<string>;
|
|
144
|
+
/**
|
|
145
|
+
* Runs a step's calls together rather than one after another, and makes an identical call —
|
|
146
|
+
* the same name and arguments, word for word — once for the run, handing a repeat the first
|
|
147
|
+
* answer. A call that threw is not an answer and is made again. Results still go into the
|
|
148
|
+
* transcript in the order the model asked.
|
|
149
|
+
*/
|
|
150
|
+
parallel?: boolean;
|
|
151
|
+
/** Hooks gathered onto the question before the first request, and told the reply after. */
|
|
152
|
+
hooks?: AgentLoopHooks;
|
|
153
|
+
/**
|
|
154
|
+
* Called before each step with the transcript, and what it returns replaces it — the point to
|
|
155
|
+
* compact or prune a run that has grown into its window. Returning nothing keeps it.
|
|
156
|
+
*/
|
|
157
|
+
beforeStep?: (messages: readonly OpenAI.ChatCompletionMessageParam[], step: number) => OpenAI.ChatCompletionMessageParam[] | undefined | Promise<OpenAI.ChatCompletionMessageParam[] | undefined>;
|
|
158
|
+
/** Stops the run: the request in flight, and between steps and calls. */
|
|
159
|
+
signal?: AbortSignal;
|
|
160
|
+
/** Told what the run is doing, as the events a watcher reads. */
|
|
161
|
+
onEvent?: (event: RunEventInput) => void;
|
|
162
|
+
/**
|
|
163
|
+
* Takes tool calls a model wrote into its reply as text and runs them as calls, with a notice
|
|
164
|
+
* saying so. On by default: a server whose tool-call parser does not match the model's template
|
|
165
|
+
* otherwise ends the run on a reply that is only a call nobody made. Off leaves such a reply as
|
|
166
|
+
* the answer. See `recoverToolCalls`.
|
|
167
|
+
*/
|
|
168
|
+
recoverToolCalls?: boolean;
|
|
169
|
+
/** Each turn as it comes back, before its tools run. Recovered calls are in it as calls. */
|
|
170
|
+
onTurn?: (turn: Turn, step: number) => void;
|
|
171
|
+
}
|
|
172
|
+
/** What a finished loop hands back. */
|
|
173
|
+
export interface AgentLoopResult {
|
|
174
|
+
/** The last turn: the one that asked for no tools, as `onTurn` was handed it. */
|
|
175
|
+
turn: Turn;
|
|
176
|
+
/** The transcript, with every assistant turn and tool result the run added. No system prompt. */
|
|
177
|
+
messages: OpenAI.ChatCompletionMessageParam[];
|
|
178
|
+
/** Summed over every turn of the run. */
|
|
179
|
+
usage: TurnUsage;
|
|
180
|
+
/** Every call, `load_tools` included, in the order they were made. */
|
|
181
|
+
toolCalls: ToolCallOutcome[];
|
|
182
|
+
/** What is loaded at the end, for `carryOver`. Empty in eager mode. */
|
|
183
|
+
loaded: string[];
|
|
184
|
+
/** The tools the model actually called, `load_tools` excluded. */
|
|
185
|
+
used: string[];
|
|
186
|
+
/** The hooks' notes from before the first request. */
|
|
187
|
+
notes: HookNote[];
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Runs a question to its answer: one `runTurn` per step, the tools it asks for between them,
|
|
191
|
+
* until a turn asks for none. Throws when `maxToolIterations` is spent, when stopped, and on
|
|
192
|
+
* whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
|
|
193
|
+
*
|
|
194
|
+
* On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
|
|
195
|
+
* prompt and marks what is loaded, a catalogued tool called without being loaded is loaded and
|
|
196
|
+
* run rather than refused, and a preselection shapes the first step. A turn cut off at
|
|
197
|
+
* `maxTokens` is said so as a notice, because it otherwise reads exactly like a finished one.
|
|
198
|
+
*
|
|
199
|
+
* @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
|
|
200
|
+
* and cancellation. See `AgentLoopOptions`.
|
|
201
|
+
*/
|
|
202
|
+
export declare function runAgentLoop(options: AgentLoopOptions): Promise<AgentLoopResult>;
|