@cubicecho/agent-core 2.18.1 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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/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 {
package/llms.txt CHANGED
@@ -15,14 +15,17 @@ The loop above a turn: send, run the tools the model asked for, send again, unti
15
15
 
16
16
  - `AgentLoopHooks` (type) — The hooks a loop runs around one question.
17
17
  - `AgentLoopOptions` (type) — What `runAgentLoop` takes.
18
+ - `AgentLoopRequest` (type) — One step's request as `onRequest` is handed it: what the model is about to read.
18
19
  - `AgentLoopResult` (type) — What a finished loop hands back.
19
20
  - `buildBody` — The one place a streamed request's body is decided from a config and what the endpoint and the model have refused.
20
21
  - `preselect` — The tools a request is likely to need, picked by a small model before the run starts, or none.
21
22
  - `preview` — A long tool argument or result cut to what a watcher needs, with the full length said.
22
23
  - `resolveApiKey` — The key to send, where an endpoint may inherit one from the settings it overrides.
23
24
  - `runAgentLoop` — Runs a question to its answer: one `runTurn` per step, the tools it asks for between them, until a turn asks for none.
25
+ - `StepWindow` (type) — How full the window is as a step is about to be sent, as `beforeStep` is told it.
24
26
  - `ToolCallOutcome` (type) — What one tool call did, in the order the model asked.
25
- - `ToolCallRequest` (type) — One call the model made, as `dispatch` is handed it.
27
+ - `ToolCallRequest` (type) — One call the model made, as `dispatch` and `onToolCall` are handed it.
28
+ - `ToolCallResult` (type) — One call's whole answer, as `onToolResult` is handed it.
26
29
 
27
30
  ### calibration
28
31
 
@@ -163,6 +166,19 @@ Lifecycle hooks, from the host's side: what a session looks like to them, where
163
166
  - `untrusted` — Fences text nobody trusted — a fetched page, an email, a submitted card, a tool result — so the model can see where it starts and ends and read it as data rather than as instructions.
164
167
  - `withContext` — The request, with the hooks' context added to this turn's question.
165
168
 
169
+ ### ledger
170
+
171
+ What stretches of a transcript cost, read off the prompt counts the server already reported.
172
+
173
+ - `estimateFrom` — A per-message cost for `planCompaction`'s `estimate`: the measured share where the ledger covers the message, the calibrated estimate where it does not.
174
+ - `LedgerEntry` (type) — One request's reported prompt, and how far into the transcript that request reached.
175
+ - `LedgerEstimateOptions` (type) — What `tokensBetween` and `estimateFrom` take besides the ledger and the transcript.
176
+ - `LedgerRequest` (type) — What `recordRequest` needs to know of one request.
177
+ - `rebaseLedger` — The ledger for a transcript that has been rewritten, keeping the boundaries the rewrite left standing.
178
+ - `recordRequest` — The ledger with one more request on it, in the epoch the request before it says it belongs to.
179
+ - `TokenLedger` (type) — A transcript's entries in the order their requests were sent, which is also by `through`.
180
+ - `tokensBetween` — What the messages from `from` up to, not including, `to` cost the window, measured where the ledger can say and estimated where it cannot.
181
+
166
182
  ### reset
167
183
 
168
184
  - `resetAll` — Forgets everything this package remembers between calls.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.18.1",
3
+ "version": "2.19.0",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",