@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/dist/hooks.d.ts CHANGED
@@ -204,11 +204,17 @@ export declare function assembleContext(outcomes: readonly HookOutcome[], maxTok
204
204
  * system prompt that changed every turn would miss the prompt cache every turn. Nothing is written
205
205
  * back: a host that stores what the user typed never remembers the context as something they said.
206
206
  *
207
+ * A question keeps its context for as long as it is sent. A host stores `context`, and the preface
208
+ * it was said under, beside the question and calls this for each stored one on every later request
209
+ * — a question sent with its blocks one turn and bare the next is a different prompt from that
210
+ * message on, and loses the server's prefix cache behind it.
211
+ *
207
212
  * @param history The request's messages. Neither the array nor any message in it is changed.
208
- * @param index Where this turn's question sits in `history` — which is not where it sits in the
213
+ * @param index Where the question sits in `history` — which is not where it sits in the
209
214
  * session once a compaction has folded the head into a summary. Anything but a user message there
210
215
  * leaves the request as it was.
211
- * @param context What `assembleContext` built. Empty returns `history` itself.
216
+ * @param context What `assembleContext` built, this turn or the turn the question was asked.
217
+ * Empty returns `history` itself.
212
218
  * @param preface Said above the blocks. Absent is what `configureHooks` last set — `HOOK_PREFACE`
213
219
  * unless something moved it. Empty says nothing, rather than leaving a blank line where it was.
214
220
  * @returns `history` when there was nothing to add or nowhere to add it, otherwise a new array.
package/dist/hooks.js CHANGED
@@ -128,11 +128,17 @@ export function assembleContext(outcomes, maxTokens) {
128
128
  * system prompt that changed every turn would miss the prompt cache every turn. Nothing is written
129
129
  * back: a host that stores what the user typed never remembers the context as something they said.
130
130
  *
131
+ * A question keeps its context for as long as it is sent. A host stores `context`, and the preface
132
+ * it was said under, beside the question and calls this for each stored one on every later request
133
+ * — a question sent with its blocks one turn and bare the next is a different prompt from that
134
+ * message on, and loses the server's prefix cache behind it.
135
+ *
131
136
  * @param history The request's messages. Neither the array nor any message in it is changed.
132
- * @param index Where this turn's question sits in `history` — which is not where it sits in the
137
+ * @param index Where the question sits in `history` — which is not where it sits in the
133
138
  * session once a compaction has folded the head into a summary. Anything but a user message there
134
139
  * leaves the request as it was.
135
- * @param context What `assembleContext` built. Empty returns `history` itself.
140
+ * @param context What `assembleContext` built, this turn or the turn the question was asked.
141
+ * Empty returns `history` itself.
136
142
  * @param preface Said above the blocks. Absent is what `configureHooks` last set — `HOOK_PREFACE`
137
143
  * unless something moved it. Empty says nothing, rather than leaving a blank line where it was.
138
144
  * @returns `history` when there was nothing to add or nowhere to add it, otherwise a new array.
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
12
- export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type ToolCallOutcome, type ToolCallRequest, } from "./agent-loop.ts";
12
+ export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type StepWindow, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult, } from "./agent-loop.ts";
13
13
  export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
14
14
  export { type Capabilities, capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
15
15
  export type { CatalogServer } from "./catalog.ts";
@@ -20,6 +20,7 @@ export { type ContinueTurnOptions, continueTurn, isContinuable, } from "./contin
20
20
  export { errorMessage } from "./errors.ts";
21
21
  export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunMetrics, type RunMetricsOptions, type RunUsage, resetEvents, runMetrics, type TurnReport, watch, } from "./events.ts";
22
22
  export { assembleContext, configureHooks, consult, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.ts";
23
+ export { estimateFrom, type LedgerEntry, type LedgerEstimateOptions, type LedgerRequest, rebaseLedger, recordRequest, type TokenLedger, tokensBetween, } from "./ledger.ts";
23
24
  export { resetAll } from "./reset.ts";
24
25
  export { backoffMs, CHARS_PER_TOKEN, type ContextBreakdown, type ContextBreakdownOptions, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, type TokenEstimateOptions, toolsChars, } from "./retry.ts";
25
26
  export { type RunTurnOptions, runTurn } from "./run-turn.ts";
@@ -31,4 +32,4 @@ export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type Turn
31
32
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, type Split, stripThinking, THINK_FENCE, } from "./thinking.ts";
32
33
  export { estimateTokens } from "./tokens.ts";
33
34
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, type ToolCall, } from "./tool-calls.ts";
34
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, type KeywordPreselection, type KeywordPreselectOptions, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectByKeywords, preselectInput, preselection, preselectSystem, requestedNames, type ToolMatch, type ToolOrder, } from "./tool-loading.ts";
35
+ export { CALL_TOOL, carryOver, catalogList, catalogPrompt, expandNames, holdsDefinitions, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, type KeywordPreselection, type KeywordPreselectOptions, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, PROXY_TOOLS, preselectByKeywords, preselectInput, preselection, preselectSystem, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, type ToolMatch, type ToolOrder, } from "./tool-loading.ts";
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ export { continueTurn, isContinuable, } from "./continuation.js";
18
18
  export { errorMessage } from "./errors.js";
19
19
  export { configureEvents, emit, endRun, fold, history, resetEvents, runMetrics, watch, } from "./events.js";
20
20
  export { assembleContext, configureHooks, consult, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.js";
21
+ export { estimateFrom, rebaseLedger, recordRequest, tokensBetween, } from "./ledger.js";
21
22
  export { resetAll } from "./reset.js";
22
23
  export { backoffMs, CHARS_PER_TOKEN, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, toolsChars, } from "./retry.js";
23
24
  export { runTurn } from "./run-turn.js";
@@ -29,4 +30,4 @@ export { streamTurn, } from "./stream.js";
29
30
  export { ALL_FENCES, DEFAULT_FENCES, FenceSplitter, stripThinking, THINK_FENCE, } from "./thinking.js";
30
31
  export { estimateTokens } from "./tokens.js";
31
32
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, } from "./tool-calls.js";
32
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectByKeywords, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
33
+ export { CALL_TOOL, carryOver, catalogList, catalogPrompt, expandNames, holdsDefinitions, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, PROXY_TOOLS, preselectByKeywords, preselectInput, preselection, preselectSystem, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, } from "./tool-loading.js";
@@ -0,0 +1,132 @@
1
+ import type OpenAI from "openai";
2
+ import { type TokenEstimateOptions } from "./retry.ts";
3
+ /**
4
+ * What stretches of a transcript cost, read off the prompt counts the server already reported.
5
+ *
6
+ * A calibrated divisor fixes the average and nothing else: a transcript is tool results, JSON
7
+ * arguments, code and prose, each at its own ratio, plus whatever the chat template adds per
8
+ * message. But a request that only appended to the one before it differs from it by exactly the
9
+ * cost of what was appended, so two reported prompts and a subtraction say what no estimate can —
10
+ * the template's tokens and a tool result's real density included, and reasoning, which is never
11
+ * replayed, left out.
12
+ *
13
+ * Everything here is a pure function over a plain array, so a host with its own loop keeps a ledger
14
+ * the same way `runAgentLoop` does, and stores it however it stores the transcript.
15
+ */
16
+ type Message = OpenAI.ChatCompletionMessageParam;
17
+ /**
18
+ * One request's reported prompt, and how far into the transcript that request reached.
19
+ *
20
+ * Three numbers and nothing else, so it serialises as it is: a host that stores a row per message
21
+ * keeps `prompt` and `epoch` in two columns on the row `through` names, and one that stores a
22
+ * session keeps the array beside it.
23
+ */
24
+ export interface LedgerEntry {
25
+ /**
26
+ * The index, in the host's transcript, of the last message the request carried. A system prompt
27
+ * the host keeps outside the transcript is not counted, and −1 is a request that carried none of
28
+ * the transcript at all.
29
+ */
30
+ through: number;
31
+ /** The prompt tokens the endpoint reported for that request, everything ahead of it included. */
32
+ prompt: number;
33
+ /**
34
+ * Which unbroken run of appending the request belongs to. Two entries with the same epoch shared
35
+ * everything up to the earlier one's last message, so their prompts subtract; two with different
36
+ * epochs did not, and the difference between them means nothing.
37
+ */
38
+ epoch: number;
39
+ }
40
+ /** A transcript's entries in the order their requests were sent, which is also by `through`. */
41
+ export type TokenLedger = LedgerEntry[];
42
+ /** What `recordRequest` needs to know of one request. */
43
+ export interface LedgerRequest {
44
+ /** The messages as they were sent, a system prompt and any context a hook added included. */
45
+ messages: Message[];
46
+ /** The names of the tools declared, in the order they were sent. Absent is none. */
47
+ tools?: readonly string[];
48
+ /** The prompt tokens the endpoint reported. Zero or less is no report, and records nothing. */
49
+ prompt: number;
50
+ /** The index, in the host's transcript, of the last message the request carried. */
51
+ through: number;
52
+ }
53
+ /** What `tokensBetween` and `estimateFrom` take besides the ledger and the transcript. */
54
+ export interface LedgerEstimateOptions extends TokenEstimateOptions {
55
+ /**
56
+ * One unmeasured message's tokens. `messageTokens` by default, divided by `charsPerToken` — the
57
+ * calibrated estimate, given `charsPerTokenFor` the model.
58
+ */
59
+ estimate?: (message: Message) => number;
60
+ }
61
+ /**
62
+ * The ledger with one more request on it, in the epoch the request before it says it belongs to.
63
+ *
64
+ * The epoch carries on only where the new request declared the same tools in the same order and
65
+ * sent every earlier message unchanged — the case in which the two prompts subtract. Anything else
66
+ * starts a new one: a fold, a prune, a system prompt that moved, and a tool array that grew, whose
67
+ * schema would otherwise be counted as the cost of the messages beside it. A request that reported
68
+ * no prompt records nothing, and neither does one that reaches no further than the last entry in
69
+ * the same epoch, so a retry or a continuation leaves the first reading standing.
70
+ *
71
+ * @param ledger The ledger so far. Not written to; handed back as it is when nothing is recorded.
72
+ * @param previous The request the ledger's last entry was recorded from — not merely the last one
73
+ * sent, so that a request which reported nothing is skipped over rather than compared against.
74
+ * `undefined` where that is not known, which starts a new epoch.
75
+ * @param next The request just answered.
76
+ */
77
+ export declare function recordRequest(ledger: TokenLedger, previous: LedgerRequest | undefined, next: LedgerRequest): TokenLedger;
78
+ /**
79
+ * The ledger for a transcript that has been rewritten, keeping the boundaries the rewrite left
80
+ * standing.
81
+ *
82
+ * A difference inside an epoch stays true after the epoch ends, since the prefix the two requests
83
+ * shared cancels whatever became of it. So the entries that fall in the part of the transcript
84
+ * still at its head keep their place, the ones in the part still at its tail move with it, and
85
+ * only the ones in between — the messages a fold removed or a prune stubbed — are dropped. The
86
+ * tail's entries move to a later epoch as well, so no difference is read across the rewrite.
87
+ *
88
+ * @param ledger The ledger for `before`. Not written to.
89
+ * @param before The transcript the ledger's indexes are in.
90
+ * @param after The transcript that replaces it. One that only appended to `before`, or is the same
91
+ * array, hands the ledger back as it is.
92
+ */
93
+ export declare function rebaseLedger(ledger: TokenLedger, before: readonly Message[], after: readonly Message[]): TokenLedger;
94
+ /**
95
+ * What the messages from `from` up to, not including, `to` cost the window, measured where the
96
+ * ledger can say and estimated where it cannot.
97
+ *
98
+ * Between two boundaries recorded in one epoch it is a subtraction and exact. A boundary falls
99
+ * after a question and after each step's tool results, never after an assistant message, so a
100
+ * stretch that starts or ends inside a group is given that group's measured total divided by
101
+ * character share — the way `contextTokens` divides a reported prompt — and messages no pair of
102
+ * entries covers fall back to the estimate: the ones before the first entry, the group either side
103
+ * of an epoch's end, and whatever has not been sent yet.
104
+ *
105
+ * A difference of zero or less is not trusted and is estimated instead. That is how a server
106
+ * reporting `prompt_tokens` net of its cache would show up, though one whose net counts still
107
+ * happened to rise would not be caught by it.
108
+ *
109
+ * @param ledger The ledger kept for `messages`.
110
+ * @param from The first message counted. Below zero counts from the start.
111
+ * @param to The first message not counted, as `slice` takes it. Past the end counts to the end.
112
+ * @param messages The transcript the ledger's indexes are in.
113
+ * @param options The estimate for what is not measured: a divisor, or a function of the caller's.
114
+ * @returns Whole tokens, rounded once over the stretch rather than per message.
115
+ */
116
+ export declare function tokensBetween(ledger: TokenLedger, from: number, to: number, messages: readonly Message[], options?: LedgerEstimateOptions): number;
117
+ /**
118
+ * A per-message cost for `planCompaction`'s `estimate`: the measured share where the ledger covers
119
+ * the message, the calibrated estimate where it does not.
120
+ *
121
+ * The planner asks about one message at a time, and nothing reports anything that fine, so a
122
+ * measured message is given its group's total by character share. The shares are not rounded,
123
+ * which is what makes a whole group of them add back up to the difference the server reported.
124
+ *
125
+ * @param ledger The ledger kept for `messages`.
126
+ * @param messages The transcript the plan will be made for. The function it returns knows a
127
+ * message by identity, so it has to be asked about these objects and not copies of them; one it
128
+ * does not know is estimated.
129
+ * @param options The estimate for what is not measured: a divisor, or a function of the caller's.
130
+ */
131
+ export declare function estimateFrom(ledger: TokenLedger, messages: readonly Message[], options?: LedgerEstimateOptions): (message: Message) => number;
132
+ export {};
package/dist/ledger.js ADDED
@@ -0,0 +1,166 @@
1
+ import { messageChars, messageTokens } from "./retry.js";
2
+ /** Whether two messages say the same thing, by identity first since most of a transcript is. */
3
+ const sameMessage = (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b);
4
+ /** Whether a request sent everything the one before it did, in the same place, and then more. */
5
+ const appended = (previous, next) => {
6
+ const before = previous.tools ?? [];
7
+ const now = next.tools ?? [];
8
+ return (before.length === now.length &&
9
+ before.every((name, at) => name === now[at]) &&
10
+ previous.messages.length <= next.messages.length &&
11
+ previous.messages.every((message, at) => sameMessage(message, next.messages[at])));
12
+ };
13
+ /**
14
+ * The ledger with one more request on it, in the epoch the request before it says it belongs to.
15
+ *
16
+ * The epoch carries on only where the new request declared the same tools in the same order and
17
+ * sent every earlier message unchanged — the case in which the two prompts subtract. Anything else
18
+ * starts a new one: a fold, a prune, a system prompt that moved, and a tool array that grew, whose
19
+ * schema would otherwise be counted as the cost of the messages beside it. A request that reported
20
+ * no prompt records nothing, and neither does one that reaches no further than the last entry in
21
+ * the same epoch, so a retry or a continuation leaves the first reading standing.
22
+ *
23
+ * @param ledger The ledger so far. Not written to; handed back as it is when nothing is recorded.
24
+ * @param previous The request the ledger's last entry was recorded from — not merely the last one
25
+ * sent, so that a request which reported nothing is skipped over rather than compared against.
26
+ * `undefined` where that is not known, which starts a new epoch.
27
+ * @param next The request just answered.
28
+ */
29
+ export function recordRequest(ledger, previous, next) {
30
+ if (!(next.prompt > 0))
31
+ return ledger;
32
+ const last = ledger.at(-1);
33
+ if (last && previous && appended(previous, next)) {
34
+ if (next.through <= last.through)
35
+ return ledger;
36
+ return [...ledger, { through: next.through, prompt: next.prompt, epoch: last.epoch }];
37
+ }
38
+ return [
39
+ ...ledger,
40
+ { through: next.through, prompt: next.prompt, epoch: last ? last.epoch + 1 : 0 },
41
+ ];
42
+ }
43
+ /**
44
+ * The ledger for a transcript that has been rewritten, keeping the boundaries the rewrite left
45
+ * standing.
46
+ *
47
+ * A difference inside an epoch stays true after the epoch ends, since the prefix the two requests
48
+ * shared cancels whatever became of it. So the entries that fall in the part of the transcript
49
+ * still at its head keep their place, the ones in the part still at its tail move with it, and
50
+ * only the ones in between — the messages a fold removed or a prune stubbed — are dropped. The
51
+ * tail's entries move to a later epoch as well, so no difference is read across the rewrite.
52
+ *
53
+ * @param ledger The ledger for `before`. Not written to.
54
+ * @param before The transcript the ledger's indexes are in.
55
+ * @param after The transcript that replaces it. One that only appended to `before`, or is the same
56
+ * array, hands the ledger back as it is.
57
+ */
58
+ export function rebaseLedger(ledger, before, after) {
59
+ if (before === after)
60
+ return ledger;
61
+ const shortest = Math.min(before.length, after.length);
62
+ let head = 0;
63
+ while (head < shortest && sameMessage(before[head], after[head]))
64
+ head++;
65
+ if (head === before.length)
66
+ return ledger;
67
+ let tail = 0;
68
+ while (tail < shortest - head &&
69
+ sameMessage(before[before.length - 1 - tail], after[after.length - 1 - tail]))
70
+ tail++;
71
+ // The boundary just ahead of a kept tail stands too: what follows it is all still there. With
72
+ // no tail kept there is nothing left for it to be the start of.
73
+ const start = tail > 0 ? before.length - tail - 1 : before.length;
74
+ const shift = after.length - before.length;
75
+ return ledger.flatMap((entry) => entry.through < head
76
+ ? [entry]
77
+ : entry.through >= start
78
+ ? [{ ...entry, through: entry.through + shift, epoch: entry.epoch + 1 }]
79
+ : []);
80
+ }
81
+ /**
82
+ * Each measured message's share of its group's cost, by index. A group is the messages between two
83
+ * neighbouring entries of one epoch, and is left out — for the estimate to answer — where the
84
+ * difference is not above zero, where the entries are out of order, or where the transcript does
85
+ * not reach its end.
86
+ */
87
+ function shares(ledger, messages) {
88
+ const out = new Map();
89
+ let floor = 0;
90
+ for (let at = 1; at < ledger.length; at++) {
91
+ const from = ledger[at - 1];
92
+ const to = ledger[at];
93
+ const tokens = to.prompt - from.prompt;
94
+ const start = from.through + 1;
95
+ const end = to.through + 1;
96
+ if (from.epoch !== to.epoch || !(tokens > 0))
97
+ continue;
98
+ if (start < floor || end <= start || end > messages.length)
99
+ continue;
100
+ floor = end;
101
+ const chars = [];
102
+ let total = 0;
103
+ for (let index = start; index < end; index++) {
104
+ chars.push(messageChars(messages[index]));
105
+ total += chars[index - start];
106
+ }
107
+ for (let index = start; index < end; index++) {
108
+ out.set(index, (tokens * chars[index - start]) / total);
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+ /** The estimate for a message nothing measured, as the options ask for it. */
114
+ const fallback = ({ charsPerToken, estimate }) => estimate ?? ((message) => messageTokens(message, { charsPerToken }));
115
+ /**
116
+ * What the messages from `from` up to, not including, `to` cost the window, measured where the
117
+ * ledger can say and estimated where it cannot.
118
+ *
119
+ * Between two boundaries recorded in one epoch it is a subtraction and exact. A boundary falls
120
+ * after a question and after each step's tool results, never after an assistant message, so a
121
+ * stretch that starts or ends inside a group is given that group's measured total divided by
122
+ * character share — the way `contextTokens` divides a reported prompt — and messages no pair of
123
+ * entries covers fall back to the estimate: the ones before the first entry, the group either side
124
+ * of an epoch's end, and whatever has not been sent yet.
125
+ *
126
+ * A difference of zero or less is not trusted and is estimated instead. That is how a server
127
+ * reporting `prompt_tokens` net of its cache would show up, though one whose net counts still
128
+ * happened to rise would not be caught by it.
129
+ *
130
+ * @param ledger The ledger kept for `messages`.
131
+ * @param from The first message counted. Below zero counts from the start.
132
+ * @param to The first message not counted, as `slice` takes it. Past the end counts to the end.
133
+ * @param messages The transcript the ledger's indexes are in.
134
+ * @param options The estimate for what is not measured: a divisor, or a function of the caller's.
135
+ * @returns Whole tokens, rounded once over the stretch rather than per message.
136
+ */
137
+ export function tokensBetween(ledger, from, to, messages, options = {}) {
138
+ const measured = shares(ledger, messages);
139
+ const estimate = fallback(options);
140
+ let total = 0;
141
+ for (let at = Math.max(0, from); at < Math.min(to, messages.length); at++) {
142
+ total += measured.get(at) ?? estimate(messages[at]);
143
+ }
144
+ return Math.round(total);
145
+ }
146
+ /**
147
+ * A per-message cost for `planCompaction`'s `estimate`: the measured share where the ledger covers
148
+ * the message, the calibrated estimate where it does not.
149
+ *
150
+ * The planner asks about one message at a time, and nothing reports anything that fine, so a
151
+ * measured message is given its group's total by character share. The shares are not rounded,
152
+ * which is what makes a whole group of them add back up to the difference the server reported.
153
+ *
154
+ * @param ledger The ledger kept for `messages`.
155
+ * @param messages The transcript the plan will be made for. The function it returns knows a
156
+ * message by identity, so it has to be asked about these objects and not copies of them; one it
157
+ * does not know is estimated.
158
+ * @param options The estimate for what is not measured: a divisor, or a function of the caller's.
159
+ */
160
+ export function estimateFrom(ledger, messages, options = {}) {
161
+ const estimate = fallback(options);
162
+ const measured = new Map();
163
+ for (const [at, tokens] of shares(ledger, messages))
164
+ measured.set(messages[at], tokens);
165
+ return (message) => measured.get(message) ?? estimate(message);
166
+ }
package/dist/retry.d.ts CHANGED
@@ -44,6 +44,12 @@ export interface TokenEstimateOptions {
44
44
  */
45
45
  charsPerToken?: number;
46
46
  }
47
+ /**
48
+ * How many characters one message is worth: its keys, and its content in whichever shape.
49
+ *
50
+ * @param message The message as it will be sent.
51
+ */
52
+ export declare function messageChars(message: OpenAI.ChatCompletionMessageParam): number;
47
53
  /**
48
54
  * How many characters a tool array is worth, measured once per array.
49
55
  *
package/dist/retry.js CHANGED
@@ -71,8 +71,12 @@ const REASONING_ALT_KEY = 15;
71
71
  export const CHARS_PER_TOKEN = 4;
72
72
  /** The divisor an option asked for, or the fallback when it asked for nothing usable. */
73
73
  const divisor = (charsPerToken) => charsPerToken !== undefined && charsPerToken > 0 ? charsPerToken : CHARS_PER_TOKEN;
74
- /** How many characters one message is worth: its keys, and its content in whichever shape. */
75
- function messageChars(message) {
74
+ /**
75
+ * How many characters one message is worth: its keys, and its content in whichever shape.
76
+ *
77
+ * @param message The message as it will be sent.
78
+ */
79
+ export function messageChars(message) {
76
80
  let chars = message.role.length + ENVELOPE;
77
81
  const { content } = message;
78
82
  if (typeof content === "string")
package/dist/spec.d.ts CHANGED
@@ -105,7 +105,11 @@ export interface PromptPart {
105
105
  }
106
106
  /** How tools reach the model, and which servers this agent may reach at all. */
107
107
  export interface ToolsSpec {
108
- discovery?: "eager" | "ondemand";
108
+ /**
109
+ * How tool definitions reach the model: all of them on every request, a name-only catalogue
110
+ * the model loads from, or that catalogue behind a fixed `load_tools` and `call_tool`.
111
+ */
112
+ discovery?: "eager" | "ondemand" | "proxy";
109
113
  /** The hard stop on a tool loop. At least one. */
110
114
  maxIterations?: number;
111
115
  /**
package/dist/spec.js CHANGED
@@ -206,10 +206,12 @@ function parseTools(report, value) {
206
206
  return void report.drop("tools", "must be an object");
207
207
  let discovery;
208
208
  if (!absent(value.discovery)) {
209
- if (value.discovery === "eager" || value.discovery === "ondemand")
209
+ if (value.discovery === "eager" ||
210
+ value.discovery === "ondemand" ||
211
+ value.discovery === "proxy")
210
212
  discovery = value.discovery;
211
213
  else
212
- report.drop("tools.discovery", 'must be "eager" or "ondemand"');
214
+ report.drop("tools.discovery", 'must be "eager", "ondemand" or "proxy"');
213
215
  }
214
216
  let servers;
215
217
  if (!absent(value.servers)) {
package/dist/stream.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type OpenAI from "openai";
2
+ import { type ContextBreakdown } from "./retry.ts";
2
3
  import { type Fence } from "./thinking.ts";
3
4
  /**
4
5
  * Reading one streamed turn back into a message.
@@ -95,6 +96,16 @@ export interface TurnUsage {
95
96
  * prompt, so this is what every change to the tool list costs the cache.
96
97
  */
97
98
  toolSchemaTokens?: number;
99
+ /**
100
+ * What the request was made of, as `contextTokens` cuts it, filled by `runAgentLoop`. Shares of
101
+ * the prompt the turn's first request reported, summing to it, or an estimate where none was.
102
+ *
103
+ * Measured on the request as first built, with the tools as `toolSchemaTokens` measures them. A
104
+ * continued turn's `prompt` is every request's summed, so it is larger than this `total`; and
105
+ * where a refusal was answered with a lesser body, the prompt is that body's and the proportions
106
+ * are still the first one's.
107
+ */
108
+ context?: ContextBreakdown;
98
109
  }
99
110
  /** One streamed turn, put back together into the shape a loop and a transcript work with. */
100
111
  export interface Turn {
@@ -180,6 +180,104 @@ export declare const inCatalog: (catalog: CatalogServer[], name: string) => bool
180
180
  * @param args The tool call's arguments, exactly as the model sent them.
181
181
  */
182
182
  export declare function requestedNames(args: Record<string, unknown>): string[];
183
+ /**
184
+ * The one tool a proxied run reaches every catalogued tool through.
185
+ *
186
+ * Proxied discovery is on-demand loading with a tool array that never changes. On-demand mode
187
+ * declares each tool as it is loaded, and a chat template renders the tool array in the system
188
+ * turn, ahead of the whole conversation — so appending one definition there moves every token
189
+ * after it, and on a model llama.cpp can only rewind to a saved checkpoint a single load
190
+ * re-prefills the transcript from the first token. Proxied, the array is `load_tools` and
191
+ * `call_tool` for the life of the session: a load answers with the definitions as its result, at
192
+ * the end of the history where the cache already stops, and the model runs a loaded tool through
193
+ * this one. The price is a level of indirection the model has to get right, which a small model
194
+ * does less reliably than a native call.
195
+ */
196
+ export declare const CALL_TOOL = "call_tool";
197
+ /**
198
+ * The whole tool array of a proxied run, `load_tools` then `call_tool`, frozen because it is
199
+ * shared.
200
+ *
201
+ * The load keeps `LOAD_TOOLS` as its name, so `requestedNames` and `expandNames` read its
202
+ * arguments as they do on demand; only what it promises differs, which is why it is not
203
+ * `LOAD_TOOLS_DEFINITION`. `call_tool`'s `arguments` says `additionalProperties: true` and has to:
204
+ * `sanitizeTools` gives an object with no properties an empty property list, a grammar-constrained
205
+ * server compiles that to `{}`, and without the keyword the model is left no way to pass an
206
+ * argument at all.
207
+ */
208
+ export declare const PROXY_TOOLS: readonly OpenAI.ChatCompletionTool[];
209
+ /**
210
+ * The catalogue block for a proxied run's system prompt, worded for `call_tool` rather than for a
211
+ * tool list.
212
+ *
213
+ * `catalogPrompt` tells the model a loaded tool is in its tool list, which proxied is never true —
214
+ * a model told so looks for it there and calls it natively. The listing itself is the same, and
215
+ * like that one it is never marked, so the head of the prompt is one text for the whole session.
216
+ *
217
+ * @param catalog The connected servers. A catalogue with no tools in it produces an empty string.
218
+ */
219
+ export declare function proxyCatalogPrompt(catalog: CatalogServer[]): string;
220
+ /**
221
+ * Whether a tool result is a proxied load carrying definitions, which is the only copy of them
222
+ * the model has.
223
+ *
224
+ * On demand the definitions are in the tool array and a load's result only repeats their
225
+ * descriptions, so clearing it loses nothing. Proxied, the result is the schema, and
226
+ * `pruneToolResults` asks this before it stubs one. Told by how `proxyLoadResult` opens, the way
227
+ * a summary is told by `SUMMARY_LEAD`, because a `tool` message carries no tool name of its own.
228
+ *
229
+ * @param result The tool message's text. A load that only pointed back or refused is not one.
230
+ */
231
+ export declare const holdsDefinitions: (result: string) => boolean;
232
+ /**
233
+ * What a proxied `load_tools` answers: each new tool's whole definition as a line of JSON, since
234
+ * the result is the only place the model will ever see it.
235
+ *
236
+ * A name loaded before this call is answered with a pointer back rather than its definition a
237
+ * second time, and one the catalogue lists but `definitions` does not hold is said to have none,
238
+ * so the model is not left waiting on a schema that is not coming. The refusals — too broad, over
239
+ * the per-call cap, not in the catalogue, nothing asked for — are `loadResult`'s, worded the same
240
+ * in both modes.
241
+ *
242
+ * @param resolved What the call asked for, from `expandNames`.
243
+ * @param catalog The servers, read for the refusals' wording.
244
+ * @param definitions The definitions of `resolved.matched`, as many as the host has. Ones that
245
+ * are not functions are skipped.
246
+ * @param loaded What was loaded before this call. Absent reports every match as newly loaded.
247
+ */
248
+ export declare function proxyLoadResult(resolved: ReturnType<typeof expandNames>, catalog: CatalogServer[], definitions: readonly OpenAI.ChatCompletionTool[], loaded?: ReadonlySet<string>): string;
249
+ /**
250
+ * The tool a `call_tool` names and the arguments to run it with, or a throw the model can read.
251
+ *
252
+ * `arguments` arrives as an object when the model follows the schema and as a JSON string when it
253
+ * copies the shape of a native call instead; both are taken, since the intent is the same. A name
254
+ * outside the catalogue is refused here rather than handed to a dispatcher, which is what keeps
255
+ * `call_tool` from reaching anything the catalogue does not offer.
256
+ *
257
+ * @param args The `call_tool` call's own arguments, parsed. An absent `arguments` is no arguments.
258
+ * @param catalog What may be called. The name is trimmed and then matched exactly.
259
+ */
260
+ export declare function proxiedCall(args: Record<string, unknown>, catalog: CatalogServer[]): {
261
+ name: string;
262
+ input: Record<string, unknown>;
263
+ };
264
+ /**
265
+ * A tool call as a watcher should see it: a `call_tool` as the tool it ran, anything else as it
266
+ * is.
267
+ *
268
+ * The transcript keeps the `call_tool` the model wrote, since the next request has to repeat it
269
+ * word for word to stay in the cache; only the display looks through it. Anything that does not
270
+ * parse as a proxied call is left as it came.
271
+ *
272
+ * @param name The name the model called.
273
+ * @param input The call's arguments as JSON text.
274
+ * @returns The inner name and its arguments as JSON text — the string itself where the model
275
+ * passed `arguments` as one.
276
+ */
277
+ export declare function shownCall(name: string, input: string): {
278
+ name: string;
279
+ input: string;
280
+ };
183
281
  /**
184
282
  * The system prompt a preselector is given, holding it to the cap its answer will be held to.
185
283
  *