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.
@@ -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
+ }