@agi-cli/server 0.1.112 → 0.1.114
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/package.json +3 -3
- package/src/index.ts +4 -0
- package/src/routes/session-files.ts +387 -0
- package/src/runtime/compaction.ts +396 -114
- package/src/runtime/history-builder.ts +7 -7
- package/src/runtime/message-service.ts +52 -9
- package/src/runtime/prompt.ts +14 -0
- package/src/runtime/runner.ts +110 -3
- package/src/runtime/session-queue.ts +2 -0
- package/src/runtime/stream-handlers.ts +174 -3
|
@@ -1,29 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Context compaction module for managing token usage.
|
|
3
3
|
*
|
|
4
|
-
* This module implements
|
|
5
|
-
* 1. Detects
|
|
6
|
-
* 2.
|
|
7
|
-
* 3. History builder
|
|
4
|
+
* This module implements intelligent context management:
|
|
5
|
+
* 1. Detects /compact command and builds summarization context
|
|
6
|
+
* 2. After LLM responds with summary, marks old parts as compacted
|
|
7
|
+
* 3. History builder skips compacted parts entirely
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
13
|
-
* -
|
|
9
|
+
* Flow:
|
|
10
|
+
* - User sends "/compact" → stored as regular user message
|
|
11
|
+
* - Runner detects command, builds context for LLM to summarize
|
|
12
|
+
* - LLM streams summary response naturally
|
|
13
|
+
* - On completion, markSessionCompacted() marks old tool_call/tool_result parts
|
|
14
|
+
* - Future history builds skip compacted parts
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import type { getDb } from '@agi-cli/database';
|
|
17
18
|
import { messages, messageParts } from '@agi-cli/database/schema';
|
|
18
|
-
import { eq, desc } from 'drizzle-orm';
|
|
19
|
+
import { eq, desc, asc, and, lt } from 'drizzle-orm';
|
|
19
20
|
import { debugLog } from './debug.ts';
|
|
21
|
+
import { streamText } from 'ai';
|
|
22
|
+
import { resolveModel } from './provider.ts';
|
|
23
|
+
import { loadConfig } from '@agi-cli/sdk';
|
|
20
24
|
|
|
21
|
-
// Token thresholds
|
|
22
|
-
export const PRUNE_MINIMUM = 20_000; // Only prune if we'd save at least this many tokens
|
|
25
|
+
// Token thresholds
|
|
23
26
|
export const PRUNE_PROTECT = 40_000; // Protect last N tokens worth of tool calls
|
|
24
27
|
|
|
25
|
-
// Tools that should never be
|
|
26
|
-
const
|
|
28
|
+
// Tools that should never be compacted
|
|
29
|
+
const PROTECTED_TOOLS = ['skill'];
|
|
27
30
|
|
|
28
31
|
// Simple token estimation: ~4 chars per token
|
|
29
32
|
export function estimateTokens(text: string): number {
|
|
@@ -44,51 +47,151 @@ export interface ModelLimits {
|
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
|
-
* Check if
|
|
48
|
-
* Returns true if we've used more tokens than (context_limit - output_limit).
|
|
50
|
+
* Check if a message content is the /compact command.
|
|
49
51
|
*/
|
|
50
|
-
export function
|
|
51
|
-
|
|
52
|
+
export function isCompactCommand(content: string): boolean {
|
|
53
|
+
const trimmed = content.trim().toLowerCase();
|
|
54
|
+
return trimmed === '/compact';
|
|
55
|
+
}
|
|
52
56
|
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Build context for the LLM to generate a summary.
|
|
59
|
+
* Returns a prompt that describes what to summarize.
|
|
60
|
+
* Includes tool calls and results with appropriate truncation to fit within model limits.
|
|
61
|
+
* @param contextTokenLimit - Max tokens for context (uses ~4 chars per token estimate)
|
|
62
|
+
*/
|
|
63
|
+
export async function buildCompactionContext(
|
|
64
|
+
db: Awaited<ReturnType<typeof getDb>>,
|
|
65
|
+
sessionId: string,
|
|
66
|
+
contextTokenLimit?: number,
|
|
67
|
+
): Promise<string> {
|
|
68
|
+
const allMessages = await db
|
|
69
|
+
.select()
|
|
70
|
+
.from(messages)
|
|
71
|
+
.where(eq(messages.sessionId, sessionId))
|
|
72
|
+
.orderBy(asc(messages.createdAt));
|
|
55
73
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
74
|
+
const lines: string[] = [];
|
|
75
|
+
let totalChars = 0;
|
|
76
|
+
// Use provided limit or default to 60k chars (~15k tokens)
|
|
77
|
+
// We use ~50% of model context for compaction, leaving room for system prompt + response
|
|
78
|
+
const maxChars = contextTokenLimit ? contextTokenLimit * 4 : 60000;
|
|
79
|
+
|
|
80
|
+
for (const msg of allMessages) {
|
|
81
|
+
if (totalChars > maxChars) {
|
|
82
|
+
lines.unshift('[...earlier content truncated...]');
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const parts = await db
|
|
87
|
+
.select()
|
|
88
|
+
.from(messageParts)
|
|
89
|
+
.where(eq(messageParts.messageId, msg.id))
|
|
90
|
+
.orderBy(asc(messageParts.index));
|
|
91
|
+
|
|
92
|
+
for (const part of parts) {
|
|
93
|
+
if (part.compactedAt) continue; // Skip already compacted
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const content = JSON.parse(part.content ?? '{}');
|
|
97
|
+
|
|
98
|
+
if (part.type === 'text' && content.text) {
|
|
99
|
+
const text = `[${msg.role.toUpperCase()}]: ${content.text}`;
|
|
100
|
+
lines.push(text.slice(0, 3000)); // Allow more text content
|
|
101
|
+
totalChars += text.length;
|
|
102
|
+
} else if (part.type === 'tool_call' && content.name) {
|
|
103
|
+
// Include tool name and relevant args (file paths, commands, etc.)
|
|
104
|
+
const argsStr =
|
|
105
|
+
typeof content.args === 'object'
|
|
106
|
+
? JSON.stringify(content.args).slice(0, 500)
|
|
107
|
+
: '';
|
|
108
|
+
const text = `[TOOL ${content.name}]: ${argsStr}`;
|
|
109
|
+
lines.push(text);
|
|
110
|
+
totalChars += text.length;
|
|
111
|
+
} else if (part.type === 'tool_result' && content.result !== null) {
|
|
112
|
+
// Include enough result context for the LLM to understand what happened
|
|
113
|
+
const resultStr =
|
|
114
|
+
typeof content.result === 'string'
|
|
115
|
+
? content.result.slice(0, 1500)
|
|
116
|
+
: JSON.stringify(content.result ?? '').slice(0, 1500);
|
|
117
|
+
const text = `[RESULT]: ${resultStr}`;
|
|
118
|
+
lines.push(text);
|
|
119
|
+
totalChars += text.length;
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
61
123
|
}
|
|
62
124
|
|
|
63
|
-
return
|
|
125
|
+
return lines.join('\n');
|
|
64
126
|
}
|
|
65
127
|
|
|
66
128
|
/**
|
|
67
|
-
*
|
|
129
|
+
* Get the system prompt addition for compaction.
|
|
130
|
+
*/
|
|
131
|
+
export function getCompactionSystemPrompt(): string {
|
|
132
|
+
return `
|
|
133
|
+
The user has requested to compact the conversation. Generate a comprehensive summary that captures:
|
|
134
|
+
|
|
135
|
+
1. **Main Goals**: What was the user trying to accomplish?
|
|
136
|
+
2. **Key Actions**: What files were created, modified, or deleted?
|
|
137
|
+
3. **Important Decisions**: What approaches or solutions were chosen and why?
|
|
138
|
+
4. **Current State**: What is done and what might be pending?
|
|
139
|
+
5. **Critical Context**: Any gotchas, errors encountered, or important details for continuing.
|
|
140
|
+
|
|
141
|
+
Format your response as a clear, structured summary. Start with "📦 **Context Compacted**" header.
|
|
142
|
+
Keep under 2000 characters but be thorough. This summary will replace detailed tool history.
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Mark old tool_call and tool_result parts as compacted.
|
|
148
|
+
* Called after the compaction summary response is complete.
|
|
68
149
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
150
|
+
* Protects:
|
|
151
|
+
* - Last N tokens of tool results (PRUNE_PROTECT)
|
|
152
|
+
* - Last 2 user turns
|
|
153
|
+
* - Protected tool names (skill, etc.)
|
|
71
154
|
*/
|
|
72
|
-
export async function
|
|
155
|
+
export async function markSessionCompacted(
|
|
73
156
|
db: Awaited<ReturnType<typeof getDb>>,
|
|
74
157
|
sessionId: string,
|
|
75
|
-
|
|
76
|
-
|
|
158
|
+
compactMessageId: string,
|
|
159
|
+
): Promise<{ compacted: number; saved: number }> {
|
|
160
|
+
debugLog(`[compaction] Marking session ${sessionId} as compacted`);
|
|
77
161
|
|
|
78
|
-
// Get
|
|
79
|
-
const
|
|
162
|
+
// Get the compact message to find the cutoff point
|
|
163
|
+
const compactMsg = await db
|
|
80
164
|
.select()
|
|
81
165
|
.from(messages)
|
|
82
|
-
.where(eq(messages.
|
|
166
|
+
.where(eq(messages.id, compactMessageId))
|
|
167
|
+
.limit(1);
|
|
168
|
+
|
|
169
|
+
if (!compactMsg.length) {
|
|
170
|
+
debugLog('[compaction] Compact message not found');
|
|
171
|
+
return { compacted: 0, saved: 0 };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const cutoffTime = compactMsg[0].createdAt;
|
|
175
|
+
|
|
176
|
+
// Get all messages before the compact command
|
|
177
|
+
const oldMessages = await db
|
|
178
|
+
.select()
|
|
179
|
+
.from(messages)
|
|
180
|
+
.where(
|
|
181
|
+
and(
|
|
182
|
+
eq(messages.sessionId, sessionId),
|
|
183
|
+
lt(messages.createdAt, cutoffTime),
|
|
184
|
+
),
|
|
185
|
+
)
|
|
83
186
|
.orderBy(desc(messages.createdAt));
|
|
84
187
|
|
|
85
188
|
let totalTokens = 0;
|
|
86
|
-
let
|
|
87
|
-
const
|
|
189
|
+
let compactedTokens = 0;
|
|
190
|
+
const toCompact: Array<{ id: string; content: string }> = [];
|
|
88
191
|
let turns = 0;
|
|
89
192
|
|
|
90
193
|
// Go backwards through messages
|
|
91
|
-
for (const msg of
|
|
194
|
+
for (const msg of oldMessages) {
|
|
92
195
|
// Count user messages as turns
|
|
93
196
|
if (msg.role === 'user') {
|
|
94
197
|
turns++;
|
|
@@ -105,31 +208,113 @@ export async function pruneSession(
|
|
|
105
208
|
.orderBy(desc(messageParts.index));
|
|
106
209
|
|
|
107
210
|
for (const part of parts) {
|
|
108
|
-
// Only
|
|
109
|
-
if (part.type !== 'tool_result') continue;
|
|
211
|
+
// Only compact tool_call and tool_result
|
|
212
|
+
if (part.type !== 'tool_call' && part.type !== 'tool_result') continue;
|
|
110
213
|
|
|
111
214
|
// Skip protected tools
|
|
112
|
-
if (part.toolName &&
|
|
215
|
+
if (part.toolName && PROTECTED_TOOLS.includes(part.toolName)) {
|
|
113
216
|
continue;
|
|
114
217
|
}
|
|
115
218
|
|
|
116
|
-
//
|
|
117
|
-
|
|
219
|
+
// Skip already compacted
|
|
220
|
+
if (part.compactedAt) continue;
|
|
221
|
+
|
|
222
|
+
// Parse content
|
|
223
|
+
let content: { result?: unknown; args?: unknown };
|
|
118
224
|
try {
|
|
119
225
|
content = JSON.parse(part.content ?? '{}');
|
|
120
226
|
} catch {
|
|
121
227
|
continue;
|
|
122
228
|
}
|
|
123
229
|
|
|
124
|
-
//
|
|
125
|
-
|
|
230
|
+
// Estimate tokens
|
|
231
|
+
const contentStr =
|
|
232
|
+
part.type === 'tool_result'
|
|
233
|
+
? typeof content.result === 'string'
|
|
234
|
+
? content.result
|
|
235
|
+
: JSON.stringify(content.result ?? '')
|
|
236
|
+
: JSON.stringify(content.args ?? '');
|
|
237
|
+
|
|
238
|
+
const estimate = estimateTokens(contentStr);
|
|
239
|
+
totalTokens += estimate;
|
|
240
|
+
|
|
241
|
+
// If we've exceeded the protection threshold, mark for compaction
|
|
242
|
+
if (totalTokens > PRUNE_PROTECT) {
|
|
243
|
+
compactedTokens += estimate;
|
|
244
|
+
toCompact.push({ id: part.id, content: part.content ?? '{}' });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
debugLog(
|
|
250
|
+
`[compaction] Found ${toCompact.length} parts to compact, saving ~${compactedTokens} tokens`,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
if (toCompact.length > 0) {
|
|
254
|
+
const compactedAt = Date.now();
|
|
255
|
+
|
|
256
|
+
for (const part of toCompact) {
|
|
257
|
+
try {
|
|
258
|
+
await db
|
|
259
|
+
.update(messageParts)
|
|
260
|
+
.set({ compactedAt })
|
|
261
|
+
.where(eq(messageParts.id, part.id));
|
|
262
|
+
} catch (err) {
|
|
126
263
|
debugLog(
|
|
127
|
-
`[compaction]
|
|
264
|
+
`[compaction] Failed to mark part ${part.id}: ${err instanceof Error ? err.message : String(err)}`,
|
|
128
265
|
);
|
|
129
|
-
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
debugLog(`[compaction] Marked ${toCompact.length} parts as compacted`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { compacted: toCompact.length, saved: compactedTokens };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Legacy prune function - marks tool results as compacted.
|
|
277
|
+
* Used for automatic overflow-triggered compaction.
|
|
278
|
+
*/
|
|
279
|
+
export async function pruneSession(
|
|
280
|
+
db: Awaited<ReturnType<typeof getDb>>,
|
|
281
|
+
sessionId: string,
|
|
282
|
+
): Promise<{ pruned: number; saved: number }> {
|
|
283
|
+
debugLog(`[compaction] Auto-pruning session ${sessionId}`);
|
|
284
|
+
|
|
285
|
+
const allMessages = await db
|
|
286
|
+
.select()
|
|
287
|
+
.from(messages)
|
|
288
|
+
.where(eq(messages.sessionId, sessionId))
|
|
289
|
+
.orderBy(desc(messages.createdAt));
|
|
290
|
+
|
|
291
|
+
let totalTokens = 0;
|
|
292
|
+
let prunedTokens = 0;
|
|
293
|
+
const toPrune: Array<{ id: string }> = [];
|
|
294
|
+
let turns = 0;
|
|
295
|
+
|
|
296
|
+
for (const msg of allMessages) {
|
|
297
|
+
if (msg.role === 'user') turns++;
|
|
298
|
+
if (turns < 2) continue;
|
|
299
|
+
|
|
300
|
+
const parts = await db
|
|
301
|
+
.select()
|
|
302
|
+
.from(messageParts)
|
|
303
|
+
.where(eq(messageParts.messageId, msg.id))
|
|
304
|
+
.orderBy(desc(messageParts.index));
|
|
305
|
+
|
|
306
|
+
for (const part of parts) {
|
|
307
|
+
if (part.type !== 'tool_result') continue;
|
|
308
|
+
if (part.toolName && PROTECTED_TOOLS.includes(part.toolName)) continue;
|
|
309
|
+
if (part.compactedAt) continue;
|
|
310
|
+
|
|
311
|
+
let content: { result?: unknown };
|
|
312
|
+
try {
|
|
313
|
+
content = JSON.parse(part.content ?? '{}');
|
|
314
|
+
} catch {
|
|
315
|
+
continue;
|
|
130
316
|
}
|
|
131
317
|
|
|
132
|
-
// Estimate tokens for this result
|
|
133
318
|
const estimate = estimateTokens(
|
|
134
319
|
typeof content.result === 'string'
|
|
135
320
|
? content.result
|
|
@@ -137,118 +322,215 @@ export async function pruneSession(
|
|
|
137
322
|
);
|
|
138
323
|
totalTokens += estimate;
|
|
139
324
|
|
|
140
|
-
// If we've exceeded the protection threshold, mark for pruning
|
|
141
325
|
if (totalTokens > PRUNE_PROTECT) {
|
|
142
326
|
prunedTokens += estimate;
|
|
143
|
-
toPrune.push({ id: part.id
|
|
327
|
+
toPrune.push({ id: part.id });
|
|
144
328
|
}
|
|
145
329
|
}
|
|
146
330
|
}
|
|
147
331
|
|
|
148
|
-
|
|
149
|
-
`[compaction] Found ${toPrune.length} tool results to prune, saving ~${prunedTokens} tokens`,
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
// Only prune if we'd save enough tokens to be worthwhile
|
|
153
|
-
if (prunedTokens > PRUNE_MINIMUM) {
|
|
332
|
+
if (toPrune.length > 0) {
|
|
154
333
|
const compactedAt = Date.now();
|
|
155
|
-
|
|
156
334
|
for (const part of toPrune) {
|
|
157
335
|
try {
|
|
158
|
-
const content = JSON.parse(part.content);
|
|
159
|
-
// Keep the structure but mark as compacted
|
|
160
|
-
content.compactedAt = compactedAt;
|
|
161
|
-
// Keep a small summary if it was a string result
|
|
162
|
-
if (typeof content.result === 'string' && content.result.length > 100) {
|
|
163
|
-
content.resultSummary = `${content.result.slice(0, 100)}...`;
|
|
164
|
-
}
|
|
165
|
-
// Clear the actual result to save space
|
|
166
|
-
content.result = null;
|
|
167
|
-
|
|
168
336
|
await db
|
|
169
337
|
.update(messageParts)
|
|
170
|
-
.set({
|
|
338
|
+
.set({ compactedAt })
|
|
171
339
|
.where(eq(messageParts.id, part.id));
|
|
172
|
-
} catch
|
|
173
|
-
debugLog(
|
|
174
|
-
`[compaction] Failed to prune part ${part.id}: ${err instanceof Error ? err.message : String(err)}`,
|
|
175
|
-
);
|
|
176
|
-
}
|
|
340
|
+
} catch {}
|
|
177
341
|
}
|
|
178
|
-
|
|
179
|
-
debugLog(
|
|
180
|
-
`[compaction] Pruned ${toPrune.length} tool results, saved ~${prunedTokens} tokens`,
|
|
181
|
-
);
|
|
182
|
-
} else {
|
|
183
|
-
debugLog(
|
|
184
|
-
`[compaction] Skipping prune, would only save ${prunedTokens} tokens (min: ${PRUNE_MINIMUM})`,
|
|
185
|
-
);
|
|
186
342
|
}
|
|
187
343
|
|
|
188
344
|
return { pruned: toPrune.length, saved: prunedTokens };
|
|
189
345
|
}
|
|
190
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Check if context is overflowing based on token usage and model limits.
|
|
349
|
+
*/
|
|
350
|
+
export function isOverflow(tokens: TokenUsage, limits: ModelLimits): boolean {
|
|
351
|
+
if (limits.context === 0) return false;
|
|
352
|
+
|
|
353
|
+
const count = tokens.input + (tokens.cacheRead ?? 0) + tokens.output;
|
|
354
|
+
const usableContext = limits.context - limits.output;
|
|
355
|
+
|
|
356
|
+
return count > usableContext;
|
|
357
|
+
}
|
|
358
|
+
|
|
191
359
|
/**
|
|
192
360
|
* Get model limits from provider catalog or use defaults.
|
|
193
361
|
*/
|
|
194
362
|
export function getModelLimits(
|
|
195
|
-
|
|
363
|
+
_provider: string,
|
|
196
364
|
model: string,
|
|
197
365
|
): ModelLimits | null {
|
|
198
|
-
// Default limits for common models
|
|
199
|
-
// These should ideally come from the provider catalog
|
|
200
366
|
const defaults: Record<string, ModelLimits> = {
|
|
201
|
-
// Anthropic
|
|
202
367
|
'claude-sonnet-4-20250514': { context: 200000, output: 16000 },
|
|
203
368
|
'claude-3-5-sonnet-20241022': { context: 200000, output: 8192 },
|
|
204
369
|
'claude-3-5-haiku-20241022': { context: 200000, output: 8192 },
|
|
205
|
-
'claude-3-opus-20240229': { context: 200000, output: 4096 },
|
|
206
|
-
// OpenAI
|
|
207
370
|
'gpt-4o': { context: 128000, output: 16384 },
|
|
208
371
|
'gpt-4o-mini': { context: 128000, output: 16384 },
|
|
209
|
-
'gpt-4-turbo': { context: 128000, output: 4096 },
|
|
210
372
|
o1: { context: 200000, output: 100000 },
|
|
211
|
-
'o1-mini': { context: 128000, output: 65536 },
|
|
212
|
-
'o1-pro': { context: 200000, output: 100000 },
|
|
213
373
|
'o3-mini': { context: 200000, output: 100000 },
|
|
214
|
-
// Google
|
|
215
374
|
'gemini-2.0-flash': { context: 1000000, output: 8192 },
|
|
216
375
|
'gemini-1.5-pro': { context: 2000000, output: 8192 },
|
|
217
|
-
'gemini-1.5-flash': { context: 1000000, output: 8192 },
|
|
218
376
|
};
|
|
219
377
|
|
|
220
|
-
|
|
221
|
-
if (defaults[model]) {
|
|
222
|
-
return defaults[model];
|
|
223
|
-
}
|
|
378
|
+
if (defaults[model]) return defaults[model];
|
|
224
379
|
|
|
225
|
-
// Try partial match (e.g., "claude-3-5-sonnet" matches "claude-3-5-sonnet-20241022")
|
|
226
380
|
for (const [key, limits] of Object.entries(defaults)) {
|
|
227
|
-
if (model.includes(key) || key.includes(model))
|
|
228
|
-
return limits;
|
|
229
|
-
}
|
|
381
|
+
if (model.includes(key) || key.includes(model)) return limits;
|
|
230
382
|
}
|
|
231
383
|
|
|
232
|
-
// Return null if no match - caller should handle
|
|
233
|
-
debugLog(
|
|
234
|
-
`[compaction] No model limits found for ${provider}/${model}, skipping overflow check`,
|
|
235
|
-
);
|
|
236
384
|
return null;
|
|
237
385
|
}
|
|
238
386
|
|
|
239
387
|
/**
|
|
240
|
-
* Check if a
|
|
388
|
+
* Check if a part is compacted.
|
|
241
389
|
*/
|
|
242
|
-
export function isCompacted(
|
|
243
|
-
|
|
244
|
-
const parsed = JSON.parse(content);
|
|
245
|
-
return !!parsed.compactedAt;
|
|
246
|
-
} catch {
|
|
247
|
-
return false;
|
|
248
|
-
}
|
|
390
|
+
export function isCompacted(part: { compactedAt?: number | null }): boolean {
|
|
391
|
+
return !!part.compactedAt;
|
|
249
392
|
}
|
|
250
393
|
|
|
394
|
+
export const COMPACTED_PLACEHOLDER = '[Compacted]';
|
|
395
|
+
|
|
251
396
|
/**
|
|
252
|
-
*
|
|
397
|
+
* Perform auto-compaction when context overflows.
|
|
398
|
+
* Streams the compaction summary (like /compact does), marks old parts as compacted.
|
|
399
|
+
* Returns info needed for caller to trigger a retry.
|
|
400
|
+
* Uses the session's model for consistency with /compact command.
|
|
253
401
|
*/
|
|
254
|
-
export
|
|
402
|
+
export async function performAutoCompaction(
|
|
403
|
+
db: Awaited<ReturnType<typeof getDb>>,
|
|
404
|
+
sessionId: string,
|
|
405
|
+
assistantMessageId: string,
|
|
406
|
+
publishFn: (event: {
|
|
407
|
+
type: string;
|
|
408
|
+
sessionId: string;
|
|
409
|
+
payload: Record<string, unknown>;
|
|
410
|
+
}) => void,
|
|
411
|
+
provider: string,
|
|
412
|
+
modelId: string,
|
|
413
|
+
): Promise<{
|
|
414
|
+
success: boolean;
|
|
415
|
+
summary?: string;
|
|
416
|
+
error?: string;
|
|
417
|
+
compactMessageId?: string;
|
|
418
|
+
}> {
|
|
419
|
+
debugLog(`[compaction] Starting auto-compaction for session ${sessionId}`);
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
// 1. Get model limits and build compaction context
|
|
423
|
+
const limits = getModelLimits(provider, modelId);
|
|
424
|
+
// Use 50% of context window for compaction, minimum 15k tokens
|
|
425
|
+
const contextTokenLimit = limits
|
|
426
|
+
? Math.max(Math.floor(limits.context * 0.5), 15000)
|
|
427
|
+
: 15000;
|
|
428
|
+
debugLog(
|
|
429
|
+
`[compaction] Model ${modelId} context limit: ${limits?.context ?? 'unknown'}, using ${contextTokenLimit} tokens for compaction`,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const context = await buildCompactionContext(
|
|
433
|
+
db,
|
|
434
|
+
sessionId,
|
|
435
|
+
contextTokenLimit,
|
|
436
|
+
);
|
|
437
|
+
if (!context || context.length < 100) {
|
|
438
|
+
debugLog('[compaction] Not enough context to compact');
|
|
439
|
+
return { success: false, error: 'Not enough context to compact' };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 2. Stream the compaction summary
|
|
443
|
+
|
|
444
|
+
// Use the session's model for consistency
|
|
445
|
+
const cfg = await loadConfig();
|
|
446
|
+
debugLog(
|
|
447
|
+
`[compaction] Using session model ${provider}/${modelId} for auto-compaction`,
|
|
448
|
+
);
|
|
449
|
+
const model = await resolveModel(
|
|
450
|
+
provider as Parameters<typeof resolveModel>[0],
|
|
451
|
+
modelId,
|
|
452
|
+
cfg,
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
// Create a text part for the compaction summary (after model created successfully)
|
|
456
|
+
const compactPartId = crypto.randomUUID();
|
|
457
|
+
const now = Date.now();
|
|
458
|
+
|
|
459
|
+
await db.insert(messageParts).values({
|
|
460
|
+
id: compactPartId,
|
|
461
|
+
messageId: assistantMessageId,
|
|
462
|
+
index: 0,
|
|
463
|
+
stepIndex: 0,
|
|
464
|
+
type: 'text',
|
|
465
|
+
content: JSON.stringify({ text: '' }),
|
|
466
|
+
agent: 'system',
|
|
467
|
+
provider: provider,
|
|
468
|
+
model: modelId,
|
|
469
|
+
startedAt: now,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const prompt = getCompactionSystemPrompt();
|
|
473
|
+
const result = streamText({
|
|
474
|
+
model,
|
|
475
|
+
system: `${prompt}\n\nIMPORTANT: Generate a comprehensive summary. This will replace the detailed conversation history.`,
|
|
476
|
+
messages: [
|
|
477
|
+
{
|
|
478
|
+
role: 'user',
|
|
479
|
+
content: `Please summarize this conversation:\n\n<conversation-to-summarize>\n${context}\n</conversation-to-summarize>`,
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
maxTokens: 2000,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Stream the summary
|
|
486
|
+
let summary = '';
|
|
487
|
+
for await (const chunk of result.textStream) {
|
|
488
|
+
summary += chunk;
|
|
489
|
+
|
|
490
|
+
// Publish delta event so UI updates in real-time
|
|
491
|
+
publishFn({
|
|
492
|
+
type: 'message.part.delta',
|
|
493
|
+
sessionId,
|
|
494
|
+
payload: {
|
|
495
|
+
messageId: assistantMessageId,
|
|
496
|
+
partId: compactPartId,
|
|
497
|
+
stepIndex: 0,
|
|
498
|
+
type: 'text',
|
|
499
|
+
delta: chunk,
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Update the part with final content
|
|
505
|
+
await db
|
|
506
|
+
.update(messageParts)
|
|
507
|
+
.set({
|
|
508
|
+
content: JSON.stringify({ text: summary }),
|
|
509
|
+
completedAt: Date.now(),
|
|
510
|
+
})
|
|
511
|
+
.where(eq(messageParts.id, compactPartId));
|
|
512
|
+
|
|
513
|
+
if (!summary || summary.length < 50) {
|
|
514
|
+
debugLog('[compaction] Failed to generate summary');
|
|
515
|
+
return { success: false, error: 'Failed to generate summary' };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
debugLog(`[compaction] Generated summary: ${summary.slice(0, 100)}...`);
|
|
519
|
+
|
|
520
|
+
// 3. Mark old parts as compacted (using the assistant message as the cutoff)
|
|
521
|
+
const compactResult = await markSessionCompacted(
|
|
522
|
+
db,
|
|
523
|
+
sessionId,
|
|
524
|
+
assistantMessageId,
|
|
525
|
+
);
|
|
526
|
+
debugLog(
|
|
527
|
+
`[compaction] Marked ${compactResult.compacted} parts as compacted, saved ~${compactResult.saved} tokens`,
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
return { success: true, summary, compactMessageId: assistantMessageId };
|
|
531
|
+
} catch (err) {
|
|
532
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
533
|
+
debugLog(`[compaction] Auto-compaction failed: ${errorMsg}`);
|
|
534
|
+
return { success: false, error: errorMsg };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
@@ -4,7 +4,6 @@ import { messages, messageParts } from '@agi-cli/database/schema';
|
|
|
4
4
|
import { eq, asc } from 'drizzle-orm';
|
|
5
5
|
import { debugLog } from './debug.ts';
|
|
6
6
|
import { ToolHistoryTracker } from './history/tool-history-tracker.ts';
|
|
7
|
-
import { COMPACTED_PLACEHOLDER } from './compaction.ts';
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* Builds the conversation history for a session from the database,
|
|
@@ -89,6 +88,9 @@ export async function buildHistoryMessages(
|
|
|
89
88
|
if (t) assistantParts.push({ type: 'text', text: t });
|
|
90
89
|
} catch {}
|
|
91
90
|
} else if (p.type === 'tool_call') {
|
|
91
|
+
// Skip compacted tool calls entirely
|
|
92
|
+
if (p.compactedAt) continue;
|
|
93
|
+
|
|
92
94
|
try {
|
|
93
95
|
const obj = JSON.parse(p.content ?? '{}') as {
|
|
94
96
|
name?: string;
|
|
@@ -104,22 +106,20 @@ export async function buildHistoryMessages(
|
|
|
104
106
|
}
|
|
105
107
|
} catch {}
|
|
106
108
|
} else if (p.type === 'tool_result') {
|
|
109
|
+
// Skip compacted tool results entirely
|
|
110
|
+
if (p.compactedAt) continue;
|
|
111
|
+
|
|
107
112
|
try {
|
|
108
113
|
const obj = JSON.parse(p.content ?? '{}') as {
|
|
109
114
|
name?: string;
|
|
110
115
|
callId?: string;
|
|
111
116
|
result?: unknown;
|
|
112
|
-
compactedAt?: number;
|
|
113
117
|
};
|
|
114
118
|
if (obj.callId) {
|
|
115
|
-
// If this tool result was compacted, return placeholder instead
|
|
116
|
-
const result = obj.compactedAt
|
|
117
|
-
? COMPACTED_PLACEHOLDER
|
|
118
|
-
: obj.result;
|
|
119
119
|
toolResults.push({
|
|
120
120
|
name: obj.name ?? 'tool',
|
|
121
121
|
callId: obj.callId,
|
|
122
|
-
result,
|
|
122
|
+
result: obj.result,
|
|
123
123
|
});
|
|
124
124
|
}
|
|
125
125
|
} catch {}
|