@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 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 mode appends the catalogue
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). Hooks are gathered once, onto the question, and never written into
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. Every
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