@cubicecho/agent-core 4.15.0 → 4.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -1
- package/dist/context/compaction.d.ts +133 -0
- package/dist/context/compaction.js +129 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/run/agent-loop.d.ts +38 -2
- package/dist/run/agent-loop.js +111 -17
- package/dist/run/run-calls.d.ts +2 -0
- package/dist/run/run-calls.js +6 -1
- package/llms.txt +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ only, Node >=22.
|
|
|
41
41
|
| `run-calls` | The tools between two turns: a step's calls read, run and written into the transcript, with every call answered whatever happens. `preview` cuts a long argument or result for a watcher. |
|
|
42
42
|
| `preselect` | Choosing a run's tools before it starts: `preselect` asks a small model, `preselectByKeywords` scores the request's own words against the tool names without one, and `preselectSystem`, `preselectInput` and `preselection` are the prompt and the reading of the reply for a host that makes the call itself. |
|
|
43
43
|
| `tool-calls` | Reading what a model meant by a tool call it did not write cleanly: `parseToolArguments` repairs almost-JSON arguments and says when they were cut off, `recoverToolCalls` finds calls written into the reply as text. Plus the shapes a call is handed round in: `ToolCallRequest`, `ToolCallResult`, `ToolCallOutcome`. |
|
|
44
|
-
| `compaction` | Keeping a long run inside its window: `pruneToolResults` clears stale tool results, `planCompaction` and `compactTranscript` fold the oldest stretch into a summary. |
|
|
44
|
+
| `compaction` | Keeping a long run inside its window: `pruneToolResults` clears stale tool results, `planPrune` moves a boundary for them that keeps the prompt cache, `planCompaction` and `compactTranscript` fold the oldest stretch into a summary. |
|
|
45
45
|
| `ledger` | What stretches of a transcript cost, read off the prompt counts the server reported rather than estimated: `recordRequest`, `tokensBetween`, `estimateFrom`, `rebaseLedger`. |
|
|
46
46
|
| `snapshot` | `exportCapabilities` and `importCapabilities`: the latched refusals as a JSON blob a consumer stores, so a restart need not learn them again. |
|
|
47
47
|
| `spec` | `parseSpec`, `resolveAgentSpec` and `exportSpec`: an agent as a versioned JSON document, layered into the flat config the loop takes. Imports nothing but types, and is published separately at `@cubicecho/agent-core/spec`. `defineSpecColumns` maps a stored row onto a layer and back, at `@cubicecho/agent-core/spec/columns`. |
|
|
@@ -470,6 +470,7 @@ const { turn, messages, usage, loaded } = await runAgentLoop({
|
|
|
470
470
|
messages: history, // ending in the question; not written to
|
|
471
471
|
tools, // every tool the run may reach
|
|
472
472
|
catalog, // the same, name-only, for on-demand and proxied loading
|
|
473
|
+
hostTools, // the host's own, declared in every mode and never catalogued
|
|
473
474
|
serverInstructions: true, // the catalogue's `instructions` after `system`; off by default
|
|
474
475
|
preselected, // from `preselect`, if a small model chose
|
|
475
476
|
dispatch: ({ name, args }, signal) => pool.call(name, args, signal),
|
|
@@ -688,6 +689,27 @@ a `tool-call` and a `tool-result` event, told to `onToolCall` and `onToolResult`
|
|
|
688
689
|
id is numbered by where the call lands in the transcript, so two questions do not share one. A name
|
|
689
690
|
with no definition in `tools`, or outside the catalogue, is left out, and none left is no exchange.
|
|
690
691
|
|
|
692
|
+
A host with tools of its own — a question for a person, a resource read — passes them as
|
|
693
|
+
`hostTools`, and they are declared on every step in every mode: after `PROXY_TOOLS` proxied, after
|
|
694
|
+
`load_tools` and ahead of anything loaded on demand, after `tools` in eager mode, and after the
|
|
695
|
+
shortlist on a preselected first step. They reach `dispatch` like any other call and are kept out
|
|
696
|
+
of discovery: taken out of `catalog`, `loaded` and `preselected` before the run starts, never
|
|
697
|
+
counted as a load, and a `call_tool` naming one is refused with a pointer to call it directly.
|
|
698
|
+
Proxied and eager, the array is one set for the whole run, so the cache holds whatever `toolOrder`
|
|
699
|
+
does with it; on demand, `toolOrder: false` keeps them in the prefix every load appends after.
|
|
700
|
+
|
|
701
|
+
```ts
|
|
702
|
+
await runAgentLoop({
|
|
703
|
+
config: { ...config, toolDiscovery: "proxy" },
|
|
704
|
+
messages: history,
|
|
705
|
+
tools: pool.tools(),
|
|
706
|
+
catalog: pool.catalog(),
|
|
707
|
+
hostTools: [ASK_PERSON_DEFINITION], // declared beside load_tools and call_tool, on every step
|
|
708
|
+
dispatch: (call, signal) =>
|
|
709
|
+
call.name === ASK_PERSON ? ask(call.args) : pool.call(call.name, call.args, signal),
|
|
710
|
+
});
|
|
711
|
+
```
|
|
712
|
+
|
|
691
713
|
### What the servers said on the way
|
|
692
714
|
|
|
693
715
|
A server can log while a call runs — a warning that the cache was stale, an error it recovered
|
|
@@ -1067,6 +1089,38 @@ way to save tokens.
|
|
|
1067
1089
|
`pruneToolResults` keeps the transcript's indexes, so a plan made before pruning still applies to
|
|
1068
1090
|
what it returns, as above.
|
|
1069
1091
|
|
|
1092
|
+
### A prune boundary that keeps the cache
|
|
1093
|
+
|
|
1094
|
+
`pruneToolResults` keeps a sliding window: applied on every step, each new result pushes an older
|
|
1095
|
+
one out and rewrites a message in the middle of the history, so the cache misses from there once
|
|
1096
|
+
per tool call. `planPrune` plans a boundary instead. The host stores its `through`, sends
|
|
1097
|
+
`pruneThrough(messages, through)` on every request, and between moves each request is the one
|
|
1098
|
+
before it with only its tail added.
|
|
1099
|
+
|
|
1100
|
+
```ts
|
|
1101
|
+
const plan = planPrune(messages, config.contextLength ?? 0, { through: stored, used: lastPromptTokens });
|
|
1102
|
+
if (plan) {
|
|
1103
|
+
stored = plan.through;
|
|
1104
|
+
notice(pruneNotice(plan)); // cleared 4 old tool results (~9,120 tokens) as the window filled
|
|
1105
|
+
}
|
|
1106
|
+
const sent = pruneThrough(messages, stored);
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
A plan never moves the boundary backwards and never moves it for nothing. It moves to the earliest
|
|
1110
|
+
of the latest `keepLast` (5) results once the window is past `at` (`COMPACT_AT`, the fold's own
|
|
1111
|
+
line) — by the larger of `used` and the estimate, since a report does not count the results that
|
|
1112
|
+
came after it — and only when what it clears is at least `minShare` (`PRUNE_SHARE`, a tenth) of
|
|
1113
|
+
the window. `compacted: true`, beside a fold that has rewritten the head anyway, skips both gates:
|
|
1114
|
+
the cache is already lost, so the move is free. `from` leaves out results a fold has already taken
|
|
1115
|
+
out of the request. `at: 0` with a larger `minShare` is a pure hysteresis on what is won back.
|
|
1116
|
+
|
|
1117
|
+
`runAgentLoop({ prune: true })` does this itself: after `beforeStep`, ahead of `compact`, which
|
|
1118
|
+
then plans from what is left — so a prune that brings the run back under the line spares it the
|
|
1119
|
+
summary — and again with `compacted` after a fold. Each move is a `notice` with the counts. Its
|
|
1120
|
+
stubs stay in the loop's transcript and in the result's `messages`, as a fold's summary does; a
|
|
1121
|
+
host that keeps the transcript whole calls `planPrune` in its own `beforeStep` and stores the
|
|
1122
|
+
boundary. An object moves `at`, `minShare`, `keepLast` and `maxChars`.
|
|
1123
|
+
|
|
1070
1124
|
### What the messages cost, measured
|
|
1071
1125
|
|
|
1072
1126
|
`planCompaction` decides where to cut by estimating each message, and a calibrated divisor fixes
|
|
@@ -63,6 +63,139 @@ export interface PruneOptions {
|
|
|
63
63
|
* see the module comment on when to run it.
|
|
64
64
|
*/
|
|
65
65
|
export declare function pruneToolResults(messages: Message[], { keepLast, maxChars }?: PruneOptions): Message[];
|
|
66
|
+
/**
|
|
67
|
+
* The share of the window a prune has to win back on its own before it is worth the prompt cache
|
|
68
|
+
* it costs. Below it the request after a prune is barely smaller than the one before, and every
|
|
69
|
+
* token of it is processed again anyway.
|
|
70
|
+
*/
|
|
71
|
+
export declare const PRUNE_SHARE = 0.1;
|
|
72
|
+
/** What `planPrune` takes beside the transcript and the window. */
|
|
73
|
+
export interface PrunePlanOptions extends PruneOptions {
|
|
74
|
+
/**
|
|
75
|
+
* The boundary in force: results before it are already sent as stubs. A plan only ever moves it
|
|
76
|
+
* forward.
|
|
77
|
+
*
|
|
78
|
+
* @defaultValue `0`
|
|
79
|
+
*/
|
|
80
|
+
through?: number;
|
|
81
|
+
/**
|
|
82
|
+
* The share of the window a move has to clear, in tokens, to be made on its own. Zero makes any
|
|
83
|
+
* move that clears something.
|
|
84
|
+
*
|
|
85
|
+
* @defaultValue `PRUNE_SHARE`
|
|
86
|
+
*/
|
|
87
|
+
minShare?: number;
|
|
88
|
+
/**
|
|
89
|
+
* The share of the window in use before a move is considered at all. Zero considers one on every
|
|
90
|
+
* step, and the hysteresis is `minShare`'s alone.
|
|
91
|
+
*
|
|
92
|
+
* @defaultValue `COMPACT_AT`, so a prune is paid for when a fold would be
|
|
93
|
+
*/
|
|
94
|
+
at?: number;
|
|
95
|
+
/**
|
|
96
|
+
* What the transcript costs now — the last request's reported prompt tokens. The fill weighed
|
|
97
|
+
* against `at` is the larger of it and the estimate, since a report does not count the results
|
|
98
|
+
* that arrived after it, which are what fills a window.
|
|
99
|
+
*/
|
|
100
|
+
used?: number;
|
|
101
|
+
/**
|
|
102
|
+
* The first message still sent. Results before it were folded into a summary and are no saving
|
|
103
|
+
* of this one, so they are not counted.
|
|
104
|
+
*
|
|
105
|
+
* @defaultValue `0`
|
|
106
|
+
*/
|
|
107
|
+
from?: number;
|
|
108
|
+
/**
|
|
109
|
+
* The head of the request was rewritten on this step anyway — a fold was just stored — so the
|
|
110
|
+
* cache is lost whatever a move does: `at` and `minShare` are not asked, and a move that clears
|
|
111
|
+
* anything is made, with a window known or not.
|
|
112
|
+
*
|
|
113
|
+
* @defaultValue `false`
|
|
114
|
+
*/
|
|
115
|
+
compacted?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* One message's tokens.
|
|
118
|
+
*
|
|
119
|
+
* @defaultValue `messageTokens` divided by `charsPerToken`
|
|
120
|
+
*/
|
|
121
|
+
estimate?: (message: Message) => number;
|
|
122
|
+
/**
|
|
123
|
+
* The divisor the default `estimate` uses. Ignored beside an `estimate` of the caller's own.
|
|
124
|
+
*
|
|
125
|
+
* @defaultValue `4`
|
|
126
|
+
*/
|
|
127
|
+
charsPerToken?: number;
|
|
128
|
+
}
|
|
129
|
+
/** Where `planPrune` puts the boundary, and what moving it there clears. */
|
|
130
|
+
export interface PrunePlan {
|
|
131
|
+
/**
|
|
132
|
+
* The new boundary: tool results before it are sent as stubs (`pruneThrough`), results at or
|
|
133
|
+
* after it whole. Always greater than the one in force. An index into the transcript the plan
|
|
134
|
+
* was made for, which the host stores.
|
|
135
|
+
*/
|
|
136
|
+
through: number;
|
|
137
|
+
/** How many results the move stubs that were being sent whole. */
|
|
138
|
+
cleared: number;
|
|
139
|
+
/** Their estimated tokens, less the stubs', rounded. */
|
|
140
|
+
tokens: number;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The transcript with every tool result before `through` replaced by a stub.
|
|
144
|
+
*
|
|
145
|
+
* @param messages - The transcript. Not written to.
|
|
146
|
+
* @param through - The boundary, as a `PrunePlan` has it. Clamped to the transcript, so one left
|
|
147
|
+
* past its end cannot reach messages not yet written.
|
|
148
|
+
* @param [options] - `maxChars`, as `pruneToolResults` reads it.
|
|
149
|
+
* @returns `messages` itself when nothing was cleared, otherwise a new array of the same length in
|
|
150
|
+
* which each cleared result is a new message and every other one the same object.
|
|
151
|
+
*
|
|
152
|
+
* @remarks
|
|
153
|
+
* `pruneToolResults` over the head, keeping none of it — so a result that is short or carries
|
|
154
|
+
* definitions stays whole here as it does there. A function of the messages before `through` and
|
|
155
|
+
* nothing else: while the boundary stays put, what this sends for them does not change however
|
|
156
|
+
* many results arrive after it, which is what keeps the prompt cache.
|
|
157
|
+
*/
|
|
158
|
+
export declare function pruneThrough(messages: Message[], through: number, { maxChars }?: Pick<PruneOptions, 'maxChars'>): Message[];
|
|
159
|
+
/**
|
|
160
|
+
* Where to move the prune boundary, or `undefined` when it should stay where it is.
|
|
161
|
+
*
|
|
162
|
+
* @param messages - The transcript as the host keeps it — whole, or with earlier stubs in it.
|
|
163
|
+
* @param window - The model's window, in tokens. Zero or less moves nothing on its own.
|
|
164
|
+
* @param [options] - The boundary in force, the gate, what to keep, and how to weigh a message.
|
|
165
|
+
* See `PrunePlanOptions`.
|
|
166
|
+
* @returns The plan, or `undefined`. `messages` is not written to.
|
|
167
|
+
*
|
|
168
|
+
* @remarks
|
|
169
|
+
* `pruneToolResults` keeps a sliding window of results: applied on every step, each new result
|
|
170
|
+
* pushes an older one out, rewrites a message in the middle of the history and loses the prompt
|
|
171
|
+
* cache from there on, once per tool call. A plan is a boundary instead. The host stores
|
|
172
|
+
* `through` and sends `pruneThrough(messages, through)` on every request, and between moves a
|
|
173
|
+
* request is the one before it with only its tail added.
|
|
174
|
+
*
|
|
175
|
+
* The rule, in the order it is asked:
|
|
176
|
+
*
|
|
177
|
+
* - **Never backwards, and never for nothing.** The target is the earliest of the latest
|
|
178
|
+
* `keepLast` results. If that is not past `through`, or moving there would clear nothing still
|
|
179
|
+
* being sent, there is no plan.
|
|
180
|
+
* - **With a fold, for free.** Given `compacted`, the move is made.
|
|
181
|
+
* - **Otherwise once the window is filling**, past `at` of it, and only when what it clears is at
|
|
182
|
+
* least `minShare` of it. A run sitting just over the line would otherwise slide the boundary
|
|
183
|
+
* forward and lose the cache on every step for a few hundred tokens each time.
|
|
184
|
+
*
|
|
185
|
+
* What is weighed is what the move would *clear*, by `estimate`: a short result and a proxied
|
|
186
|
+
* load's definitions stay whole wherever the boundary is, a stub is not cleared twice, and a
|
|
187
|
+
* result before `from` is not being sent.
|
|
188
|
+
*/
|
|
189
|
+
export declare function planPrune(messages: Message[], window: number, { through, minShare, at, used, from, compacted, keepLast, maxChars, charsPerToken, estimate, }?: PrunePlanOptions): PrunePlan | undefined;
|
|
190
|
+
/**
|
|
191
|
+
* What a host says when a plan is applied, so every host says it the same way.
|
|
192
|
+
*
|
|
193
|
+
* @param plan - The plan that was applied.
|
|
194
|
+
* @param [compacted] - Whether it was made alongside a fold rather than because the window filled.
|
|
195
|
+
* @returns One line with the counts: `cleared 3 old tool results (~12,345 tokens) as the window
|
|
196
|
+
* filled`.
|
|
197
|
+
*/
|
|
198
|
+
export declare function pruneNotice({ cleared, tokens }: PrunePlan, compacted?: boolean): string;
|
|
66
199
|
/** What `planCompaction` takes. */
|
|
67
200
|
export interface CompactionOptions {
|
|
68
201
|
/** The model's window, in tokens. Zero or less never compacts. */
|
|
@@ -14,6 +14,8 @@ const SUMMARY_TOKENS = 1024;
|
|
|
14
14
|
const SUMMARY_SLICE = 4000;
|
|
15
15
|
/** The fewest messages worth folding: a summary of one costs a round trip and saves nothing. */
|
|
16
16
|
const FEWEST_FOLDED = 2;
|
|
17
|
+
/** How many of the latest tool results a prune leaves whole when the caller does not say. */
|
|
18
|
+
const KEEP_LAST = 5;
|
|
17
19
|
/** The locale a cleared result's length is written in, so the stub reads the same on every host. */
|
|
18
20
|
const COUNT_LOCALE = 'en-US';
|
|
19
21
|
/**
|
|
@@ -105,7 +107,7 @@ const systemHead = (messages) => {
|
|
|
105
107
|
* few. Returns the same array when there was nothing to clear. Rewrites the prefix;
|
|
106
108
|
* see the module comment on when to run it.
|
|
107
109
|
*/
|
|
108
|
-
export function pruneToolResults(messages, { keepLast =
|
|
110
|
+
export function pruneToolResults(messages, { keepLast = KEEP_LAST, maxChars = 256 } = {}) {
|
|
109
111
|
let kept = 0;
|
|
110
112
|
let out;
|
|
111
113
|
for (let at = messages.length - 1; at >= 0; at--) {
|
|
@@ -133,6 +135,132 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
|
|
|
133
135
|
}
|
|
134
136
|
return out ?? messages;
|
|
135
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* The share of the window a prune has to win back on its own before it is worth the prompt cache
|
|
140
|
+
* it costs. Below it the request after a prune is barely smaller than the one before, and every
|
|
141
|
+
* token of it is processed again anyway.
|
|
142
|
+
*/
|
|
143
|
+
export const PRUNE_SHARE = 0.1;
|
|
144
|
+
/**
|
|
145
|
+
* The transcript with every tool result before `through` replaced by a stub.
|
|
146
|
+
*
|
|
147
|
+
* @param messages - The transcript. Not written to.
|
|
148
|
+
* @param through - The boundary, as a `PrunePlan` has it. Clamped to the transcript, so one left
|
|
149
|
+
* past its end cannot reach messages not yet written.
|
|
150
|
+
* @param [options] - `maxChars`, as `pruneToolResults` reads it.
|
|
151
|
+
* @returns `messages` itself when nothing was cleared, otherwise a new array of the same length in
|
|
152
|
+
* which each cleared result is a new message and every other one the same object.
|
|
153
|
+
*
|
|
154
|
+
* @remarks
|
|
155
|
+
* `pruneToolResults` over the head, keeping none of it — so a result that is short or carries
|
|
156
|
+
* definitions stays whole here as it does there. A function of the messages before `through` and
|
|
157
|
+
* nothing else: while the boundary stays put, what this sends for them does not change however
|
|
158
|
+
* many results arrive after it, which is what keeps the prompt cache.
|
|
159
|
+
*/
|
|
160
|
+
export function pruneThrough(messages, through, { maxChars } = {}) {
|
|
161
|
+
const end = clamp(through, messages.length);
|
|
162
|
+
if (end <= 0) {
|
|
163
|
+
return messages;
|
|
164
|
+
}
|
|
165
|
+
const head = messages.slice(0, end);
|
|
166
|
+
const stubbed = pruneToolResults(head, { keepLast: 0, ...(maxChars === undefined ? {} : { maxChars }) });
|
|
167
|
+
return stubbed === head ? messages : [...stubbed, ...messages.slice(end)];
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Where the latest `keepLast` tool results begin, which is where a move puts the boundary.
|
|
171
|
+
*
|
|
172
|
+
* @param messages - The transcript.
|
|
173
|
+
* @param keepLast - How many of the latest results stay whole.
|
|
174
|
+
* @returns The index of the earliest of them; the length when `keepLast` is zero; and zero when
|
|
175
|
+
* there are not that many results, which is nowhere.
|
|
176
|
+
*/
|
|
177
|
+
const keepBoundary = (messages, keepLast) => {
|
|
178
|
+
if (keepLast <= 0) {
|
|
179
|
+
return messages.length;
|
|
180
|
+
}
|
|
181
|
+
let kept = 0;
|
|
182
|
+
for (let at = messages.length - 1; at >= 0; at--) {
|
|
183
|
+
if (messages[at].role === Role.Tool && ++kept === keepLast) {
|
|
184
|
+
return at;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return 0;
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Where to move the prune boundary, or `undefined` when it should stay where it is.
|
|
191
|
+
*
|
|
192
|
+
* @param messages - The transcript as the host keeps it — whole, or with earlier stubs in it.
|
|
193
|
+
* @param window - The model's window, in tokens. Zero or less moves nothing on its own.
|
|
194
|
+
* @param [options] - The boundary in force, the gate, what to keep, and how to weigh a message.
|
|
195
|
+
* See `PrunePlanOptions`.
|
|
196
|
+
* @returns The plan, or `undefined`. `messages` is not written to.
|
|
197
|
+
*
|
|
198
|
+
* @remarks
|
|
199
|
+
* `pruneToolResults` keeps a sliding window of results: applied on every step, each new result
|
|
200
|
+
* pushes an older one out, rewrites a message in the middle of the history and loses the prompt
|
|
201
|
+
* cache from there on, once per tool call. A plan is a boundary instead. The host stores
|
|
202
|
+
* `through` and sends `pruneThrough(messages, through)` on every request, and between moves a
|
|
203
|
+
* request is the one before it with only its tail added.
|
|
204
|
+
*
|
|
205
|
+
* The rule, in the order it is asked:
|
|
206
|
+
*
|
|
207
|
+
* - **Never backwards, and never for nothing.** The target is the earliest of the latest
|
|
208
|
+
* `keepLast` results. If that is not past `through`, or moving there would clear nothing still
|
|
209
|
+
* being sent, there is no plan.
|
|
210
|
+
* - **With a fold, for free.** Given `compacted`, the move is made.
|
|
211
|
+
* - **Otherwise once the window is filling**, past `at` of it, and only when what it clears is at
|
|
212
|
+
* least `minShare` of it. A run sitting just over the line would otherwise slide the boundary
|
|
213
|
+
* forward and lose the cache on every step for a few hundred tokens each time.
|
|
214
|
+
*
|
|
215
|
+
* What is weighed is what the move would *clear*, by `estimate`: a short result and a proxied
|
|
216
|
+
* load's definitions stay whole wherever the boundary is, a stub is not cleared twice, and a
|
|
217
|
+
* result before `from` is not being sent.
|
|
218
|
+
*/
|
|
219
|
+
export function planPrune(messages, window, { through = 0, minShare = PRUNE_SHARE, at = COMPACT_AT, used, from = 0, compacted = false, keepLast = KEEP_LAST, maxChars, charsPerToken, estimate = (message) => messageTokens(message, { charsPerToken }), } = {}) {
|
|
220
|
+
const hasWindow = window > 0;
|
|
221
|
+
if (hasWindow === false && compacted === false) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
if (compacted === false) {
|
|
225
|
+
const estimated = messages.reduce((total, message) => total + estimate(message), 0);
|
|
226
|
+
const isFilling = Math.max(used ?? 0, estimated) >= window * at;
|
|
227
|
+
if (isFilling === false) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const current = clamp(through, messages.length);
|
|
232
|
+
const target = keepBoundary(messages, keepLast);
|
|
233
|
+
if (target <= current) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
const sent = pruneThrough(messages, target, { maxChars });
|
|
237
|
+
let cleared = 0;
|
|
238
|
+
let tokens = 0;
|
|
239
|
+
for (let index = Math.max(current, from); index < target; index++) {
|
|
240
|
+
if (sent[index] !== messages[index]) {
|
|
241
|
+
cleared++;
|
|
242
|
+
tokens += estimate(messages[index]) - estimate(sent[index]);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (cleared === 0) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
const isWorthIt = compacted || tokens >= window * minShare;
|
|
249
|
+
return isWorthIt ? { through: target, cleared, tokens: Math.round(tokens) } : undefined;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* What a host says when a plan is applied, so every host says it the same way.
|
|
253
|
+
*
|
|
254
|
+
* @param plan - The plan that was applied.
|
|
255
|
+
* @param [compacted] - Whether it was made alongside a fold rather than because the window filled.
|
|
256
|
+
* @returns One line with the counts: `cleared 3 old tool results (~12,345 tokens) as the window
|
|
257
|
+
* filled`.
|
|
258
|
+
*/
|
|
259
|
+
export function pruneNotice({ cleared, tokens }, compacted = false) {
|
|
260
|
+
const results = cleared === 1 ? 'result' : 'results';
|
|
261
|
+
const why = compacted ? 'alongside a fold' : 'as the window filled';
|
|
262
|
+
return `cleared ${cleared} old tool ${results} (~${tokens.toLocaleString(COUNT_LOCALE)} tokens) ${why}`;
|
|
263
|
+
}
|
|
136
264
|
/**
|
|
137
265
|
* An index held between the start of the transcript and `most`, for a caller's `from` that is
|
|
138
266
|
* neither.
|
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 { applyCompaction, COMPACT_AT, type CompactionOptions, type CompactionPlan, type CompactionRecord, type CompactionRunOptions, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, } from './context/compaction.ts';
|
|
12
|
+
export { applyCompaction, COMPACT_AT, type CompactionOptions, type CompactionPlan, type CompactionRecord, type CompactionRunOptions, compactTranscript, KEEP_RATIO, PRUNE_SHARE, type PruneOptions, type PrunePlan, type PrunePlanOptions, planCompaction, planPrune, pruneNotice, pruneThrough, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, } from './context/compaction.ts';
|
|
13
13
|
export { estimateFrom, type LedgerEntry, type LedgerEstimateOptions, type LedgerRequest, rebaseLedger, recordRequest, type TokenLedger, tokensBetween, } from './context/ledger.ts';
|
|
14
14
|
export { type AgentConfig, type Endpoint, type ModelParams, normalizeBaseUrl, type RetryPolicy, ToolDiscovery, type ToolPolicy, } from './core/config.ts';
|
|
15
15
|
export { calibrate, charsPerTokenFor, resetCalibration } from './endpoint/calibration.ts';
|
package/dist/index.js
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 { applyCompaction, COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, } from './context/compaction.js';
|
|
12
|
+
export { applyCompaction, COMPACT_AT, compactTranscript, KEEP_RATIO, PRUNE_SHARE, planCompaction, planPrune, pruneNotice, pruneThrough, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, } from './context/compaction.js';
|
|
13
13
|
export { estimateFrom, rebaseLedger, recordRequest, tokensBetween, } from './context/ledger.js';
|
|
14
14
|
export { normalizeBaseUrl, ToolDiscovery, } from './core/config.js';
|
|
15
15
|
export { calibrate, charsPerTokenFor, resetCalibration } from './endpoint/calibration.js';
|
package/dist/run/agent-loop.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type OpenAI from 'openai';
|
|
2
|
-
import { type CompactionOptions } from '../context/compaction.ts';
|
|
2
|
+
import { type CompactionOptions, type PrunePlanOptions } from '../context/compaction.ts';
|
|
3
3
|
import { type TokenLedger } from '../context/ledger.ts';
|
|
4
4
|
import type { Endpoint, ModelParams, RetryPolicy, ToolPolicy } from '../core/config.ts';
|
|
5
5
|
import { HookEvent } from '../hooks/hook-events.ts';
|
|
@@ -152,6 +152,23 @@ export interface AgentLoopOptions {
|
|
|
152
152
|
* loaded so far, by name; proxied mode declares none, and answers a load with them instead.
|
|
153
153
|
*/
|
|
154
154
|
tools?: OpenAI.ChatCompletionTool[];
|
|
155
|
+
/**
|
|
156
|
+
* The host's own tools — a question for a person, a resource read — declared on every step in
|
|
157
|
+
* every discovery mode and dispatched like any other call, but never catalogued, loaded, or
|
|
158
|
+
* reached through `call_tool`.
|
|
159
|
+
*
|
|
160
|
+
* @remarks
|
|
161
|
+
* Each sits after the loop's own definitions: after `PROXY_TOOLS` proxied, after `load_tools`
|
|
162
|
+
* and ahead of anything loaded on demand, after `tools` in eager mode, and after the shortlist
|
|
163
|
+
* on a preselected first step; `toolOrder` then orders the array as it always does. Proxied and
|
|
164
|
+
* eager, the array is one set for the whole run, so the cache holds in either order; on demand,
|
|
165
|
+
* `toolOrder: false` keeps them in the prefix every load appends after.
|
|
166
|
+
*
|
|
167
|
+
* A name of one is taken out of `catalog`, `loaded` and `preselected` before the run starts, is
|
|
168
|
+
* never counted as a load, and is declared once where `tools` or the loop has it too. A
|
|
169
|
+
* `call_tool` naming one is refused with a pointer to call it directly.
|
|
170
|
+
*/
|
|
171
|
+
hostTools?: OpenAI.ChatCompletionTool[];
|
|
155
172
|
/**
|
|
156
173
|
* The same tools as a name-only catalogue. On-demand and proxied modes need it, and are eager
|
|
157
174
|
* without it.
|
|
@@ -266,13 +283,30 @@ export interface AgentLoopOptions {
|
|
|
266
283
|
* more: the plan is made from the last request's reported prompt, or an estimate before the
|
|
267
284
|
* first step and after a `beforeStep` rewrite; `hooks`, when given, are told `beforeCompact`
|
|
268
285
|
* beside the summary; and a fold is said as a `notice` event. A summary that fails is a notice
|
|
269
|
-
* too and folds nothing. Tool results are not pruned — `
|
|
286
|
+
* too and folds nothing. Tool results are not pruned by this — `prune` is.
|
|
270
287
|
*
|
|
271
288
|
* The result's `messages` then open with the summary in place of what was folded, so a host
|
|
272
289
|
* that keeps its transcript append-only wants `runCompaction` and a stored fold instead. `true`
|
|
273
290
|
* plans by `planCompaction`'s defaults; an object moves its thresholds.
|
|
274
291
|
*/
|
|
275
292
|
compact?: boolean | Pick<CompactionOptions, 'compactAt' | 'keepRatio' | 'target'>;
|
|
293
|
+
/**
|
|
294
|
+
* Has the loop stub old tool results itself before a step, at a boundary `planPrune` moves, so
|
|
295
|
+
* the prompt cache is lost once per move rather than once per tool call. Off by default, and
|
|
296
|
+
* nothing moves without a `config.contextLength` — except alongside a fold.
|
|
297
|
+
*
|
|
298
|
+
* @remarks
|
|
299
|
+
* It runs after `beforeStep`, on what that returned, and before `compact`, which then plans from
|
|
300
|
+
* what is left: a prune that brings the run back under the line spares it the summary's round
|
|
301
|
+
* trip. Where a fold is made anyway, the boundary is moved again after it with `compacted`, since
|
|
302
|
+
* the cache is already lost. Each move is said as a `notice` event, with `pruneNotice`'s counts.
|
|
303
|
+
*
|
|
304
|
+
* The stubs are in the result's `messages`, as a fold is, so a host that keeps its transcript
|
|
305
|
+
* whole calls `planPrune` and `pruneThrough` in its own `beforeStep` and stores the boundary
|
|
306
|
+
* instead. `true` plans by `planPrune`'s defaults — past `COMPACT_AT` of the window, and only for
|
|
307
|
+
* at least `PRUNE_SHARE` of it; an object moves the gate and what is kept.
|
|
308
|
+
*/
|
|
309
|
+
prune?: boolean | Pick<PrunePlanOptions, 'at' | 'minShare' | 'keepLast' | 'maxChars'>;
|
|
276
310
|
/**
|
|
277
311
|
* Tools already loaded, carried from an earlier question. See `carryOver`. Not read in proxied
|
|
278
312
|
* mode, where a tool is loaded only while its definition is in the history.
|
|
@@ -578,6 +612,8 @@ export interface AgentLoopResult {
|
|
|
578
612
|
* tool array to go into, so it is written into the transcript as a `load_tools` exchange after
|
|
579
613
|
* the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
|
|
580
614
|
* a session switched out of proxied mode still has it in its history, and the model copies it.
|
|
615
|
+
* The host's own `hostTools` are declared beside whatever the mode sends, in every mode, and are
|
|
616
|
+
* never catalogued.
|
|
581
617
|
*
|
|
582
618
|
* Neither side task is run unless asked for. `preselect` has the loop make the preselection and
|
|
583
619
|
* `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
|
package/dist/run/agent-loop.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { compactTranscript, planCompaction, summariser } from '../context/compaction.js';
|
|
1
|
+
import { compactTranscript, planCompaction, planPrune, pruneNotice, pruneThrough, summariser, } from '../context/compaction.js';
|
|
2
2
|
import { breakReason, rebaseLedger, recordRequest, } from '../context/ledger.js';
|
|
3
3
|
import { ToolDiscovery } from '../core/config.js';
|
|
4
4
|
import { counted } from '../core/guards.js';
|
|
@@ -112,6 +112,8 @@ function cacheDiagnosis(previous, next, usage) {
|
|
|
112
112
|
* tool array to go into, so it is written into the transcript as a `load_tools` exchange after
|
|
113
113
|
* the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
|
|
114
114
|
* a session switched out of proxied mode still has it in its history, and the model copies it.
|
|
115
|
+
* The host's own `hostTools` are declared beside whatever the mode sends, in every mode, and are
|
|
116
|
+
* never catalogued.
|
|
115
117
|
*
|
|
116
118
|
* Neither side task is run unless asked for. `preselect` has the loop make the preselection and
|
|
117
119
|
* `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
|
|
@@ -133,9 +135,10 @@ export async function runAgentLoop(options) {
|
|
|
133
135
|
}),
|
|
134
136
|
};
|
|
135
137
|
let result;
|
|
138
|
+
const apart = hostToolsApart(options);
|
|
136
139
|
try {
|
|
137
|
-
pin = await pinSession(
|
|
138
|
-
result = await runSteps(
|
|
140
|
+
pin = await pinSession(apart);
|
|
141
|
+
result = await runSteps(apart, standing, pin);
|
|
139
142
|
}
|
|
140
143
|
catch (error) {
|
|
141
144
|
// Wrapped rather than annotated: an abort's reason is one object shared by every run under
|
|
@@ -265,6 +268,36 @@ function ownSummariser(options, notice) {
|
|
|
265
268
|
// A summary that fails folds nothing, and the step goes out as it was.
|
|
266
269
|
return async (text) => (await tryAsk('compaction', () => write(text), { onNotice: notice, signal })) ?? '';
|
|
267
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* A step's transcript with the loop's prune boundary moved, where a move was due.
|
|
273
|
+
*
|
|
274
|
+
* @param transcript - The step's messages. Not written to.
|
|
275
|
+
* @param through - The boundary in force, which only ever moves forward.
|
|
276
|
+
* @param context - The run's options, read for `prune`'s gate and the window; what the last
|
|
277
|
+
* request reported, where it still describes `transcript`; the characters per token an estimate is
|
|
278
|
+
* made at; whether a fold was just made; and `notice`, told of a move.
|
|
279
|
+
* @returns `transcript` itself and the boundary unmoved where nothing was due, otherwise the
|
|
280
|
+
* stubbed array, the new boundary and what it won back.
|
|
281
|
+
*/
|
|
282
|
+
function pruneBeforeStep(transcript, through, { options, used, charsPerToken, compacted = false, notice, }) {
|
|
283
|
+
const gate = typeof options.prune === 'object' ? options.prune : {};
|
|
284
|
+
const plan = planPrune(transcript, options.config.contextLength ?? 0, {
|
|
285
|
+
...gate,
|
|
286
|
+
through,
|
|
287
|
+
...(used === undefined ? {} : { used }),
|
|
288
|
+
charsPerToken,
|
|
289
|
+
compacted,
|
|
290
|
+
});
|
|
291
|
+
if (!plan) {
|
|
292
|
+
return { messages: transcript, through, tokens: 0 };
|
|
293
|
+
}
|
|
294
|
+
notice(pruneNotice(plan, compacted));
|
|
295
|
+
return {
|
|
296
|
+
messages: pruneThrough(transcript, plan.through, { maxChars: gate.maxChars }),
|
|
297
|
+
through: plan.through,
|
|
298
|
+
tokens: plan.tokens,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
268
301
|
/**
|
|
269
302
|
* A step's transcript after the loop's own compaction: folded where a fold was due and its summary
|
|
270
303
|
* came back, and otherwise the array it was given.
|
|
@@ -369,6 +402,43 @@ function recorder(heard) {
|
|
|
369
402
|
};
|
|
370
403
|
return { recorded, onEvent };
|
|
371
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* The names of the host's own tools.
|
|
407
|
+
*
|
|
408
|
+
* @param [tools] - The host's definitions. One that is not a function has no name, and is left out.
|
|
409
|
+
* @returns A new set, empty where there are none.
|
|
410
|
+
*/
|
|
411
|
+
const hostToolNames = (tools = []) => new Set(tools.flatMap((tool) => toolName(tool) ?? []));
|
|
412
|
+
/**
|
|
413
|
+
* The run's options with the host's own tools kept out of discovery: off the catalogue, and out of
|
|
414
|
+
* what was carried and what was picked.
|
|
415
|
+
*
|
|
416
|
+
* @param options - What `runAgentLoop` was given. Not written to.
|
|
417
|
+
* @returns `options` itself where there are no host tools, and otherwise a copy with `catalog`,
|
|
418
|
+
* `loaded` and `preselected` filtered, a server that offers none of them kept as it was.
|
|
419
|
+
*
|
|
420
|
+
* @remarks
|
|
421
|
+
* Done once, here, so that preselection, `load_tools`, `call_tool` and the catalogue prompt all
|
|
422
|
+
* read one catalogue, and a host tool is never loadable however a name of one reaches the loop.
|
|
423
|
+
*/
|
|
424
|
+
function hostToolsApart(options) {
|
|
425
|
+
const own = hostToolNames(options.hostTools);
|
|
426
|
+
if (own.size === 0) {
|
|
427
|
+
return options;
|
|
428
|
+
}
|
|
429
|
+
const isCatalogued = (name) => own.has(name) === false;
|
|
430
|
+
const { catalog, loaded, preselected } = options;
|
|
431
|
+
const apart = (server) => server.tools.every((entry) => isCatalogued(entry.name))
|
|
432
|
+
? server
|
|
433
|
+
: { ...server, tools: server.tools.filter((entry) => isCatalogued(entry.name)) };
|
|
434
|
+
return {
|
|
435
|
+
...options,
|
|
436
|
+
...(catalog ? { catalog: catalog.map(apart) } : {}),
|
|
437
|
+
...(loaded ? { loaded: [...loaded].filter(isCatalogued) } : {}),
|
|
438
|
+
// Undefined stays undefined: an empty shortlist is a host saying it has already decided.
|
|
439
|
+
...(preselected ? { preselected: preselected.filter(isCatalogued) } : {}),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
372
442
|
/** What opens the notice naming the loop's own picks, ahead of the names. */
|
|
373
443
|
export const PRESELECTED_NOTICE = 'tools picked before the run: ';
|
|
374
444
|
/**
|
|
@@ -540,11 +610,17 @@ async function runSteps(options, standing, pin) {
|
|
|
540
610
|
const summarise = ownSummariser(options, notice);
|
|
541
611
|
const used = new Set();
|
|
542
612
|
const definitions = definitionsByName(tools);
|
|
613
|
+
// Each once, by name, so one given twice is declared once.
|
|
614
|
+
const hostTools = loadedTools([], options.hostTools ?? []);
|
|
615
|
+
const hostNames = hostToolNames(hostTools);
|
|
616
|
+
// After the head the mode declares, which a host tool of the same name does not displace.
|
|
617
|
+
const withHost = (head) => (hostTools.length ? loadedTools(head, hostTools) : head);
|
|
543
618
|
// Looked through unless the host has a tool by that name, which is then the host's to answer.
|
|
544
|
-
const proxies = onDemand && !definitions.has(CALL_TOOL);
|
|
619
|
+
const proxies = onDemand && !definitions.has(CALL_TOOL) && !hostNames.has(CALL_TOOL);
|
|
545
620
|
// What a call the model wrote as text may name: the host's tools, and the loop's own on demand.
|
|
546
621
|
const recoverable = [
|
|
547
622
|
...tools.map(toolName).filter((name) => name !== undefined),
|
|
623
|
+
...hostNames,
|
|
548
624
|
...(onDemand ? [LOAD_TOOLS, ...(proxies ? [CALL_TOOL] : [])] : []),
|
|
549
625
|
];
|
|
550
626
|
// In the order the names are given, not the order of `tools`: `loaded` is a set, which iterates
|
|
@@ -575,6 +651,9 @@ async function runSteps(options, standing, pin) {
|
|
|
575
651
|
const preface = hooks?.preface ?? configureHooks().preface;
|
|
576
652
|
const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
|
|
577
653
|
let previous;
|
|
654
|
+
// The loop's prune boundary. Its stubs stay in `messages`, so between moves each request only
|
|
655
|
+
// extends the last; a rewrite or a fold renumbers the transcript, and it starts again from zero.
|
|
656
|
+
let pruneBoundary = 0;
|
|
578
657
|
let ledger = options.ledger ?? [];
|
|
579
658
|
// The request the ledger's last entry came from, which is what the next one is measured against.
|
|
580
659
|
// Nothing before this run's first: an earlier run's head is not something the loop can see.
|
|
@@ -593,6 +672,7 @@ async function runSteps(options, standing, pin) {
|
|
|
593
672
|
proxied,
|
|
594
673
|
proxies,
|
|
595
674
|
definitions,
|
|
675
|
+
hostNames,
|
|
596
676
|
loaded,
|
|
597
677
|
loads,
|
|
598
678
|
used,
|
|
@@ -622,14 +702,28 @@ async function runSteps(options, standing, pin) {
|
|
|
622
702
|
ledger,
|
|
623
703
|
});
|
|
624
704
|
const given = rewritten ?? messages;
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
705
|
+
const isOwn = given === messages;
|
|
706
|
+
if (isOwn === false) {
|
|
707
|
+
pruneBoundary = 0;
|
|
708
|
+
}
|
|
709
|
+
// Read again after the turn: a reported prompt calibrates it.
|
|
710
|
+
const planRatio = charsPerTokenFor(supports, config.model);
|
|
711
|
+
// The last request's prompt describes this transcript only while nothing has rewritten it,
|
|
712
|
+
// and less what a prune has just won back of it.
|
|
713
|
+
const ownUsed = isOwn ? reported.used : undefined;
|
|
714
|
+
const trimmed = options.prune
|
|
715
|
+
? pruneBeforeStep(given, pruneBoundary, { options, used: ownUsed, charsPerToken: planRatio, notice })
|
|
716
|
+
: { messages: given, through: pruneBoundary, tokens: 0 };
|
|
717
|
+
pruneBoundary = trimmed.through;
|
|
718
|
+
const foldUsed = ownUsed === undefined ? {} : { used: Math.max(0, ownUsed - trimmed.tokens) };
|
|
719
|
+
let next = summarise
|
|
720
|
+
? await foldBeforeStep(trimmed.messages, foldUsed, summarise, { options, charsPerToken: planRatio, notice })
|
|
721
|
+
: trimmed.messages;
|
|
722
|
+
if (options.prune && next !== trimmed.messages) {
|
|
723
|
+
const after = pruneBeforeStep(next, 0, { options, charsPerToken: planRatio, compacted: true, notice });
|
|
724
|
+
next = after.messages;
|
|
725
|
+
pruneBoundary = after.through;
|
|
726
|
+
}
|
|
633
727
|
// A rewrite may have folded a definition away, and a load answered "already loaded" would
|
|
634
728
|
// then point at nothing. Forgetting costs a definition sent twice at worst.
|
|
635
729
|
if (proxied && next !== messages) {
|
|
@@ -644,15 +738,15 @@ async function runSteps(options, standing, pin) {
|
|
|
644
738
|
// Ordered here rather than left to `buildBody`, so `names` below is what the request actually
|
|
645
739
|
// declared — a diagnosis reading an order the server never saw calls an untouched tool array
|
|
646
740
|
// `tools-changed`.
|
|
647
|
-
let offered = tools;
|
|
741
|
+
let offered = withHost(tools);
|
|
648
742
|
if (routed) {
|
|
649
|
-
offered = byName(new Set(preselected));
|
|
743
|
+
offered = withHost(byName(new Set(preselected)));
|
|
650
744
|
}
|
|
651
745
|
else if (proxied) {
|
|
652
|
-
offered = [...PROXY_TOOLS];
|
|
746
|
+
offered = withHost([...PROXY_TOOLS]);
|
|
653
747
|
}
|
|
654
748
|
else if (onDemand) {
|
|
655
|
-
offered = loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded));
|
|
749
|
+
offered = loadedTools(withHost([LOAD_TOOLS_DEFINITION]), byName(loaded));
|
|
656
750
|
}
|
|
657
751
|
const declared = orderTools(offered, toolOrder);
|
|
658
752
|
// Unmarked, so the system prompt is the same text on every step and a load does not throw
|
|
@@ -736,7 +830,7 @@ async function runSteps(options, standing, pin) {
|
|
|
736
830
|
if (turn.finishReason === FinishReason.Length) {
|
|
737
831
|
notice(`the model stopped at maxTokens (${config.maxTokens}); this turn is cut short`);
|
|
738
832
|
}
|
|
739
|
-
const canRecover = recover && (tools.length > 0 || onDemand);
|
|
833
|
+
const canRecover = recover && (tools.length > 0 || hostTools.length > 0 || onDemand);
|
|
740
834
|
const { calls, content } = turnCalls(turn, canRecover ? recoverable : undefined, notice);
|
|
741
835
|
const shown = { ...turn, content, toolCalls: calls };
|
|
742
836
|
onTurn?.(shown, step);
|
package/dist/run/run-calls.d.ts
CHANGED
|
@@ -86,6 +86,8 @@ export interface Calling {
|
|
|
86
86
|
/** Whether a `call_tool` is looked through, which it is unless the host has a tool by that name. */
|
|
87
87
|
proxies: boolean;
|
|
88
88
|
definitions: ReadonlyMap<string, OpenAI.ChatCompletionTool>;
|
|
89
|
+
/** The host's own tools by name: declared on every step, and never reached through `call_tool`. */
|
|
90
|
+
hostNames: ReadonlySet<string>;
|
|
89
91
|
loaded: Set<string>;
|
|
90
92
|
loads: {
|
|
91
93
|
toolsLoaded: number;
|
package/dist/run/run-calls.js
CHANGED
|
@@ -256,7 +256,12 @@ function prepare(run, entry) {
|
|
|
256
256
|
// nothing a load could not, and the refusal is the call's answer.
|
|
257
257
|
let inner = args ?? {};
|
|
258
258
|
let refused;
|
|
259
|
-
if (proxy && args) {
|
|
259
|
+
if (proxy && args && run.hostNames.has(name)) {
|
|
260
|
+
// Declared already, so the model is pointed at the call it can make rather than told the
|
|
261
|
+
// tool does not exist.
|
|
262
|
+
refused = { error: new Error(`${name} is not in the catalogue; call it directly, not through ${CALL_TOOL}.`) };
|
|
263
|
+
}
|
|
264
|
+
else if (proxy && args) {
|
|
260
265
|
try {
|
|
261
266
|
inner = proxiedCall(args, catalog).input;
|
|
262
267
|
}
|
package/llms.txt
CHANGED
|
@@ -21,8 +21,14 @@ Keeping a long run inside its window: stale tool results cleared, and the oldest
|
|
|
21
21
|
- `CompactionRunOptions` (type) — What `runCompaction` and `compactTranscript` take beside the plan.
|
|
22
22
|
- `compactTranscript` — The transcript with the plan's stretch replaced by one system message holding its summary.
|
|
23
23
|
- `KEEP_RATIO` — The fraction of the window the kept tail may fill, leaving room for the run to grow again.
|
|
24
|
+
- `PRUNE_SHARE` — The share of the window a prune has to win back on its own before it is worth the prompt cache it costs.
|
|
24
25
|
- `PruneOptions` (type) — What `pruneToolResults` takes.
|
|
26
|
+
- `PrunePlan` (type) — Where `planPrune` puts the boundary, and what moving it there clears.
|
|
27
|
+
- `PrunePlanOptions` (type) — What `planPrune` takes beside the transcript and the window.
|
|
25
28
|
- `planCompaction` — Where to fold a transcript that has grown into its window, or `undefined` when it should not be.
|
|
29
|
+
- `planPrune` — Where to move the prune boundary, or `undefined` when it should stay where it is.
|
|
30
|
+
- `pruneNotice` — What a host says when a plan is applied, so every host says it the same way.
|
|
31
|
+
- `pruneThrough` — The transcript with every tool result before `through` replaced by a stub.
|
|
26
32
|
- `pruneToolResults` — The transcript with every tool result but the latest few replaced by a one-line stub.
|
|
27
33
|
- `requestIndex` — Where a stored index sits in the request `applyCompaction` builds, once a fold has shifted everything after it.
|
|
28
34
|
- `runCompaction` — The hooks and the summariser for a plan, as a record to store rather than a transcript to send.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cubicecho/agent-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.17.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",
|