@cubicecho/agent-core 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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. |
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. |
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
+ | `hooks` | The host's side of lifecycle hooks: `gather` before a request and `notify` after, the shared context budget, `withContext` to put what they add on the turn's question, `untrusted` to fence text nobody vouched for, and `turnMessages` to hand them a transcript. Running a hook is a runner the caller passes. |
29
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`.
@@ -293,6 +459,26 @@ Neither function rejects. A hook failing is an outcome, and a runner that throws
293
459
  noted once for its event and costs only that event's context. `notify` takes no signal: a reader
294
460
  who leaves once the turn is answered has not asked for it not to be remembered.
295
461
 
462
+ ### Untrusted text
463
+
464
+ Hook context is not the only text in a prompt that nobody vouched for. A fetched page, an email, a
465
+ submitted card and a tool result all reach the model in the same words as the operator's own, and
466
+ `untrusted` gives the model a fence it can see around them. Put `UNTRUSTED_PREFACE` in the system
467
+ prompt once, where it costs the prompt cache nothing, and wrap each piece where it is pasted in:
468
+
469
+ ```ts
470
+ import { UNTRUSTED_PREFACE, untrusted } from "@cubicecho/agent-core";
471
+
472
+ const system = `${instructions}\n\n${UNTRUSTED_PREFACE}`;
473
+ const content = `Summarise this page.\n\n${untrusted(page, { source: url })}`;
474
+ ```
475
+
476
+ Any `untrusted` tag inside the text, opening or closing and in any case, has its `<` escaped, so a
477
+ page that writes `</untrusted>` followed by an instruction leaves that instruction inside the
478
+ block. This is one layer and not a defence on its own. A model can still be talked out of a fence,
479
+ and the tool policy is what decides what the text can make the agent do. `withContext` does not
480
+ fence hook blocks this way, because they have to stay identical to the MCP pool's `contextBlocks`.
481
+
296
482
  ## The config seam
297
483
 
298
484
  Nothing here imports a config type from a consumer, and no function asks for a whole
@@ -340,6 +526,22 @@ up a week is still picked up without a restart.
340
526
 
341
527
  `resetAll` drops all four, and `reset.ts` names each seam separately for a test that wants one.
342
528
 
529
+ The latches can outlive the process as well, because otherwise every restart spends one refused
530
+ request per endpoint and model learning the same facts again. `exportCapabilities` returns every
531
+ refusal as a JSON-safe `CapabilitySnapshot`, and `importCapabilities` takes one back:
532
+
533
+ ```ts
534
+ importCapabilities(settings.capabilities); // on boot; false if the version moved on
535
+ // ...
536
+ settings.capabilities = exportCapabilities(); // on shutdown, or after a notice
537
+ ```
538
+
539
+ A snapshot names endpoints by `endpointId`, a SHA-256 digest of the URL and key, so it can be
540
+ written to a settings row or a file without a credential going with it. Importing merges and only
541
+ latches off, the same as a refusal does. A snapshot of another `version` is ignored. How old is too
542
+ old is left to the consumer, who can read `savedAt` first: a server upgraded between boots may
543
+ accept what it used to refuse, and nothing latched ever unlatches without a reset.
544
+
343
545
  ## Where the merged behaviour came from
344
546
 
345
547
  - `schema-compat` — `kanban_server`/`task_server`'s version, which strips **every** sibling of a
@@ -352,3 +554,9 @@ up a week is still picked up without a restart.
352
554
  its `fold` fix: two blocks in different steps are not one block.
353
555
  - `retry` — `kanban_server`'s, which is the only one of the three with the `ContextOverflow`
354
556
  guard. `min-agent` had no retry layer at all.
557
+ - `agent-loop` — `task_server`'s `length` notice and abort checks, `min-agent`'s parallel dispatch
558
+ with its dedupe (as an option) and its handling of nameless call fragments and empty stored
559
+ arguments, and the ceiling test `task_server` and `kanban_server` agreed on rather than
560
+ `min-agent`'s inverted one.
561
+ - `compaction` — `min-agent`'s arithmetic and `SUMMARY_PROMPT`, the only implementation of the
562
+ 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>;