@cubicecho/agent-core 2.18.1 → 2.19.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
@@ -2,6 +2,8 @@
2
2
 
3
3
  The endpoint-agnostic half of an OpenAI-compatible agent loop.
4
4
 
5
+ Site: <https://cubicecho.github.io/agent-core/>
6
+
5
7
  Extracted from three servers that had each written it separately — `kanban_server`,
6
8
  `task_server` and `min-agent` — after the copies drifted far enough that a fix in one was a bug
7
9
  still live in another.
@@ -37,6 +39,7 @@ only, Node >=22.
37
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`, and `resolveApiKey` for a caller deciding which key an endpoint gets. |
38
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. |
39
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
+ | `ledger` | What stretches of a transcript cost, read off the prompt counts the server reported rather than estimated: `recordRequest`, `tokensBetween`, `estimateFrom`, `rebaseLedger`. |
40
43
  | `snapshot` | `exportCapabilities` and `importCapabilities`: the latched refusals as a JSON blob a consumer stores, so a restart need not learn them again. |
41
44
  | `spec` | `parseSpec`, `resolveAgentSpec` and `exportSpec`: an agent as a versioned JSON document, layered into the flat config the loop takes. Imports nothing but types, and is published separately at `@cubicecho/agent-core/spec`. |
42
45
  | `reset` | `resetAll`: drops every cache and latch in one call, so a teardown cannot forget one. |
@@ -102,7 +105,7 @@ than zero otherwise:
102
105
  | `firstTokenMs` | `streamTurn`, from the request to the first chunk that carried something |
103
106
  | `wallMs`, `retries`, `timeouts` | `runTurn`: the whole turn with its backoff, the lost requests sent again, and how many of those went silent |
104
107
  | `continuations` | `continueTurn` |
105
- | `cacheExpected`, `cacheBroken`, `cacheBreakReason`, `toolsDeclared`, `toolSchemaTokens` | `runAgentLoop`, below |
108
+ | `cacheExpected`, `cacheBroken`, `cacheBreakReason`, `toolsDeclared`, `toolSchemaTokens`, `context` | `runAgentLoop`, below |
106
109
 
107
110
  `reasoning` is the scratchpad `onThinking` was told, kept because two common families want it
108
111
  back. gpt-oss and DeepSeek in thinking mode read the analysis behind a tool call off the assistant
@@ -365,6 +368,10 @@ the total is `requestTokens`, and the tool block is counted the way `requestToke
365
368
  `TurnMetrics.toolSchemaTokens` count it rather than shared out, so the breakdown and the metrics
366
369
  line cannot disagree about the same tool list.
367
370
 
371
+ A host on `runAgentLoop` has no body to hand it, since the loop builds the request. It does not
372
+ need one: each `usage` event's `turn.context` is this breakdown for that step, and `onRequest` is
373
+ handed the request itself for a host that cuts it some other way. Both are under the loop, below.
374
+
368
375
  ## The loop
369
376
 
370
377
  `runAgentLoop` is the part of an agent that three servers had each written, and that had drifted
@@ -404,6 +411,26 @@ predicate is asked per call — which is how `send_email` opts out, since twice
404
411
  nothing in an OpenAI tool definition says which tools those are. A pool that reads the MCP
405
412
  `readOnlyHint` and `idempotentHint` annotations can answer it; this package cannot.
406
413
 
414
+ Three kinds of call never reach `dispatch`: `load_tools`, which the loop answers itself, a call
415
+ whose arguments could not be read, and that identical repeat. A host that draws each call as a
416
+ card and fills it in when the result lands hears of those through `onToolCall` and `onToolResult`,
417
+ which are told of every call the model made — the request as `dispatch` would have been handed it
418
+ (with empty `args` where they could not be read), then `{ id, name, ok, content }` with the whole
419
+ text the model reads. A repeat gets a result of its own under its own id, carrying the answer it
420
+ shares. Neither is awaited. The `tool-call` and `tool-result` events carry the same `id`, with
421
+ `text` still cut by `preview`, and so does each entry of the result's `toolCalls` — which is what
422
+ tells two calls to one tool apart when `parallel` lands their results out of order. An id is
423
+ distinct within a step, and a server is free to use it again in a later one.
424
+
425
+ ```ts
426
+ await runAgentLoop({
427
+ // ...
428
+ onToolCall: ({ id, name, args }) => send({ type: "tool_use", id, name, input: args }),
429
+ onToolResult: ({ id, ok, content }) =>
430
+ send({ type: "tool_result", toolUseId: id, content, isError: !ok }),
431
+ });
432
+ ```
433
+
407
434
  Arguments go through `parseToolArguments`, which is lenient where the model's meaning is plain:
408
435
  JSON held in a string is opened, and the almost-JSON local models write — single quotes, Python's
409
436
  `True` and `None`, bare keys, a trailing comma — is repaired, without touching what is inside a
@@ -442,6 +469,20 @@ run. A preselection shapes the first step alone: those tools, no catalogue, no `
442
469
  a model with the menu still in front of it shops, reloading what it has or picking a sibling —
443
470
  and everything is back from the second step on.
444
471
 
472
+ That first step has a request head of its own — a different system prompt and a different tool
473
+ array from the last turn's final request, and from this turn's second step — so a prompt cache
474
+ misses the whole transcript twice on a question that has a preselection. `preselectRouting:
475
+ "append"` drops the separate head: the shortlist is loaded as a `load_tools` call would have loaded
476
+ it, after whatever `loaded` carried and without declaring a carried name twice, and the first step
477
+ is sent like every other, catalogue and `load_tools` included. The trade is the menu back in front
478
+ of the model on the step the shortlist was meant to settle. Neither cost has been measured against
479
+ the other, so `"exclusive"` stays the default.
480
+
481
+ Appended is where the tools go before `toolOrder` orders them. With `toolOrder: false` a carried
482
+ tool array stays a strict prefix of the new one. With name order, the default, a preselected tool
483
+ is sorted in and moves every definition after it — the first step still matches the second, but
484
+ the last turn's cache holds only as far as the first new name.
485
+
445
486
  That preselection costs a round trip to a model, which on a local box is a few seconds before the
446
487
  run has started, spent on a model doing term matching. `preselect(..., { keywords: true })` does
447
488
  the matching directly and spends the model only on what the words cannot settle:
@@ -503,6 +544,25 @@ reported — `cacheBroken`, a hit short of 90% of the previous prompt. A broken
503
544
  `history-rewritten` (a compaction or a prune in `beforeStep`), or `none-known` where the new request
504
545
  only appended, which points at the server — a slot evicted, a template that re-renders the tail.
505
546
 
547
+ The same `turn` carries `context`, the request cut by `contextTokens` into `system`, `tools`,
548
+ `history` and `toolResults`: shares of the prompt the endpoint reported for it, adding up to it, or
549
+ an estimate where it reported none. A host that wants a different cut — the catalogue apart from the
550
+ prompt it rides on, this turn's messages apart from the ones before — takes the request from
551
+ `onRequest` and measures it itself:
552
+
553
+ ```ts
554
+ await runAgentLoop({
555
+ ...options,
556
+ onRequest: ({ messages, tools, step }) => measure(step, messages, tools),
557
+ });
558
+ ```
559
+
560
+ It is called once a step, before the request goes out, with the messages and tools as the client
561
+ is about to be sent them. It is for reading: `beforeStep` is where the transcript is changed. What
562
+ it reports is the request as first built. A retry sends that again, but a refusal is answered with
563
+ a lesser body and a continuation with the reply so far on the end, and neither is reported — and
564
+ `context` is measured on the same first request, against the first prompt reported for the turn.
565
+
506
566
  `runMetrics(events)` adds a run up from those events — tokens, cache hit ratio and breaks by
507
567
  reason, prefill, decode and tool time, the slowest turn, mean time to first token, draft
508
568
  acceptance, the largest prompt against a `contextLength`, turns cut off, tool errors by name, and
@@ -519,9 +579,53 @@ What it cannot say: whether a failed run was stopped, errored or ran out of tool
519
579
  whether the host compacted — neither is in the events.
520
580
 
521
581
  `beforeStep` is handed the transcript before each request and may return a replacement, which is
522
- where compaction goes (below). Hooks are gathered once, onto the question, and never written into
582
+ where compaction goes (below). Its third argument is `{ used, limit, ledger }` — the prompt the
583
+ run's last request reported, the window, and the token ledger so far — so a host can compact
584
+ mid-run without tracking usage itself; `used` is absent before the first step and where the
585
+ endpoint reported nothing. Hooks are gathered once, onto the question, and never written into
523
586
  the transcript that comes back; `afterTurn` is told the reply without the run waiting on it.
524
587
 
588
+ That leaves an earlier question's context to the host, because the loop only ever touches the
589
+ current one. A question sent with `<context>` blocks one turn and bare the next is a different
590
+ prompt from that message on, and the server's prefix cache is lost behind it — on every turn. So
591
+ the result hands back what it said, `context` and the `preface` above it, for the host to keep
592
+ beside the question and put back with `withContext` on every later call:
593
+
594
+ ```ts
595
+ import { runAgentLoop, withContext } from "@cubicecho/agent-core";
596
+
597
+ // Stored: what the user typed, and what the hooks added to it the turn it was asked.
598
+ const history = session.messages.reduce(
599
+ (sent, message, at) => withContext(sent, at, message.hookContext ?? "", message.hookPreface),
600
+ session.messages.map(({ hookContext, hookPreface, ...message }) => message),
601
+ );
602
+ const result = await runAgentLoop({
603
+ config,
604
+ messages: [...history, { role: "user", content: prompt }],
605
+ dispatch,
606
+ hooks: { run, context: { session: { id }, prompt } },
607
+ });
608
+ session.messages.push(
609
+ { role: "user", content: prompt, hookContext: result.context, hookPreface: result.preface },
610
+ ...result.messages.slice(history.length + 1),
611
+ );
612
+ await result.afterTurn; // only if its notes are wanted, or the process is about to exit
613
+ ```
614
+
615
+ `withContext` with those two arguments builds the question exactly as the loop sent it, whatever
616
+ `configureHooks` has since been told, so the next request is the last one with its tail added.
617
+ `result.messages` is the transcript the loop was given plus what the run added — earlier questions
618
+ as the host passed them, this one as typed — which is why the snippet stores only the new tail.
619
+
620
+ `afterTurn` on the result is the `afterTurn` hooks' notes as a promise. The loop still returns
621
+ without waiting; a host that stores those notes with the turn, or would otherwise exit before the
622
+ turn is remembered, awaits it once the answer is out.
623
+
624
+ The loop gathers before its first request, and nothing of the host's can run beside that. A host
625
+ with pre-turn work to overlap with the hooks — compaction, a preselection — calls `gather` itself,
626
+ applies `withContext` to the current question as well, calls `notify` after, and passes no `hooks`
627
+ (see [Hooks](#hooks)).
628
+
525
629
  `resolveApiKey` is exported and not applied, because which key an endpoint gets is a rule a
526
630
  consumer states and a library guessing it could send one where it was not meant to go. The rule
527
631
  it encodes is the conservative one: an endpoint's own key wins; one that names a base URL of its
@@ -607,6 +711,73 @@ way to save tokens.
607
711
  `pruneToolResults` keeps the transcript's indexes, so a plan made before pruning still applies to
608
712
  what it returns, as above.
609
713
 
714
+ ### What the messages cost, measured
715
+
716
+ `planCompaction` decides where to cut by estimating each message, and a calibrated divisor fixes
717
+ only the average: a transcript is tool results, JSON arguments, code and prose, each at its own
718
+ ratio, plus whatever the chat template adds per message. The server has already said what they
719
+ cost. A request that only appended to the one before it differs from it by exactly the cost of what
720
+ was appended, so two reported prompts and a subtraction give a number with no tokenizer in it — the
721
+ template's tokens and a tool result's real density included, and reasoning, which is never
722
+ replayed, left out.
723
+
724
+ `runAgentLoop` keeps that as a ledger — one `{ through, prompt, epoch }` per request: the index of
725
+ the request's last message, the prompt it reported, and an epoch that changes whenever the prefix
726
+ did. It comes back as `result.ledger`, rides on every `usage` event, and is handed to `beforeStep`;
727
+ pass it back as `ledger` on the next run and it is continued. It is a plain array of three numbers
728
+ an entry, so it stores as two columns on the message row `through` names or as JSON beside the
729
+ session.
730
+
731
+ ```ts
732
+ const result = await runAgentLoop({
733
+ ...options,
734
+ ledger: session.ledger,
735
+ beforeStep: async (messages, step, { used, limit, ledger }) => {
736
+ const plan = planCompaction(messages, {
737
+ limit,
738
+ used,
739
+ target: 0.5,
740
+ estimate: estimateFrom(ledger, messages, { charsPerToken }),
741
+ });
742
+ if (!plan) return;
743
+ return compactTranscript(messages, plan, summarise);
744
+ },
745
+ });
746
+ session.ledger = result.ledger;
747
+ ```
748
+
749
+ `tokensBetween(ledger, from, to, messages)` is the cost of `messages.slice(from, to)`. Between two
750
+ boundaries recorded in one epoch it is the subtraction, and exact. A boundary falls after a question
751
+ and after each step's tool results, never after an assistant message, so a stretch that starts or
752
+ ends inside a group gets that group's total by character share, the way `contextTokens` divides a
753
+ reported prompt. Whatever no pair of entries covers is the calibrated estimate: the messages before
754
+ the first entry, the group either side of an epoch's end, and what has not been sent yet.
755
+ `estimateFrom(ledger, messages)` is the same thing one message at a time, which is the function
756
+ `planCompaction` already takes as `estimate`.
757
+
758
+ `target` is the share of the window the whole request should come down to. `keepRatio` bounds the
759
+ kept tail alone, with the system prompt, the tool schemas and the summary sitting on top of it
760
+ uncounted; given a `target`, the cut is the first user message at which `used`, less what is
761
+ folded, plus the summary's ceiling (`summaryTokens`, 1024 unless given) is at or under it, and the
762
+ plan's `after` says what that leaves. Where no cut reaches it the plan folds as far as the last
763
+ user message and `after` says how far short it fell. Without `target` nothing changes.
764
+
765
+ A new epoch starts whenever a request did more than append: a fold or a prune in `beforeStep`, a
766
+ system prompt that moved, and a tool array that grew — a `load_tools` in on-demand mode adds a
767
+ schema to the head, so the group beside it is left unmeasured rather than charged for it. When
768
+ `beforeStep` returns a different transcript the loop moves the entries that still stand to their new
769
+ indexes with `rebaseLedger(ledger, before, after)`, which a host that folds between runs calls
770
+ itself. Differences inside an old epoch stay good, since the prefix they shared cancels.
771
+
772
+ What is not measured: the first request of every run starts an epoch, because the loop cannot see
773
+ whether the run before it had the same system prompt and tools, so a run's question and the answer
774
+ before it are estimated; a step whose prompt was not reported has no entry, and the next one that
775
+ does is measured across it; and the prompt recorded is the first request's, before any continuation.
776
+ A difference of zero or less is not trusted, and neither is a prompt reported smaller than its own
777
+ cache count — both are how a server reporting `prompt_tokens` net of its cache would show up.
778
+ Whether any endpoint does that has not been checked, and one whose net counts kept rising would not
779
+ be caught. A host with its own loop keeps a ledger with `recordRequest(ledger, previous, next)`.
780
+
610
781
  ### A fold you store, instead of a transcript you rewrite
611
782
 
612
783
  `compactTranscript` hands back a new array, which is the whole answer for a host whose transcript
@@ -746,7 +917,10 @@ void notify(run, "afterTurn", { ...context, reply, turn: { ...context.turn, mess
746
917
 
747
918
  The context goes on this turn's question and never into the system prompt — a prompt that changed
748
919
  every turn would miss the prompt cache every turn — and `withContext` returns a new array, so a
749
- host that stores what the user typed never stores the context as something they said. Every
920
+ host that stores what the user typed never stores the context as something they said. It is stored
921
+ beside the question instead, and put back on every later request: a question that loses its blocks
922
+ on the next turn rewrites the prompt from there on, and the server's prefix cache with it.
923
+ `runAgentLoop` hands back `context` and `preface` for the same purpose. Every
750
924
  injecting hook shares `HOOK_CONTEXT_TOKENS` (2000) by default, each held to its own `maxTokens`
751
925
  inside that, so a generous hook cannot crowd out the conversation it was meant to inform.
752
926
 
@@ -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,67 @@ 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
+ /** One call the model made, as `dispatch` and `onToolCall` are handed it. */
101
102
  export interface ToolCallRequest {
102
103
  id: string;
103
104
  name: string;
104
- /** Parsed by `parseToolArguments`, repairs and all. */
105
+ /**
106
+ * Parsed by `parseToolArguments`, repairs and all. Empty for a call whose arguments could not be
107
+ * read, which only `onToolCall` is ever handed.
108
+ */
105
109
  args: Record<string, unknown>;
106
110
  /** The arguments as the model wrote them, before any repair. */
107
111
  raw: string;
108
112
  }
109
113
  /** What one tool call did, in the order the model asked. */
110
114
  export interface ToolCallOutcome {
115
+ /**
116
+ * The id the model's reply gave the call, which is what tells two calls to one tool apart.
117
+ * Distinct within a step — `streamTurn` mints one where the server sent none or repeated one —
118
+ * but nothing stops a server using the same id again in a later step. `runAgentLoop` always
119
+ * sets it; optional so an outcome built before it existed still compiles.
120
+ */
121
+ id?: string;
111
122
  name: string;
112
123
  /** False when the arguments did not parse, the tool threw, or `load_tools` loaded nothing. */
113
124
  ok: boolean;
114
125
  }
115
- /** The hooks a loop runs around one question. See `hooks.ts`. */
126
+ /**
127
+ * One step's request as `onRequest` is handed it: what the model is about to read.
128
+ *
129
+ * The loop assembles this and nothing else sees it — the system prompt with the catalogue on it,
130
+ * the hooks' context on the question, the tool array ordered and sanitised as it is sent — so a
131
+ * host that wants to say what is filling the window has nothing to measure without it.
132
+ */
133
+ export interface AgentLoopRequest {
134
+ /** The messages as sent, system prompt first. The request's own array, not a copy. */
135
+ messages: readonly OpenAI.ChatCompletionMessageParam[];
136
+ /** The tools as sent: ordered, sanitised, relaxed where the endpoint needs it. Empty for none. */
137
+ tools: readonly OpenAI.ChatCompletionTool[];
138
+ /** Which step of the run this is, from zero, as `beforeStep` and `onTurn` count them. */
139
+ step: number;
140
+ }
141
+ /**
142
+ * One call's whole answer, as `onToolResult` is handed it.
143
+ *
144
+ * The `tool-result` event carries the same answer cut by `preview`, which is right for a readout
145
+ * and wrong for a host that renders or stores the result: this is the text the model reads.
146
+ */
147
+ export interface ToolCallResult extends ToolCallOutcome {
148
+ id: string;
149
+ /**
150
+ * What went into the transcript for this call, uncut: what the tool returned, what it threw, the
151
+ * `load_tools` answer, or why the arguments could not be read.
152
+ */
153
+ content: string;
154
+ }
155
+ /**
156
+ * The hooks a loop runs around one question. See `hooks.ts`.
157
+ *
158
+ * The loop gathers before its first request and nothing else can run beside that, so a host with
159
+ * pre-turn work of its own to overlap with the hooks — compaction, a preselection — calls `gather`
160
+ * itself, puts the context on with `withContext`, calls `notify` after, and passes no `hooks`.
161
+ */
116
162
  export interface AgentLoopHooks {
117
163
  run: HookRunner;
118
164
  /** What the hooks are told. `reply` and `turn` are filled in for `afterTurn`. */
@@ -126,6 +172,20 @@ export interface AgentLoopHooks {
126
172
  /** Hears each note, from before the request and from `afterTurn`. */
127
173
  onNote?: (note: HookNote) => void;
128
174
  }
175
+ /** How full the window is as a step is about to be sent, as `beforeStep` is told it. */
176
+ export interface StepWindow {
177
+ /**
178
+ * The prompt tokens this run's last request reported. Absent before the first step — an earlier
179
+ * run's figure is the host's to vouch for, since the transcript may have been folded since —
180
+ * and where that request reported none. It does not count the reply and tool results added
181
+ * since.
182
+ */
183
+ used?: number;
184
+ /** The model's window, `config.contextLength`. Zero where the config names none. */
185
+ limit: number;
186
+ /** The run's ledger so far, indexed into the transcript `beforeStep` is handed beside it. */
187
+ ledger: TokenLedger;
188
+ }
129
189
  /** What `runAgentLoop` takes. */
130
190
  export interface AgentLoopOptions {
131
191
  /**
@@ -156,9 +216,27 @@ export interface AgentLoopOptions {
156
216
  /**
157
217
  * What `preselect` picked. The first step is sent these and nothing else — no catalogue, no
158
218
  * `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.
219
+ * has or picks a sibling. Everything comes back on the step after. `preselectRouting` trades
220
+ * that for a first step shaped like the rest.
160
221
  */
161
222
  preselected?: readonly string[];
223
+ /**
224
+ * What a preselection does to the first step. `"exclusive"`, the default, is the shortlist alone
225
+ * as `preselected` describes — and a system prompt and tool array unlike the last request's and
226
+ * unlike the next step's, so a prompt cache misses the whole transcript on the first step and
227
+ * again on the second. `"append"` loads the shortlist as a `load_tools` call would have and
228
+ * sends the first step like any other: the catalogue, `load_tools`, what `loaded` carried, then
229
+ * the shortlist after it, with a name already carried left where it is. The price is the menu
230
+ * back in front of the model. Which costs more has not been measured, which is why the default
231
+ * has not moved.
232
+ *
233
+ * Appended is before `toolOrder` has its say: sorted, a preselected tool lands at its name's
234
+ * place and moves every definition after it, so only `toolOrder: false` keeps a carried array
235
+ * a strict prefix. Either way the first step's system prompt is the second's, and so is its
236
+ * tool array until something else is loaded. Nothing to do without a preselection, or in eager
237
+ * mode.
238
+ */
239
+ preselectRouting?: "exclusive" | "append";
162
240
  /** Tools already loaded, carried from an earlier question. See `carryOver`. */
163
241
  loaded?: Iterable<string>;
164
242
  /** Runs one tool call and returns what the model reads. What it throws, the model reads too. */
@@ -184,13 +262,49 @@ export interface AgentLoopOptions {
184
262
  * `idempotentHint` annotations can answer it; nothing in an OpenAI tool definition can.
185
263
  */
186
264
  dedupeToolCalls?: boolean | ((call: ToolCallRequest) => boolean);
187
- /** Hooks gathered onto the question before the first request, and told the reply after. */
265
+ /**
266
+ * Told of every call the model made, as it starts — the ones `dispatch` never sees included:
267
+ * `load_tools`, an identical repeat answered from the first, and a call whose arguments could
268
+ * not be read, which arrives with empty `args` and the model's own text in `raw`.
269
+ *
270
+ * For a host that shows each call as it is made and fills it in when its result lands, which
271
+ * `dispatch` alone cannot do for those three. Not awaited: a promise it returns is dropped.
272
+ */
273
+ onToolCall?: (call: ToolCallRequest) => void;
274
+ /**
275
+ * Told of every call's answer as it lands, whole, under the id `onToolCall` announced it by.
276
+ *
277
+ * One per call the model made: a deduplicated repeat gets its own, carrying the answer it
278
+ * shares with the first. With `parallel` they arrive as the calls finish, which need not be
279
+ * the order they were made in; the transcript and the result's `toolCalls` keep that order.
280
+ * Not awaited: a promise it returns is dropped.
281
+ */
282
+ onToolResult?: (result: ToolCallResult) => void;
283
+ /**
284
+ * Hooks gathered onto the question before the first request, and told the reply after.
285
+ *
286
+ * Only this question is touched — the last user message of `messages` — and the context is on
287
+ * the request, never in the transcript handed back. An earlier question's context is therefore
288
+ * the host's to send again: keep the result's `context` and `preface` beside the question, and
289
+ * put them back with `withContext` on every later call. A question sent bare that was first
290
+ * sent with context is a different prompt from that message on, and the server's prefix cache
291
+ * is lost behind it on every turn.
292
+ */
188
293
  hooks?: AgentLoopHooks;
189
294
  /**
190
295
  * 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.
296
+ * compact or prune a run that has grown into its window. Returning nothing keeps it. `window`
297
+ * is what `planCompaction` wants to know, so a host need not track usage itself to compact
298
+ * mid-run.
192
299
  */
193
- beforeStep?: (messages: readonly OpenAI.ChatCompletionMessageParam[], step: number) => OpenAI.ChatCompletionMessageParam[] | undefined | Promise<OpenAI.ChatCompletionMessageParam[] | undefined>;
300
+ beforeStep?: (messages: readonly OpenAI.ChatCompletionMessageParam[], step: number, window: StepWindow) => OpenAI.ChatCompletionMessageParam[] | undefined | Promise<OpenAI.ChatCompletionMessageParam[] | undefined>;
301
+ /**
302
+ * The ledger an earlier run handed back for this transcript, to be continued. Its indexes have
303
+ * to be into `messages` as given here — see `rebaseLedger` for a transcript rewritten since.
304
+ * This run's requests start a new epoch, because the loop cannot see whether the request before
305
+ * its first one had the same system prompt and tools. Absent starts an empty one.
306
+ */
307
+ ledger?: TokenLedger;
194
308
  /** Stops the run: the request in flight, and between steps and calls. */
195
309
  signal?: AbortSignal;
196
310
  /** Told what the run is doing, as the events a watcher reads. */
@@ -202,6 +316,16 @@ export interface AgentLoopOptions {
202
316
  * the answer. See `recoverToolCalls`.
203
317
  */
204
318
  recoverToolCalls?: boolean;
319
+ /**
320
+ * Each step's request as first built, before it goes out — for measuring what was sent, not for
321
+ * changing it, which is `beforeStep`'s.
322
+ *
323
+ * Called once a step, synchronously, and what it throws ends the run. It is not called again
324
+ * for what `runTurn` sends after that: a retry sends the same request, but a refusal is answered
325
+ * with a lesser body — relaxed schemas, a field dropped — and a continuation with the reply so
326
+ * far on the end, and neither is reported.
327
+ */
328
+ onRequest?: (request: AgentLoopRequest) => void;
205
329
  /** Each turn as it comes back, before its tools run. Recovered calls are in it as calls. */
206
330
  onTurn?: (turn: Turn, step: number) => void;
207
331
  /**
@@ -219,7 +343,7 @@ export interface AgentLoopResult {
219
343
  messages: OpenAI.ChatCompletionMessageParam[];
220
344
  /** Summed over every turn of the run. */
221
345
  usage: TurnUsage;
222
- /** Every call, `load_tools` included, in the order they were made. */
346
+ /** Every call, `load_tools` included, in the order they were made, each under its call id. */
223
347
  toolCalls: ToolCallOutcome[];
224
348
  /** What is loaded at the end, for `carryOver`. Empty in eager mode. */
225
349
  loaded: string[];
@@ -227,11 +351,39 @@ export interface AgentLoopResult {
227
351
  used: string[];
228
352
  /** The hooks' notes from before the first request. */
229
353
  notes: HookNote[];
354
+ /**
355
+ * The `<context>` blocks the hooks added to this question, as `withContext` takes them. Empty
356
+ * when no hook added any, and without `hooks`. Not in `messages`, so a host that wants the next
357
+ * turn's request to begin with this one's stores it beside the question; see `hooks`.
358
+ */
359
+ context: string;
360
+ /**
361
+ * What was said above `context`: the hooks' own `preface`, or what `configureHooks` had set when
362
+ * the run began. Handed back because the second is not the host's to know later — with it,
363
+ * `withContext(messages, at, context, preface)` is the question as it was sent, whatever the
364
+ * process's preface has since become. Empty when the preface was turned off; with no `context`
365
+ * nothing was said either way, and `withContext` adds nothing.
366
+ */
367
+ preface: string;
368
+ /**
369
+ * The `afterTurn` hooks' notes, once they have run — failures only, as `notify` returns them.
370
+ * The loop does not wait for it, so the answer is never held for a hook; a host that stores the
371
+ * notes with the turn, or must not exit before the turn is remembered, awaits it. Each note
372
+ * reaches `onNote` as well. Already resolved and empty without `hooks`, and rejects only if
373
+ * `onNote` throws.
374
+ */
375
+ afterTurn: Promise<HookNote[]>;
230
376
  /**
231
377
  * The run summed and derived: what `runMetrics` makes of the events this loop emitted, plus the
232
378
  * `load_tools` findings only the loop sees, `wallMs` from the call to the return, and `outcome`.
233
379
  */
234
380
  metrics: RunMetrics;
381
+ /**
382
+ * What each step's request reported its prompt as, indexed into `messages` above, for
383
+ * `tokensBetween` and `estimateFrom`. The entries of a `ledger` passed in come first. A step
384
+ * whose prompt was not reported has no entry.
385
+ */
386
+ ledger: TokenLedger;
235
387
  }
236
388
  /**
237
389
  * Runs a question to its answer: one `runTurn` per step, the tools it asks for between them,
@@ -242,10 +394,18 @@ export interface AgentLoopResult {
242
394
  * prompt unchanged from step to step, a load adds to the tool array — which every request sends
243
395
  * in the stable order `toolOrder` asks for — and
244
396
  * 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
397
+ * preselection shapes the first step unless `preselectRouting` says to append it. A turn cut off
398
+ * at `maxTokens` is said so as a notice, because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
247
399
  * 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`.
400
+ * cache compared against the request before it and the request broken down by what filled it; see
401
+ * `TurnUsage` and `runMetrics`.
402
+ *
403
+ * Each step's reported prompt goes on a token ledger, in the same epoch as the step before where
404
+ * the request only appended to it and in a new one where it did not — a `beforeStep` that rewrote
405
+ * the transcript, a tool array a load grew — so that what the messages between two steps cost can
406
+ * be read back as a subtraction. The prompt recorded is the first request's, before any
407
+ * continuation, and a step that reported none, or reported a cache count above its prompt, is not
408
+ * recorded. The ledger comes back in the result and on every `usage` event; see `tokensBetween`.
249
409
  *
250
410
  * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
251
411
  * and cancellation. See `AgentLoopOptions`.