@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.
- package/README.md +230 -7
- package/dist/agent-loop.d.ts +218 -18
- package/dist/agent-loop.js +180 -39
- package/dist/compaction.d.ts +30 -2
- package/dist/compaction.js +53 -13
- package/dist/config.d.ts +4 -2
- 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 +3 -2
- package/dist/index.js +2 -1
- 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/spec.d.ts +5 -1
- package/dist/spec.js +4 -2
- package/dist/stream.d.ts +11 -0
- package/dist/tool-loading.d.ts +98 -0
- package/dist/tool-loading.js +215 -0
- package/llms.txt +24 -1
- 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.
|
|
@@ -21,7 +23,7 @@ only, Node >=22.
|
|
|
21
23
|
| Module | What it does |
|
|
22
24
|
| --- | --- |
|
|
23
25
|
| `schema-compat` | Makes an MCP tool schema something a strict or grammar-constrained server will accept. `sanitizeTools`, `relaxTools`, `isGrammarError`. |
|
|
24
|
-
| `tool-loading` | On-demand tool discovery: a name-only catalogue plus a `load_tools` meta-tool, so a run pays for the schemas it asks for instead of all of them. Plus `preselectByKeywords`, which picks from it without a model. |
|
|
26
|
+
| `tool-loading` | On-demand tool discovery: a name-only catalogue plus a `load_tools` meta-tool, so a run pays for the schemas it asks for instead of all of them. Plus `preselectByKeywords`, which picks from it without a model, and the proxied form of the same thing — `PROXY_TOOLS`, `proxyLoadResult`, `proxiedCall` — for a server whose prompt cache a growing tool array throws away. |
|
|
25
27
|
| `stream` | Reads one streamed turn back into a message: token callbacks, tool-call reassembly, fenced reasoning taken out of the answer, and the idle watchdog that turns a silent endpoint into `EndpointSilent`. |
|
|
26
28
|
| `capabilities` | What an endpoint turned out not to support — and, under it, what one model on that endpoint did not — plus the loop that answers either when it says so. `capabilitiesFor`, `modelCapabilitiesFor`, `negotiate`. |
|
|
27
29
|
| `thinking` | Tells a scratchpad fenced inside `content` from the answer: `FenceSplitter` for a stream, `stripThinking` for a whole reply, and the fence tables both read. |
|
|
@@ -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
|
|
@@ -378,10 +385,10 @@ import { runAgentLoop, emit } from "@cubicecho/agent-core";
|
|
|
378
385
|
|
|
379
386
|
const { turn, messages, usage, loaded } = await runAgentLoop({
|
|
380
387
|
config, // Endpoint & ModelParams & { maxToolIterations, toolDiscovery?, maxRetries?, loadingTimeoutSeconds?, contextLength? }
|
|
381
|
-
system, // sent as the first message; on-demand
|
|
388
|
+
system, // sent as the first message; on-demand and proxied modes append the catalogue
|
|
382
389
|
messages: history, // ending in the question; not written to
|
|
383
390
|
tools, // every tool the run may reach
|
|
384
|
-
catalog, // the same, name-only, for on-demand loading
|
|
391
|
+
catalog, // the same, name-only, for on-demand and proxied loading
|
|
385
392
|
preselected, // from `preselect`, if a small model chose
|
|
386
393
|
dispatch: ({ name, args }, signal) => pool.call(name, args, signal),
|
|
387
394
|
hooks: { run, context: { session: { id } } },
|
|
@@ -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,67 @@ 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
|
+
|
|
486
|
+
`toolDiscovery: "proxy"` is the same catalogue behind a tool array that never changes. A chat
|
|
487
|
+
template renders the tool array in the system turn, so on a server that caches by prefix —
|
|
488
|
+
llama.cpp, and anything built on it — a `load_tools` that appends a definition changes the head of
|
|
489
|
+
the prompt and the whole transcript is prefilled again, on the very step that was meant to save
|
|
490
|
+
tokens. Proxied, every step declares `PROXY_TOOLS` and nothing else: `load_tools`, and `call_tool`,
|
|
491
|
+
which takes `{ name, arguments }`. A load answers with each new tool's name, description and
|
|
492
|
+
parameters as a line of JSON, since no request will ever declare them, and with a pointer back for
|
|
493
|
+
one whose definition is already in the conversation. A `call_tool` is refused for a name outside
|
|
494
|
+
the catalogue, takes `arguments` as an object or as JSON in a string, and reaches `dispatch` as the
|
|
495
|
+
tool it names — the inner name and arguments, under the call's own id — so a dispatcher, a watcher
|
|
496
|
+
and `used` see the same thing in every mode. The transcript keeps the `call_tool` the model wrote.
|
|
497
|
+
|
|
498
|
+
```ts
|
|
499
|
+
const { messages, used } = await runAgentLoop({
|
|
500
|
+
config: { ...config, toolDiscovery: "proxy" },
|
|
501
|
+
messages: history,
|
|
502
|
+
tools, // never declared; a load answers with them
|
|
503
|
+
catalog,
|
|
504
|
+
preselected, // written into the transcript, not into the tool array
|
|
505
|
+
dispatch: ({ name, args }, signal) => pool.call(name, args, signal),
|
|
506
|
+
});
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
A preselection has no tool array to go into there, and no step of its own. It is written into the
|
|
510
|
+
transcript after the question as a `load_tools` call the model did not make, and the result that
|
|
511
|
+
call would have had:
|
|
512
|
+
|
|
513
|
+
```ts
|
|
514
|
+
{ role: "assistant", content: null, tool_calls: [{ id: "preselect-3", type: "function",
|
|
515
|
+
function: { name: "load_tools", arguments: '{"names":["files__read_file"]}' } }] },
|
|
516
|
+
{ role: "tool", tool_call_id: "preselect-3", content: "Loaded 1 tool(s). Run them with `call_tool`.\n\n{...}" },
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
The pair is in the `messages` the loop hands back, to be stored like any other, and is reported as
|
|
520
|
+
a `tool-call` and a `tool-result` event, told to `onToolCall` and `onToolResult`, and counted in
|
|
521
|
+
`toolCalls` and in the `load_tools` metrics. The
|
|
522
|
+
id is numbered by where the call lands in the transcript, so two questions do not share one. A name
|
|
523
|
+
with no definition in `tools`, or outside the catalogue, is left out, and none left is no exchange.
|
|
524
|
+
|
|
525
|
+
What is loaded there is what the history holds, so `loaded` comes back empty and the `loaded`
|
|
526
|
+
option is not read: there is nothing to carry, and a definition a compaction folded away has to be
|
|
527
|
+
loadable again. For the same reason a `beforeStep` that returns a different array makes the loop
|
|
528
|
+
forget what it loaded, which costs a definition sent twice at worst. `call_tool` is honoured in
|
|
529
|
+
`ondemand` mode as well — a session switched out of proxied mode still has it in its history, and
|
|
530
|
+
a model copies what it has seen work — unless the host has a tool of that name, which is then the
|
|
531
|
+
host's. Eager mode does not know it.
|
|
532
|
+
|
|
445
533
|
That preselection costs a round trip to a model, which on a local box is a few seconds before the
|
|
446
534
|
run has started, spent on a model doing term matching. `preselect(..., { keywords: true })` does
|
|
447
535
|
the matching directly and spends the model only on what the words cannot settle:
|
|
@@ -503,6 +591,25 @@ reported — `cacheBroken`, a hit short of 90% of the previous prompt. A broken
|
|
|
503
591
|
`history-rewritten` (a compaction or a prune in `beforeStep`), or `none-known` where the new request
|
|
504
592
|
only appended, which points at the server — a slot evicted, a template that re-renders the tail.
|
|
505
593
|
|
|
594
|
+
The same `turn` carries `context`, the request cut by `contextTokens` into `system`, `tools`,
|
|
595
|
+
`history` and `toolResults`: shares of the prompt the endpoint reported for it, adding up to it, or
|
|
596
|
+
an estimate where it reported none. A host that wants a different cut — the catalogue apart from the
|
|
597
|
+
prompt it rides on, this turn's messages apart from the ones before — takes the request from
|
|
598
|
+
`onRequest` and measures it itself:
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
await runAgentLoop({
|
|
602
|
+
...options,
|
|
603
|
+
onRequest: ({ messages, tools, step }) => measure(step, messages, tools),
|
|
604
|
+
});
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
It is called once a step, before the request goes out, with the messages and tools as the client
|
|
608
|
+
is about to be sent them. It is for reading: `beforeStep` is where the transcript is changed. What
|
|
609
|
+
it reports is the request as first built. A retry sends that again, but a refusal is answered with
|
|
610
|
+
a lesser body and a continuation with the reply so far on the end, and neither is reported — and
|
|
611
|
+
`context` is measured on the same first request, against the first prompt reported for the turn.
|
|
612
|
+
|
|
506
613
|
`runMetrics(events)` adds a run up from those events — tokens, cache hit ratio and breaks by
|
|
507
614
|
reason, prefill, decode and tool time, the slowest turn, mean time to first token, draft
|
|
508
615
|
acceptance, the largest prompt against a `contextLength`, turns cut off, tool errors by name, and
|
|
@@ -519,9 +626,53 @@ What it cannot say: whether a failed run was stopped, errored or ran out of tool
|
|
|
519
626
|
whether the host compacted — neither is in the events.
|
|
520
627
|
|
|
521
628
|
`beforeStep` is handed the transcript before each request and may return a replacement, which is
|
|
522
|
-
where compaction goes (below).
|
|
629
|
+
where compaction goes (below). Its third argument is `{ used, limit, ledger }` — the prompt the
|
|
630
|
+
run's last request reported, the window, and the token ledger so far — so a host can compact
|
|
631
|
+
mid-run without tracking usage itself; `used` is absent before the first step and where the
|
|
632
|
+
endpoint reported nothing. Hooks are gathered once, onto the question, and never written into
|
|
523
633
|
the transcript that comes back; `afterTurn` is told the reply without the run waiting on it.
|
|
524
634
|
|
|
635
|
+
That leaves an earlier question's context to the host, because the loop only ever touches the
|
|
636
|
+
current one. A question sent with `<context>` blocks one turn and bare the next is a different
|
|
637
|
+
prompt from that message on, and the server's prefix cache is lost behind it — on every turn. So
|
|
638
|
+
the result hands back what it said, `context` and the `preface` above it, for the host to keep
|
|
639
|
+
beside the question and put back with `withContext` on every later call:
|
|
640
|
+
|
|
641
|
+
```ts
|
|
642
|
+
import { runAgentLoop, withContext } from "@cubicecho/agent-core";
|
|
643
|
+
|
|
644
|
+
// Stored: what the user typed, and what the hooks added to it the turn it was asked.
|
|
645
|
+
const history = session.messages.reduce(
|
|
646
|
+
(sent, message, at) => withContext(sent, at, message.hookContext ?? "", message.hookPreface),
|
|
647
|
+
session.messages.map(({ hookContext, hookPreface, ...message }) => message),
|
|
648
|
+
);
|
|
649
|
+
const result = await runAgentLoop({
|
|
650
|
+
config,
|
|
651
|
+
messages: [...history, { role: "user", content: prompt }],
|
|
652
|
+
dispatch,
|
|
653
|
+
hooks: { run, context: { session: { id }, prompt } },
|
|
654
|
+
});
|
|
655
|
+
session.messages.push(
|
|
656
|
+
{ role: "user", content: prompt, hookContext: result.context, hookPreface: result.preface },
|
|
657
|
+
...result.messages.slice(history.length + 1),
|
|
658
|
+
);
|
|
659
|
+
await result.afterTurn; // only if its notes are wanted, or the process is about to exit
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
`withContext` with those two arguments builds the question exactly as the loop sent it, whatever
|
|
663
|
+
`configureHooks` has since been told, so the next request is the last one with its tail added.
|
|
664
|
+
`result.messages` is the transcript the loop was given plus what the run added — earlier questions
|
|
665
|
+
as the host passed them, this one as typed — which is why the snippet stores only the new tail.
|
|
666
|
+
|
|
667
|
+
`afterTurn` on the result is the `afterTurn` hooks' notes as a promise. The loop still returns
|
|
668
|
+
without waiting; a host that stores those notes with the turn, or would otherwise exit before the
|
|
669
|
+
turn is remembered, awaits it once the answer is out.
|
|
670
|
+
|
|
671
|
+
The loop gathers before its first request, and nothing of the host's can run beside that. A host
|
|
672
|
+
with pre-turn work to overlap with the hooks — compaction, a preselection — calls `gather` itself,
|
|
673
|
+
applies `withContext` to the current question as well, calls `notify` after, and passes no `hooks`
|
|
674
|
+
(see [Hooks](#hooks)).
|
|
675
|
+
|
|
525
676
|
`resolveApiKey` is exported and not applied, because which key an endpoint gets is a rule a
|
|
526
677
|
consumer states and a library guessing it could send one where it was not meant to go. The rule
|
|
527
678
|
it encodes is the conservative one: an endpoint's own key wins; one that names a base URL of its
|
|
@@ -559,7 +710,9 @@ Two ways to make a transcript smaller, cheap first.
|
|
|
559
710
|
`pruneToolResults(messages, { keepLast: 5, maxChars: 256 })` replaces every tool result but the
|
|
560
711
|
latest five with a stub — `[result cleared, 10,412 chars]`. A 40k-character `read_file` is 10k
|
|
561
712
|
tokens on every turn after it, and by then the model has usually taken what it wanted; the stub
|
|
562
|
-
keeps the call answered and says how much was there.
|
|
713
|
+
keeps the call answered and says how much was there. A proxied `load_tools` result that carries
|
|
714
|
+
definitions is never cleared, however old: it is the only copy of the schema the model has, and
|
|
715
|
+
`holdsDefinitions` is the test for a host with a prune of its own.
|
|
563
716
|
|
|
564
717
|
`planCompaction(messages, { limit, used })` says where to fold the oldest stretch into a summary,
|
|
565
718
|
once `used` (the last turn's prompt tokens, or the estimate) is past three quarters of the window.
|
|
@@ -607,6 +760,73 @@ way to save tokens.
|
|
|
607
760
|
`pruneToolResults` keeps the transcript's indexes, so a plan made before pruning still applies to
|
|
608
761
|
what it returns, as above.
|
|
609
762
|
|
|
763
|
+
### What the messages cost, measured
|
|
764
|
+
|
|
765
|
+
`planCompaction` decides where to cut by estimating each message, and a calibrated divisor fixes
|
|
766
|
+
only the average: a transcript is tool results, JSON arguments, code and prose, each at its own
|
|
767
|
+
ratio, plus whatever the chat template adds per message. The server has already said what they
|
|
768
|
+
cost. A request that only appended to the one before it differs from it by exactly the cost of what
|
|
769
|
+
was appended, so two reported prompts and a subtraction give a number with no tokenizer in it — the
|
|
770
|
+
template's tokens and a tool result's real density included, and reasoning, which is never
|
|
771
|
+
replayed, left out.
|
|
772
|
+
|
|
773
|
+
`runAgentLoop` keeps that as a ledger — one `{ through, prompt, epoch }` per request: the index of
|
|
774
|
+
the request's last message, the prompt it reported, and an epoch that changes whenever the prefix
|
|
775
|
+
did. It comes back as `result.ledger`, rides on every `usage` event, and is handed to `beforeStep`;
|
|
776
|
+
pass it back as `ledger` on the next run and it is continued. It is a plain array of three numbers
|
|
777
|
+
an entry, so it stores as two columns on the message row `through` names or as JSON beside the
|
|
778
|
+
session.
|
|
779
|
+
|
|
780
|
+
```ts
|
|
781
|
+
const result = await runAgentLoop({
|
|
782
|
+
...options,
|
|
783
|
+
ledger: session.ledger,
|
|
784
|
+
beforeStep: async (messages, step, { used, limit, ledger }) => {
|
|
785
|
+
const plan = planCompaction(messages, {
|
|
786
|
+
limit,
|
|
787
|
+
used,
|
|
788
|
+
target: 0.5,
|
|
789
|
+
estimate: estimateFrom(ledger, messages, { charsPerToken }),
|
|
790
|
+
});
|
|
791
|
+
if (!plan) return;
|
|
792
|
+
return compactTranscript(messages, plan, summarise);
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
session.ledger = result.ledger;
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
`tokensBetween(ledger, from, to, messages)` is the cost of `messages.slice(from, to)`. Between two
|
|
799
|
+
boundaries recorded in one epoch it is the subtraction, and exact. A boundary falls after a question
|
|
800
|
+
and after each step's tool results, never after an assistant message, so a stretch that starts or
|
|
801
|
+
ends inside a group gets that group's total by character share, the way `contextTokens` divides a
|
|
802
|
+
reported prompt. Whatever no pair of entries covers is the calibrated estimate: the messages before
|
|
803
|
+
the first entry, the group either side of an epoch's end, and what has not been sent yet.
|
|
804
|
+
`estimateFrom(ledger, messages)` is the same thing one message at a time, which is the function
|
|
805
|
+
`planCompaction` already takes as `estimate`.
|
|
806
|
+
|
|
807
|
+
`target` is the share of the window the whole request should come down to. `keepRatio` bounds the
|
|
808
|
+
kept tail alone, with the system prompt, the tool schemas and the summary sitting on top of it
|
|
809
|
+
uncounted; given a `target`, the cut is the first user message at which `used`, less what is
|
|
810
|
+
folded, plus the summary's ceiling (`summaryTokens`, 1024 unless given) is at or under it, and the
|
|
811
|
+
plan's `after` says what that leaves. Where no cut reaches it the plan folds as far as the last
|
|
812
|
+
user message and `after` says how far short it fell. Without `target` nothing changes.
|
|
813
|
+
|
|
814
|
+
A new epoch starts whenever a request did more than append: a fold or a prune in `beforeStep`, a
|
|
815
|
+
system prompt that moved, and a tool array that grew — a `load_tools` in on-demand mode adds a
|
|
816
|
+
schema to the head, so the group beside it is left unmeasured rather than charged for it. When
|
|
817
|
+
`beforeStep` returns a different transcript the loop moves the entries that still stand to their new
|
|
818
|
+
indexes with `rebaseLedger(ledger, before, after)`, which a host that folds between runs calls
|
|
819
|
+
itself. Differences inside an old epoch stay good, since the prefix they shared cancels.
|
|
820
|
+
|
|
821
|
+
What is not measured: the first request of every run starts an epoch, because the loop cannot see
|
|
822
|
+
whether the run before it had the same system prompt and tools, so a run's question and the answer
|
|
823
|
+
before it are estimated; a step whose prompt was not reported has no entry, and the next one that
|
|
824
|
+
does is measured across it; and the prompt recorded is the first request's, before any continuation.
|
|
825
|
+
A difference of zero or less is not trusted, and neither is a prompt reported smaller than its own
|
|
826
|
+
cache count — both are how a server reporting `prompt_tokens` net of its cache would show up.
|
|
827
|
+
Whether any endpoint does that has not been checked, and one whose net counts kept rising would not
|
|
828
|
+
be caught. A host with its own loop keeps a ledger with `recordRequest(ledger, previous, next)`.
|
|
829
|
+
|
|
610
830
|
### A fold you store, instead of a transcript you rewrite
|
|
611
831
|
|
|
612
832
|
`compactTranscript` hands back a new array, which is the whole answer for a host whose transcript
|
|
@@ -746,7 +966,10 @@ void notify(run, "afterTurn", { ...context, reply, turn: { ...context.turn, mess
|
|
|
746
966
|
|
|
747
967
|
The context goes on this turn's question and never into the system prompt — a prompt that changed
|
|
748
968
|
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.
|
|
969
|
+
host that stores what the user typed never stores the context as something they said. It is stored
|
|
970
|
+
beside the question instead, and put back on every later request: a question that loses its blocks
|
|
971
|
+
on the next turn rewrites the prompt from there on, and the server's prefix cache with it.
|
|
972
|
+
`runAgentLoop` hands back `context` and `preface` for the same purpose. Every
|
|
750
973
|
injecting hook shares `HOOK_CONTEXT_TOKENS` (2000) by default, each held to its own `maxTokens`
|
|
751
974
|
inside that, so a generous hook cannot crowd out the conversation it was meant to inform.
|
|
752
975
|
|