@cubicecho/agent-core 2.18.1 → 2.20.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.
@@ -4,6 +4,7 @@ import type { CatalogServer } from "./catalog.ts";
4
4
  import type { Endpoint, ModelParams, RetryPolicy, ToolPolicy } from "./config.ts";
5
5
  import { type RunEventInput, type RunMetrics } from "./events.ts";
6
6
  import { type HookContext, type HookEvent, type HookNote, type HookRunner } from "./hooks.ts";
7
+ import { type TokenLedger } from "./ledger.ts";
7
8
  import type { Turn, TurnUsage } from "./stream.ts";
8
9
  import { type KeywordPreselectOptions, type ToolOrder } from "./tool-loading.ts";
9
10
  /**
@@ -97,22 +98,76 @@ export declare function preselect(config: Endpoint, model: string, catalog: Cata
97
98
  */
98
99
  keywords?: boolean | KeywordPreselectOptions;
99
100
  }): Promise<string[]>;
100
- /** One call the model made, as `dispatch` is handed it. */
101
+ /**
102
+ * One call the model made, as `dispatch` and `onToolCall` are handed it.
103
+ *
104
+ * A `call_tool` arrives as the tool it names — that tool's name and arguments, under the
105
+ * `call_tool`'s id — so a dispatcher is the same in every discovery mode.
106
+ */
101
107
  export interface ToolCallRequest {
102
108
  id: string;
103
109
  name: string;
104
- /** Parsed by `parseToolArguments`, repairs and all. */
110
+ /**
111
+ * Parsed by `parseToolArguments`, repairs and all. Empty for a call whose arguments could not be
112
+ * read, which only `onToolCall` is ever handed.
113
+ */
105
114
  args: Record<string, unknown>;
106
- /** The arguments as the model wrote them, before any repair. */
115
+ /**
116
+ * The arguments as the model wrote them, before any repair. Through `call_tool`, the inner
117
+ * arguments as JSON text.
118
+ */
107
119
  raw: string;
108
120
  }
109
121
  /** What one tool call did, in the order the model asked. */
110
122
  export interface ToolCallOutcome {
123
+ /**
124
+ * The id the model's reply gave the call, which is what tells two calls to one tool apart.
125
+ * Distinct within a step — `streamTurn` mints one where the server sent none or repeated one —
126
+ * but nothing stops a server using the same id again in a later step. `runAgentLoop` always
127
+ * sets it; optional so an outcome built before it existed still compiles.
128
+ */
129
+ id?: string;
130
+ /** The tool that was called. For a `call_tool`, the tool it named. */
111
131
  name: string;
112
132
  /** False when the arguments did not parse, the tool threw, or `load_tools` loaded nothing. */
113
133
  ok: boolean;
114
134
  }
115
- /** The hooks a loop runs around one question. See `hooks.ts`. */
135
+ /**
136
+ * One step's request as `onRequest` is handed it: what the model is about to read.
137
+ *
138
+ * The loop assembles this and nothing else sees it — the system prompt with the catalogue on it,
139
+ * the hooks' context on the question, the tool array ordered and sanitised as it is sent — so a
140
+ * host that wants to say what is filling the window has nothing to measure without it.
141
+ */
142
+ export interface AgentLoopRequest {
143
+ /** The messages as sent, system prompt first. The request's own array, not a copy. */
144
+ messages: readonly OpenAI.ChatCompletionMessageParam[];
145
+ /** The tools as sent: ordered, sanitised, relaxed where the endpoint needs it. Empty for none. */
146
+ tools: readonly OpenAI.ChatCompletionTool[];
147
+ /** Which step of the run this is, from zero, as `beforeStep` and `onTurn` count them. */
148
+ step: number;
149
+ }
150
+ /**
151
+ * One call's whole answer, as `onToolResult` is handed it.
152
+ *
153
+ * The `tool-result` event carries the same answer cut by `preview`, which is right for a readout
154
+ * and wrong for a host that renders or stores the result: this is the text the model reads.
155
+ */
156
+ export interface ToolCallResult extends ToolCallOutcome {
157
+ id: string;
158
+ /**
159
+ * What went into the transcript for this call, uncut: what the tool returned, what it threw, the
160
+ * `load_tools` answer, or why the arguments could not be read.
161
+ */
162
+ content: string;
163
+ }
164
+ /**
165
+ * The hooks a loop runs around one question. See `hooks.ts`.
166
+ *
167
+ * The loop gathers before its first request and nothing else can run beside that, so a host with
168
+ * pre-turn work of its own to overlap with the hooks — compaction, a preselection — calls `gather`
169
+ * itself, puts the context on with `withContext`, calls `notify` after, and passes no `hooks`.
170
+ */
116
171
  export interface AgentLoopHooks {
117
172
  run: HookRunner;
118
173
  /** What the hooks are told. `reply` and `turn` are filled in for `afterTurn`. */
@@ -126,26 +181,47 @@ export interface AgentLoopHooks {
126
181
  /** Hears each note, from before the request and from `afterTurn`. */
127
182
  onNote?: (note: HookNote) => void;
128
183
  }
184
+ /** How full the window is as a step is about to be sent, as `beforeStep` is told it. */
185
+ export interface StepWindow {
186
+ /**
187
+ * The prompt tokens this run's last request reported. Absent before the first step — an earlier
188
+ * run's figure is the host's to vouch for, since the transcript may have been folded since —
189
+ * and where that request reported none. It does not count the reply and tool results added
190
+ * since.
191
+ */
192
+ used?: number;
193
+ /** The model's window, `config.contextLength`. Zero where the config names none. */
194
+ limit: number;
195
+ /** The run's ledger so far, indexed into the transcript `beforeStep` is handed beside it. */
196
+ ledger: TokenLedger;
197
+ }
129
198
  /** What `runAgentLoop` takes. */
130
199
  export interface AgentLoopOptions {
131
200
  /**
132
201
  * The endpoint, what to ask the model for, and how long it may keep calling tools.
133
202
  * `toolDiscovery` absent is eager, `maxRetries` absent is none, and `contextLength` is handed
134
203
  * to `runTurn` as `contextLimit`, which sizes each request against the window before sending.
204
+ * `"proxy"` is on-demand loading behind a tool array that never changes; see `PROXY_TOOLS`.
135
205
  */
136
206
  config: Endpoint & ModelParams & Pick<ToolPolicy, "maxToolIterations"> & Partial<Pick<ToolPolicy, "toolDiscovery">> & Partial<RetryPolicy> & {
137
207
  contextLength?: number;
138
208
  };
139
- /** The standing instruction, sent as the first message. On-demand mode appends the catalogue. */
209
+ /**
210
+ * The standing instruction, sent as the first message. On-demand and proxied modes append the
211
+ * catalogue, each in its own wording.
212
+ */
140
213
  system?: string;
141
214
  /** The transcript so far, ending in the question. Not written to; see the result's `messages`. */
142
215
  messages: OpenAI.ChatCompletionMessageParam[];
143
216
  /**
144
217
  * Every tool this run may reach. Eager mode sends all of them; on-demand mode sends the ones
145
- * loaded so far, by name.
218
+ * loaded so far, by name; proxied mode declares none, and answers a load with them instead.
146
219
  */
147
220
  tools?: OpenAI.ChatCompletionTool[];
148
- /** The same tools as a name-only catalogue. On-demand mode needs it, and is eager without it. */
221
+ /**
222
+ * The same tools as a name-only catalogue. On-demand and proxied modes need it, and are eager
223
+ * without it.
224
+ */
149
225
  catalog?: CatalogServer[];
150
226
  /**
151
227
  * How the declared tools are ordered before each request. By name unless told otherwise, so a
@@ -156,10 +232,38 @@ export interface AgentLoopOptions {
156
232
  /**
157
233
  * What `preselect` picked. The first step is sent these and nothing else — no catalogue, no
158
234
  * `load_tools` — because a model with the menu still in front of it shops: it reloads what it
159
- * has or picks a sibling. Everything comes back on the step after.
235
+ * has or picks a sibling. Everything comes back on the step after. `preselectRouting` trades
236
+ * that for a first step shaped like the rest.
237
+ *
238
+ * Proxied, the tool array is fixed and there is no such step. The shortlist is written into the
239
+ * transcript instead, after the question, as a `load_tools` call the model did not make and the
240
+ * result that call would have had — an assistant message and a tool message, handed back in
241
+ * `messages` like any other, reported as a `tool-call` and a `tool-result` event and told to
242
+ * `onToolCall` and `onToolResult`. Names with no definition in `tools`, or outside the
243
+ * catalogue, are left out, and none left is no exchange.
160
244
  */
161
245
  preselected?: readonly string[];
162
- /** Tools already loaded, carried from an earlier question. See `carryOver`. */
246
+ /**
247
+ * What a preselection does to the first step. `"exclusive"`, the default, is the shortlist alone
248
+ * as `preselected` describes — and a system prompt and tool array unlike the last request's and
249
+ * unlike the next step's, so a prompt cache misses the whole transcript on the first step and
250
+ * again on the second. `"append"` loads the shortlist as a `load_tools` call would have and
251
+ * sends the first step like any other: the catalogue, `load_tools`, what `loaded` carried, then
252
+ * the shortlist after it, with a name already carried left where it is. The price is the menu
253
+ * back in front of the model. Which costs more has not been measured, which is why the default
254
+ * has not moved.
255
+ *
256
+ * Appended is before `toolOrder` has its say: sorted, a preselected tool lands at its name's
257
+ * place and moves every definition after it, so only `toolOrder: false` keeps a carried array
258
+ * a strict prefix. Either way the first step's system prompt is the second's, and so is its
259
+ * tool array until something else is loaded. Nothing to do without a preselection, in eager
260
+ * mode, or proxied, where no step is routed either way.
261
+ */
262
+ preselectRouting?: "exclusive" | "append";
263
+ /**
264
+ * Tools already loaded, carried from an earlier question. See `carryOver`. Not read in proxied
265
+ * mode, where a tool is loaded only while its definition is in the history.
266
+ */
163
267
  loaded?: Iterable<string>;
164
268
  /** Runs one tool call and returns what the model reads. What it throws, the model reads too. */
165
269
  dispatch: (call: ToolCallRequest, signal?: AbortSignal) => Promise<string>;
@@ -184,13 +288,49 @@ export interface AgentLoopOptions {
184
288
  * `idempotentHint` annotations can answer it; nothing in an OpenAI tool definition can.
185
289
  */
186
290
  dedupeToolCalls?: boolean | ((call: ToolCallRequest) => boolean);
187
- /** Hooks gathered onto the question before the first request, and told the reply after. */
291
+ /**
292
+ * Told of every call the model made, as it starts — the ones `dispatch` never sees included:
293
+ * `load_tools`, an identical repeat answered from the first, and a call whose arguments could
294
+ * not be read, which arrives with empty `args` and the model's own text in `raw`.
295
+ *
296
+ * For a host that shows each call as it is made and fills it in when its result lands, which
297
+ * `dispatch` alone cannot do for those three. Not awaited: a promise it returns is dropped.
298
+ */
299
+ onToolCall?: (call: ToolCallRequest) => void;
300
+ /**
301
+ * Told of every call's answer as it lands, whole, under the id `onToolCall` announced it by.
302
+ *
303
+ * One per call the model made: a deduplicated repeat gets its own, carrying the answer it
304
+ * shares with the first. With `parallel` they arrive as the calls finish, which need not be
305
+ * the order they were made in; the transcript and the result's `toolCalls` keep that order.
306
+ * Not awaited: a promise it returns is dropped.
307
+ */
308
+ onToolResult?: (result: ToolCallResult) => void;
309
+ /**
310
+ * Hooks gathered onto the question before the first request, and told the reply after.
311
+ *
312
+ * Only this question is touched — the last user message of `messages` — and the context is on
313
+ * the request, never in the transcript handed back. An earlier question's context is therefore
314
+ * the host's to send again: keep the result's `context` and `preface` beside the question, and
315
+ * put them back with `withContext` on every later call. A question sent bare that was first
316
+ * sent with context is a different prompt from that message on, and the server's prefix cache
317
+ * is lost behind it on every turn.
318
+ */
188
319
  hooks?: AgentLoopHooks;
189
320
  /**
190
321
  * Called before each step with the transcript, and what it returns replaces it — the point to
191
- * compact or prune a run that has grown into its window. Returning nothing keeps it.
322
+ * compact or prune a run that has grown into its window. Returning nothing keeps it. `window`
323
+ * is what `planCompaction` wants to know, so a host need not track usage itself to compact
324
+ * mid-run.
325
+ */
326
+ beforeStep?: (messages: readonly OpenAI.ChatCompletionMessageParam[], step: number, window: StepWindow) => OpenAI.ChatCompletionMessageParam[] | undefined | Promise<OpenAI.ChatCompletionMessageParam[] | undefined>;
327
+ /**
328
+ * The ledger an earlier run handed back for this transcript, to be continued. Its indexes have
329
+ * to be into `messages` as given here — see `rebaseLedger` for a transcript rewritten since.
330
+ * This run's requests start a new epoch, because the loop cannot see whether the request before
331
+ * its first one had the same system prompt and tools. Absent starts an empty one.
192
332
  */
193
- beforeStep?: (messages: readonly OpenAI.ChatCompletionMessageParam[], step: number) => OpenAI.ChatCompletionMessageParam[] | undefined | Promise<OpenAI.ChatCompletionMessageParam[] | undefined>;
333
+ ledger?: TokenLedger;
194
334
  /** Stops the run: the request in flight, and between steps and calls. */
195
335
  signal?: AbortSignal;
196
336
  /** Told what the run is doing, as the events a watcher reads. */
@@ -202,6 +342,16 @@ export interface AgentLoopOptions {
202
342
  * the answer. See `recoverToolCalls`.
203
343
  */
204
344
  recoverToolCalls?: boolean;
345
+ /**
346
+ * Each step's request as first built, before it goes out — for measuring what was sent, not for
347
+ * changing it, which is `beforeStep`'s.
348
+ *
349
+ * Called once a step, synchronously, and what it throws ends the run. It is not called again
350
+ * for what `runTurn` sends after that: a retry sends the same request, but a refusal is answered
351
+ * with a lesser body — relaxed schemas, a field dropped — and a continuation with the reply so
352
+ * far on the end, and neither is reported.
353
+ */
354
+ onRequest?: (request: AgentLoopRequest) => void;
205
355
  /** Each turn as it comes back, before its tools run. Recovered calls are in it as calls. */
206
356
  onTurn?: (turn: Turn, step: number) => void;
207
357
  /**
@@ -219,19 +369,54 @@ export interface AgentLoopResult {
219
369
  messages: OpenAI.ChatCompletionMessageParam[];
220
370
  /** Summed over every turn of the run. */
221
371
  usage: TurnUsage;
222
- /** Every call, `load_tools` included, in the order they were made. */
372
+ /**
373
+ * Every call, `load_tools` included, in the order they were made, each under its call id — a
374
+ * proxied preselection's among them, first.
375
+ */
223
376
  toolCalls: ToolCallOutcome[];
224
- /** What is loaded at the end, for `carryOver`. Empty in eager mode. */
377
+ /**
378
+ * What is loaded at the end, for `carryOver`. Empty in eager mode, and in proxied mode: a
379
+ * definition loaded there is already in the history, and one a compaction folded away has to be
380
+ * loadable again rather than answered "already loaded".
381
+ */
225
382
  loaded: string[];
226
- /** The tools the model actually called, `load_tools` excluded. */
383
+ /** The tools the model actually called, `load_tools` excluded and `call_tool` looked through. */
227
384
  used: string[];
228
385
  /** The hooks' notes from before the first request. */
229
386
  notes: HookNote[];
387
+ /**
388
+ * The `<context>` blocks the hooks added to this question, as `withContext` takes them. Empty
389
+ * when no hook added any, and without `hooks`. Not in `messages`, so a host that wants the next
390
+ * turn's request to begin with this one's stores it beside the question; see `hooks`.
391
+ */
392
+ context: string;
393
+ /**
394
+ * What was said above `context`: the hooks' own `preface`, or what `configureHooks` had set when
395
+ * the run began. Handed back because the second is not the host's to know later — with it,
396
+ * `withContext(messages, at, context, preface)` is the question as it was sent, whatever the
397
+ * process's preface has since become. Empty when the preface was turned off; with no `context`
398
+ * nothing was said either way, and `withContext` adds nothing.
399
+ */
400
+ preface: string;
401
+ /**
402
+ * The `afterTurn` hooks' notes, once they have run — failures only, as `notify` returns them.
403
+ * The loop does not wait for it, so the answer is never held for a hook; a host that stores the
404
+ * notes with the turn, or must not exit before the turn is remembered, awaits it. Each note
405
+ * reaches `onNote` as well. Already resolved and empty without `hooks`, and rejects only if
406
+ * `onNote` throws.
407
+ */
408
+ afterTurn: Promise<HookNote[]>;
230
409
  /**
231
410
  * The run summed and derived: what `runMetrics` makes of the events this loop emitted, plus the
232
411
  * `load_tools` findings only the loop sees, `wallMs` from the call to the return, and `outcome`.
233
412
  */
234
413
  metrics: RunMetrics;
414
+ /**
415
+ * What each step's request reported its prompt as, indexed into `messages` above, for
416
+ * `tokensBetween` and `estimateFrom`. The entries of a `ledger` passed in come first. A step
417
+ * whose prompt was not reported has no entry.
418
+ */
419
+ ledger: TokenLedger;
235
420
  }
236
421
  /**
237
422
  * Runs a question to its answer: one `runTurn` per step, the tools it asks for between them,
@@ -242,10 +427,25 @@ export interface AgentLoopResult {
242
427
  * prompt unchanged from step to step, a load adds to the tool array — which every request sends
243
428
  * in the stable order `toolOrder` asks for — and
244
429
  * a catalogued tool called without being loaded is loaded and run rather than refused, and a
245
- * preselection shapes the first step. A turn cut off at `maxTokens` is said so as a notice,
246
- * because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
430
+ * preselection shapes the first step unless `preselectRouting` says to append it. A turn cut off
431
+ * at `maxTokens` is said so as a notice, because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
247
432
  * continued first. Every turn ends in a `usage` event carrying the turn's own report, with the
248
- * cache compared against the request before it; see `TurnUsage` and `runMetrics`.
433
+ * cache compared against the request before it and the request broken down by what filled it; see
434
+ * `TurnUsage` and `runMetrics`.
435
+ *
436
+ * Each step's reported prompt goes on a token ledger, in the same epoch as the step before where
437
+ * the request only appended to it and in a new one where it did not — a `beforeStep` that rewrote
438
+ * the transcript, a tool array a load grew — so that what the messages between two steps cost can
439
+ * be read back as a subtraction. The prompt recorded is the first request's, before any
440
+ * continuation, and a step that reported none, or reported a cache count above its prompt, is not
441
+ * recorded. The ledger comes back in the result and on every `usage` event; see `tokensBetween`.
442
+ *
443
+ * Proxied discovery is the same catalogue behind a tool array that never moves: every step
444
+ * declares `PROXY_TOOLS` and nothing else, a load answers with the definitions themselves, and a
445
+ * `call_tool` is dispatched, counted and reported as the tool it names. A preselection has no
446
+ * tool array to go into, so it is written into the transcript as a `load_tools` exchange after
447
+ * the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
448
+ * a session switched out of proxied mode still has it in its history, and the model copies it.
249
449
  *
250
450
  * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
251
451
  * and cancellation. See `AgentLoopOptions`.