@cubicecho/agent-core 2.4.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/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
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
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/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
@@ -39,8 +39,9 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
39
39
  /**
40
40
  * What the model will read, in tokens. Zero — the default — sends whatever it is given.
41
41
  *
42
- * With a limit, the request is sized before it is sent and a `ContextOverflow` is raised here
43
- * rather than by the endpoint one round trip later. It is opt-in because the number is the
42
+ * With a limit, the request is sized before it is sent the prompt plus the reply ceiling
43
+ * the body carries, since that is what the endpoint weighs and a `ContextOverflow` is raised
44
+ * here rather than by the endpoint one round trip later. It is opt-in because the number is the
44
45
  * caller's to find: `contextLimitFor` asks the endpoint, an operator's own setting overrides
45
46
  * it, and neither is something a turn should be doing network I/O to discover. A limit below
46
47
  * `SMALLEST_LIKELY_WINDOW` is not believed — a model with a window that small is rare enough
@@ -57,6 +58,11 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
57
58
  * `request` has to know what this model refused before it can build one that avoids it.
58
59
  */
59
60
  model?: string;
61
+ /**
62
+ * The body fields a refusal may take away when the endpoint has never heard of them — the keys
63
+ * of `extraBody`, ordinarily. See `NegotiateOptions.droppable`; it needs `model` too.
64
+ */
65
+ droppable?: Iterable<string>;
60
66
  }
61
67
  /**
62
68
  * `request` is a callback rather than a body because the body has to be rebuilt from whatever
@@ -71,4 +77,4 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
71
77
  * @param options Retry budget, context limit, the model to negotiate for, notices, and the
72
78
  * stream's own callbacks.
73
79
  */
74
- export declare function runTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities, model: ModelCapabilities | undefined) => OpenAI.ChatCompletionCreateParamsStreaming, { maxRetries, onNotice, contextLimit, model, ...stream }?: RunTurnOptions): Promise<Turn>;
80
+ export declare function runTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities, model: ModelCapabilities | undefined) => OpenAI.ChatCompletionCreateParamsStreaming, { maxRetries, onNotice, contextLimit, model, droppable, ...stream }?: RunTurnOptions): Promise<Turn>;
package/dist/run-turn.js CHANGED
@@ -15,7 +15,7 @@ import { streamTurn } from "./stream.js";
15
15
  * @param options Retry budget, context limit, the model to negotiate for, notices, and the
16
16
  * stream's own callbacks.
17
17
  */
18
- export async function runTurn(client, supports, request, { maxRetries = 0, onNotice, contextLimit = 0, model, ...stream } = {}) {
18
+ export async function runTurn(client, supports, request, { maxRetries = 0, onNotice, contextLimit = 0, model, droppable, ...stream } = {}) {
19
19
  // Sized once rather than per build. `request` is called again for every downgrade and every
20
20
  // retry, but a downgraded body is strictly smaller than the one before it and the transcript
21
21
  // does not change between attempts — so the first body is the one worth measuring, and
@@ -26,11 +26,18 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
26
26
  if (!sized && contextLimit >= SMALLEST_LIKELY_WINDOW) {
27
27
  sized = true;
28
28
  const needed = requestTokens(body);
29
+ // The endpoint refuses on the prompt plus the reply — llama.cpp sizes the slot with
30
+ // `n_predict` in, OpenAI with the ceiling — so a prompt that fits the window but not the
31
+ // window less the ceiling was let through here to be refused one round trip later, which
32
+ // is the trip this guard exists to save. Read off the body under whichever spelling was
33
+ // chosen. No ceiling reserves nothing: the server then gives the reply what is left.
34
+ const reserve = Math.max(0, body.max_completion_tokens ?? body.max_tokens ?? 0);
29
35
  // Not retried, and deliberately not a capability: `isTransient` refuses it and none of the
30
36
  // words below are ones `negotiate` reads as a refusal it can answer, so this leaves both
31
37
  // loops on the first attempt instead of being sent again to be refused again.
32
- if (needed > contextLimit) {
33
- throw new ContextOverflow(`the request is about ${compact(needed)} tokens, over this model's ${compact(contextLimit)}`);
38
+ if (needed + reserve > contextLimit) {
39
+ const reserved = reserve ? ` plus ${compact(reserve)} reserved for the reply` : "";
40
+ throw new ContextOverflow(`the request is about ${compact(needed)} tokens${reserved}, over this model's ${compact(contextLimit)}`);
34
41
  }
35
42
  }
36
43
  return body;
@@ -38,7 +45,7 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
38
45
  for (let attempt = 0;; attempt++) {
39
46
  const produced = { any: false };
40
47
  try {
41
- return await negotiate(supports, (capabilities, box, forModel) => streamTurn(client, measured(capabilities, forModel), { ...stream, produced: box }), { produced, onNotice, model });
48
+ return await negotiate(supports, (capabilities, box, forModel) => streamTurn(client, measured(capabilities, forModel), { ...stream, produced: box }), { produced, onNotice, model, droppable });
42
49
  }
43
50
  catch (error) {
44
51
  // The abort is read before the classification, not after. A run stopped by its operator
@@ -1,4 +1,8 @@
1
1
  import type { Endpoint } from "./config.ts";
2
+ /** An (endpoint, model) pair as `noHints` holds it: `[endpointId, model]`, stringified. */
3
+ export declare const hintKey: (endpoint: string, model: string) => string;
4
+ /** The pairs that refused the hints, the live set, for `exportCapabilities` and `importCapabilities`. */
5
+ export declare const refusedHints: () => Set<string>;
2
6
  /** Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints. */
3
7
  export declare const resetHints: () => void;
4
8
  /** What a side task may be given. All optional — one given none of them still runs. */
@@ -32,7 +36,37 @@ export interface SideTaskOptions {
32
36
  * @param user The input it applies to.
33
37
  * @param options Reply ceiling, temperature, cancellation, notices.
34
38
  */
35
- export declare function ask(config: Endpoint, model: string, system: string, user: string, { maxTokens, temperature, signal, onNotice }?: SideTaskOptions): Promise<string>;
39
+ export declare function ask(config: Endpoint, model: string, system: string, user: string, options?: SideTaskOptions): Promise<string>;
40
+ /** What `askJson` takes besides a side task's options. */
41
+ export interface AskJsonOptions extends SideTaskOptions {
42
+ /** What the schema is called in the request, `answer` by default. Letters, digits, `_` and `-`. */
43
+ name?: string;
44
+ /**
45
+ * Asks the server to hold the reply to the schema exactly, true by default. OpenAI's strict mode
46
+ * wants every property required and `additionalProperties: false`; a schema written otherwise
47
+ * wants this off there.
48
+ */
49
+ strict?: boolean;
50
+ }
51
+ /**
52
+ * A side task whose answer is JSON matching a schema, parsed. Undefined when no JSON came back.
53
+ * Throws like any request.
54
+ *
55
+ * Sends `response_format` with the schema, which a llama.cpp server compiles into a grammar and
56
+ * vLLM, LM Studio, Ollama and OpenAI each hold the reply to, so a small model that wraps JSON in
57
+ * prose cannot. The schema is normalised as a tool's parameters are, and relaxed where the endpoint
58
+ * could not build a grammar, since the same converter reads both. A model that refuses the field
59
+ * latches it off and is asked in words: the schema rides on the system prompt either way, and
60
+ * the reply goes through `parseJson`, which finds the JSON in whatever came back.
61
+ *
62
+ * @param config Where to send it and how long to wait.
63
+ * @param model The model to ask.
64
+ * @param system The instruction. The schema is appended to it.
65
+ * @param user The input it applies to.
66
+ * @param schema The JSON Schema of the answer. Its root is held to an object, as a tool's is.
67
+ * @param options A side task's options, plus the schema's `name` and whether it is `strict`.
68
+ */
69
+ export declare function askJson<T>(config: Endpoint, model: string, system: string, user: string, schema: Record<string, unknown>, { name, strict, ...options }?: AskJsonOptions): Promise<T | undefined>;
36
70
  /**
37
71
  * A side task is never worth failing the work it supports. Callers that can carry on without
38
72
  * an answer use this and get `undefined` instead of an exception.