@cubicecho/agent-core 2.3.0 → 2.5.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.
@@ -0,0 +1,133 @@
1
+ import type OpenAI from "openai";
2
+ import type { Endpoint } from "./config.ts";
3
+ import { type HookContext, type HookNote, type HookRunner } from "./hooks.ts";
4
+ import { type SideTaskOptions } from "./side-task.ts";
5
+ /**
6
+ * Keeping a long run inside its window: stale tool results cleared, and the oldest stretch folded
7
+ * into a summary the model writes itself.
8
+ *
9
+ * Both rewrite the transcript's prefix, and a prefix that changes is a prompt cache that misses —
10
+ * on a local server that is the whole prompt processed again, every token of it. So neither is
11
+ * meant to run a little on every turn: run them rarely, and together, at the point
12
+ * `planCompaction` says the window is filling, and the cache is paid for once rather than on
13
+ * every step.
14
+ */
15
+ type Message = OpenAI.ChatCompletionMessageParam;
16
+ /** The fraction of the window in use before a summary is worth its own round trip. */
17
+ export declare const COMPACT_AT = 0.75;
18
+ /** The fraction of the window the kept tail may fill, leaving room for the run to grow again. */
19
+ export declare const KEEP_RATIO = 0.35;
20
+ /**
21
+ * The summariser's instruction when the caller gives none.
22
+ *
23
+ * Asks for notes rather than a retelling, because what the summary replaces is the model's only
24
+ * record of what was decided, and a narrative spends its words on the order things happened in.
25
+ */
26
+ export declare const SUMMARY_PROMPT: string;
27
+ /**
28
+ * How a summary message opens, which is also how `planCompaction` knows one from a system prompt.
29
+ */
30
+ export declare const SUMMARY_LEAD = "Summary of the earlier part of this conversation, which is no longer shown in full:\n\n";
31
+ /** What `pruneToolResults` takes. */
32
+ export interface PruneOptions {
33
+ /** How many of the latest tool results are left whole, 5 by default. */
34
+ keepLast?: number;
35
+ /** A result this long or shorter is left whole wherever it is, 256 characters by default. */
36
+ maxChars?: number;
37
+ }
38
+ /**
39
+ * The transcript with every tool result but the latest few replaced by a one-line stub.
40
+ *
41
+ * The cheap half of compaction. A `read_file` of a 40k-character file is 10k tokens on every turn
42
+ * after it, and by then the model has usually taken what it wanted from it; the stub keeps the
43
+ * call answered — a call with no result is a malformed transcript — and says how much was there,
44
+ * so a model that does need it again knows to ask. Short results are kept, since a stub saves
45
+ * nothing on them. Returns the same array when there was nothing to clear. Rewrites the prefix;
46
+ * see the module comment on when to run it.
47
+ *
48
+ * @param messages The transcript. Not written to.
49
+ * @param options How many results to keep and how long one must be to clear.
50
+ */
51
+ export declare function pruneToolResults(messages: Message[], { keepLast, maxChars }?: PruneOptions): Message[];
52
+ /** What `planCompaction` takes. */
53
+ export interface CompactionOptions {
54
+ /** The model's window, in tokens. Zero or less never compacts. */
55
+ limit: number;
56
+ /**
57
+ * What the transcript costs now. The last turn's reported prompt tokens are the best number;
58
+ * absent is the estimate of the whole transcript.
59
+ */
60
+ used?: number;
61
+ /** The fraction of `limit` in use before compacting. `COMPACT_AT` by default. */
62
+ compactAt?: number;
63
+ /** The fraction of `limit` the kept tail may fill. `KEEP_RATIO` by default. */
64
+ keepRatio?: number;
65
+ /** One message's tokens. `messageTokens` by default. */
66
+ estimate?: (message: Message) => number;
67
+ }
68
+ /** Where to cut, as `compactTranscript` takes it. */
69
+ export interface CompactionPlan {
70
+ /** The first message folded away. Everything before it is a system prompt and stays. */
71
+ from: number;
72
+ /** The first message kept whole, always a user message. */
73
+ cut: number;
74
+ /** The messages from `from` to `cut`, the ones the summary replaces. */
75
+ toSummarise: Message[];
76
+ /** The summary an earlier compaction left, which this one continues. */
77
+ previous?: string;
78
+ }
79
+ /**
80
+ * Where to fold a transcript that has grown into its window, or `undefined` when it should not be.
81
+ *
82
+ * The kept tail is walked back from the end until it fills `keepRatio` of the window, then moved
83
+ * forward onto a user message: a transcript resuming mid-exchange — a tool result with no call
84
+ * before it, a reply with no question — is malformed and servers refuse it. The system prompts at
85
+ * the head are never folded, and a summary an earlier compaction left there is continued rather
86
+ * than summarised as if it were conversation. No plan comes back when the window is not full
87
+ * enough, or when the only legal cut folds too little to pay for the summary.
88
+ *
89
+ * @param messages The transcript, system prompts included if the caller keeps them in it.
90
+ * @param options The window, what is in use, and the ratios. See `CompactionOptions`.
91
+ */
92
+ export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, estimate, }: CompactionOptions): CompactionPlan | undefined;
93
+ /**
94
+ * What the summariser is handed for a plan: the earlier summary if there was one, then each
95
+ * message as its role and at most 4000 characters of its text.
96
+ *
97
+ * @param plan What `planCompaction` returned.
98
+ */
99
+ export declare function summaryInput(plan: CompactionPlan): string;
100
+ /**
101
+ * A summariser that asks `model` with `SUMMARY_PROMPT`, for `compactTranscript`.
102
+ *
103
+ * @param config The endpoint the summary is written through.
104
+ * @param model The model to write it, which may be a smaller one than the run's.
105
+ * @param options Cancellation and notices; the ceiling is 1024 and the instruction
106
+ * `SUMMARY_PROMPT` unless given.
107
+ */
108
+ export declare const summariser: (config: Endpoint, model: string, { system, maxTokens, ...options }?: SideTaskOptions & {
109
+ system?: string;
110
+ }) => (text: string) => Promise<string>;
111
+ /**
112
+ * The transcript with the plan's stretch replaced by one system message holding its summary.
113
+ *
114
+ * `beforeCompact` is told what is being folded while the summary is written, beside it rather
115
+ * than ahead of it — a memory server filing it is not a rescue worth making the run wait for, and
116
+ * `notify` never rejects. A hook cannot veto the compaction: `HookOutcome` has no way to say so,
117
+ * and a run over its window has no better option anyway. An empty summary folds nothing, and the
118
+ * transcript comes back as it was. Rewrites the prefix; see the module comment on when to run it.
119
+ *
120
+ * @param messages The transcript the plan was made for. Not written to.
121
+ * @param plan What `planCompaction` returned for it.
122
+ * @param summarise Writes the summary from `summaryInput`'s text. See `summariser`.
123
+ * @param options Hooks to tell. `context` is extended with `compacting` and `range`, whose
124
+ * indexes are the plan's.
125
+ */
126
+ export declare function compactTranscript(messages: Message[], plan: CompactionPlan, summarise: (text: string) => Promise<string>, { hooks, }?: {
127
+ hooks?: {
128
+ run: HookRunner;
129
+ context: HookContext;
130
+ onNote?: (note: HookNote) => void;
131
+ };
132
+ }): Promise<Message[]>;
133
+ export {};
@@ -0,0 +1,173 @@
1
+ import { notify, turnMessages } from "./hooks.js";
2
+ import { messageTokens } from "./retry.js";
3
+ import { ask } from "./side-task.js";
4
+ /** The fraction of the window in use before a summary is worth its own round trip. */
5
+ export const COMPACT_AT = 0.75;
6
+ /** The fraction of the window the kept tail may fill, leaving room for the run to grow again. */
7
+ export const KEEP_RATIO = 0.35;
8
+ /** How much of any one message the summariser is shown. A pasted file is not worth it whole. */
9
+ const SUMMARY_SLICE = 4000;
10
+ /**
11
+ * The summariser's instruction when the caller gives none.
12
+ *
13
+ * Asks for notes rather than a retelling, because what the summary replaces is the model's only
14
+ * record of what was decided, and a narrative spends its words on the order things happened in.
15
+ */
16
+ export const SUMMARY_PROMPT = "You maintain the running memory of a long conversation. Rewrite the exchange below as " +
17
+ "notes the assistant can rely on after the original messages are gone. Keep decisions, " +
18
+ "facts, file paths, names, numbers, and anything still unresolved. Drop pleasantries and " +
19
+ "anything already superseded. Write compact prose or bullets — no preamble, no sign-off.";
20
+ /**
21
+ * How a summary message opens, which is also how `planCompaction` knows one from a system prompt.
22
+ */
23
+ export const SUMMARY_LEAD = "Summary of the earlier part of this conversation, which is no longer shown in full:\n\n";
24
+ /** A message's content as plain text: parts joined, anything but text left out. */
25
+ const textOf = (content) => typeof content === "string"
26
+ ? content
27
+ : Array.isArray(content)
28
+ ? content.map((part) => ("text" in part ? part.text : "")).join(" ")
29
+ : "";
30
+ /** What the summariser reads for one message: its text and the calls it made. */
31
+ const messageText = (message) => {
32
+ const calls = "tool_calls" in message && message.tool_calls
33
+ ? message.tool_calls
34
+ .map((call) => call.type === "function" ? `${call.function.name}(${call.function.arguments})` : "")
35
+ .join(" ")
36
+ : "";
37
+ return `${textOf(message.content)} ${calls}`.trim();
38
+ };
39
+ const isSummary = (message) => message.role === "system" && textOf(message.content).startsWith(SUMMARY_LEAD);
40
+ /**
41
+ * The transcript with every tool result but the latest few replaced by a one-line stub.
42
+ *
43
+ * The cheap half of compaction. A `read_file` of a 40k-character file is 10k tokens on every turn
44
+ * after it, and by then the model has usually taken what it wanted from it; the stub keeps the
45
+ * call answered — a call with no result is a malformed transcript — and says how much was there,
46
+ * so a model that does need it again knows to ask. Short results are kept, since a stub saves
47
+ * nothing on them. Returns the same array when there was nothing to clear. Rewrites the prefix;
48
+ * see the module comment on when to run it.
49
+ *
50
+ * @param messages The transcript. Not written to.
51
+ * @param options How many results to keep and how long one must be to clear.
52
+ */
53
+ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}) {
54
+ let kept = 0;
55
+ let out;
56
+ for (let at = messages.length - 1; at >= 0; at--) {
57
+ const message = messages[at];
58
+ if (message.role !== "tool")
59
+ continue;
60
+ if (kept++ < keepLast)
61
+ continue;
62
+ const text = textOf(message.content);
63
+ if (text.length <= maxChars || text.startsWith("[result cleared"))
64
+ continue;
65
+ out ??= [...messages];
66
+ out[at] = {
67
+ ...message,
68
+ content: `[result cleared, ${text.length.toLocaleString("en-US")} chars]`,
69
+ };
70
+ }
71
+ return out ?? messages;
72
+ }
73
+ /**
74
+ * Where to fold a transcript that has grown into its window, or `undefined` when it should not be.
75
+ *
76
+ * The kept tail is walked back from the end until it fills `keepRatio` of the window, then moved
77
+ * forward onto a user message: a transcript resuming mid-exchange — a tool result with no call
78
+ * before it, a reply with no question — is malformed and servers refuse it. The system prompts at
79
+ * the head are never folded, and a summary an earlier compaction left there is continued rather
80
+ * than summarised as if it were conversation. No plan comes back when the window is not full
81
+ * enough, or when the only legal cut folds too little to pay for the summary.
82
+ *
83
+ * @param messages The transcript, system prompts included if the caller keeps them in it.
84
+ * @param options The window, what is in use, and the ratios. See `CompactionOptions`.
85
+ */
86
+ export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, estimate = messageTokens, }) {
87
+ if (!(limit > 0))
88
+ return undefined;
89
+ const cost = used ?? messages.reduce((total, message) => total + estimate(message), 0);
90
+ if (cost < limit * compactAt)
91
+ return undefined;
92
+ let from = 0;
93
+ let previous;
94
+ while (from < messages.length && messages[from].role === "system") {
95
+ if (isSummary(messages[from]))
96
+ previous = textOf(messages[from].content).slice(SUMMARY_LEAD.length);
97
+ from++;
98
+ }
99
+ const budget = limit * keepRatio;
100
+ let kept = 0;
101
+ let cut = messages.length;
102
+ for (let at = messages.length - 1; at > from; at--) {
103
+ kept += estimate(messages[at]);
104
+ if (kept > budget)
105
+ break;
106
+ cut = at;
107
+ }
108
+ while (cut < messages.length && messages[cut].role !== "user")
109
+ cut++;
110
+ if (cut >= messages.length || cut - from < 2)
111
+ return undefined;
112
+ return { from, cut, toSummarise: messages.slice(from, cut), ...(previous ? { previous } : {}) };
113
+ }
114
+ /**
115
+ * What the summariser is handed for a plan: the earlier summary if there was one, then each
116
+ * message as its role and at most 4000 characters of its text.
117
+ *
118
+ * @param plan What `planCompaction` returned.
119
+ */
120
+ export function summaryInput(plan) {
121
+ const transcript = plan.toSummarise
122
+ .map((message) => {
123
+ const text = messageText(message);
124
+ return text ? `${message.role}: ${text.slice(0, SUMMARY_SLICE)}` : "";
125
+ })
126
+ .filter(Boolean)
127
+ .join("\n\n");
128
+ return plan.previous
129
+ ? `Notes so far:\n${plan.previous}\n\nContinue them with this exchange:\n\n${transcript}`
130
+ : transcript;
131
+ }
132
+ /**
133
+ * A summariser that asks `model` with `SUMMARY_PROMPT`, for `compactTranscript`.
134
+ *
135
+ * @param config The endpoint the summary is written through.
136
+ * @param model The model to write it, which may be a smaller one than the run's.
137
+ * @param options Cancellation and notices; the ceiling is 1024 and the instruction
138
+ * `SUMMARY_PROMPT` unless given.
139
+ */
140
+ export const summariser = (config, model, { system = SUMMARY_PROMPT, maxTokens = 1024, ...options } = {}) => (text) => ask(config, model, system, text, { maxTokens, ...options });
141
+ /**
142
+ * The transcript with the plan's stretch replaced by one system message holding its summary.
143
+ *
144
+ * `beforeCompact` is told what is being folded while the summary is written, beside it rather
145
+ * than ahead of it — a memory server filing it is not a rescue worth making the run wait for, and
146
+ * `notify` never rejects. A hook cannot veto the compaction: `HookOutcome` has no way to say so,
147
+ * and a run over its window has no better option anyway. An empty summary folds nothing, and the
148
+ * transcript comes back as it was. Rewrites the prefix; see the module comment on when to run it.
149
+ *
150
+ * @param messages The transcript the plan was made for. Not written to.
151
+ * @param plan What `planCompaction` returned for it.
152
+ * @param summarise Writes the summary from `summaryInput`'s text. See `summariser`.
153
+ * @param options Hooks to tell. `context` is extended with `compacting` and `range`, whose
154
+ * indexes are the plan's.
155
+ */
156
+ export async function compactTranscript(messages, plan, summarise, { hooks, } = {}) {
157
+ const [summary] = await Promise.all([
158
+ summarise(summaryInput(plan)),
159
+ hooks &&
160
+ notify(hooks.run, "beforeCompact", {
161
+ ...hooks.context,
162
+ compacting: turnMessages(hooks.context.session.id, messages, plan.from, plan.cut),
163
+ range: { from: plan.from, through: plan.cut },
164
+ }, hooks.onNote),
165
+ ]);
166
+ if (!summary.trim())
167
+ return messages;
168
+ return [
169
+ ...messages.slice(0, plan.from).filter((message) => !isSummary(message)),
170
+ { role: "system", content: `${SUMMARY_LEAD}${summary.trim()}` },
171
+ ...messages.slice(plan.cut),
172
+ ];
173
+ }
package/dist/config.d.ts CHANGED
@@ -30,8 +30,28 @@ export interface Endpoint {
30
30
  /** What to ask the model for. */
31
31
  export interface ModelParams {
32
32
  model: string;
33
+ /** The reply's ceiling. Zero or less sends none, leaving it to the server. */
33
34
  maxTokens: number;
34
35
  temperature: number;
36
+ /**
37
+ * `reasoning_effort` for a model that deliberates. Absent or `"off"` sends none, which is the
38
+ * only value a server that has never heard of reasoning accepts — a setting that means "leave
39
+ * the field out" rather than a level to ask for.
40
+ */
41
+ reasoningEffort?: string;
42
+ /**
43
+ * Request fields this interface cannot spell, merged into the body last by `buildBody`.
44
+ *
45
+ * What a model card asks for and nothing here names: `top_k`, `min_p`, `repeat_penalty` —
46
+ * what actually stops a small model looping — and a server's own fields, such as llama.cpp's
47
+ * `id_slot`, which pins a session to one slot of a `--parallel` server so its KV cache is
48
+ * still warm on the next turn. A local server ignores a field it does not know; OpenAI refuses
49
+ * one by name, and `negotiate` drops that name for the model and sends the request again.
50
+ * `model`, `messages`, `stream` and `tools` are the loop's and are not overridden from here.
51
+ * Ollama's `options` object is not read on its `/v1` route, so sampling there has to go in as
52
+ * top-level fields like any other.
53
+ */
54
+ extraBody?: Record<string, unknown>;
35
55
  }
36
56
  /** How tools reach the model, and how long it may keep calling them. */
37
57
  export interface ToolPolicy {
package/dist/events.d.ts CHANGED
@@ -94,6 +94,11 @@ export interface RunUsage {
94
94
  promptTokens: number;
95
95
  completionTokens: number;
96
96
  totalTokens: number;
97
+ /**
98
+ * How much of `promptTokens` the endpoint served from its prompt cache. Optional so a caller
99
+ * emitting usage before this field existed still compiles; absent reads the same as zero.
100
+ */
101
+ cachedTokens?: number;
97
102
  }
98
103
  /**
99
104
  * One thing that happened in a run, as a watcher receives it.
package/dist/hooks.d.ts CHANGED
@@ -122,12 +122,40 @@ export interface Gathered {
122
122
  notes: HookNote[];
123
123
  }
124
124
  /**
125
- * The most context all of a request's hooks add between them, in estimated tokens.
125
+ * The most context all of a request's hooks add between them by default, in estimated tokens.
126
126
  *
127
127
  * Enough for a handful of recalled memories, and small against any window worth running an agent
128
128
  * in. The point is that a generous hook cannot crowd out the conversation it was meant to inform.
129
+ * `configureHooks` moves it for a process, and `gather` and `assembleContext` for one request.
129
130
  */
130
131
  export declare const HOOK_CONTEXT_TOKENS = 2000;
132
+ /** What hooks are held to across a process. Every field optional; see `configureHooks`. */
133
+ export interface HookOptions {
134
+ /**
135
+ * The budget every injecting hook shares, when a call does not give its own. Each hook is still
136
+ * held to its own `maxTokens` inside it.
137
+ */
138
+ contextTokens?: number;
139
+ }
140
+ /**
141
+ * Changes what hooks are held to, for a process whose windows are not the size these defaults
142
+ * were chosen for.
143
+ *
144
+ * Module-level for the same reason `configureEvents` is: a budget is a deployment's setting, said
145
+ * once at startup. A caller that sizes it per model or per agent — a 128k window can afford more
146
+ * recall than an 8k one — passes `maxTokens` to `gather` instead, which wins over this.
147
+ *
148
+ * @param options The limits to change. A field left out — or given anything that is not a number
149
+ * above zero — keeps what it has, so a half-built config narrows nothing. `Infinity` is a number
150
+ * above zero, and lifts the shared budget entirely.
151
+ * @returns Everything in force afterwards, including what this call did not change.
152
+ */
153
+ export declare function configureHooks(options?: HookOptions): Required<HookOptions>;
154
+ /**
155
+ * Test seam: puts `configureHooks` back to the defaults, so one test's budget is not the next's.
156
+ * `resetAll` calls it.
157
+ */
158
+ export declare const resetHooks: () => void;
131
159
  /**
132
160
  * Said once, above the blocks, so the model reads them as background rather than instructions.
133
161
  * Names no host; `withContext` takes another for one that wants to.
@@ -148,7 +176,8 @@ export declare const HOOK_PREFACE: string;
148
176
  *
149
177
  * @param outcomes What the runners returned. An injecting outcome on an event that cannot inject
150
178
  * adds nothing; a failed one is noted wherever it falls, including past the budget.
151
- * @param maxTokens The budget every block shares. Defaults to `HOOK_CONTEXT_TOKENS`.
179
+ * @param maxTokens The budget every block shares. Absent, or not a number above zero, is what
180
+ * `configureHooks` last set — `HOOK_CONTEXT_TOKENS` unless something moved it.
152
181
  */
153
182
  export declare function assembleContext(outcomes: readonly HookOutcome[], maxTokens?: number): Gathered;
154
183
  /**
@@ -217,7 +246,8 @@ export declare const turnIndex: (messages: readonly {
217
246
  * @param context What the hooks are told.
218
247
  * @param options `signal` is handed to the runner, and should be the turn's own: a user who
219
248
  * stopped the turn stopped its recall. `onNote` hears each note as the whole is assembled.
220
- * `maxTokens` is the shared budget, `HOOK_CONTEXT_TOKENS` if absent.
249
+ * `maxTokens` is the shared budget for this request, read as `assembleContext` reads it: absent
250
+ * or unusable is the process's, from `configureHooks`.
221
251
  */
222
252
  export declare function gather(run: HookRunner, events: readonly HookEvent[], context: HookContext, { signal, onNote, maxTokens, }?: {
223
253
  signal?: AbortSignal;
package/dist/hooks.js CHANGED
@@ -16,12 +16,50 @@ export const HOOK_EVENTS = [
16
16
  */
17
17
  export const INJECT_EVENTS = new Set(["sessionStart", "beforeTurn"]);
18
18
  /**
19
- * The most context all of a request's hooks add between them, in estimated tokens.
19
+ * The most context all of a request's hooks add between them by default, in estimated tokens.
20
20
  *
21
21
  * Enough for a handful of recalled memories, and small against any window worth running an agent
22
22
  * in. The point is that a generous hook cannot crowd out the conversation it was meant to inform.
23
+ * `configureHooks` moves it for a process, and `gather` and `assembleContext` for one request.
23
24
  */
24
25
  export const HOOK_CONTEXT_TOKENS = 2000;
26
+ /** The numbers this module was written with. */
27
+ const HOOK_DEFAULTS = { contextTokens: HOOK_CONTEXT_TOKENS };
28
+ /** What is in force now. Read where it is used, so a change applies from the next request. */
29
+ let hookLimits = { ...HOOK_DEFAULTS };
30
+ /**
31
+ * Changes what hooks are held to, for a process whose windows are not the size these defaults
32
+ * were chosen for.
33
+ *
34
+ * Module-level for the same reason `configureEvents` is: a budget is a deployment's setting, said
35
+ * once at startup. A caller that sizes it per model or per agent — a 128k window can afford more
36
+ * recall than an 8k one — passes `maxTokens` to `gather` instead, which wins over this.
37
+ *
38
+ * @param options The limits to change. A field left out — or given anything that is not a number
39
+ * above zero — keeps what it has, so a half-built config narrows nothing. `Infinity` is a number
40
+ * above zero, and lifts the shared budget entirely.
41
+ * @returns Everything in force afterwards, including what this call did not change.
42
+ */
43
+ export function configureHooks(options = {}) {
44
+ for (const [name, value] of Object.entries(options)) {
45
+ if (typeof value === "number" && value > 0)
46
+ hookLimits[name] = value;
47
+ }
48
+ return { ...hookLimits };
49
+ }
50
+ /**
51
+ * Test seam: puts `configureHooks` back to the defaults, so one test's budget is not the next's.
52
+ * `resetAll` calls it.
53
+ */
54
+ export const resetHooks = () => {
55
+ hookLimits = { ...HOOK_DEFAULTS };
56
+ };
57
+ /**
58
+ * The budget a call is held to: its own when it gave a usable one, the process's otherwise. The
59
+ * same rule `configureHooks` applies, so a `0` threaded through for "no opinion" does not quietly
60
+ * turn every hook's context off.
61
+ */
62
+ const budget = (given) => typeof given === "number" && given > 0 ? given : hookLimits.contextTokens;
25
63
  /**
26
64
  * Said once, above the blocks, so the model reads them as background rather than instructions.
27
65
  * Names no host; `withContext` takes another for one that wants to.
@@ -44,12 +82,13 @@ const attribute = (text) => text.replaceAll("&", "&amp;").replaceAll('"', "&quot
44
82
  *
45
83
  * @param outcomes What the runners returned. An injecting outcome on an event that cannot inject
46
84
  * adds nothing; a failed one is noted wherever it falls, including past the budget.
47
- * @param maxTokens The budget every block shares. Defaults to `HOOK_CONTEXT_TOKENS`.
85
+ * @param maxTokens The budget every block shares. Absent, or not a number above zero, is what
86
+ * `configureHooks` last set — `HOOK_CONTEXT_TOKENS` unless something moved it.
48
87
  */
49
- export function assembleContext(outcomes, maxTokens = HOOK_CONTEXT_TOKENS) {
88
+ export function assembleContext(outcomes, maxTokens) {
50
89
  const blocks = [];
51
90
  const notes = [];
52
- let remaining = maxTokens;
91
+ let remaining = budget(maxTokens);
53
92
  for (const outcome of outcomes) {
54
93
  const base = { event: outcome.event, source: outcome.label, hookId: outcome.hookId };
55
94
  if (!outcome.ok) {
@@ -182,7 +221,8 @@ const runSafely = (run, event, context, signal) => Promise.resolve()
182
221
  * @param context What the hooks are told.
183
222
  * @param options `signal` is handed to the runner, and should be the turn's own: a user who
184
223
  * stopped the turn stopped its recall. `onNote` hears each note as the whole is assembled.
185
- * `maxTokens` is the shared budget, `HOOK_CONTEXT_TOKENS` if absent.
224
+ * `maxTokens` is the shared budget for this request, read as `assembleContext` reads it: absent
225
+ * or unusable is the process's, from `configureHooks`.
186
226
  */
187
227
  export async function gather(run, events, context, { signal, onNote, maxTokens, } = {}) {
188
228
  const outcomes = await Promise.all(events.map((event) => runSafely(run, event, context, signal)));
package/dist/index.d.ts CHANGED
@@ -9,18 +9,22 @@
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
13
  export { type Capabilities, capabilitiesFor, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
13
14
  export type { CatalogServer } from "./catalog.ts";
14
15
  export { contextLimitFor, getClient, listModels, type ModelInfo, NO_KEY, resetClients, timeoutMs, } from "./client.ts";
16
+ export { COMPACT_AT, type CompactionOptions, type CompactionPlan, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.ts";
15
17
  export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
16
18
  export { errorMessage } from "./errors.ts";
17
19
  export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, resetEvents, watch, } from "./events.ts";
18
- export { assembleContext, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, turnIndex, turnMessages, withContext, } from "./hooks.ts";
20
+ export { assembleContext, configureHooks, 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, withContext, } from "./hooks.ts";
19
21
  export { resetAll } from "./reset.ts";
20
- export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
22
+ export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, messageTokens, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
21
23
  export { type RunTurnOptions, runTurn } from "./run-turn.ts";
22
24
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
23
- export { ask, clean, listLines, parseJson, resetHints, type SideTaskOptions, tryAsk, } from "./side-task.ts";
25
+ export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTaskOptions, tryAsk, } from "./side-task.ts";
26
+ export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from "./snapshot.ts";
24
27
  export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
25
28
  export { estimateTokens } from "./tokens.ts";
26
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.ts";
29
+ export { parseToolArguments, recoverToolCalls, ToolArgumentsError, type ToolCall, } from "./tool-calls.ts";
30
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.ts";
package/dist/index.js CHANGED
@@ -9,16 +9,20 @@
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 { buildBody, preselect, preview, resolveApiKey, runAgentLoop, } from "./agent-loop.js";
12
13
  export { capabilitiesFor, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
13
14
  export { contextLimitFor, getClient, listModels, NO_KEY, resetClients, timeoutMs, } from "./client.js";
15
+ export { COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.js";
14
16
  export { errorMessage } from "./errors.js";
15
17
  export { configureEvents, emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
16
- export { assembleContext, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, INJECT_EVENTS, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
18
+ export { assembleContext, configureHooks, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, withContext, } from "./hooks.js";
17
19
  export { resetAll } from "./reset.js";
18
- export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
20
+ export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, messageTokens, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
19
21
  export { runTurn } from "./run-turn.js";
20
22
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
21
- export { ask, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
23
+ export { ask, askJson, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
24
+ export { CAPABILITY_SNAPSHOT_VERSION, exportCapabilities, importCapabilities, } from "./snapshot.js";
22
25
  export { streamTurn, } from "./stream.js";
23
26
  export { estimateTokens } from "./tokens.js";
24
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
27
+ export { parseToolArguments, recoverToolCalls, ToolArgumentsError, } from "./tool-calls.js";
28
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
package/dist/reset.d.ts CHANGED
@@ -1,20 +1,21 @@
1
1
  /**
2
2
  * Forgets everything this package remembers between calls.
3
3
  *
4
- * Four modules here keep state for the life of the process, each for a good reason and each
4
+ * Five modules here keep state for the life of the process, each for a good reason and each
5
5
  * with its own seam: the pooled clients and their model listings, the endpoints that turned
6
6
  * out not to take `stream_options` or a grammar, the models that refused the no-thinking
7
- * hints, and the event bus. `resetClients`, `resetCapabilities`, `resetHints` and `resetEvents` stay
8
- * exported, because a test that means to clear one thing should say so.
7
+ * hints, the event bus, and the hooks' configured budget. `resetClients`, `resetCapabilities`,
8
+ * `resetHints`, `resetEvents` and `resetHooks` stay exported, because a test that means to clear
9
+ * one thing should say so.
9
10
  *
10
- * This is for the other case, which is every teardown. What all four hold is *latched
11
+ * This is for the other case, which is every teardown. What they hold is *latched
11
12
  * refusals* — a fact one test taught the process about an endpoint, still true as far as the
12
13
  * next test can tell. Miss one and the suite becomes order-dependent in the way that passes
13
14
  * locally and fails in CI on a different shard: the test that latched it still passes, and the
14
15
  * one that reads the latch fails only when it happens to run second. `tests/side-task-hints.test.ts`
15
16
  * was written that way and only passed because every case had been handed a hostname of its own.
16
17
  *
17
- * It is also the seam that does not need finding again. A fifth module with a cache is a fifth
18
+ * It is also the seam that does not need finding again. A sixth module with a cache is a sixth
18
19
  * line here, rather than an edit to the teardown of three consumers who will not all notice.
19
20
  */
20
21
  export declare function resetAll(): void;
package/dist/reset.js CHANGED
@@ -1,24 +1,26 @@
1
1
  import { resetCapabilities } from "./capabilities.js";
2
2
  import { resetClients } from "./client.js";
3
3
  import { resetEvents } from "./events.js";
4
+ import { resetHooks } from "./hooks.js";
4
5
  import { resetHints } from "./side-task.js";
5
6
  /**
6
7
  * Forgets everything this package remembers between calls.
7
8
  *
8
- * Four modules here keep state for the life of the process, each for a good reason and each
9
+ * Five modules here keep state for the life of the process, each for a good reason and each
9
10
  * with its own seam: the pooled clients and their model listings, the endpoints that turned
10
11
  * out not to take `stream_options` or a grammar, the models that refused the no-thinking
11
- * hints, and the event bus. `resetClients`, `resetCapabilities`, `resetHints` and `resetEvents` stay
12
- * exported, because a test that means to clear one thing should say so.
12
+ * hints, the event bus, and the hooks' configured budget. `resetClients`, `resetCapabilities`,
13
+ * `resetHints`, `resetEvents` and `resetHooks` stay exported, because a test that means to clear
14
+ * one thing should say so.
13
15
  *
14
- * This is for the other case, which is every teardown. What all four hold is *latched
16
+ * This is for the other case, which is every teardown. What they hold is *latched
15
17
  * refusals* — a fact one test taught the process about an endpoint, still true as far as the
16
18
  * next test can tell. Miss one and the suite becomes order-dependent in the way that passes
17
19
  * locally and fails in CI on a different shard: the test that latched it still passes, and the
18
20
  * one that reads the latch fails only when it happens to run second. `tests/side-task-hints.test.ts`
19
21
  * was written that way and only passed because every case had been handed a hostname of its own.
20
22
  *
21
- * It is also the seam that does not need finding again. A fifth module with a cache is a fifth
23
+ * It is also the seam that does not need finding again. A sixth module with a cache is a sixth
22
24
  * line here, rather than an edit to the teardown of three consumers who will not all notice.
23
25
  */
24
26
  export function resetAll() {
@@ -26,4 +28,5 @@ export function resetAll() {
26
28
  resetCapabilities();
27
29
  resetHints();
28
30
  resetEvents();
31
+ resetHooks();
29
32
  }
package/dist/retry.d.ts CHANGED
@@ -47,6 +47,15 @@ export declare const compact: (tokens: number) => string;
47
47
  * @param body The request as it will be sent, tools included.
48
48
  */
49
49
  export declare const requestTokens: (body: OpenAI.ChatCompletionCreateParamsStreaming) => number;
50
+ /**
51
+ * One message's estimated tokens, by the same count `requestTokens` sums for a whole request.
52
+ *
53
+ * For the arithmetic that weighs part of a transcript against a window — `planCompaction`'s kept
54
+ * tail — where `estimateTokens` on the text alone would leave out the calls and the envelope.
55
+ *
56
+ * @param message The message as it will be sent.
57
+ */
58
+ export declare const messageTokens: (message: OpenAI.ChatCompletionMessageParam) => number;
50
59
  /**
51
60
  * Whether a refusal means the request was too big, rather than merely refused.
52
61
  *
package/dist/retry.js CHANGED
@@ -136,6 +136,15 @@ export const requestTokens = (body) => {
136
136
  chars += messageChars(message);
137
137
  return Math.ceil(chars / CHARS_PER_TOKEN) + (body.tools?.length ? toolsCost(body.tools) : 0);
138
138
  };
139
+ /**
140
+ * One message's estimated tokens, by the same count `requestTokens` sums for a whole request.
141
+ *
142
+ * For the arithmetic that weighs part of a transcript against a window — `planCompaction`'s kept
143
+ * tail — where `estimateTokens` on the text alone would leave out the calls and the envelope.
144
+ *
145
+ * @param message The message as it will be sent.
146
+ */
147
+ export const messageTokens = (message) => Math.ceil(messageChars(message) / CHARS_PER_TOKEN);
139
148
  /**
140
149
  * Servers refuse an over-long request in their own words; these are the ones worth reading as
141
150
  * that rather than as a broken request. Matched loosely — every one of them is some