@gaunt-sloth/core 2.0.0-beta.5 → 2.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/schema.d.ts +4 -0
- package/dist/config/schema.js +64 -2
- package/dist/config/schema.js.map +1 -1
- package/dist/config/tokenBudget.d.ts +88 -0
- package/dist/config/tokenBudget.js +155 -0
- package/dist/config/tokenBudget.js.map +1 -0
- package/dist/config/types.d.ts +33 -4
- package/dist/config/types.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/core/GthAbstractAgent.d.ts +24 -0
- package/dist/core/GthAbstractAgent.js +37 -1
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +216 -1
- package/dist/core/GthAgentRunner.js +424 -3
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/GthLangChainAgent.d.ts +196 -0
- package/dist/core/GthLangChainAgent.js +392 -2
- package/dist/core/GthLangChainAgent.js.map +1 -1
- package/dist/core/approvals/conversationGrants.d.ts +60 -0
- package/dist/core/approvals/conversationGrants.js +77 -0
- package/dist/core/approvals/conversationGrants.js.map +1 -0
- package/dist/core/approvals/grants.d.ts +16 -0
- package/dist/core/approvals/grants.js +20 -5
- package/dist/core/approvals/grants.js.map +1 -1
- package/dist/core/compaction.d.ts +181 -0
- package/dist/core/compaction.js +293 -0
- package/dist/core/compaction.js.map +1 -0
- package/dist/core/compactionThreshold.d.ts +158 -0
- package/dist/core/compactionThreshold.js +183 -0
- package/dist/core/compactionThreshold.js.map +1 -0
- package/dist/core/contextWindow.d.ts +146 -0
- package/dist/core/contextWindow.js +256 -0
- package/dist/core/contextWindow.js.map +1 -0
- package/dist/core/exitOutputChannel.d.ts +51 -0
- package/dist/core/exitOutputChannel.js +65 -0
- package/dist/core/exitOutputChannel.js.map +1 -0
- package/dist/core/refusal.d.ts +17 -2
- package/dist/core/refusal.js +80 -14
- package/dist/core/refusal.js.map +1 -1
- package/dist/core/runStats.d.ts +1 -1
- package/dist/core/terminationNotice.d.ts +8 -0
- package/dist/core/terminationNotice.js +10 -4
- package/dist/core/terminationNotice.js.map +1 -1
- package/dist/core/terminationReason.d.ts +28 -0
- package/dist/core/terminationReason.js +27 -0
- package/dist/core/terminationReason.js.map +1 -1
- package/dist/core/types.d.ts +35 -1
- package/dist/core/types.js.map +1 -1
- package/dist/history/checkpointRetention.d.ts +279 -0
- package/dist/history/checkpointRetention.js +567 -0
- package/dist/history/checkpointRetention.js.map +1 -0
- package/dist/history/checkpointSaver.d.ts +93 -0
- package/dist/history/checkpointSaver.js +464 -0
- package/dist/history/checkpointSaver.js.map +1 -0
- package/dist/history/historyEnabled.d.ts +27 -0
- package/dist/history/historyEnabled.js +23 -0
- package/dist/history/historyEnabled.js.map +1 -0
- package/dist/history/historyFormat.d.ts +27 -0
- package/dist/history/historyFormat.js +125 -2
- package/dist/history/historyFormat.js.map +1 -1
- package/dist/history/historyStore.d.ts +61 -0
- package/dist/history/historyStore.js +180 -7
- package/dist/history/historyStore.js.map +1 -1
- package/dist/history/recordSession.d.ts +84 -22
- package/dist/history/recordSession.js +187 -12
- package/dist/history/recordSession.js.map +1 -1
- package/dist/history/sessionCheckpointer.d.ts +48 -0
- package/dist/history/sessionCheckpointer.js +200 -0
- package/dist/history/sessionCheckpointer.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/modelCatalog.d.ts +14 -0
- package/dist/providers/modelCatalog.js +4 -0
- package/dist/providers/modelCatalog.js.map +1 -1
- package/dist/providers/modelDiscovery.d.ts +3 -1
- package/dist/providers/modelDiscovery.js +22 -8
- package/dist/providers/modelDiscovery.js.map +1 -1
- package/dist/providers/ollama.js +3 -19
- package/dist/providers/ollama.js.map +1 -1
- package/dist/runtime/conversation.js +7 -1
- package/dist/runtime/conversation.js.map +1 -1
- package/dist/runtime/singleShot.js +6 -1
- package/dist/runtime/singleShot.js.map +1 -1
- package/dist/utils/consoleUtils.d.ts +77 -0
- package/dist/utils/consoleUtils.js +81 -0
- package/dist/utils/consoleUtils.js.map +1 -1
- package/package.json +2 -2
- package/schema/gsloth-config.schema.json +33 -14
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* GS2-23 — **the one conversation-compaction mechanism.** Folds the older part of a conversation
|
|
4
|
+
* into a single summary message and keeps a recent tail verbatim, so the model has room to go on.
|
|
5
|
+
*
|
|
6
|
+
* Every path that shortens a conversation goes through {@link compactMessages}: the manual
|
|
7
|
+
* `/compact` (this node), the reactive compact-and-retry on a context overflow (EXT-160) and the
|
|
8
|
+
* preventive threshold (EXT-161). Built separately they would disagree about what a context window
|
|
9
|
+
* is, so the invariants live here once, as tests rather than as comments:
|
|
10
|
+
*
|
|
11
|
+
* - **(a)** a `tool_call` is never separated from its `tool_result`: the cut moves so an `AIMessage`
|
|
12
|
+
* with `tool_calls` and every `ToolMessage` answering it fall on the same side, by widening the
|
|
13
|
+
* kept tail back to the pair's start;
|
|
14
|
+
* - **(b)** a system prompt survives untouched, and the summary is carried in a `HumanMessage` —
|
|
15
|
+
* never a mid-list `SystemMessage`, which `ChatAnthropic` rejects outright;
|
|
16
|
+
* - **(c)** the tail is never truncated at its end, so the last message of the input is the last
|
|
17
|
+
* message of the output: a history that ended on the pending user turn still does, and no
|
|
18
|
+
* compaction turns a completed exchange into a trailing assistant turn. The guarantee is
|
|
19
|
+
* relative to the input — the mechanism never CREATES a trailing assistant turn; a history a
|
|
20
|
+
* thrown turn left ending on its unanswered human turn still ends on it, and having the next
|
|
21
|
+
* human turn present before the model is invoked again is the caller's job;
|
|
22
|
+
* - **(d)** the operation converges: a previous summary is recognised by its marker and folded into
|
|
23
|
+
* the new one, so a compacted history compacted again holds exactly one summary message.
|
|
24
|
+
*
|
|
25
|
+
* The function is pure over its inputs and does no I/O of its own: the model call is injected as
|
|
26
|
+
* `summarize`, which is what lets the involuntary path bind a different model, a test bind a stub,
|
|
27
|
+
* and the manual command bind the session's own. Applying the result to the live graph is the
|
|
28
|
+
* runner's job (`GthAgentRunner.compactConversation`), through {@link replaceGraphMessages}.
|
|
29
|
+
*/
|
|
30
|
+
import { AIMessage, HumanMessage, RemoveMessage, SystemMessage, ToolMessage, getBufferString, } from '@langchain/core/messages';
|
|
31
|
+
import { REMOVE_ALL_MESSAGES } from '@langchain/langgraph';
|
|
32
|
+
/**
|
|
33
|
+
* How many trailing messages a compaction keeps verbatim when the caller names no number.
|
|
34
|
+
*
|
|
35
|
+
* Small on purpose: a compaction is asked for when the conversation has grown too long, and the
|
|
36
|
+
* summary is what carries the rest. Six is roughly the last exchange with a tool call in it, or the
|
|
37
|
+
* last three plain exchanges — enough that the model still sees, word for word, what it was just
|
|
38
|
+
* doing. The kept tail can be wider than this when the cut would have split a tool pair (see
|
|
39
|
+
* {@link compactionCutIndex}).
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_KEEP_RECENT = 6;
|
|
42
|
+
/**
|
|
43
|
+
* The marker a summary message carries in `additional_kwargs.lc_source`, so a later compaction can
|
|
44
|
+
* recognise it and fold it in rather than nest a summary inside a summary.
|
|
45
|
+
*
|
|
46
|
+
* It rides in the slot LangChain's own `summarizationMiddleware` uses (`lc_source: 'summarization'`),
|
|
47
|
+
* which is why {@link isCompactionSummary} accepts both values: a history that the opt-in
|
|
48
|
+
* middleware already summarised converges through here the same way one of ours does.
|
|
49
|
+
*/
|
|
50
|
+
export const COMPACTION_SUMMARY_SOURCE = 'gth-compaction';
|
|
51
|
+
/** The marker LangChain's `summarizationMiddleware` stamps on the summary messages IT produces. */
|
|
52
|
+
const LANGCHAIN_SUMMARY_SOURCE = 'summarization';
|
|
53
|
+
/** The line the summary message opens with, so the model reads what follows as a summary. */
|
|
54
|
+
export const COMPACTION_SUMMARY_PREFIX = 'Here is a summary of the conversation to date:';
|
|
55
|
+
/** Whether a message is a summary produced by a previous compaction (ours or LangChain's). */
|
|
56
|
+
export function isCompactionSummary(message) {
|
|
57
|
+
if (!HumanMessage.isInstance(message))
|
|
58
|
+
return false;
|
|
59
|
+
const source = message.additional_kwargs?.lc_source;
|
|
60
|
+
return source === COMPACTION_SUMMARY_SOURCE || source === LANGCHAIN_SUMMARY_SOURCE;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The summary as the graph will hold it: a `HumanMessage`, marked so {@link isCompactionSummary}
|
|
64
|
+
* finds it again. A `HumanMessage` and not a `SystemMessage` because a system message anywhere but
|
|
65
|
+
* first is rejected by the Anthropic converter, and because the summary is conversation content —
|
|
66
|
+
* what the two parties said — rather than an instruction about how to behave.
|
|
67
|
+
*/
|
|
68
|
+
export function createCompactionSummaryMessage(summaryText) {
|
|
69
|
+
return new HumanMessage({
|
|
70
|
+
content: `${COMPACTION_SUMMARY_PREFIX}\n\n${summaryText.trim()}`,
|
|
71
|
+
additional_kwargs: { lc_source: COMPACTION_SUMMARY_SOURCE },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** The ids of the tool calls an AI message issued, or an empty set for any other message. */
|
|
75
|
+
function toolCallIdsOf(message) {
|
|
76
|
+
const ids = new Set();
|
|
77
|
+
if (!AIMessage.isInstance(message))
|
|
78
|
+
return ids;
|
|
79
|
+
for (const call of message.tool_calls ?? []) {
|
|
80
|
+
if (typeof call.id === 'string' && call.id.length > 0)
|
|
81
|
+
ids.add(call.id);
|
|
82
|
+
}
|
|
83
|
+
return ids;
|
|
84
|
+
}
|
|
85
|
+
/** How many leading `SystemMessage`s the list opens with — the system prompt, when it is in-band. */
|
|
86
|
+
function leadingSystemCount(messages) {
|
|
87
|
+
let count = 0;
|
|
88
|
+
while (count < messages.length && SystemMessage.isInstance(messages[count]))
|
|
89
|
+
count++;
|
|
90
|
+
return count;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The index of the `AIMessage` a tool result at `resultIndex` answers: the NEAREST earlier message
|
|
94
|
+
* that issued its `tool_call_id`, or `-1` for an orphan.
|
|
95
|
+
*
|
|
96
|
+
* Nearest, not earliest, because ids are not unique in every history: a replayed fixture and more
|
|
97
|
+
* than one provider reuse short ids (`call_0`) turn after turn. A result answers the most recent
|
|
98
|
+
* call with its id; pairing it with the first one ever issued would widen the tail back to the
|
|
99
|
+
* head of the conversation and fold nothing.
|
|
100
|
+
*/
|
|
101
|
+
function issuerOf(conversation, resultIndex, id) {
|
|
102
|
+
for (let j = resultIndex - 1; j >= 0; j--) {
|
|
103
|
+
if (toolCallIdsOf(conversation[j]).has(id))
|
|
104
|
+
return j;
|
|
105
|
+
}
|
|
106
|
+
return -1;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Widen a proposed tail start backwards until no tool pair straddles it: for every `ToolMessage`
|
|
110
|
+
* at or after `start`, the `AIMessage` that issued its `tool_call_id` ({@link issuerOf}) must be at
|
|
111
|
+
* or after `start` too. Repeats until stable, because moving the start back can bring another
|
|
112
|
+
* answered call into the tail.
|
|
113
|
+
*/
|
|
114
|
+
function widenTailToPairStart(conversation, start) {
|
|
115
|
+
let tailStart = start;
|
|
116
|
+
for (;;) {
|
|
117
|
+
let widenedTo = tailStart;
|
|
118
|
+
for (let i = tailStart; i < conversation.length; i++) {
|
|
119
|
+
const message = conversation[i];
|
|
120
|
+
if (!ToolMessage.isInstance(message) || !message.tool_call_id)
|
|
121
|
+
continue;
|
|
122
|
+
const issuer = issuerOf(conversation, i, message.tool_call_id);
|
|
123
|
+
if (issuer !== -1 && issuer < widenedTo)
|
|
124
|
+
widenedTo = issuer;
|
|
125
|
+
}
|
|
126
|
+
if (widenedTo === tailStart)
|
|
127
|
+
return tailStart;
|
|
128
|
+
tailStart = widenedTo;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* **The cut rule.** Returns the index in `messages` where the kept tail begins, or `null` when
|
|
133
|
+
* there is nothing to fold.
|
|
134
|
+
*
|
|
135
|
+
* The tail is the last `keepRecent` messages, widened backwards so no `tool_call` is separated from
|
|
136
|
+
* its `tool_result` (`widenTailToPairStart`). A system prompt at the head is never part of
|
|
137
|
+
* either side. A previous summary sits at the head of the conversation and is always in the folded
|
|
138
|
+
* span, never in the tail — and when it would be the ONLY thing folded, nothing is folded: rewriting
|
|
139
|
+
* a summary with no new material behind it is the thrash invariant (d) forbids.
|
|
140
|
+
*/
|
|
141
|
+
export function compactionCutIndex(messages, keepRecent = DEFAULT_KEEP_RECENT) {
|
|
142
|
+
const keep = Math.max(1, Math.floor(keepRecent));
|
|
143
|
+
const systemEnd = leadingSystemCount(messages);
|
|
144
|
+
const conversation = messages.slice(systemEnd);
|
|
145
|
+
if (conversation.length <= keep)
|
|
146
|
+
return null;
|
|
147
|
+
const tailStart = widenTailToPairStart(conversation, conversation.length - keep);
|
|
148
|
+
if (tailStart <= 0)
|
|
149
|
+
return null;
|
|
150
|
+
if (tailStart === 1 && isCompactionSummary(conversation[0]))
|
|
151
|
+
return null;
|
|
152
|
+
return systemEnd + tailStart;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Fold the older part of `messages` into one summary message. See the module doc for the four
|
|
156
|
+
* invariants this holds and the tests that pin them.
|
|
157
|
+
*
|
|
158
|
+
* `summarize` is called exactly once, over the folded span (a previous summary included, so its
|
|
159
|
+
* content is carried forward rather than lost), and its failure propagates: a compaction that
|
|
160
|
+
* cannot get a summary leaves the conversation alone and says so, rather than replacing history
|
|
161
|
+
* with an error string.
|
|
162
|
+
*/
|
|
163
|
+
export async function compactMessages(input) {
|
|
164
|
+
const messages = [...input.messages];
|
|
165
|
+
const unchanged = {
|
|
166
|
+
messages,
|
|
167
|
+
summaryText: '',
|
|
168
|
+
removedCount: 0,
|
|
169
|
+
keptCount: messages.length,
|
|
170
|
+
changed: false,
|
|
171
|
+
};
|
|
172
|
+
const cut = compactionCutIndex(messages, input.keepRecent);
|
|
173
|
+
if (cut === null)
|
|
174
|
+
return unchanged;
|
|
175
|
+
const systemEnd = leadingSystemCount(messages);
|
|
176
|
+
const folded = messages.slice(systemEnd, cut);
|
|
177
|
+
const kept = messages.slice(cut);
|
|
178
|
+
const summaryText = (await input.summarize(folded, input.focus)).trim();
|
|
179
|
+
if (summaryText.length === 0) {
|
|
180
|
+
throw new Error('The model returned an empty summary, so the conversation was left unchanged.');
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
messages: [
|
|
184
|
+
...messages.slice(0, systemEnd),
|
|
185
|
+
createCompactionSummaryMessage(summaryText),
|
|
186
|
+
...kept,
|
|
187
|
+
],
|
|
188
|
+
summaryText,
|
|
189
|
+
removedCount: folded.length,
|
|
190
|
+
keptCount: kept.length,
|
|
191
|
+
changed: true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The prompt the summariser sends, in the shape of LangChain's `summarizationMiddleware` prompt
|
|
196
|
+
* (role, objective, instructions, then the rendered messages) so the two mechanisms ask the model
|
|
197
|
+
* for the same kind of artifact. The `focus` block is the one addition: the user's own steer on
|
|
198
|
+
* what the summary should keep, appended when `/compact` was given free text.
|
|
199
|
+
*/
|
|
200
|
+
export function buildCompactionPrompt(span, focus) {
|
|
201
|
+
const focusBlock = focus && focus.trim().length > 0
|
|
202
|
+
? `\n<focus>\nThe user asked that the summary pay particular attention to: ${focus.trim()}\n</focus>\n`
|
|
203
|
+
: '';
|
|
204
|
+
return `<role>
|
|
205
|
+
Context Extraction Assistant
|
|
206
|
+
</role>
|
|
207
|
+
|
|
208
|
+
<primary_objective>
|
|
209
|
+
Your sole objective in this task is to extract the highest quality, most relevant context from the conversation history below.
|
|
210
|
+
</primary_objective>
|
|
211
|
+
|
|
212
|
+
<objective_information>
|
|
213
|
+
The conversation history below is about to be replaced by the context you extract, so that the conversation can continue with room to spare. Because of this, ensure the context you extract is the most important information to the overall goal: what was asked, what was decided, what was done, what was found, and what remains open.
|
|
214
|
+
</objective_information>
|
|
215
|
+
|
|
216
|
+
<instructions>
|
|
217
|
+
Extract and record all of the most important context from the conversation history. Do not repeat actions that were already completed; record their outcomes instead. Keep file paths, identifiers, commands, numbers and error messages exact. If the history opens with an earlier summary, fold it into yours rather than repeating it separately.
|
|
218
|
+
Respond ONLY with the extracted context. Do not include any additional information, or text before or after the extracted context.
|
|
219
|
+
</instructions>
|
|
220
|
+
${focusBlock}
|
|
221
|
+
<messages>
|
|
222
|
+
Messages to summarize:
|
|
223
|
+
${getBufferString(span)}
|
|
224
|
+
</messages>`;
|
|
225
|
+
}
|
|
226
|
+
/** The text of a model reply, whether it came back as a string or as content blocks. */
|
|
227
|
+
function contentText(content) {
|
|
228
|
+
if (typeof content === 'string')
|
|
229
|
+
return content;
|
|
230
|
+
if (!Array.isArray(content))
|
|
231
|
+
return '';
|
|
232
|
+
return content
|
|
233
|
+
.map((block) => {
|
|
234
|
+
if (typeof block === 'string')
|
|
235
|
+
return block;
|
|
236
|
+
if (block &&
|
|
237
|
+
typeof block === 'object' &&
|
|
238
|
+
typeof block.text === 'string') {
|
|
239
|
+
return block.text;
|
|
240
|
+
}
|
|
241
|
+
return '';
|
|
242
|
+
})
|
|
243
|
+
.join('');
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* A {@link ConversationSummarizer} bound to a chat model: renders the span with
|
|
247
|
+
* {@link buildCompactionPrompt} and returns the reply's text. The session model is what the runner
|
|
248
|
+
* binds; a caller with a cheaper model for summaries binds that instead.
|
|
249
|
+
*/
|
|
250
|
+
export function createModelSummarizer(model) {
|
|
251
|
+
return async (span, focus) => contentText((await model.invoke(buildCompactionPrompt(span, focus))).content);
|
|
252
|
+
}
|
|
253
|
+
/** The characters of one message as a model would read them: its text, its tool calls, its result. */
|
|
254
|
+
function messageCharacters(message) {
|
|
255
|
+
let total = contentText(message.content).length;
|
|
256
|
+
if (AIMessage.isInstance(message)) {
|
|
257
|
+
for (const call of message.tool_calls ?? []) {
|
|
258
|
+
total += call.name.length;
|
|
259
|
+
try {
|
|
260
|
+
total += JSON.stringify(call.args ?? {}).length;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
/* an argument that cannot be serialised contributes only its name */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return total;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The size of a conversation as a message count and a character estimate. Characters, not tokens,
|
|
271
|
+
* on purpose: precise token accounting belongs with the threshold that needs it (EXT-161), and a
|
|
272
|
+
* before/after comparison only has to agree with itself.
|
|
273
|
+
*/
|
|
274
|
+
export function conversationSize(messages) {
|
|
275
|
+
let characters = 0;
|
|
276
|
+
for (const message of messages)
|
|
277
|
+
characters += messageCharacters(message);
|
|
278
|
+
return { messages: messages.length, characters };
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Replace a thread's whole message list with `messages`, through the graph's own `updateState`.
|
|
282
|
+
*
|
|
283
|
+
* The write is `RemoveMessage(REMOVE_ALL_MESSAGES)` followed by the replacement, which the
|
|
284
|
+
* `messagesStateReducer` behind `state.messages` turns into "discard everything, keep what
|
|
285
|
+
* follows". Going through `updateState` is what makes a compaction durable for free: the graph
|
|
286
|
+
* writes a new checkpoint, so the next turn and a later resume both load the compacted history.
|
|
287
|
+
*/
|
|
288
|
+
export async function replaceGraphMessages(graph, runConfig, messages) {
|
|
289
|
+
await graph.updateState(runConfig, {
|
|
290
|
+
messages: [new RemoveMessage({ id: REMOVE_ALL_MESSAGES }), ...messages],
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
//# sourceMappingURL=compaction.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compaction.js","sourceRoot":"","sources":["../../src/core/compaction.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* EXT-161 — **the preventive compaction threshold: one number, one place it is decided.**
|
|
4
|
+
*
|
|
5
|
+
* `contextWindow.ts` answers "how big is the window". This module answers the question that
|
|
6
|
+
* actually gates a compaction: **at what prompt size do we fold the conversation, and who said
|
|
7
|
+
* so.** Three things can say so, in this order:
|
|
8
|
+
*
|
|
9
|
+
* 1. **the running session** — `/autocompact 300K`, which wins for the rest of the session;
|
|
10
|
+
* 2. **the user's config** — the `autocompact` key;
|
|
11
|
+
* 3. **the default** — derived from the resolved window by the guard, which is the only place that
|
|
12
|
+
* knows how many tokens are being held back for the answer.
|
|
13
|
+
*
|
|
14
|
+
* **Compaction is ON BY DEFAULT.** That is a ruling, and it is a deliberate exception to the rule
|
|
15
|
+
* that a default-on middleware must not touch `state.messages` — compaction changes what the model
|
|
16
|
+
* sees by construction, which is the whole feature. The exception is paid for the way the rule
|
|
17
|
+
* demands: the shape of the history a compaction leaves behind is pinned per provider by
|
|
18
|
+
* `compaction.ts`'s invariants (a)–(d), a compaction announces itself in the transcript, the
|
|
19
|
+
* resolved number and its provenance are readable with `/status`, and the off switch is one key —
|
|
20
|
+
* `autocompact: false`.
|
|
21
|
+
*
|
|
22
|
+
* **Why the provenance is carried rather than recomputed.** A threshold that is wrong is diagnosed
|
|
23
|
+
* by knowing where the number came from; a `/status` that reports a models.dev-derived number after
|
|
24
|
+
* a human typed `/autocompact 50000` would send the next diagnosis to the wrong place entirely. So
|
|
25
|
+
* a session override re-labels the provenance, and {@link AutocompactController} is the single
|
|
26
|
+
* object both the guard and `/status` read, over one memoised window resolution.
|
|
27
|
+
*/
|
|
28
|
+
import { type TokenBudget } from '#src/config/tokenBudget.js';
|
|
29
|
+
import type { ContextWindowOrigin, ResolvedContextWindow } from '#src/core/contextWindow.js';
|
|
30
|
+
/**
|
|
31
|
+
* The share of a model's context window that `gth init` seeds as an explicit threshold.
|
|
32
|
+
*
|
|
33
|
+
* Init writes an **absolute number** derived from this rather than the percentage itself, because
|
|
34
|
+
* the point of seeding is that the user opens their config and sees the number that will actually
|
|
35
|
+
* be enforced. A percentage would leave them one lookup away from it.
|
|
36
|
+
*
|
|
37
|
+
* 0.8 leaves a fifth of the window for the answer and the tool round that follows it — comfortably
|
|
38
|
+
* more than the flat answer reserve on any real cloud window, which is what makes the seeded number
|
|
39
|
+
* the binding one rather than a decoration.
|
|
40
|
+
*/
|
|
41
|
+
export declare const DEFAULT_AUTOCOMPACT_SEED_FRACTION = 0.8;
|
|
42
|
+
/**
|
|
43
|
+
* The `autocompact` config value, in every form the key accepts.
|
|
44
|
+
*
|
|
45
|
+
* `false` disables it; `true` (and an absent key) is on with the derived default; a bare count or
|
|
46
|
+
* suffixed string is on with that threshold; the object form spells both out. The shorthand union
|
|
47
|
+
* mirrors `toolLoopGuard`, which is the shape a reader of this config already knows.
|
|
48
|
+
*/
|
|
49
|
+
export type AutocompactConfig = boolean | number | string | {
|
|
50
|
+
enabled?: boolean;
|
|
51
|
+
threshold?: number | string;
|
|
52
|
+
};
|
|
53
|
+
/** The `autocompact` key as the read site sees it, after defaulting and parsing. */
|
|
54
|
+
export interface ResolvedAutocompactConfig {
|
|
55
|
+
/** Whether preventive compaction may fire at all. On by default — ruled. */
|
|
56
|
+
enabled: boolean;
|
|
57
|
+
/** The configured budget, or `null` when the user named none and the default applies. */
|
|
58
|
+
budget: TokenBudget | null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read the `autocompact` config key.
|
|
62
|
+
*
|
|
63
|
+
* Defaulting happens **here, at the read site**, not in `DEFAULT_CONFIG`, so an absent key stays
|
|
64
|
+
* absent in the effective-config snapshot and the snapshot does not churn — the same placement
|
|
65
|
+
* `injectModelContext` and `toolLoopGuard` use.
|
|
66
|
+
*
|
|
67
|
+
* A malformed threshold raises the `TokenBudgetError` from the shared parser, which config
|
|
68
|
+
* validation turns into a field-scoped issue naming the offending text. It must never resolve to a
|
|
69
|
+
* number: `NaN`, `0` and 4097 are all thresholds the user cannot see and did not choose.
|
|
70
|
+
*/
|
|
71
|
+
export declare function resolveAutocompactConfig(raw: unknown): ResolvedAutocompactConfig;
|
|
72
|
+
/** Where the threshold number in force came from — what `/status` names. */
|
|
73
|
+
export type AutocompactThresholdOrigin =
|
|
74
|
+
/** A `/autocompact` typed in this session; outranks the config for the rest of it. */
|
|
75
|
+
'session'
|
|
76
|
+
/** The `autocompact` key in the user's config. */
|
|
77
|
+
| 'config'
|
|
78
|
+
/** Derived from the resolved window, holding back room for the answer. */
|
|
79
|
+
| 'default'
|
|
80
|
+
/** Nothing will fire: switched off, or no window and no absolute threshold to fall back on. */
|
|
81
|
+
| 'none';
|
|
82
|
+
/** The whole picture, as `/status` prints it and the guard enforces it. */
|
|
83
|
+
export interface AutocompactStatus {
|
|
84
|
+
/** Whether preventive compaction may fire at all (the config off switch). */
|
|
85
|
+
enabled: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* The prompt size, in tokens, at which the conversation is folded — or `null` when nothing will
|
|
88
|
+
* fire preventively, which is what an unknown window with no absolute threshold must produce.
|
|
89
|
+
*/
|
|
90
|
+
thresholdTokens: number | null;
|
|
91
|
+
/** Where that number came from. */
|
|
92
|
+
thresholdOrigin: AutocompactThresholdOrigin;
|
|
93
|
+
/** The resolved context window, or `null` when no source knew it. */
|
|
94
|
+
window: number | null;
|
|
95
|
+
/** Which source the window came from. */
|
|
96
|
+
windowOrigin: ContextWindowOrigin;
|
|
97
|
+
/** The budget exactly as written, when one was written — so `/status` can echo `80%` as `80%`. */
|
|
98
|
+
budget: TokenBudget | null;
|
|
99
|
+
}
|
|
100
|
+
/** What {@link AutocompactController} needs to build a status. */
|
|
101
|
+
export interface AutocompactControllerOptions {
|
|
102
|
+
/** The `autocompact` key, already read through {@link resolveAutocompactConfig}. */
|
|
103
|
+
config: ResolvedAutocompactConfig;
|
|
104
|
+
/** The one memoised window resolution this session uses — shared with the guard. */
|
|
105
|
+
window: Pick<ResolvedContextWindow, 'read'>;
|
|
106
|
+
/**
|
|
107
|
+
* The threshold to use when the user named none, given a known window: the guard's
|
|
108
|
+
* `window − reserve`. Supplied as a callback because only the guard knows the reserve, and
|
|
109
|
+
* duplicating that arithmetic here is how the two would come to disagree about what "full" means.
|
|
110
|
+
*/
|
|
111
|
+
defaultThreshold: (_window: number) => number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* **The single object the guard and `/status` both read.**
|
|
115
|
+
*
|
|
116
|
+
* Holds the session override — the write half of `/autocompact` — and resolves it against the
|
|
117
|
+
* config and the window on demand. It is mutable by design and the guard reads it through a
|
|
118
|
+
* closure rather than capturing a value, because `createContextGuardMiddleware` runs its factory
|
|
119
|
+
* once per session and its hook holds no state: a captured threshold could never be changed by a
|
|
120
|
+
* command typed later, which is precisely what `/autocompact` has to do.
|
|
121
|
+
*/
|
|
122
|
+
export declare class AutocompactController {
|
|
123
|
+
private readonly options;
|
|
124
|
+
private sessionBudget;
|
|
125
|
+
constructor(options: AutocompactControllerOptions);
|
|
126
|
+
/**
|
|
127
|
+
* Set the threshold for the rest of this session, overriding the config.
|
|
128
|
+
*
|
|
129
|
+
* Deliberately takes an already-parsed {@link TokenBudget} rather than raw text: parsing is the
|
|
130
|
+
* shared parser's job, and a second entry point that took a string would be a second place the
|
|
131
|
+
* grammar could drift.
|
|
132
|
+
*
|
|
133
|
+
* **Refused while the config has compaction off.** `autocompact: false` means nothing fires,
|
|
134
|
+
* whatever number is named, so recording the budget would only make the next {@link status}
|
|
135
|
+
* describe a threshold that can never trigger. Nothing is recorded and the status is unchanged;
|
|
136
|
+
* the surface reads `enabled: false` off the status that comes back and says so. Turning it back
|
|
137
|
+
* on is a config edit — removing the key, or setting a threshold there — not a session command.
|
|
138
|
+
*/
|
|
139
|
+
setSessionBudget(budget: TokenBudget): void;
|
|
140
|
+
/** The session override in force, or `null`. Read by `/autocompact` with no argument. */
|
|
141
|
+
get sessionOverride(): TokenBudget | null;
|
|
142
|
+
/** Whether the config off switch leaves anything to do at all. */
|
|
143
|
+
get enabled(): boolean;
|
|
144
|
+
/** The full picture — the one call `/status` makes. */
|
|
145
|
+
status(): Promise<AutocompactStatus>;
|
|
146
|
+
/** The number the guard compares against, or `null` for "never fire". */
|
|
147
|
+
threshold(): Promise<number | null>;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The absolute threshold `gth init` writes for a model whose window it resolved — a plain number,
|
|
151
|
+
* because the point of seeding is that the user can read the enforced value straight out of their
|
|
152
|
+
* config.
|
|
153
|
+
*
|
|
154
|
+
* Returns `null` when the window is unknown, and the caller then writes **no key at all**: seeding
|
|
155
|
+
* a guess would put a number in the user's config that looks chosen and was not, which is worse
|
|
156
|
+
* than the absent key that leaves the runtime default in charge.
|
|
157
|
+
*/
|
|
158
|
+
export declare function seedAutocompactThreshold(window: number | null): number | null;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* EXT-161 — **the preventive compaction threshold: one number, one place it is decided.**
|
|
4
|
+
*
|
|
5
|
+
* `contextWindow.ts` answers "how big is the window". This module answers the question that
|
|
6
|
+
* actually gates a compaction: **at what prompt size do we fold the conversation, and who said
|
|
7
|
+
* so.** Three things can say so, in this order:
|
|
8
|
+
*
|
|
9
|
+
* 1. **the running session** — `/autocompact 300K`, which wins for the rest of the session;
|
|
10
|
+
* 2. **the user's config** — the `autocompact` key;
|
|
11
|
+
* 3. **the default** — derived from the resolved window by the guard, which is the only place that
|
|
12
|
+
* knows how many tokens are being held back for the answer.
|
|
13
|
+
*
|
|
14
|
+
* **Compaction is ON BY DEFAULT.** That is a ruling, and it is a deliberate exception to the rule
|
|
15
|
+
* that a default-on middleware must not touch `state.messages` — compaction changes what the model
|
|
16
|
+
* sees by construction, which is the whole feature. The exception is paid for the way the rule
|
|
17
|
+
* demands: the shape of the history a compaction leaves behind is pinned per provider by
|
|
18
|
+
* `compaction.ts`'s invariants (a)–(d), a compaction announces itself in the transcript, the
|
|
19
|
+
* resolved number and its provenance are readable with `/status`, and the off switch is one key —
|
|
20
|
+
* `autocompact: false`.
|
|
21
|
+
*
|
|
22
|
+
* **Why the provenance is carried rather than recomputed.** A threshold that is wrong is diagnosed
|
|
23
|
+
* by knowing where the number came from; a `/status` that reports a models.dev-derived number after
|
|
24
|
+
* a human typed `/autocompact 50000` would send the next diagnosis to the wrong place entirely. So
|
|
25
|
+
* a session override re-labels the provenance, and {@link AutocompactController} is the single
|
|
26
|
+
* object both the guard and `/status` read, over one memoised window resolution.
|
|
27
|
+
*/
|
|
28
|
+
import { parseTokenBudget, resolveTokenBudget } from '#src/config/tokenBudget.js';
|
|
29
|
+
/**
|
|
30
|
+
* The share of a model's context window that `gth init` seeds as an explicit threshold.
|
|
31
|
+
*
|
|
32
|
+
* Init writes an **absolute number** derived from this rather than the percentage itself, because
|
|
33
|
+
* the point of seeding is that the user opens their config and sees the number that will actually
|
|
34
|
+
* be enforced. A percentage would leave them one lookup away from it.
|
|
35
|
+
*
|
|
36
|
+
* 0.8 leaves a fifth of the window for the answer and the tool round that follows it — comfortably
|
|
37
|
+
* more than the flat answer reserve on any real cloud window, which is what makes the seeded number
|
|
38
|
+
* the binding one rather than a decoration.
|
|
39
|
+
*/
|
|
40
|
+
export const DEFAULT_AUTOCOMPACT_SEED_FRACTION = 0.8;
|
|
41
|
+
/**
|
|
42
|
+
* Read the `autocompact` config key.
|
|
43
|
+
*
|
|
44
|
+
* Defaulting happens **here, at the read site**, not in `DEFAULT_CONFIG`, so an absent key stays
|
|
45
|
+
* absent in the effective-config snapshot and the snapshot does not churn — the same placement
|
|
46
|
+
* `injectModelContext` and `toolLoopGuard` use.
|
|
47
|
+
*
|
|
48
|
+
* A malformed threshold raises the `TokenBudgetError` from the shared parser, which config
|
|
49
|
+
* validation turns into a field-scoped issue naming the offending text. It must never resolve to a
|
|
50
|
+
* number: `NaN`, `0` and 4097 are all thresholds the user cannot see and did not choose.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveAutocompactConfig(raw) {
|
|
53
|
+
if (raw === undefined || raw === null)
|
|
54
|
+
return { enabled: true, budget: null };
|
|
55
|
+
if (typeof raw === 'boolean')
|
|
56
|
+
return { enabled: raw, budget: null };
|
|
57
|
+
if (typeof raw === 'number' || typeof raw === 'string') {
|
|
58
|
+
return { enabled: true, budget: parseTokenBudget(raw) };
|
|
59
|
+
}
|
|
60
|
+
if (typeof raw === 'object') {
|
|
61
|
+
const value = raw;
|
|
62
|
+
const enabled = value.enabled !== false;
|
|
63
|
+
const budget = value.threshold === undefined || value.threshold === null
|
|
64
|
+
? null
|
|
65
|
+
: parseTokenBudget(value.threshold);
|
|
66
|
+
return { enabled, budget };
|
|
67
|
+
}
|
|
68
|
+
return { enabled: true, budget: null };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* **The single object the guard and `/status` both read.**
|
|
72
|
+
*
|
|
73
|
+
* Holds the session override — the write half of `/autocompact` — and resolves it against the
|
|
74
|
+
* config and the window on demand. It is mutable by design and the guard reads it through a
|
|
75
|
+
* closure rather than capturing a value, because `createContextGuardMiddleware` runs its factory
|
|
76
|
+
* once per session and its hook holds no state: a captured threshold could never be changed by a
|
|
77
|
+
* command typed later, which is precisely what `/autocompact` has to do.
|
|
78
|
+
*/
|
|
79
|
+
export class AutocompactController {
|
|
80
|
+
options;
|
|
81
|
+
sessionBudget = null;
|
|
82
|
+
constructor(options) {
|
|
83
|
+
this.options = options;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Set the threshold for the rest of this session, overriding the config.
|
|
87
|
+
*
|
|
88
|
+
* Deliberately takes an already-parsed {@link TokenBudget} rather than raw text: parsing is the
|
|
89
|
+
* shared parser's job, and a second entry point that took a string would be a second place the
|
|
90
|
+
* grammar could drift.
|
|
91
|
+
*
|
|
92
|
+
* **Refused while the config has compaction off.** `autocompact: false` means nothing fires,
|
|
93
|
+
* whatever number is named, so recording the budget would only make the next {@link status}
|
|
94
|
+
* describe a threshold that can never trigger. Nothing is recorded and the status is unchanged;
|
|
95
|
+
* the surface reads `enabled: false` off the status that comes back and says so. Turning it back
|
|
96
|
+
* on is a config edit — removing the key, or setting a threshold there — not a session command.
|
|
97
|
+
*/
|
|
98
|
+
setSessionBudget(budget) {
|
|
99
|
+
if (!this.options.config.enabled)
|
|
100
|
+
return;
|
|
101
|
+
this.sessionBudget = budget;
|
|
102
|
+
}
|
|
103
|
+
/** The session override in force, or `null`. Read by `/autocompact` with no argument. */
|
|
104
|
+
get sessionOverride() {
|
|
105
|
+
return this.sessionBudget;
|
|
106
|
+
}
|
|
107
|
+
/** Whether the config off switch leaves anything to do at all. */
|
|
108
|
+
get enabled() {
|
|
109
|
+
return this.options.config.enabled;
|
|
110
|
+
}
|
|
111
|
+
/** The full picture — the one call `/status` makes. */
|
|
112
|
+
async status() {
|
|
113
|
+
const reading = await this.options.window.read();
|
|
114
|
+
const budget = this.sessionBudget ?? this.options.config.budget;
|
|
115
|
+
const budgetOrigin = this.sessionBudget
|
|
116
|
+
? 'session'
|
|
117
|
+
: this.options.config.budget
|
|
118
|
+
? 'config'
|
|
119
|
+
: 'default';
|
|
120
|
+
if (!this.options.config.enabled) {
|
|
121
|
+
return {
|
|
122
|
+
enabled: false,
|
|
123
|
+
thresholdTokens: null,
|
|
124
|
+
thresholdOrigin: 'none',
|
|
125
|
+
window: reading.tokens,
|
|
126
|
+
windowOrigin: reading.origin,
|
|
127
|
+
budget,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// A named budget resolves against the window — which an absolute count does not need, so an
|
|
131
|
+
// explicit `300K` still fires on a model nothing knows the window of. A PERCENTAGE without a
|
|
132
|
+
// window cannot resolve, and falls through to the same "nothing fires" answer as no threshold
|
|
133
|
+
// at all rather than to a guess.
|
|
134
|
+
const named = budget ? resolveTokenBudget(budget, reading.tokens) : null;
|
|
135
|
+
if (named !== null) {
|
|
136
|
+
return {
|
|
137
|
+
enabled: true,
|
|
138
|
+
thresholdTokens: named,
|
|
139
|
+
thresholdOrigin: budgetOrigin === 'default' ? 'config' : budgetOrigin,
|
|
140
|
+
window: reading.tokens,
|
|
141
|
+
windowOrigin: reading.origin,
|
|
142
|
+
budget,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (reading.tokens === null) {
|
|
146
|
+
return {
|
|
147
|
+
enabled: true,
|
|
148
|
+
thresholdTokens: null,
|
|
149
|
+
thresholdOrigin: 'none',
|
|
150
|
+
window: null,
|
|
151
|
+
windowOrigin: reading.origin,
|
|
152
|
+
budget,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
enabled: true,
|
|
157
|
+
thresholdTokens: this.options.defaultThreshold(reading.tokens),
|
|
158
|
+
thresholdOrigin: 'default',
|
|
159
|
+
window: reading.tokens,
|
|
160
|
+
windowOrigin: reading.origin,
|
|
161
|
+
budget,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** The number the guard compares against, or `null` for "never fire". */
|
|
165
|
+
async threshold() {
|
|
166
|
+
return (await this.status()).thresholdTokens;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The absolute threshold `gth init` writes for a model whose window it resolved — a plain number,
|
|
171
|
+
* because the point of seeding is that the user can read the enforced value straight out of their
|
|
172
|
+
* config.
|
|
173
|
+
*
|
|
174
|
+
* Returns `null` when the window is unknown, and the caller then writes **no key at all**: seeding
|
|
175
|
+
* a guess would put a number in the user's config that looks chosen and was not, which is worse
|
|
176
|
+
* than the absent key that leaves the runtime default in charge.
|
|
177
|
+
*/
|
|
178
|
+
export function seedAutocompactThreshold(window) {
|
|
179
|
+
if (window === null || !Number.isFinite(window) || window <= 0)
|
|
180
|
+
return null;
|
|
181
|
+
return Math.max(1, Math.floor(window * DEFAULT_AUTOCOMPACT_SEED_FRACTION));
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=compactionThreshold.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compactionThreshold.js","sourceRoot":"","sources":["../../src/core/compactionThreshold.ts"],"names":[],"mappings":"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"}
|