@cubicecho/agent-core 2.18.0 → 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 +177 -3
- package/dist/agent-loop.d.ts +171 -11
- package/dist/agent-loop.js +77 -19
- package/dist/compaction.d.ts +27 -1
- package/dist/compaction.js +45 -12
- package/dist/events.d.ts +17 -4
- package/dist/hooks.d.ts +8 -2
- package/dist/hooks.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/ledger.d.ts +132 -0
- package/dist/ledger.js +166 -0
- package/dist/retry.d.ts +6 -0
- package/dist/retry.js +6 -2
- package/dist/stream.d.ts +11 -0
- package/dist/tool-loading.d.ts +17 -8
- package/dist/tool-loading.js +25 -8
- package/llms.txt +18 -2
- package/package.json +1 -1
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).
|
|
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.
|
|
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
|
|
package/dist/agent-loop.d.ts
CHANGED
|
@@ -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`
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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`.
|