@bahulam/code 0.1.24 → 0.1.25
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 +1 -1
- package/src/commands/install.mjs +88 -6
- package/src/commands/plugin-manage.mjs +146 -5
- package/src/config/cli-args.mjs +23 -3
- package/src/config/model-catalog.mjs +9 -0
- package/src/config/model-defaults.mjs +16 -0
- package/src/core/approval.mjs +8 -1
- package/src/core/attachments.mjs +4 -0
- package/src/core/backend-url.mjs +16 -0
- package/src/core/context-reduction.mjs +207 -0
- package/src/core/headless.mjs +85 -13
- package/src/core/local-agent.mjs +352 -44
- package/src/core/mode-selector.mjs +5 -4
- package/src/core/model-selection.mjs +72 -0
- package/src/core/request-retry.mjs +106 -0
- package/src/core/tool-error.mjs +155 -0
- package/src/core/usage-normalization.mjs +32 -0
- package/src/local-service/agent-relay.mjs +279 -7
- package/src/local-service/server.mjs +37 -0
- package/src/orchestration/node-runner.mjs +7 -3
- package/src/plugins/executor.mjs +4 -4
- package/src/plugins/lifecycle.mjs +337 -0
- package/src/plugins/manifest.mjs +37 -0
- package/src/plugins/pi-compat/requirements.mjs +30 -17
- package/src/plugins/preflight.mjs +37 -0
- package/src/terminal/main.mjs +10 -1
- package/src/terminal/paste-input.mjs +52 -0
- package/src/terminal/repl.mjs +169 -36
- package/src/tools/registry.mjs +1 -16
- package/src/core/agent-loop.mjs +0 -503
- package/src/core/context-manager.mjs +0 -198
- package/src/tools/agent.mjs +0 -142
package/src/core/agent-loop.mjs
DELETED
|
@@ -1,503 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent Loop — async generator yielding 13 event types.
|
|
3
|
-
* Handles streaming, tool calls, thinking, auto-compaction, hooks, multi-provider.
|
|
4
|
-
*/
|
|
5
|
-
import { streamResponse, accumulateStream } from './streaming.mjs';
|
|
6
|
-
import { ContextManager } from './context-manager.mjs';
|
|
7
|
-
import { buildSystemPrompt } from './system-prompt.mjs';
|
|
8
|
-
import { createStagnationTracker, stagnationMessage } from './stagnation.mjs';
|
|
9
|
-
import { PromptCache } from './cache.mjs';
|
|
10
|
-
import fs from 'fs';
|
|
11
|
-
import path from 'path';
|
|
12
|
-
export function createAgentLoop({ model, tools, permissions, settings, hooks }) {
|
|
13
|
-
const contextManager = new ContextManager(settings.maxContextTokens || 180000);
|
|
14
|
-
|
|
15
|
-
// Build system prompt using the new builder
|
|
16
|
-
const promptResult = buildSystemPrompt({
|
|
17
|
-
cwd: process.cwd(),
|
|
18
|
-
tools: tools.list?.() || [],
|
|
19
|
-
override: settings.systemPromptOverride,
|
|
20
|
-
addDirs: settings.addDirs,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
const state = {
|
|
24
|
-
messages: [],
|
|
25
|
-
systemPrompt: promptResult.full,
|
|
26
|
-
turnCount: 0,
|
|
27
|
-
tokenUsage: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
|
|
28
|
-
model,
|
|
29
|
-
tools,
|
|
30
|
-
_contextManager: contextManager,
|
|
31
|
-
_promptCache: new PromptCache(),
|
|
32
|
-
};
|
|
33
|
-
const stagnation = createStagnationTracker({
|
|
34
|
-
enabled: settings.stagnationDetection === true,
|
|
35
|
-
threshold: settings.stagnationThreshold,
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
async function* run(userMessage, options = {}) {
|
|
39
|
-
// Add user message (skip for continuation turns)
|
|
40
|
-
if (userMessage && !options.continuation) {
|
|
41
|
-
state.messages = contextManager.addMessage(state.messages, {
|
|
42
|
-
role: 'user',
|
|
43
|
-
content: userMessage,
|
|
44
|
-
});
|
|
45
|
-
state.turnCount++;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Check max turns
|
|
49
|
-
if (settings.maxTurns && state.turnCount > settings.maxTurns) {
|
|
50
|
-
yield { type: 'error', message: `Max turns (${settings.maxTurns}) reached.` };
|
|
51
|
-
yield { type: 'stop', reason: 'max_turns' };
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Auto-compact if needed
|
|
56
|
-
if (contextManager.shouldCompact(state.messages)) {
|
|
57
|
-
yield { type: 'compaction', count: contextManager.compactionCount + 1 };
|
|
58
|
-
state.messages = contextManager.compact(state.messages);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
yield { type: 'stream_request_start', turn: state.turnCount };
|
|
62
|
-
|
|
63
|
-
// Detect provider and call API
|
|
64
|
-
const provider = detectProvider(model);
|
|
65
|
-
let response;
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
if (settings.stream !== false) {
|
|
69
|
-
// Streaming mode
|
|
70
|
-
response = await callApiStreaming(provider, model, state, tools.list(), settings);
|
|
71
|
-
const collectedContent = [];
|
|
72
|
-
let currentText = '';
|
|
73
|
-
let currentThinking = '';
|
|
74
|
-
|
|
75
|
-
for await (const event of response.events) {
|
|
76
|
-
if (event.type === 'content_block_start') {
|
|
77
|
-
if (event.content_block?.type === 'thinking') {
|
|
78
|
-
currentThinking = '';
|
|
79
|
-
}
|
|
80
|
-
} else if (event.type === 'content_block_delta') {
|
|
81
|
-
if (event.delta?.type === 'text_delta') {
|
|
82
|
-
currentText += event.delta.text;
|
|
83
|
-
yield { type: 'stream_event', text: event.delta.text };
|
|
84
|
-
} else if (event.delta?.type === 'thinking_delta') {
|
|
85
|
-
currentThinking += event.delta.thinking;
|
|
86
|
-
yield { type: 'thinking', text: event.delta.thinking };
|
|
87
|
-
}
|
|
88
|
-
} else if (event.type === 'ping') {
|
|
89
|
-
// Keepalive, ignore
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Use the accumulated message
|
|
94
|
-
response = response.accumulated;
|
|
95
|
-
} else {
|
|
96
|
-
// Non-streaming mode
|
|
97
|
-
response = await callApi(provider, model, state, tools.list(), settings);
|
|
98
|
-
}
|
|
99
|
-
} catch (err) {
|
|
100
|
-
yield { type: 'error', message: err.message };
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Track token usage (PRD-071 §1.1: also record cache hits/writes so
|
|
105
|
-
// /extra-usage and /status stop reporting zeros)
|
|
106
|
-
if (response.usage) {
|
|
107
|
-
state.tokenUsage.input += response.usage.input_tokens || 0;
|
|
108
|
-
state.tokenUsage.output += response.usage.output_tokens || 0;
|
|
109
|
-
state.tokenUsage.cache_read += response.usage.cache_read_input_tokens || 0;
|
|
110
|
-
state.tokenUsage.cache_creation += response.usage.cache_creation_input_tokens || 0;
|
|
111
|
-
state._promptCache.updateStats(response.usage);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Build assistant message for history
|
|
115
|
-
const assistantMessage = { role: 'assistant', content: response.content };
|
|
116
|
-
state.messages.push(assistantMessage);
|
|
117
|
-
|
|
118
|
-
// Process content blocks
|
|
119
|
-
const toolUseBlocks = [];
|
|
120
|
-
|
|
121
|
-
for (const block of response.content || []) {
|
|
122
|
-
if (block.type === 'text') {
|
|
123
|
-
yield { type: 'assistant', content: block.text };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (block.type === 'thinking') {
|
|
127
|
-
yield { type: 'thinking_complete', thinking: block.thinking };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (block.type === 'tool_use') {
|
|
131
|
-
toolUseBlocks.push(block);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Process tool calls
|
|
136
|
-
if (toolUseBlocks.length > 0) {
|
|
137
|
-
const toolResults = [];
|
|
138
|
-
|
|
139
|
-
for (const block of toolUseBlocks) {
|
|
140
|
-
// Only consecutive identical calls indicate a loop. The same read or
|
|
141
|
-
// validation later in a task can be legitimate progress verification.
|
|
142
|
-
const stagnationResult = stagnation.record(block.name, block.input);
|
|
143
|
-
if (stagnationResult.detected) {
|
|
144
|
-
yield { type: 'stagnation', tool: block.name, count: stagnationResult.count };
|
|
145
|
-
toolResults.push({
|
|
146
|
-
type: 'tool_result',
|
|
147
|
-
tool_use_id: block.id,
|
|
148
|
-
content: stagnationMessage(block.name, stagnationResult.count),
|
|
149
|
-
});
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Run pre-tool hooks
|
|
154
|
-
if (hooks) {
|
|
155
|
-
const hookResult = await hooks.runPreToolUse(block.name, block.input);
|
|
156
|
-
if (!hookResult.allow) {
|
|
157
|
-
yield { type: 'hookPermissionResult', tool: block.name, allowed: false, message: hookResult.message };
|
|
158
|
-
toolResults.push({
|
|
159
|
-
type: 'tool_result',
|
|
160
|
-
tool_use_id: block.id,
|
|
161
|
-
content: `Blocked by hook: ${hookResult.message}`,
|
|
162
|
-
});
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Check permission
|
|
168
|
-
const allowed = await permissions.check(block.name, block.input);
|
|
169
|
-
if (!allowed) {
|
|
170
|
-
yield { type: 'hookPermissionResult', tool: block.name, allowed: false };
|
|
171
|
-
toolResults.push({
|
|
172
|
-
type: 'tool_result',
|
|
173
|
-
tool_use_id: block.id,
|
|
174
|
-
content: 'Permission denied',
|
|
175
|
-
});
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Execute tool
|
|
180
|
-
yield { type: 'tool_progress', tool: block.name, status: 'running' };
|
|
181
|
-
|
|
182
|
-
let result;
|
|
183
|
-
try {
|
|
184
|
-
result = await tools.call(block.name, block.input);
|
|
185
|
-
} catch (err) {
|
|
186
|
-
result = `Tool error: ${err.message}`;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Run post-tool hooks
|
|
190
|
-
if (hooks) {
|
|
191
|
-
result = await hooks.runPostToolUse(block.name, result);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const resultStagnation = stagnation.recordResult(block.name, block.input || {}, result);
|
|
195
|
-
if (resultStagnation.detected) {
|
|
196
|
-
yield {
|
|
197
|
-
type: 'stagnation',
|
|
198
|
-
tool: block.name,
|
|
199
|
-
count: resultStagnation.count,
|
|
200
|
-
kind: resultStagnation.kind,
|
|
201
|
-
target: resultStagnation.target,
|
|
202
|
-
};
|
|
203
|
-
result = {
|
|
204
|
-
...(typeof result === 'object' && result !== null ? result : {}),
|
|
205
|
-
success: false,
|
|
206
|
-
output: stagnationMessage(block.name, resultStagnation.count, resultStagnation),
|
|
207
|
-
_stagnation: true,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
yield { type: 'result', tool: block.name, result };
|
|
212
|
-
|
|
213
|
-
toolResults.push({
|
|
214
|
-
type: 'tool_result',
|
|
215
|
-
tool_use_id: block.id,
|
|
216
|
-
content: typeof result === 'string' ? result : JSON.stringify(result),
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Add tool results as a single user message
|
|
221
|
-
state.messages.push({ role: 'user', content: toolResults });
|
|
222
|
-
|
|
223
|
-
// Recursive: continue the loop after tool execution
|
|
224
|
-
yield* run(null, { continuation: true });
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// No tool calls — check stop hooks
|
|
229
|
-
if (hooks) {
|
|
230
|
-
const allowStop = await hooks.runStop();
|
|
231
|
-
if (!allowStop) {
|
|
232
|
-
// Hook prevented stopping — continue with a nudge
|
|
233
|
-
state.messages = contextManager.addMessage(state.messages, {
|
|
234
|
-
role: 'user',
|
|
235
|
-
content: '[System: A hook prevented stopping. Please continue with the task.]',
|
|
236
|
-
});
|
|
237
|
-
yield* run(null, { continuation: true });
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
yield { type: 'stop', reason: response.stop_reason || 'end_turn' };
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return { run, state };
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function detectProvider(model) {
|
|
249
|
-
if (model.startsWith('gpt-') || model.startsWith('o1') || model.startsWith('o3')) return 'openai';
|
|
250
|
-
if (model.startsWith('gemini')) return 'google';
|
|
251
|
-
return 'anthropic';
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
async function callApi(provider, model, state, toolDefs, settings) {
|
|
255
|
-
const callers = { anthropic: callAnthropic, openai: callOpenAI, google: callGoogle };
|
|
256
|
-
const caller = callers[provider] || callers.anthropic;
|
|
257
|
-
return caller(model, state, toolDefs, settings, false);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
async function callApiStreaming(provider, model, state, toolDefs, settings) {
|
|
261
|
-
const callers = { anthropic: callAnthropic, openai: callOpenAI, google: callGoogle };
|
|
262
|
-
const caller = callers[provider] || callers.anthropic;
|
|
263
|
-
return caller(model, state, toolDefs, settings, true);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
async function callAnthropic(model, state, toolDefs, settings, stream) {
|
|
267
|
-
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
268
|
-
if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
|
|
269
|
-
|
|
270
|
-
const body = {
|
|
271
|
-
model,
|
|
272
|
-
max_tokens: settings.maxTokens || 16384,
|
|
273
|
-
messages: state.messages,
|
|
274
|
-
...(state.systemPrompt && { system: state.systemPrompt }),
|
|
275
|
-
...(toolDefs.length > 0 && { tools: toolDefs }),
|
|
276
|
-
...(stream && { stream: true }),
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
// Enable extended thinking if model supports it
|
|
280
|
-
if (model.includes('opus') || settings.thinking) {
|
|
281
|
-
body.thinking = { type: 'enabled', budget_tokens: settings.thinkingBudget || 10000 };
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
285
|
-
method: 'POST',
|
|
286
|
-
headers: {
|
|
287
|
-
'Content-Type': 'application/json',
|
|
288
|
-
'x-api-key': apiKey,
|
|
289
|
-
'anthropic-version': '2023-06-01',
|
|
290
|
-
},
|
|
291
|
-
body: JSON.stringify(body),
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
if (!res.ok) {
|
|
295
|
-
const err = await res.text();
|
|
296
|
-
throw new Error(`Anthropic API error ${res.status}: ${err}`);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (stream) {
|
|
300
|
-
const collected = [];
|
|
301
|
-
const eventGenerator = async function* () {
|
|
302
|
-
for await (const event of streamResponse(res)) {
|
|
303
|
-
collected.push(event);
|
|
304
|
-
yield event;
|
|
305
|
-
}
|
|
306
|
-
};
|
|
307
|
-
return {
|
|
308
|
-
events: eventGenerator(),
|
|
309
|
-
get accumulated() {
|
|
310
|
-
return accumulateFromCollected(collected);
|
|
311
|
-
},
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
return res.json();
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
async function callOpenAI(model, state, toolDefs, settings, stream) {
|
|
319
|
-
const apiKey = process.env.OPENAI_API_KEY;
|
|
320
|
-
if (!apiKey) throw new Error('OPENAI_API_KEY not set');
|
|
321
|
-
|
|
322
|
-
const messages = [];
|
|
323
|
-
if (state.systemPrompt) {
|
|
324
|
-
messages.push({ role: 'system', content: state.systemPrompt });
|
|
325
|
-
}
|
|
326
|
-
for (const msg of state.messages) {
|
|
327
|
-
if (typeof msg.content === 'string') {
|
|
328
|
-
messages.push({ role: msg.role, content: msg.content });
|
|
329
|
-
} else if (Array.isArray(msg.content)) {
|
|
330
|
-
for (const block of msg.content) {
|
|
331
|
-
if (block.type === 'tool_result') {
|
|
332
|
-
messages.push({
|
|
333
|
-
role: 'tool',
|
|
334
|
-
tool_call_id: block.tool_use_id,
|
|
335
|
-
content: block.content,
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const tools = toolDefs.map(t => ({
|
|
343
|
-
type: 'function',
|
|
344
|
-
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
345
|
-
}));
|
|
346
|
-
|
|
347
|
-
const body = {
|
|
348
|
-
model,
|
|
349
|
-
messages,
|
|
350
|
-
...(tools.length > 0 && { tools }),
|
|
351
|
-
};
|
|
352
|
-
|
|
353
|
-
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
|
|
354
|
-
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
355
|
-
method: 'POST',
|
|
356
|
-
headers: {
|
|
357
|
-
'Content-Type': 'application/json',
|
|
358
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
359
|
-
},
|
|
360
|
-
body: JSON.stringify(body),
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
if (!res.ok) {
|
|
364
|
-
const err = await res.text();
|
|
365
|
-
throw new Error(`OpenAI API error ${res.status}: ${err}`);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const data = await res.json();
|
|
369
|
-
return convertOpenAIResponse(data);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
async function callGoogle(model, state, toolDefs, settings, stream) {
|
|
373
|
-
const apiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
|
|
374
|
-
if (!apiKey) throw new Error('GOOGLE_API_KEY or GEMINI_API_KEY not set');
|
|
375
|
-
|
|
376
|
-
const contents = [];
|
|
377
|
-
for (const msg of state.messages) {
|
|
378
|
-
const role = msg.role === 'assistant' ? 'model' : 'user';
|
|
379
|
-
if (typeof msg.content === 'string') {
|
|
380
|
-
contents.push({ role, parts: [{ text: msg.content }] });
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const body = {
|
|
385
|
-
contents,
|
|
386
|
-
...(state.systemPrompt && {
|
|
387
|
-
systemInstruction: { parts: [{ text: state.systemPrompt }] },
|
|
388
|
-
}),
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
const res = await fetch(
|
|
392
|
-
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
|
|
393
|
-
{
|
|
394
|
-
method: 'POST',
|
|
395
|
-
headers: { 'Content-Type': 'application/json' },
|
|
396
|
-
body: JSON.stringify(body),
|
|
397
|
-
}
|
|
398
|
-
);
|
|
399
|
-
|
|
400
|
-
if (!res.ok) {
|
|
401
|
-
const err = await res.text();
|
|
402
|
-
throw new Error(`Google API error ${res.status}: ${err}`);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const data = await res.json();
|
|
406
|
-
return convertGoogleResponse(data);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function convertOpenAIResponse(data) {
|
|
410
|
-
const choice = data.choices?.[0];
|
|
411
|
-
if (!choice) throw new Error('No choices in OpenAI response');
|
|
412
|
-
|
|
413
|
-
const content = [];
|
|
414
|
-
if (choice.message?.content) {
|
|
415
|
-
content.push({ type: 'text', text: choice.message.content });
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
if (choice.message?.tool_calls) {
|
|
419
|
-
for (const tc of choice.message.tool_calls) {
|
|
420
|
-
content.push({
|
|
421
|
-
type: 'tool_use',
|
|
422
|
-
id: tc.id,
|
|
423
|
-
name: tc.function.name,
|
|
424
|
-
input: JSON.parse(tc.function.arguments || '{}'),
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
return {
|
|
430
|
-
content,
|
|
431
|
-
stop_reason: choice.finish_reason === 'stop' ? 'end_turn' : choice.finish_reason,
|
|
432
|
-
usage: {
|
|
433
|
-
input_tokens: data.usage?.prompt_tokens || 0,
|
|
434
|
-
output_tokens: data.usage?.completion_tokens || 0,
|
|
435
|
-
},
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function convertGoogleResponse(data) {
|
|
440
|
-
const candidate = data.candidates?.[0];
|
|
441
|
-
if (!candidate) throw new Error('No candidates in Google response');
|
|
442
|
-
|
|
443
|
-
const content = [];
|
|
444
|
-
for (const part of candidate.content?.parts || []) {
|
|
445
|
-
if (part.text) content.push({ type: 'text', text: part.text });
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
return {
|
|
449
|
-
content,
|
|
450
|
-
stop_reason: 'end_turn',
|
|
451
|
-
usage: {
|
|
452
|
-
input_tokens: data.usageMetadata?.promptTokenCount || 0,
|
|
453
|
-
output_tokens: data.usageMetadata?.candidatesTokenCount || 0,
|
|
454
|
-
},
|
|
455
|
-
};
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
function accumulateFromCollected(events) {
|
|
459
|
-
const message = {
|
|
460
|
-
content: [],
|
|
461
|
-
stop_reason: null,
|
|
462
|
-
usage: { input_tokens: 0, output_tokens: 0 },
|
|
463
|
-
};
|
|
464
|
-
|
|
465
|
-
let currentBlock = null;
|
|
466
|
-
|
|
467
|
-
for (const event of events) {
|
|
468
|
-
switch (event.type) {
|
|
469
|
-
case 'message_start':
|
|
470
|
-
if (event.message?.usage) {
|
|
471
|
-
message.usage.input_tokens = event.message.usage.input_tokens || 0;
|
|
472
|
-
}
|
|
473
|
-
break;
|
|
474
|
-
case 'content_block_start':
|
|
475
|
-
currentBlock = { ...event.content_block };
|
|
476
|
-
if (currentBlock.type === 'text') currentBlock.text = '';
|
|
477
|
-
if (currentBlock.type === 'thinking') currentBlock.thinking = '';
|
|
478
|
-
if (currentBlock.type === 'tool_use') currentBlock.input = '';
|
|
479
|
-
message.content.push(currentBlock);
|
|
480
|
-
break;
|
|
481
|
-
case 'content_block_delta':
|
|
482
|
-
if (!currentBlock) break;
|
|
483
|
-
if (event.delta?.type === 'text_delta') currentBlock.text += event.delta.text;
|
|
484
|
-
else if (event.delta?.type === 'thinking_delta') currentBlock.thinking += event.delta.thinking;
|
|
485
|
-
else if (event.delta?.type === 'input_json_delta') currentBlock.input += event.delta.partial_json;
|
|
486
|
-
break;
|
|
487
|
-
case 'content_block_stop':
|
|
488
|
-
if (currentBlock?.type === 'tool_use' && typeof currentBlock.input === 'string') {
|
|
489
|
-
try { currentBlock.input = JSON.parse(currentBlock.input || '{}'); } catch { currentBlock.input = {}; }
|
|
490
|
-
}
|
|
491
|
-
currentBlock = null;
|
|
492
|
-
break;
|
|
493
|
-
case 'message_delta':
|
|
494
|
-
if (event.delta?.stop_reason) message.stop_reason = event.delta.stop_reason;
|
|
495
|
-
if (event.usage) message.usage.output_tokens = event.usage.output_tokens || 0;
|
|
496
|
-
break;
|
|
497
|
-
case 'ping':
|
|
498
|
-
break;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
return message;
|
|
503
|
-
}
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Context Manager — tracks token usage and compacts conversation history.
|
|
3
|
-
*
|
|
4
|
-
* Features:
|
|
5
|
-
* - Proper token estimation (4 chars ~ 1 token for English)
|
|
6
|
-
* - Micro-compaction (remove stale tool results older than 5 turns)
|
|
7
|
-
* - Keep system prompt and recent 3 turns intact during compaction
|
|
8
|
-
* - Track pre/post compaction token counts
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const DEFAULT_MAX_TOKENS = 180000; // ~200k model limit with buffer
|
|
12
|
-
const COMPACT_THRESHOLD = 0.80;
|
|
13
|
-
const CHARS_PER_TOKEN = 4; // rough estimate for English text
|
|
14
|
-
const STALE_TOOL_RESULT_TURNS = 5; // tool results older than this are micro-compacted
|
|
15
|
-
|
|
16
|
-
export class ContextManager {
|
|
17
|
-
/**
|
|
18
|
-
* @param {number} maxTokens - Maximum tokens for context window
|
|
19
|
-
*/
|
|
20
|
-
constructor(maxTokens = DEFAULT_MAX_TOKENS) {
|
|
21
|
-
this.maxTokens = maxTokens;
|
|
22
|
-
this.threshold = COMPACT_THRESHOLD;
|
|
23
|
-
this.compactionCount = 0;
|
|
24
|
-
this.lastPreCompactTokens = 0;
|
|
25
|
-
this.lastPostCompactTokens = 0;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Estimate token count for a message array.
|
|
30
|
-
* Uses character-based heuristic (no external tokenizer dependency).
|
|
31
|
-
* @param {Array} messages - conversation messages
|
|
32
|
-
* @returns {number} estimated token count
|
|
33
|
-
*/
|
|
34
|
-
getTokenCount(messages) {
|
|
35
|
-
let chars = 0;
|
|
36
|
-
for (const msg of messages) {
|
|
37
|
-
// Role overhead (~4 tokens)
|
|
38
|
-
chars += 16;
|
|
39
|
-
|
|
40
|
-
if (typeof msg.content === 'string') {
|
|
41
|
-
chars += msg.content.length;
|
|
42
|
-
} else if (Array.isArray(msg.content)) {
|
|
43
|
-
for (const block of msg.content) {
|
|
44
|
-
if (block.type === 'text') chars += (block.text || '').length;
|
|
45
|
-
else if (block.type === 'tool_result') chars += (block.content || '').length;
|
|
46
|
-
else if (block.type === 'tool_use') chars += JSON.stringify(block.input || {}).length + 20;
|
|
47
|
-
else if (block.type === 'thinking') chars += (block.thinking || '').length;
|
|
48
|
-
else chars += JSON.stringify(block).length;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Check if compaction is needed.
|
|
57
|
-
* @param {Array} messages - current conversation messages
|
|
58
|
-
* @returns {boolean}
|
|
59
|
-
*/
|
|
60
|
-
shouldCompact(messages) {
|
|
61
|
-
const tokenCount = this.getTokenCount(messages);
|
|
62
|
-
return tokenCount >= this.maxTokens * this.threshold;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Micro-compact: remove verbose tool results from messages older than N turns.
|
|
67
|
-
* Keeps the tool call reference but truncates result content.
|
|
68
|
-
* @param {Array} messages
|
|
69
|
-
* @param {number} recentTurns - number of recent user/assistant pairs to preserve
|
|
70
|
-
* @returns {Array}
|
|
71
|
-
*/
|
|
72
|
-
microCompact(messages, recentTurns = STALE_TOOL_RESULT_TURNS) {
|
|
73
|
-
// Count turns (each user message is roughly one turn)
|
|
74
|
-
let turnCount = 0;
|
|
75
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
76
|
-
if (messages[i].role === 'user') turnCount++;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (turnCount <= recentTurns) return messages;
|
|
80
|
-
|
|
81
|
-
// Mark the boundary: keep last recentTurns user messages intact
|
|
82
|
-
let usersSeen = 0;
|
|
83
|
-
let boundary = messages.length;
|
|
84
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
85
|
-
if (messages[i].role === 'user') {
|
|
86
|
-
usersSeen++;
|
|
87
|
-
if (usersSeen >= recentTurns) {
|
|
88
|
-
boundary = i;
|
|
89
|
-
break;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Truncate tool results before the boundary
|
|
95
|
-
const result = messages.map((msg, idx) => {
|
|
96
|
-
if (idx >= boundary) return msg;
|
|
97
|
-
if (!Array.isArray(msg.content)) return msg;
|
|
98
|
-
|
|
99
|
-
const newContent = msg.content.map(block => {
|
|
100
|
-
if (block.type === 'tool_result' && typeof block.content === 'string' && block.content.length > 200) {
|
|
101
|
-
return {
|
|
102
|
-
...block,
|
|
103
|
-
content: block.content.slice(0, 100) + '...[truncated]',
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
return block;
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
return { ...msg, content: newContent };
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
return result;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Compact messages by summarizing older history.
|
|
117
|
-
* Keeps the most recent N messages intact and replaces older ones
|
|
118
|
-
* with a summary message.
|
|
119
|
-
*
|
|
120
|
-
* @param {Array} messages - current conversation messages
|
|
121
|
-
* @param {number} keepRecent - number of recent messages to preserve (default 6 = ~3 turns)
|
|
122
|
-
* @returns {Array} compacted message array
|
|
123
|
-
*/
|
|
124
|
-
compact(messages, keepRecent = 6) {
|
|
125
|
-
if (messages.length <= keepRecent) return messages;
|
|
126
|
-
|
|
127
|
-
this.lastPreCompactTokens = this.getTokenCount(messages);
|
|
128
|
-
this.compactionCount++;
|
|
129
|
-
|
|
130
|
-
// First try micro-compaction
|
|
131
|
-
let working = this.microCompact(messages);
|
|
132
|
-
if (!this.shouldCompact(working)) {
|
|
133
|
-
this.lastPostCompactTokens = this.getTokenCount(working);
|
|
134
|
-
return working;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Full compaction
|
|
138
|
-
const oldMessages = messages.slice(0, -keepRecent);
|
|
139
|
-
const recentMessages = messages.slice(-keepRecent);
|
|
140
|
-
|
|
141
|
-
// Build a summary of old messages
|
|
142
|
-
const summaryParts = [];
|
|
143
|
-
for (const msg of oldMessages) {
|
|
144
|
-
const role = msg.role;
|
|
145
|
-
let text = '';
|
|
146
|
-
if (typeof msg.content === 'string') {
|
|
147
|
-
text = msg.content.slice(0, 200);
|
|
148
|
-
} else if (Array.isArray(msg.content)) {
|
|
149
|
-
text = msg.content
|
|
150
|
-
.map(b => {
|
|
151
|
-
if (b.type === 'text') return b.text?.slice(0, 100);
|
|
152
|
-
if (b.type === 'tool_use') return `[tool:${b.name}]`;
|
|
153
|
-
if (b.type === 'tool_result') return `[result:${String(b.content).slice(0, 80)}]`;
|
|
154
|
-
return `[${b.type}]`;
|
|
155
|
-
})
|
|
156
|
-
.filter(Boolean)
|
|
157
|
-
.join(' ');
|
|
158
|
-
}
|
|
159
|
-
if (text) summaryParts.push(`${role}: ${text}`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const summary = {
|
|
163
|
-
role: 'user',
|
|
164
|
-
content: `[Context compacted — summary of ${oldMessages.length} earlier messages]\n` +
|
|
165
|
-
summaryParts.join('\n').slice(0, 2000),
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
const compacted = [summary, ...recentMessages];
|
|
169
|
-
this.lastPostCompactTokens = this.getTokenCount(compacted);
|
|
170
|
-
return compacted;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Add a message and auto-compact if needed.
|
|
175
|
-
* @param {Array} messages - mutable message array
|
|
176
|
-
* @param {object} msg - new message to add
|
|
177
|
-
* @returns {Array} possibly compacted array with new message
|
|
178
|
-
*/
|
|
179
|
-
addMessage(messages, msg) {
|
|
180
|
-
messages.push(msg);
|
|
181
|
-
if (this.shouldCompact(messages)) {
|
|
182
|
-
return this.compact(messages);
|
|
183
|
-
}
|
|
184
|
-
return messages;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Get compaction statistics.
|
|
189
|
-
* @returns {object}
|
|
190
|
-
*/
|
|
191
|
-
getStats() {
|
|
192
|
-
return {
|
|
193
|
-
compactionCount: this.compactionCount,
|
|
194
|
-
lastPreCompactTokens: this.lastPreCompactTokens,
|
|
195
|
-
lastPostCompactTokens: this.lastPostCompactTokens,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
}
|