pi-harness-runtime 0.10.13 → 0.10.14
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/.versionrc.js +32 -0
- package/cli.js +112 -0
- package/footer-status.js +155 -0
- package/harness/agent-handoff.js +123 -0
- package/harness/auto-compact.js +243 -0
- package/harness/auto-quota-resume.js +104 -0
- package/harness/blackboard.js +258 -0
- package/harness/context-compact-orchestrator.js +365 -0
- package/harness/context-window-manager.js +330 -0
- package/harness/continue-prompt.js +164 -0
- package/harness/e2e/minimax-quota-parser.js +52 -0
- package/harness/e2e/minimax-quota-scraper.js +475 -0
- package/harness/e2e/openai-quota-scraper.js +332 -0
- package/harness/e2e/playwright-runner.js +165 -0
- package/harness/e2e/quota-status.js +140 -0
- package/harness/e2e/test-engine.js +290 -0
- package/harness/forked-summarizer.js +212 -0
- package/harness/index.js +54 -0
- package/harness/job-state-machine.js +277 -0
- package/harness/loop-runtime.js +531 -0
- package/harness/master-planner.js +229 -0
- package/harness/notification-events.js +233 -0
- package/harness/output-limit-handler.js +233 -0
- package/harness/partial-recovery.js +413 -0
- package/harness/project-detector/detector.js +283 -0
- package/harness/repair-engine.js +256 -0
- package/harness/session-memory.js +293 -0
- package/harness/task-graph.js +87 -0
- package/index.js +1121 -0
- package/mirror.js +205 -0
- package/package.json +6 -4
- package/proactive-compact.js +42 -0
- package/renderer.js +134 -0
- package/status-parsers.js +63 -0
- package/tracker.js +49 -0
- package/windows.js +90 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Compact Orchestrator — Integration Layer
|
|
3
|
+
*
|
|
4
|
+
* Connects token estimation + microcompact + full compact into the harness loop.
|
|
5
|
+
* Enhanced with proper proactive/reactive compact and circuit breaker.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* LoopRuntime.executeTask()
|
|
9
|
+
* +-> CompactOrchestrator.invokeWithCompact()
|
|
10
|
+
* +-> beforeInvoke():
|
|
11
|
+
* | 1. Estimate token count with buffer awareness
|
|
12
|
+
* | 2. Check circuit breaker status
|
|
13
|
+
* | 3. Microcompact: prune old tool results if time-based
|
|
14
|
+
* | 4. If over autoCompactThreshold → Full compact via forked summarization
|
|
15
|
+
* +-> afterInvoke():
|
|
16
|
+
* 1. Update token stats
|
|
17
|
+
* 2. Handle reactive 413 (context too long)
|
|
18
|
+
* 3. Generate continue prompt
|
|
19
|
+
*/
|
|
20
|
+
import { roughTokenCount, roughMessagesTokens, } from "../packages/token-estimation/src/index.js";
|
|
21
|
+
import { ContextWindowManager, microcompactToolResults, MAX_CONSECUTIVE_COMPACT_FAILURES, } from "./context-window-manager.js";
|
|
22
|
+
import { continuePromptGenerator } from "./continue-prompt.js";
|
|
23
|
+
// --- Orchestrator -------------------------------------------------------------
|
|
24
|
+
/**
|
|
25
|
+
* Compact Orchestrator
|
|
26
|
+
*
|
|
27
|
+
* Wraps LLM invocations with compact checks.
|
|
28
|
+
* Call invokeWithCompact() instead of calling the LLM directly.
|
|
29
|
+
*
|
|
30
|
+
* Usage:
|
|
31
|
+
* const orchestrator = new CompactOrchestrator({ jobId: "job-1" });
|
|
32
|
+
* const result = await orchestrator.invokeWithCompact(
|
|
33
|
+
* { messages, model, maxOutputTokens, tools },
|
|
34
|
+
* { onCheckpoint, onQuotaEvent, summarizeViaForkedAgent }
|
|
35
|
+
* );
|
|
36
|
+
*/
|
|
37
|
+
export class CompactOrchestrator {
|
|
38
|
+
provider;
|
|
39
|
+
model;
|
|
40
|
+
autoCompactThreshold;
|
|
41
|
+
blockingThreshold;
|
|
42
|
+
maxCompactAttempts;
|
|
43
|
+
microcompactTimeGapMs;
|
|
44
|
+
microcompactKeepRecent;
|
|
45
|
+
contextWindow;
|
|
46
|
+
compactAttempts = 0;
|
|
47
|
+
lastAssistantTimestamp = 0;
|
|
48
|
+
// Tool result tracking for microcompact
|
|
49
|
+
toolResultsById = new Map();
|
|
50
|
+
constructor(config) {
|
|
51
|
+
this.provider = config.provider ?? "openai";
|
|
52
|
+
this.model = config.model ?? "gpt-4";
|
|
53
|
+
this.autoCompactThreshold = config.autoCompactThreshold ?? 0.85;
|
|
54
|
+
this.blockingThreshold = config.blockingThreshold ?? 0.97;
|
|
55
|
+
this.maxCompactAttempts =
|
|
56
|
+
config.maxCompactAttempts ?? MAX_CONSECUTIVE_COMPACT_FAILURES;
|
|
57
|
+
this.microcompactTimeGapMs = config.microcompactTimeGapMs ?? 30 * 60 * 1000;
|
|
58
|
+
this.microcompactKeepRecent = config.microcompactKeepRecent ?? 2;
|
|
59
|
+
this.contextWindow = new ContextWindowManager();
|
|
60
|
+
// Set context window size if provided
|
|
61
|
+
if (config.contextWindowSize) {
|
|
62
|
+
this.contextWindow.setContextWindow(this.provider, this.model, config.contextWindowSize);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Main entry point: invoke LLM with compact checks.
|
|
67
|
+
*
|
|
68
|
+
* @param opts - InvokeWithCompactOptions (messages array is modified in-place on compact)
|
|
69
|
+
* @param callbacks - CompactOrchestratorCallbacks (for side-effects)
|
|
70
|
+
*/
|
|
71
|
+
async invokeWithCompact(opts, callbacks) {
|
|
72
|
+
const { messages, model, maxOutputTokens } = opts;
|
|
73
|
+
this.compactAttempts++;
|
|
74
|
+
// -- Estimate tokens with buffer awareness -----------------------------
|
|
75
|
+
const estimate = this.contextWindow.estimateTokensWithBuffer({
|
|
76
|
+
messages,
|
|
77
|
+
tools: opts.tools,
|
|
78
|
+
systemPrompt: opts.systemPrompt,
|
|
79
|
+
provider: this.provider,
|
|
80
|
+
model: model ?? this.model,
|
|
81
|
+
});
|
|
82
|
+
// -- Circuit breaker check ---------------------------------------------
|
|
83
|
+
if (this.contextWindow.shouldCircuitBreak()) {
|
|
84
|
+
return {
|
|
85
|
+
success: false,
|
|
86
|
+
error: `Compact circuit breaker engaged after ${this.contextWindow.getConsecutiveFailures()} consecutive failures.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// -- Blocking: must compact before API call ---------------------------
|
|
90
|
+
if (this.contextWindow.shouldBlockApiCall(estimate)) {
|
|
91
|
+
callbacks.onPreCompact?.("token_threshold");
|
|
92
|
+
const compact = await this.runFullCompact(messages, model ?? this.model, maxOutputTokens, "token_threshold", callbacks);
|
|
93
|
+
if (!compact.success) {
|
|
94
|
+
return {
|
|
95
|
+
success: false,
|
|
96
|
+
error: `Context too large and compact failed: ${compact.error}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (compact.result) {
|
|
100
|
+
callbacks.onPostCompact?.(compact.result);
|
|
101
|
+
callbacks.onCheckpoint?.(compact.result);
|
|
102
|
+
}
|
|
103
|
+
this.contextWindow.recordCompactSuccess();
|
|
104
|
+
}
|
|
105
|
+
// -- Time-based microcompact -----------------------------------------
|
|
106
|
+
this.tryMicrocompact(messages);
|
|
107
|
+
// -- Proactive auto-compact at 85% ----------------------------------
|
|
108
|
+
if (this.contextWindow.shouldProactiveCompact(estimate)) {
|
|
109
|
+
callbacks.onPreCompact?.("token_threshold");
|
|
110
|
+
const compact = await this.runFullCompact(messages, model ?? this.model, maxOutputTokens, "token_threshold", callbacks);
|
|
111
|
+
if (compact.success) {
|
|
112
|
+
this.contextWindow.recordCompactSuccess();
|
|
113
|
+
if (compact.result) {
|
|
114
|
+
callbacks.onPostCompact?.(compact.result);
|
|
115
|
+
callbacks.onCheckpoint?.(compact.result);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// -- Make the LLM call -----------------------------------------------
|
|
120
|
+
const invoke = opts.invokeAgent ?? callbacks.invokeAgent;
|
|
121
|
+
if (!invoke) {
|
|
122
|
+
return {
|
|
123
|
+
success: false,
|
|
124
|
+
error: "No invokeAgent provided — must pass invokeAgent in opts or callbacks",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const result = await invoke(opts);
|
|
128
|
+
// Track tool results
|
|
129
|
+
this.trackToolResults(messages);
|
|
130
|
+
// -- Update stats -----------------------------------------------------
|
|
131
|
+
if (result.usage) {
|
|
132
|
+
this.contextWindow.updateStats({
|
|
133
|
+
provider: this.provider,
|
|
134
|
+
model: model ?? this.model,
|
|
135
|
+
maxTokens: estimate.total + (result.usage.outputTokens ?? 0),
|
|
136
|
+
usedTokens: result.usage.inputTokens ?? estimate.total,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// -- Reactive compact on context-too-long error ---------------------
|
|
140
|
+
const isContextError = this.isContextTooLongError(result.error);
|
|
141
|
+
if (isContextError) {
|
|
142
|
+
const failures = this.contextWindow.recordCompactFailure();
|
|
143
|
+
if (this.contextWindow.shouldCircuitBreak()) {
|
|
144
|
+
return {
|
|
145
|
+
success: false,
|
|
146
|
+
error: `Context too long, compact circuit breaker engaged after ${failures} failures.`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
callbacks.onPreCompact?.("output_limit");
|
|
150
|
+
const compact = await this.runFullCompact(messages, model ?? this.model, maxOutputTokens, "output_limit", callbacks);
|
|
151
|
+
if (compact.success) {
|
|
152
|
+
this.contextWindow.recordCompactSuccess();
|
|
153
|
+
if (compact.result) {
|
|
154
|
+
callbacks.onPostCompact?.(compact.result);
|
|
155
|
+
callbacks.onCheckpoint?.(compact.result);
|
|
156
|
+
}
|
|
157
|
+
// Retry once after compact
|
|
158
|
+
const retryResult = await invoke(opts);
|
|
159
|
+
if (retryResult.success) {
|
|
160
|
+
this.lastAssistantTimestamp = Date.now();
|
|
161
|
+
return retryResult;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (result.success) {
|
|
166
|
+
this.lastAssistantTimestamp = Date.now();
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Run full compact: summarize old messages.
|
|
172
|
+
*/
|
|
173
|
+
async runFullCompact(messages, model, maxOutputTokens, reason, callbacks) {
|
|
174
|
+
const beforeTokens = this.estimateTokens({
|
|
175
|
+
messages,
|
|
176
|
+
model,
|
|
177
|
+
maxOutputTokens,
|
|
178
|
+
});
|
|
179
|
+
// Keep last 5-10 messages; compact everything before that
|
|
180
|
+
const keepRecent = Math.min(10, Math.floor(messages.length * 0.2));
|
|
181
|
+
const recentMessages = messages.slice(-keepRecent);
|
|
182
|
+
const oldMessages = messages.slice(0, -keepRecent);
|
|
183
|
+
if (oldMessages.length === 0) {
|
|
184
|
+
return { success: false, error: "Nothing to compact" };
|
|
185
|
+
}
|
|
186
|
+
// -- Generate summary ------------------------------------------------
|
|
187
|
+
let summary = "";
|
|
188
|
+
let droppedCount = oldMessages.length;
|
|
189
|
+
if (callbacks.summarizeViaForkedAgent) {
|
|
190
|
+
try {
|
|
191
|
+
const result = await callbacks.summarizeViaForkedAgent(oldMessages, reason);
|
|
192
|
+
summary = result.summary;
|
|
193
|
+
droppedCount = result.droppedCount;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
summary = this.heuristicSummary(oldMessages, reason);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
summary = this.heuristicSummary(oldMessages, reason);
|
|
201
|
+
}
|
|
202
|
+
// -- Build compact boundary message -----------------------------------
|
|
203
|
+
const boundaryMsg = {
|
|
204
|
+
role: "system",
|
|
205
|
+
content: continuePromptGenerator.generateBoundary({
|
|
206
|
+
summary,
|
|
207
|
+
reason,
|
|
208
|
+
messagesCompacted: droppedCount,
|
|
209
|
+
}),
|
|
210
|
+
timestamp: Date.now(),
|
|
211
|
+
metadata: {
|
|
212
|
+
compactBoundary: true,
|
|
213
|
+
reason,
|
|
214
|
+
compactedMessages: droppedCount,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
// -- Preserve tool results from recent messages -----------------------
|
|
218
|
+
const preservedRecent = recentMessages.map((msg) => {
|
|
219
|
+
if (msg.role !== "assistant" || !msg.toolResults)
|
|
220
|
+
return msg;
|
|
221
|
+
return {
|
|
222
|
+
...msg,
|
|
223
|
+
toolResults: this.preserveRecentToolResults(msg, this.microcompactKeepRecent),
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
// -- Rebuild message array -------------------------------------------
|
|
227
|
+
messages.length = 0;
|
|
228
|
+
messages.push(boundaryMsg, ...preservedRecent);
|
|
229
|
+
const afterTokens = this.estimateTokens({
|
|
230
|
+
messages,
|
|
231
|
+
model,
|
|
232
|
+
maxOutputTokens,
|
|
233
|
+
});
|
|
234
|
+
const result = {
|
|
235
|
+
trigger: reason,
|
|
236
|
+
beforeTokens,
|
|
237
|
+
afterTokens,
|
|
238
|
+
messagesCompacted: droppedCount,
|
|
239
|
+
summary,
|
|
240
|
+
};
|
|
241
|
+
const continueMessage = continuePromptGenerator.generateMinimal({
|
|
242
|
+
summary,
|
|
243
|
+
recentMessages: preservedRecent,
|
|
244
|
+
});
|
|
245
|
+
return { success: true, result, continueMessage };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Preserve only recent N tool results per message
|
|
249
|
+
*/
|
|
250
|
+
preserveRecentToolResults(msg, keepCount) {
|
|
251
|
+
if (!msg.toolResults)
|
|
252
|
+
return undefined;
|
|
253
|
+
const sorted = [...msg.toolResults]
|
|
254
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
255
|
+
.slice(0, keepCount);
|
|
256
|
+
return sorted.map((tr) => ({
|
|
257
|
+
...tr,
|
|
258
|
+
content: tr.content.substring(0, 1000),
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Heuristic summary when LLM summarization unavailable
|
|
263
|
+
*/
|
|
264
|
+
heuristicSummary(messages, reason) {
|
|
265
|
+
const summaries = [];
|
|
266
|
+
// Extract key information from last 20 messages
|
|
267
|
+
for (const msg of messages.slice(-20)) {
|
|
268
|
+
if (msg.role === "user" && msg.content.length > 50) {
|
|
269
|
+
summaries.push(`- User: ${msg.content.substring(0, 200)}`);
|
|
270
|
+
}
|
|
271
|
+
if (msg.metadata?.action) {
|
|
272
|
+
summaries.push(`- Action: ${msg.metadata.action}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return [
|
|
276
|
+
`[Context compacted due to: ${reason}]`,
|
|
277
|
+
"",
|
|
278
|
+
"The conversation history was too long to fit in the context window.",
|
|
279
|
+
"Key context from earlier conversation:",
|
|
280
|
+
"",
|
|
281
|
+
summaries.join("\n") || "- Conversation was in progress",
|
|
282
|
+
"",
|
|
283
|
+
"Please continue from the recent messages provided.",
|
|
284
|
+
].join("\n");
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Microcompact: prune old tool results based on time gap.
|
|
288
|
+
* Uses the microcompactToolResults helper from context-window-manager.
|
|
289
|
+
*/
|
|
290
|
+
tryMicrocompact(messages) {
|
|
291
|
+
if (Date.now() - this.lastAssistantTimestamp < this.microcompactTimeGapMs) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const freed = microcompactToolResults(messages, {
|
|
295
|
+
maxAgeMs: this.microcompactTimeGapMs,
|
|
296
|
+
keepRecent: this.microcompactKeepRecent,
|
|
297
|
+
});
|
|
298
|
+
if (freed > 0) {
|
|
299
|
+
this.lastAssistantTimestamp = Date.now();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Check if error is a context-too-long error
|
|
304
|
+
*/
|
|
305
|
+
isContextTooLongError(error) {
|
|
306
|
+
if (!error)
|
|
307
|
+
return false;
|
|
308
|
+
return (/context.*(length|window).*exceed/i.test(error) ||
|
|
309
|
+
/too many (tokens|input tokens)/i.test(error) ||
|
|
310
|
+
/413/i.test(error) ||
|
|
311
|
+
/prompt is too long/i.test(error));
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Estimate token count for an invoke call.
|
|
315
|
+
*/
|
|
316
|
+
estimateTokens(opts) {
|
|
317
|
+
const messageTokens = roughMessagesTokens(opts.messages.map((m) => ({ role: m.role, content: m.content })));
|
|
318
|
+
const toolTokens = (opts.tools ?? []).reduce((sum, tool) => sum +
|
|
319
|
+
roughTokenCount(tool.name) +
|
|
320
|
+
roughTokenCount(tool.description ?? "") +
|
|
321
|
+
roughTokenCount(JSON.stringify(tool.input_schema ?? {})), 0);
|
|
322
|
+
return messageTokens + toolTokens;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Track tool results for microcompact decisions.
|
|
326
|
+
*/
|
|
327
|
+
trackToolResults(messages) {
|
|
328
|
+
for (const msg of messages) {
|
|
329
|
+
if (msg.role !== "assistant" || !msg.toolResults)
|
|
330
|
+
continue;
|
|
331
|
+
for (const tr of msg.toolResults) {
|
|
332
|
+
this.toolResultsById.set(tr.id, {
|
|
333
|
+
timestamp: tr.timestamp,
|
|
334
|
+
tokens: tr.tokens,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/** Get current compact attempt count */
|
|
340
|
+
getCompactAttempts() {
|
|
341
|
+
return this.compactAttempts;
|
|
342
|
+
}
|
|
343
|
+
/** Get consecutive failure count */
|
|
344
|
+
getConsecutiveFailures() {
|
|
345
|
+
return this.contextWindow.getConsecutiveFailures();
|
|
346
|
+
}
|
|
347
|
+
/** Check if circuit breaker is engaged */
|
|
348
|
+
isCircuitBroken() {
|
|
349
|
+
return this.contextWindow.shouldCircuitBreak();
|
|
350
|
+
}
|
|
351
|
+
/** Reset compact state for a new task */
|
|
352
|
+
reset() {
|
|
353
|
+
this.compactAttempts = 0;
|
|
354
|
+
this.toolResultsById.clear();
|
|
355
|
+
this.contextWindow.resetCircuitBreaker();
|
|
356
|
+
}
|
|
357
|
+
/** Get utilization status for a model */
|
|
358
|
+
getUtilizationStatus(provider, model) {
|
|
359
|
+
return this.contextWindow.getUtilizationStatus(provider, model);
|
|
360
|
+
}
|
|
361
|
+
/** Generate utilization report */
|
|
362
|
+
generateUtilizationReport() {
|
|
363
|
+
return this.contextWindow.generateReport();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Window Manager — RFC-0010
|
|
3
|
+
*
|
|
4
|
+
* Manages context window usage across providers.
|
|
5
|
+
* Tracks utilization and suggests strategies when approaching limits.
|
|
6
|
+
*
|
|
7
|
+
* Enhanced with:
|
|
8
|
+
* - Compact thresholds (proactive at 85%, blocking at 97%)
|
|
9
|
+
* - Circuit breaker for consecutive compact failures
|
|
10
|
+
* - Buffer-aware token estimation
|
|
11
|
+
*/
|
|
12
|
+
import { roughTokenCount, roughMessagesTokens, } from "../packages/token-estimation/src/index.ts";
|
|
13
|
+
// --- Constants ----------------------------------------------------------------
|
|
14
|
+
/** Proactive compact triggers at this many tokens before limit */
|
|
15
|
+
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000;
|
|
16
|
+
/** Warning threshold buffer */
|
|
17
|
+
export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000;
|
|
18
|
+
/** Blocking threshold buffer */
|
|
19
|
+
export const BLOCKING_THRESHOLD_BUFFER_TOKENS = 20_000;
|
|
20
|
+
/** Maximum consecutive compact failures before circuit breaker */
|
|
21
|
+
export const MAX_CONSECUTIVE_COMPACT_FAILURES = 3;
|
|
22
|
+
// --- Context Window Manager -------------------------------------------------
|
|
23
|
+
export class ContextWindowManager {
|
|
24
|
+
stats = new Map();
|
|
25
|
+
configs = new Map();
|
|
26
|
+
consecutiveFailures = 0;
|
|
27
|
+
lastCompactTimestamp = 0;
|
|
28
|
+
modelContextWindows = new Map();
|
|
29
|
+
constructor(defaultConfigs) {
|
|
30
|
+
// Initialize with defaults
|
|
31
|
+
const defaults = {
|
|
32
|
+
minimax: {
|
|
33
|
+
warningThreshold: 0.8,
|
|
34
|
+
criticalThreshold: 0.95,
|
|
35
|
+
strategy: "truncate",
|
|
36
|
+
},
|
|
37
|
+
anthropic: {
|
|
38
|
+
warningThreshold: 0.85,
|
|
39
|
+
criticalThreshold: 0.97,
|
|
40
|
+
strategy: "truncate",
|
|
41
|
+
},
|
|
42
|
+
openai: {
|
|
43
|
+
warningThreshold: 0.8,
|
|
44
|
+
criticalThreshold: 0.95,
|
|
45
|
+
strategy: "truncate",
|
|
46
|
+
},
|
|
47
|
+
...defaultConfigs,
|
|
48
|
+
};
|
|
49
|
+
for (const [provider, config] of Object.entries(defaults)) {
|
|
50
|
+
this.configs.set(provider, config);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// --- Public API -----------------------------------------------------------
|
|
54
|
+
/**
|
|
55
|
+
* Update context window stats after a request
|
|
56
|
+
*/
|
|
57
|
+
updateStats(update) {
|
|
58
|
+
const { provider, model, maxTokens, usedTokens } = update;
|
|
59
|
+
const stats = {
|
|
60
|
+
provider,
|
|
61
|
+
model,
|
|
62
|
+
maxTokens,
|
|
63
|
+
usedTokens,
|
|
64
|
+
availableTokens: maxTokens - usedTokens,
|
|
65
|
+
utilizationPct: usedTokens / maxTokens,
|
|
66
|
+
};
|
|
67
|
+
this.stats.set(`${provider}:${model}`, stats);
|
|
68
|
+
return stats;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Get stats for a provider/model
|
|
72
|
+
*/
|
|
73
|
+
getStats(provider, model) {
|
|
74
|
+
return this.stats.get(`${provider}:${model}`) ?? null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Get all stats
|
|
78
|
+
*/
|
|
79
|
+
getAllStats() {
|
|
80
|
+
return Array.from(this.stats.values());
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Check utilization status
|
|
84
|
+
*/
|
|
85
|
+
getUtilizationStatus(provider, model) {
|
|
86
|
+
const stats = this.getStats(provider, model);
|
|
87
|
+
if (!stats)
|
|
88
|
+
return "unknown";
|
|
89
|
+
const config = this.configs.get(provider);
|
|
90
|
+
if (!config)
|
|
91
|
+
return "ok";
|
|
92
|
+
const pct = stats.utilizationPct;
|
|
93
|
+
if (pct >= config.criticalThreshold)
|
|
94
|
+
return "critical";
|
|
95
|
+
if (pct >= config.warningThreshold)
|
|
96
|
+
return "warning";
|
|
97
|
+
return "ok";
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get compact thresholds for a provider
|
|
101
|
+
*/
|
|
102
|
+
getCompactThresholds(provider) {
|
|
103
|
+
const config = this.configs.get(provider);
|
|
104
|
+
return {
|
|
105
|
+
autoCompactAt: 0.85,
|
|
106
|
+
warningAt: config?.warningThreshold ?? 0.8,
|
|
107
|
+
blockingAt: config?.criticalThreshold ?? 0.97,
|
|
108
|
+
maxRetries: MAX_CONSECUTIVE_COMPACT_FAILURES,
|
|
109
|
+
reservedOutputTokens: 20_000,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get effective context window size (minus reserved output tokens)
|
|
114
|
+
*/
|
|
115
|
+
getEffectiveContextWindow(provider, model) {
|
|
116
|
+
const key = `${provider}:${model}`;
|
|
117
|
+
const base = this.modelContextWindows.get(key) ?? 200_000;
|
|
118
|
+
const thresholds = this.getCompactThresholds(provider);
|
|
119
|
+
return base - thresholds.reservedOutputTokens;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Set the context window size for a model
|
|
123
|
+
*/
|
|
124
|
+
setContextWindow(provider, model, maxTokens) {
|
|
125
|
+
this.modelContextWindows.set(`${provider}:${model}`, maxTokens);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Estimate remaining capacity
|
|
129
|
+
*/
|
|
130
|
+
estimateRemainingRequests(provider, model, avgTokensPerRequest) {
|
|
131
|
+
const stats = this.getStats(provider, model);
|
|
132
|
+
if (!stats)
|
|
133
|
+
return null;
|
|
134
|
+
return Math.floor(stats.availableTokens / avgTokensPerRequest);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Estimate tokens with buffer awareness for compact decisions
|
|
138
|
+
*/
|
|
139
|
+
estimateTokensWithBuffer(opts) {
|
|
140
|
+
const { messages, tools, systemPrompt, provider, model } = opts;
|
|
141
|
+
// Calculate message tokens
|
|
142
|
+
const messageTokens = roughMessagesTokens(messages.map((m) => ({
|
|
143
|
+
role: m.role,
|
|
144
|
+
content: m.content,
|
|
145
|
+
})));
|
|
146
|
+
// Calculate tool tokens
|
|
147
|
+
const toolTokens = (tools ?? []).reduce((sum, tool) => sum +
|
|
148
|
+
roughTokenCount(tool.name) +
|
|
149
|
+
roughTokenCount(tool.description ?? "") +
|
|
150
|
+
roughTokenCount(JSON.stringify(tool.input_schema ?? {})), 0);
|
|
151
|
+
// Calculate system prompt tokens
|
|
152
|
+
const systemTokens = systemPrompt ? roughTokenCount(systemPrompt) : 0;
|
|
153
|
+
const total = messageTokens + toolTokens + systemTokens;
|
|
154
|
+
const effectiveWindow = this.getEffectiveContextWindow(provider, model);
|
|
155
|
+
const bufferRemaining = effectiveWindow - total;
|
|
156
|
+
// Determine if we should compact or block
|
|
157
|
+
const autoCompactTokens = effectiveWindow - AUTOCOMPACT_BUFFER_TOKENS;
|
|
158
|
+
const blockingTokens = effectiveWindow - BLOCKING_THRESHOLD_BUFFER_TOKENS;
|
|
159
|
+
const shouldCompact = total >= autoCompactTokens;
|
|
160
|
+
const shouldBlock = total >= blockingTokens;
|
|
161
|
+
return {
|
|
162
|
+
total,
|
|
163
|
+
bufferRemaining,
|
|
164
|
+
utilizationPct: total / effectiveWindow,
|
|
165
|
+
atRisk: bufferRemaining < AUTOCOMPACT_BUFFER_TOKENS,
|
|
166
|
+
shouldCompact,
|
|
167
|
+
shouldBlock,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Check if we should trigger proactive compact
|
|
172
|
+
*/
|
|
173
|
+
shouldProactiveCompact(estimate) {
|
|
174
|
+
return estimate.shouldCompact && !this.shouldCircuitBreak();
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Check if we should block (must compact before API call)
|
|
178
|
+
*/
|
|
179
|
+
shouldBlockApiCall(estimate) {
|
|
180
|
+
return estimate.shouldBlock;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Record a compact failure (for circuit breaker)
|
|
184
|
+
*/
|
|
185
|
+
recordCompactFailure() {
|
|
186
|
+
this.consecutiveFailures++;
|
|
187
|
+
return this.consecutiveFailures;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Record a compact success (reset circuit breaker)
|
|
191
|
+
*/
|
|
192
|
+
recordCompactSuccess() {
|
|
193
|
+
this.consecutiveFailures = 0;
|
|
194
|
+
this.lastCompactTimestamp = Date.now();
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Check if circuit breaker should activate
|
|
198
|
+
*/
|
|
199
|
+
shouldCircuitBreak() {
|
|
200
|
+
return this.consecutiveFailures >= MAX_CONSECUTIVE_COMPACT_FAILURES;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Get consecutive failure count
|
|
204
|
+
*/
|
|
205
|
+
getConsecutiveFailures() {
|
|
206
|
+
return this.consecutiveFailures;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Reset circuit breaker
|
|
210
|
+
*/
|
|
211
|
+
resetCircuitBreaker() {
|
|
212
|
+
this.consecutiveFailures = 0;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Get time since last compact (ms)
|
|
216
|
+
*/
|
|
217
|
+
getTimeSinceLastCompact() {
|
|
218
|
+
return Date.now() - this.lastCompactTimestamp;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Prepare messages for a request (truncation/summarization strategy)
|
|
222
|
+
*/
|
|
223
|
+
prepareMessages(messages, maxTokens, strategy = "truncate") {
|
|
224
|
+
const estimateTokens = (text) => Math.ceil(text.length / 4);
|
|
225
|
+
let totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m.content), 0);
|
|
226
|
+
if (totalTokens <= maxTokens) {
|
|
227
|
+
return messages;
|
|
228
|
+
}
|
|
229
|
+
if (strategy === "truncate") {
|
|
230
|
+
// Remove oldest messages first
|
|
231
|
+
const truncated = [...messages];
|
|
232
|
+
while (totalTokens > maxTokens && truncated.length > 1) {
|
|
233
|
+
const removed = truncated.shift();
|
|
234
|
+
if (removed) {
|
|
235
|
+
totalTokens -= estimateTokens(removed.content);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return truncated;
|
|
239
|
+
}
|
|
240
|
+
// summarize and split both fall back to truncate for now
|
|
241
|
+
// In a real implementation, split would return multiple message batches
|
|
242
|
+
return this.prepareMessages(messages, maxTokens, "truncate");
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Set config for a provider
|
|
246
|
+
*/
|
|
247
|
+
setConfig(provider, config) {
|
|
248
|
+
this.configs.set(provider, config);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Get config for a provider
|
|
252
|
+
*/
|
|
253
|
+
getConfig(provider) {
|
|
254
|
+
return this.configs.get(provider);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Generate utilization report
|
|
258
|
+
*/
|
|
259
|
+
generateReport() {
|
|
260
|
+
const lines = ["Context Window Utilization Report", "=".repeat(40), ""];
|
|
261
|
+
for (const [, stats] of this.stats.entries()) {
|
|
262
|
+
const config = this.configs.get(stats.provider);
|
|
263
|
+
const bar = this.renderBar(stats.utilizationPct);
|
|
264
|
+
const status = this.getUtilizationStatus(stats.provider, stats.model);
|
|
265
|
+
lines.push(`${stats.provider}/${stats.model}`);
|
|
266
|
+
lines.push(` ${bar} ${(stats.utilizationPct * 100).toFixed(1)}%`);
|
|
267
|
+
lines.push(` Used: ${stats.usedTokens.toLocaleString()} / ${stats.maxTokens.toLocaleString()} tokens`);
|
|
268
|
+
lines.push(` Available: ${stats.availableTokens.toLocaleString()} tokens`);
|
|
269
|
+
lines.push(` Status: ${status.toUpperCase()}`);
|
|
270
|
+
if (config) {
|
|
271
|
+
lines.push(` Thresholds: warning=${(config.warningThreshold * 100).toFixed(0)}%, critical=${(config.criticalThreshold * 100).toFixed(0)}%`);
|
|
272
|
+
}
|
|
273
|
+
lines.push("");
|
|
274
|
+
}
|
|
275
|
+
// Add circuit breaker status
|
|
276
|
+
lines.push("Circuit Breaker Status", "-".repeat(20));
|
|
277
|
+
lines.push(`Consecutive failures: ${this.consecutiveFailures}`);
|
|
278
|
+
lines.push(`Circuit broken: ${this.shouldCircuitBreak() ? "YES ⚠️" : "No"}`);
|
|
279
|
+
return lines.join("\n");
|
|
280
|
+
}
|
|
281
|
+
renderBar(pct, width = 20) {
|
|
282
|
+
const filled = Math.round(pct * width);
|
|
283
|
+
const empty = width - filled;
|
|
284
|
+
return "[" + "█".repeat(filled) + "░".repeat(empty) + "]";
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// --- Microcompact Helpers ---------------------------------------------------
|
|
288
|
+
/**
|
|
289
|
+
* Microcompact: prune old tool results based on time gap.
|
|
290
|
+
* Returns the number of tokens freed.
|
|
291
|
+
*/
|
|
292
|
+
export function microcompactToolResults(messages, options = {}) {
|
|
293
|
+
const maxAgeMs = options.maxAgeMs ?? 30 * 60 * 1000; // 30 minutes
|
|
294
|
+
const keepRecent = options.keepRecent ?? 2;
|
|
295
|
+
const now = Date.now();
|
|
296
|
+
let freedTokens = 0;
|
|
297
|
+
for (const msg of messages) {
|
|
298
|
+
if (msg.role !== "assistant" || !msg.toolResults)
|
|
299
|
+
continue;
|
|
300
|
+
// Sort by timestamp, newest first
|
|
301
|
+
const sorted = [...msg.toolResults].sort((a, b) => b.timestamp - a.timestamp);
|
|
302
|
+
// Keep IDs of recent tool results
|
|
303
|
+
const keepIds = new Set(sorted.slice(0, keepRecent).map((r) => r.id));
|
|
304
|
+
// Prune old ones
|
|
305
|
+
for (const tr of msg.toolResults) {
|
|
306
|
+
if (!keepIds.has(tr.id) && now - tr.timestamp > maxAgeMs) {
|
|
307
|
+
freedTokens += tr.tokens;
|
|
308
|
+
// Truncate content but keep marker
|
|
309
|
+
tr.content = "[Old tool result cleared]";
|
|
310
|
+
tr.tokens = 0;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return freedTokens;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Parse token gap from a prompt-too-long error message.
|
|
318
|
+
* Returns the gap (excess tokens) or undefined if unparseable.
|
|
319
|
+
*/
|
|
320
|
+
export function parseTokenGapFromError(errorMessage) {
|
|
321
|
+
// Match patterns like: "137500 tokens > 135000 maximum"
|
|
322
|
+
const match = errorMessage.match(/prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i);
|
|
323
|
+
if (match) {
|
|
324
|
+
const actual = parseInt(match[1], 10);
|
|
325
|
+
const limit = parseInt(match[2], 10);
|
|
326
|
+
const gap = actual - limit;
|
|
327
|
+
return gap > 0 ? gap : undefined;
|
|
328
|
+
}
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|