scalix 0.0.1 → 1.0.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/LICENSE +100 -0
  3. package/README.md +300 -2
  4. package/app.py +55 -0
  5. package/bin/scalix.js +19 -0
  6. package/dist/agent-replay.js +166 -0
  7. package/dist/agent.js +852 -0
  8. package/dist/auto-verify.js +136 -0
  9. package/dist/change-tracker.js +53 -0
  10. package/dist/cli.js +2329 -0
  11. package/dist/commands.js +71 -0
  12. package/dist/config.js +16 -0
  13. package/dist/constants.js +324 -0
  14. package/dist/context-builder.js +151 -0
  15. package/dist/context-manager.js +148 -0
  16. package/dist/cost-tracker.js +85 -0
  17. package/dist/diff.js +140 -0
  18. package/dist/git-context.js +180 -0
  19. package/dist/git-undo.js +67 -0
  20. package/dist/headless.js +654 -0
  21. package/dist/hooks.js +94 -0
  22. package/dist/ipc-server.js +228 -0
  23. package/dist/logger.js +45 -0
  24. package/dist/markdown.js +41 -0
  25. package/dist/mcp.js +49 -0
  26. package/dist/model-router.js +68 -0
  27. package/dist/plugins.js +132 -0
  28. package/dist/project-index.js +374 -0
  29. package/dist/project-memory.js +165 -0
  30. package/dist/safe-math.js +194 -0
  31. package/dist/sandbox.js +279 -0
  32. package/dist/scoped-rules.js +82 -0
  33. package/dist/sessions.js +71 -0
  34. package/dist/settings.js +155 -0
  35. package/dist/sub-agent.js +79 -0
  36. package/dist/token-counter.js +47 -0
  37. package/dist/tool-helper.js +97 -0
  38. package/dist/tool-selector.js +77 -0
  39. package/dist/tools-advanced.js +232 -0
  40. package/dist/tools-browser.js +273 -0
  41. package/dist/tools-extra.js +383 -0
  42. package/dist/tools-vision.js +115 -0
  43. package/dist/tools-voice.js +147 -0
  44. package/dist/tools-web.js +346 -0
  45. package/dist/tools.js +370 -0
  46. package/dist/types.js +1 -0
  47. package/docs/DISTRIBUTION.md +301 -0
  48. package/package.json +67 -6
package/dist/agent.js ADDED
@@ -0,0 +1,852 @@
1
+ import OpenAI from 'openai';
2
+ import { EventEmitter } from 'eventemitter3';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { getToolPermission } from './sandbox.js';
5
+ import { logger } from './logger.js';
6
+ import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, modelSupportsVision, getModelSpec } from './constants.js';
7
+ import { estimateMessageTokens as estimateTokenCount } from './token-counter.js';
8
+ import { ContextManager } from './context-manager.js';
9
+ import { zodToJsonSchema } from './tool-helper.js';
10
+ import { ToolSelector } from './tool-selector.js';
11
+ import { AgentReplay } from './agent-replay.js';
12
+ import { loadHooks, runHooks } from './hooks.js';
13
+ import { setAgentToolConfig } from './tools-advanced.js';
14
+ import { runVerification, VERIFY_DEFAULTS } from './auto-verify.js';
15
+ import { ChangeTracker } from './change-tracker.js';
16
+ import fs from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ function transformToolsForApi(tools) {
19
+ return tools
20
+ .filter((t) => !('type' in t && t.type === 'mcp'))
21
+ .map((t) => {
22
+ const local = t;
23
+ const fn = local.function ?? local;
24
+ return {
25
+ type: 'function',
26
+ function: {
27
+ name: fn.name,
28
+ description: fn.description || '',
29
+ parameters: fn.inputSchema ? zodToJsonSchema(fn.inputSchema) : (fn.parameters ?? {}),
30
+ },
31
+ };
32
+ });
33
+ }
34
+ function sleep(ms) {
35
+ return new Promise((r) => setTimeout(r, ms));
36
+ }
37
+ function snakeToCamel(s) {
38
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
39
+ }
40
+ function normalizeArgs(args) {
41
+ const out = {};
42
+ for (const [key, val] of Object.entries(args)) {
43
+ const camel = snakeToCamel(key);
44
+ // Prefer the camelCase version if both exist
45
+ if (!(camel in out))
46
+ out[camel] = val;
47
+ // Also keep original key so either convention works
48
+ if (!(key in out))
49
+ out[key] = val;
50
+ }
51
+ return out;
52
+ }
53
+ function backoffDelay(attempt, base = BACKOFF_DEFAULTS.baseMs, max = BACKOFF_DEFAULTS.maxMs) {
54
+ const delay = Math.min(base * Math.pow(2, attempt), max);
55
+ return delay + Math.random() * delay * BACKOFF_DEFAULTS.jitterRatio;
56
+ }
57
+ function withTimeout(promise, ms, label) {
58
+ if (!isFinite(ms))
59
+ return promise;
60
+ let timer;
61
+ const timeout = new Promise((_, reject) => {
62
+ timer = setTimeout(() => reject(new Error(`Tool "${label}" timed out after ${ms}ms`)), ms);
63
+ });
64
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
65
+ }
66
+ export function messageContentToString(content) {
67
+ if (!content)
68
+ return '';
69
+ if (typeof content === 'string')
70
+ return content;
71
+ return content.filter((p) => p.type === 'text').map(p => p.text).join('\n');
72
+ }
73
+ function stripImageParts(content) {
74
+ const parts = [];
75
+ for (const part of content) {
76
+ if (part.type === 'text') {
77
+ parts.push(part.text);
78
+ }
79
+ else if (part.type === 'image_url') {
80
+ parts.push('[Image attached — model does not support vision, image omitted]');
81
+ }
82
+ }
83
+ return parts.join('\n');
84
+ }
85
+ function messagesToApi(messages, instructions, vision = true) {
86
+ const out = [
87
+ { role: 'system', content: instructions },
88
+ ];
89
+ for (const m of messages) {
90
+ if (m.role === 'tool' && m.tool_call_id) {
91
+ out.push({ role: 'tool', tool_call_id: m.tool_call_id, content: messageContentToString(m.content) });
92
+ }
93
+ else if (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0) {
94
+ out.push({
95
+ role: 'assistant',
96
+ content: messageContentToString(m.content) || null,
97
+ tool_calls: m.tool_calls.map((tc) => ({
98
+ id: tc.id,
99
+ type: 'function',
100
+ function: { name: tc.function.name, arguments: tc.function.arguments },
101
+ })),
102
+ });
103
+ }
104
+ else if (m.role === 'system') {
105
+ out.push({ role: 'system', content: messageContentToString(m.content) });
106
+ }
107
+ else if (m.role === 'user') {
108
+ if (Array.isArray(m.content)) {
109
+ out.push({ role: 'user', content: vision ? m.content : stripImageParts(m.content) });
110
+ }
111
+ else {
112
+ out.push({ role: 'user', content: m.content || '' });
113
+ }
114
+ }
115
+ else if (m.role === 'assistant') {
116
+ out.push({ role: 'assistant', content: messageContentToString(m.content) });
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+ function estimateMessageTokens(messages) {
122
+ return estimateTokenCount(messages);
123
+ }
124
+ export class Agent extends EventEmitter {
125
+ client;
126
+ messages = [];
127
+ config;
128
+ modelIndex = 0;
129
+ log;
130
+ contextManager;
131
+ toolSelector = null;
132
+ _aborted = false;
133
+ _thinkingActive = false;
134
+ replay;
135
+ hooks = [];
136
+ instructionLoader;
137
+ _abortController = null;
138
+ modelFailures = new Map();
139
+ changeTracker = new ChangeTracker();
140
+ verifyConfig = null;
141
+ verifyFailCounts = new Map();
142
+ compressAttempts = 0;
143
+ constructor(config) {
144
+ super();
145
+ this.client = new OpenAI({
146
+ apiKey: config.apiKey,
147
+ baseURL: config.baseUrl,
148
+ });
149
+ this.log = logger.child();
150
+ const resolvedModels = Array.isArray(config.model)
151
+ ? (config.model.length > 0 ? config.model : FALLBACK_MODELS)
152
+ : config.model ? [config.model] : [...FALLBACK_MODELS];
153
+ if (resolvedModels.length === 0) {
154
+ throw new Error('No models configured. Run setup or set SCALIX_MODEL environment variable.');
155
+ }
156
+ this.config = {
157
+ apiKey: config.apiKey,
158
+ models: resolvedModels,
159
+ instructions: config.instructions ?? DEFAULT_INSTRUCTIONS,
160
+ tools: config.tools ?? [],
161
+ maxSteps: config.maxSteps ?? AGENT_DEFAULTS.maxSteps,
162
+ maxMessages: config.maxMessages ?? AGENT_DEFAULTS.maxMessages,
163
+ confirmToolCall: config.confirmToolCall,
164
+ permissionMode: config.permissionMode ?? 'default',
165
+ maxRetries: config.maxRetries ?? AGENT_DEFAULTS.maxRetries,
166
+ toolTimeoutMs: config.toolTimeoutMs ?? AGENT_DEFAULTS.toolTimeoutMs,
167
+ messageKeepRatio: config.messageKeepRatio ?? AGENT_DEFAULTS.messageKeepRatio,
168
+ circuitBreakerThreshold: config.circuitBreakerThreshold ?? AGENT_DEFAULTS.circuitBreakerThreshold,
169
+ circuitBreakerResetMs: config.circuitBreakerResetMs ?? AGENT_DEFAULTS.circuitBreakerResetMs,
170
+ fileRefTruncation: config.fileRefTruncation ?? TOOL_DEFAULTS.fileRefTruncation,
171
+ thinkingMode: config.thinkingMode ?? 'off',
172
+ };
173
+ this.contextManager = new ContextManager(config.contextManager);
174
+ if (config.dynamicToolSelection) {
175
+ this.toolSelector = new ToolSelector();
176
+ this.toolSelector.index(this.config.tools);
177
+ }
178
+ this.replay = new AgentReplay(randomUUID().slice(0, 8), this.config.models[0]);
179
+ this.hooks = loadHooks();
180
+ this.instructionLoader = config.instructionLoader;
181
+ if (config.autoVerify === true) {
182
+ this.verifyConfig = { ...VERIFY_DEFAULTS };
183
+ }
184
+ else if (config.autoVerify && typeof config.autoVerify === 'object') {
185
+ this.verifyConfig = { ...VERIFY_DEFAULTS, ...config.autoVerify };
186
+ }
187
+ setAgentToolConfig({
188
+ apiKey: config.apiKey,
189
+ baseUrl: config.baseUrl,
190
+ models: this.config.models,
191
+ permissionMode: this.config.permissionMode,
192
+ confirmToolCall: this.config.confirmToolCall,
193
+ });
194
+ }
195
+ pickModel() {
196
+ const startIdx = this.modelIndex;
197
+ for (let i = 0; i < this.config.models.length; i++) {
198
+ const idx = (startIdx + i) % this.config.models.length;
199
+ const model = this.config.models[idx];
200
+ if (!this.isModelCircuitOpen(model)) {
201
+ this.modelIndex = idx + 1;
202
+ return model;
203
+ }
204
+ }
205
+ this.modelFailures.clear();
206
+ this.modelIndex = (startIdx + 1) % this.config.models.length;
207
+ return this.config.models[startIdx % this.config.models.length];
208
+ }
209
+ shouldFallbackToNext(err) {
210
+ const e = err;
211
+ const message = (e?.message || e?.error?.message || '').toLowerCase();
212
+ const statusCode = e?.status || e?.statusCode || e?.error?.code;
213
+ if (statusCode === 401 || statusCode === 403 || message.includes('unauthorized') || message.includes('forbidden') || message.includes('invalid api key')) {
214
+ return false;
215
+ }
216
+ return (message.includes('429') ||
217
+ message.includes('rate limit') ||
218
+ message.includes('provider returned error') ||
219
+ message.includes('safety') ||
220
+ message.includes('streaming failed') ||
221
+ message.includes('stream error') ||
222
+ message.includes('try again') ||
223
+ statusCode === 429 ||
224
+ statusCode === 500 ||
225
+ statusCode === 502 ||
226
+ statusCode === 503 ||
227
+ statusCode === 404);
228
+ }
229
+ isModelCircuitOpen(model) {
230
+ const state = this.modelFailures.get(model);
231
+ if (!state)
232
+ return false;
233
+ if (Date.now() - state.lastFailure > this.config.circuitBreakerResetMs) {
234
+ this.modelFailures.delete(model);
235
+ return false;
236
+ }
237
+ return state.count >= this.config.circuitBreakerThreshold;
238
+ }
239
+ recordModelFailure(model) {
240
+ const state = this.modelFailures.get(model) ?? { count: 0, lastFailure: 0 };
241
+ state.count++;
242
+ state.lastFailure = Date.now();
243
+ this.modelFailures.set(model, state);
244
+ }
245
+ recordModelSuccess(model) {
246
+ this.modelFailures.delete(model);
247
+ }
248
+ truncateMessages() {
249
+ if (this.messages.length <= this.config.maxMessages)
250
+ return;
251
+ const keepCount = Math.floor(this.config.maxMessages * this.config.messageKeepRatio);
252
+ const dropped = this.messages.length - keepCount;
253
+ this.messages = this.messages.slice(dropped);
254
+ this.log.info(`Truncated ${dropped} oldest messages, keeping ${this.messages.length}`);
255
+ }
256
+ emitThinkingEnd() {
257
+ if (this._thinkingActive) {
258
+ this._thinkingActive = false;
259
+ this.emit('thinking:end');
260
+ }
261
+ }
262
+ getMessages() { return [...this.messages]; }
263
+ clearHistory() {
264
+ this.messages = [];
265
+ this.verifyFailCounts.clear();
266
+ this.modelFailures.clear();
267
+ }
268
+ loadMessages(messages) { this.messages = [...messages]; }
269
+ getInstructions() { return this.config.instructions; }
270
+ setInstructions(instructions) { this.config.instructions = instructions; }
271
+ getPermissionMode() { return this.config.permissionMode; }
272
+ setPermissionMode(mode) { this.config.permissionMode = mode; }
273
+ getThinking() { return this.config.thinkingMode; }
274
+ setThinking(mode) { this.config.thinkingMode = mode; }
275
+ async compact() {
276
+ const msgCount = this.messages.length;
277
+ if (msgCount < 4)
278
+ return 'Not enough messages to compact.';
279
+ const summaryPrompt = 'Compress this conversation into a detailed summary that preserves ALL technical details: file paths, function names, line numbers, error messages, code changes, commands run, configurations, what worked, what failed, user preferences, and pending work. This summary replaces the original — anything omitted is permanently lost. Be specific and factual, not vague.';
280
+ try {
281
+ const model = this.config.models[0];
282
+ const apiMessages = [
283
+ { role: 'system', content: 'You are a conversation summarizer. Be concise and factual.' },
284
+ ...this.messages.map((m) => ({ role: (m.role === 'tool' ? 'user' : m.role), content: messageContentToString(m.content) })),
285
+ { role: 'user', content: summaryPrompt },
286
+ ];
287
+ const result = await this.client.chat.completions.create({
288
+ model,
289
+ messages: apiMessages,
290
+ stream: false,
291
+ });
292
+ const summary = result.choices?.[0]?.message?.content?.trim() || '';
293
+ if (!summary)
294
+ return 'Compaction failed: no summary generated.';
295
+ this.messages = [
296
+ { role: 'system', content: `[Conversation compacted — ${msgCount} messages summarized]\n\n${summary}` },
297
+ ];
298
+ if (this.instructionLoader) {
299
+ try {
300
+ const reloaded = await this.instructionLoader();
301
+ this.config.instructions = reloaded;
302
+ }
303
+ catch (err) {
304
+ this.log.warn('Failed to reload instructions after compaction', { error: err?.message });
305
+ }
306
+ }
307
+ return `Compacted ${msgCount} messages into summary.`;
308
+ }
309
+ catch (err) {
310
+ return `Compaction failed: ${err.message}`;
311
+ }
312
+ }
313
+ getContextUsage() {
314
+ return this.contextManager.getTokenUsage(this.messages);
315
+ }
316
+ async autoCompress(reqLog) {
317
+ if (!this.contextManager.shouldCompress(this.messages)) {
318
+ this.compressAttempts = 0;
319
+ return;
320
+ }
321
+ if (this.compressAttempts >= 3) {
322
+ this.messages = this.contextManager.truncateToolResults(this.messages);
323
+ this.messages = this.contextManager.applySlidingWindow(this.messages);
324
+ return;
325
+ }
326
+ this.compressAttempts++;
327
+ const beforeTokens = this.contextManager.getTokenUsage(this.messages).tokens;
328
+ const { toCompress, toKeep } = this.contextManager.splitForCompression(this.messages);
329
+ if (toCompress.length < 4) {
330
+ this.messages = this.contextManager.truncateToolResults(this.messages);
331
+ this.messages = this.contextManager.applySlidingWindow(this.messages);
332
+ return;
333
+ }
334
+ reqLog.info('Auto-compressing context', { messagesToCompress: toCompress.length, keeping: toKeep.length });
335
+ const backup = [...this.messages];
336
+ try {
337
+ const prompt = this.contextManager.buildCompressionPrompt(toCompress);
338
+ const model = this.config.models[0];
339
+ const result = await this.client.chat.completions.create({
340
+ model,
341
+ messages: [
342
+ { role: 'system', content: 'You compress conversation history. Preserve every file path, function name, error message, code change, command, config value, and user instruction verbatim. Omit greetings and filler only. This output replaces the original — anything omitted is permanently lost.' },
343
+ { role: 'user', content: prompt },
344
+ ],
345
+ stream: false,
346
+ });
347
+ const summary = result.choices?.[0]?.message?.content?.trim() || '';
348
+ if (summary) {
349
+ this.messages = [
350
+ { role: 'system', content: `[Context auto-compressed — ${toCompress.length} messages summarized]\n\n${summary}` },
351
+ ...this.contextManager.truncateToolResults(toKeep),
352
+ ];
353
+ const afterTokens = this.contextManager.getTokenUsage(this.messages).tokens;
354
+ const saved = beforeTokens - afterTokens;
355
+ reqLog.info('Context compressed', { before: beforeTokens, after: afterTokens, saved });
356
+ this.emit('context:compress', { before: beforeTokens, after: afterTokens, saved });
357
+ }
358
+ else {
359
+ this.messages = backup;
360
+ reqLog.warn('Auto-compression produced empty summary, keeping original messages');
361
+ }
362
+ }
363
+ catch (err) {
364
+ this.messages = backup;
365
+ reqLog.warn('Auto-compression failed, falling back to truncation', { error: err.message });
366
+ this.messages = this.contextManager.truncateToolResults(this.messages);
367
+ this.messages = this.contextManager.applySlidingWindow(this.messages);
368
+ }
369
+ }
370
+ addTool(newTool) {
371
+ this.config.tools.push(newTool);
372
+ if (this.toolSelector)
373
+ this.toolSelector.index(this.config.tools);
374
+ }
375
+ addModel(model) {
376
+ if (!this.config.models.includes(model))
377
+ this.config.models.push(model);
378
+ }
379
+ setModel(model) { this.config.models = [model]; }
380
+ getModels() { return [...this.config.models]; }
381
+ updateLimit(key, value) {
382
+ if (key in this.config) {
383
+ this.config[key] = value;
384
+ }
385
+ }
386
+ getReplay() { return this.replay; }
387
+ getChangeTracker() { return this.changeTracker; }
388
+ getHooks() { return this.hooks; }
389
+ getApiKey() { return this.config.apiKey; }
390
+ getBaseUrl() { return this.client.baseURL; }
391
+ abort() {
392
+ this._aborted = true;
393
+ if (this._abortController) {
394
+ this._abortController.abort();
395
+ this._abortController = null;
396
+ }
397
+ this.emitThinkingEnd();
398
+ }
399
+ removeModel(model) {
400
+ const idx = this.config.models.indexOf(model);
401
+ if (idx !== -1 && this.config.models.length > 1) {
402
+ this.config.models.splice(idx, 1);
403
+ return true;
404
+ }
405
+ return false;
406
+ }
407
+ async expandFileReferences(content) {
408
+ const pattern = /(?:^|\s)@((?:[\w./-]+\/)?[\w.-]+\.\w+)/g;
409
+ const matches = [];
410
+ let match;
411
+ while ((match = pattern.exec(content)) !== null) {
412
+ matches.push(match[1]);
413
+ }
414
+ if (matches.length === 0)
415
+ return content;
416
+ const { isImageFile, encodeImageToDataUrl } = await import('./tools-vision.js');
417
+ const contextBlocks = [];
418
+ const imageParts = [];
419
+ for (const filePath of matches) {
420
+ try {
421
+ const resolved = path.resolve(process.cwd(), filePath);
422
+ if (isImageFile(filePath)) {
423
+ const dataUrl = await encodeImageToDataUrl(resolved);
424
+ imageParts.push({ type: 'image_url', image_url: { url: dataUrl, detail: 'auto' } });
425
+ contextBlocks.push(`[Image attached: ${filePath}]`);
426
+ }
427
+ else {
428
+ const fileContent = await fs.readFile(resolved, 'utf-8');
429
+ const truncated = fileContent.length > this.config.fileRefTruncation
430
+ ? fileContent.slice(0, this.config.fileRefTruncation) + '\n... (truncated)'
431
+ : fileContent;
432
+ contextBlocks.push(`--- File: ${filePath} ---\n${truncated}\n--- End: ${filePath} ---`);
433
+ }
434
+ }
435
+ catch {
436
+ contextBlocks.push(`--- File: ${filePath} ---\n[File not found or unreadable]\n--- End: ${filePath} ---`);
437
+ }
438
+ }
439
+ if (imageParts.length > 0) {
440
+ const textContent = contextBlocks.join('\n\n') + '\n\n' + content;
441
+ return [
442
+ { type: 'text', text: textContent },
443
+ ...imageParts,
444
+ ];
445
+ }
446
+ if (contextBlocks.length === 0)
447
+ return content;
448
+ return contextBlocks.join('\n\n') + '\n\n' + content;
449
+ }
450
+ async send(content, images) {
451
+ this._aborted = false;
452
+ const hasFileRefs = /@(?:[\w./-]+\/)?[\w.-]+\.\w+/.test(content);
453
+ const expandedContent = hasFileRefs ? await this.expandFileReferences(content) : content;
454
+ let finalContent = expandedContent;
455
+ if (images && images.length > 0) {
456
+ const textPart = {
457
+ type: 'text',
458
+ text: typeof expandedContent === 'string' ? expandedContent : expandedContent.filter((p) => p.type === 'text').map(p => p.text).join('\n'),
459
+ };
460
+ const existingImages = Array.isArray(expandedContent) ? expandedContent.filter((p) => p.type === 'image_url') : [];
461
+ const newImages = images.map(dataUrl => ({ type: 'image_url', image_url: { url: dataUrl, detail: 'auto' } }));
462
+ finalContent = [textPart, ...existingImages, ...newImages];
463
+ }
464
+ const userMessage = { role: 'user', content: finalContent };
465
+ this.messages.push(userMessage);
466
+ this.emit('message:user', userMessage);
467
+ this._thinkingActive = true;
468
+ this.emit('thinking:start');
469
+ this.truncateMessages();
470
+ this.replay.record('user_message', { content });
471
+ const requestId = randomUUID().slice(0, 8);
472
+ const reqLog = logger.child(requestId);
473
+ reqLog.info('User message received', { length: content.length });
474
+ try {
475
+ const initialModel = this.pickModel();
476
+ reqLog.info('Starting agent loop', { model: initialModel });
477
+ return await this.loopStreamingTurns(initialModel, reqLog);
478
+ }
479
+ catch (err) {
480
+ const error = err instanceof Error ? err : new Error(String(err));
481
+ error.metadata = {
482
+ model: this.config.models[this.modelIndex % this.config.models.length],
483
+ status: err.status ?? err.statusCode,
484
+ error: err.error,
485
+ requestId,
486
+ };
487
+ reqLog.error('Agent send failed', { error: error.message });
488
+ this.emit('error', error);
489
+ throw error;
490
+ }
491
+ finally {
492
+ this.emitThinkingEnd();
493
+ }
494
+ }
495
+ async loopStreamingTurns(initialModel, reqLog) {
496
+ let currentModel = initialModel;
497
+ let turnCount = 0;
498
+ let retryAttempt = 0;
499
+ let emptyTurnCount = 0;
500
+ while (turnCount < this.config.maxSteps) {
501
+ if (this._aborted) {
502
+ reqLog.info('Agent aborted by user');
503
+ return '[interrupted]';
504
+ }
505
+ turnCount++;
506
+ let fullText = '';
507
+ const toolCalls = new Map();
508
+ try {
509
+ // Sync context limit to the current model's actual context window
510
+ const modelSpec = getModelSpec(currentModel);
511
+ const inputBudget = modelSpec.contextWindow - modelSpec.maxOutputTokens;
512
+ if (inputBudget > 0) {
513
+ this.contextManager.setMaxContextTokens(inputBudget);
514
+ }
515
+ await this.autoCompress(reqLog);
516
+ this.emit('context:usage', this.contextManager.getTokenUsage(this.messages));
517
+ let toolsForRequest = this.config.tools;
518
+ if (this.toolSelector) {
519
+ const lastUserMsg = [...this.messages].reverse().find((m) => m.role === 'user');
520
+ const recentTools = this.messages
521
+ .filter((m) => m.role === 'assistant' && m.tool_calls)
522
+ .flatMap((m) => (m.tool_calls || []).map((tc) => tc.function.name))
523
+ .slice(-10);
524
+ if (lastUserMsg?.content) {
525
+ toolsForRequest = this.toolSelector.select(messageContentToString(lastUserMsg.content), recentTools);
526
+ }
527
+ }
528
+ const apiTools = toolsForRequest.length > 0 ? transformToolsForApi(toolsForRequest) : undefined;
529
+ const estimatedTokens = estimateMessageTokens(this.messages);
530
+ this.emit('cost:estimate', { estimatedTokens, model: currentModel });
531
+ this._abortController = new AbortController();
532
+ const vision = modelSupportsVision(currentModel);
533
+ const apiMessages = messagesToApi(this.messages, this.config.instructions, vision);
534
+ const thinkingLevel = this.config.thinkingMode !== 'off' ? this.config.thinkingMode : undefined;
535
+ const stream = await this.client.chat.completions.create({
536
+ model: currentModel,
537
+ messages: apiMessages,
538
+ tools: apiTools && apiTools.length > 0 ? apiTools : undefined,
539
+ max_tokens: modelSpec.maxOutputTokens,
540
+ temperature: 0.7,
541
+ stream: true,
542
+ stream_options: { include_usage: true },
543
+ ...(thinkingLevel && { thinking_level: thinkingLevel }),
544
+ }, {
545
+ signal: this._abortController.signal,
546
+ });
547
+ this.emit('stream:start');
548
+ retryAttempt = 0;
549
+ this.recordModelSuccess(currentModel);
550
+ try {
551
+ for await (const chunk of stream) {
552
+ if (this._aborted)
553
+ break;
554
+ const choice = chunk.choices?.[0];
555
+ if (choice?.delta) {
556
+ const delta = choice.delta;
557
+ if (delta.content) {
558
+ fullText += delta.content;
559
+ this.emit('stream:delta', delta.content, fullText);
560
+ }
561
+ if (delta.tool_calls) {
562
+ for (const tc of delta.tool_calls) {
563
+ const idx = tc.index ?? toolCalls.size;
564
+ if (!toolCalls.has(idx)) {
565
+ toolCalls.set(idx, { id: tc.id || '', name: tc.function?.name || '', arguments: '' });
566
+ }
567
+ const entry = toolCalls.get(idx);
568
+ if (tc.id)
569
+ entry.id = tc.id;
570
+ if (tc.function?.name)
571
+ entry.name = tc.function.name;
572
+ if (tc.function?.arguments)
573
+ entry.arguments += tc.function.arguments;
574
+ }
575
+ }
576
+ }
577
+ if (chunk.usage) {
578
+ const inTok = chunk.usage.prompt_tokens ?? 0;
579
+ const outTok = chunk.usage.completion_tokens ?? 0;
580
+ const totalTok = chunk.usage.total_tokens ?? (inTok + outTok);
581
+ const cost = chunk.usage.cost ?? 0;
582
+ this.emit('usage', {
583
+ prompt_tokens: inTok,
584
+ completion_tokens: outTok,
585
+ total_tokens: totalTok,
586
+ cost,
587
+ model: currentModel,
588
+ });
589
+ this.replay.recordUsage(totalTok, cost);
590
+ }
591
+ }
592
+ }
593
+ finally {
594
+ stream.controller?.abort();
595
+ }
596
+ for (const [, call] of toolCalls) {
597
+ this.emit('tool:call', call.name, call.arguments);
598
+ this.replay.record('tool_call', { name: call.name, args: call.arguments });
599
+ }
600
+ this.emit('stream:end', fullText);
601
+ this.replay.record('stream_end', { text: fullText.slice(0, 200) });
602
+ const resolvedToolCalls = Array.from(toolCalls.values());
603
+ const hasToolCalls = resolvedToolCalls.length > 0;
604
+ if (!fullText && !hasToolCalls) {
605
+ emptyTurnCount++;
606
+ reqLog.warn('Empty assistant turn (no text, no tool calls) — skipping', { emptyTurns: emptyTurnCount });
607
+ if (emptyTurnCount >= 3) {
608
+ reqLog.error('Too many empty turns, aborting');
609
+ return '[no response]';
610
+ }
611
+ continue;
612
+ }
613
+ emptyTurnCount = 0;
614
+ const assistantMessage = {
615
+ role: 'assistant',
616
+ content: fullText || null,
617
+ tool_calls: hasToolCalls
618
+ ? resolvedToolCalls.map(tc => ({
619
+ id: tc.id,
620
+ type: 'function',
621
+ function: { name: tc.name, arguments: tc.arguments },
622
+ }))
623
+ : undefined,
624
+ };
625
+ this.messages.push(assistantMessage);
626
+ this.emit('message:assistant', assistantMessage);
627
+ if (resolvedToolCalls.length > 0) {
628
+ for (const call of resolvedToolCalls) {
629
+ if (this._aborted) {
630
+ reqLog.info('Agent aborted, skipping remaining tool calls');
631
+ break;
632
+ }
633
+ const tool = this.config.tools.find((t) => (t.function?.name || t.name) === call.name);
634
+ let result;
635
+ if (this.hooks.length > 0) {
636
+ const hookResults = await runHooks(this.hooks, 'PreToolUse', {
637
+ toolName: call.name,
638
+ toolArgs: call.arguments,
639
+ });
640
+ const blocked = hookResults.find((r) => !r.allowed);
641
+ if (blocked) {
642
+ result = { denied: true, message: blocked.error || `Blocked by PreToolUse hook` };
643
+ this.emit('tool:blocked', call.name, 'Blocked by hook');
644
+ this.messages.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: call.id, name: call.name });
645
+ this.emit('tool:result', call.name, result);
646
+ continue;
647
+ }
648
+ }
649
+ const permission = getToolPermission(call.name, this.config.permissionMode);
650
+ if (permission === 'block') {
651
+ result = { denied: true, message: `Tool ${call.name} is blocked in ${this.config.permissionMode} mode` };
652
+ reqLog.info('Tool blocked by permission mode', { tool: call.name, mode: this.config.permissionMode });
653
+ this.emit('tool:blocked', call.name, `Blocked in ${this.config.permissionMode} mode`);
654
+ this.messages.push({
655
+ role: 'tool',
656
+ content: JSON.stringify(result),
657
+ tool_call_id: call.id,
658
+ name: call.name,
659
+ });
660
+ this.emit('tool:result', call.name, result);
661
+ continue;
662
+ }
663
+ if (permission === 'ask') {
664
+ if (!this.config.confirmToolCall) {
665
+ result = { denied: true, message: `Tool ${call.name} requires confirmation but no confirmation handler is available` };
666
+ reqLog.warn('Tool requires confirmation but no confirmToolCall provided', { tool: call.name });
667
+ this.emit('tool:blocked', call.name, 'No confirmation handler');
668
+ this.messages.push({
669
+ role: 'tool',
670
+ content: JSON.stringify(result),
671
+ tool_call_id: call.id,
672
+ name: call.name,
673
+ });
674
+ this.emit('tool:result', call.name, result);
675
+ continue;
676
+ }
677
+ let args;
678
+ try {
679
+ args = JSON.parse(call.arguments);
680
+ }
681
+ catch {
682
+ args = call.arguments;
683
+ }
684
+ const CONFIRM_TIMEOUT_MS = 300_000;
685
+ const confirmed = await new Promise((resolve) => {
686
+ const timer = setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
687
+ this.config.confirmToolCall(call.name, args).then((v) => {
688
+ clearTimeout(timer);
689
+ resolve(v);
690
+ }).catch(() => {
691
+ clearTimeout(timer);
692
+ resolve(false);
693
+ });
694
+ });
695
+ if (this._aborted)
696
+ break;
697
+ if (!confirmed) {
698
+ result = { denied: true, message: `User denied execution of ${call.name}` };
699
+ reqLog.info('Tool call denied by user', { tool: call.name });
700
+ this.emit('tool:blocked', call.name, 'User denied');
701
+ this.messages.push({
702
+ role: 'tool',
703
+ content: JSON.stringify(result),
704
+ tool_call_id: call.id,
705
+ name: call.name,
706
+ });
707
+ this.emit('tool:result', call.name, result);
708
+ continue;
709
+ }
710
+ }
711
+ const execFn = tool?.function?.execute ?? tool?.execute;
712
+ if (tool && typeof execFn === 'function') {
713
+ try {
714
+ let args;
715
+ try {
716
+ args = normalizeArgs(JSON.parse(call.arguments));
717
+ }
718
+ catch {
719
+ args = {};
720
+ }
721
+ reqLog.debug('Executing tool', { tool: call.name });
722
+ const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
723
+ ? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
724
+ : this.config.toolTimeoutMs;
725
+ result = await withTimeout(execFn(args, { context: {}, workspaceRoot: process.cwd(), permissionMode: this.config.permissionMode }), toolTimeout, call.name);
726
+ if (call.name === 'add_mcp_server' && result?.success) {
727
+ this.addTool({ type: 'mcp', serverLabel: args.label, serverUrl: args.url });
728
+ }
729
+ }
730
+ catch (e) {
731
+ result = { error: e.message };
732
+ reqLog.error('Tool execution failed', { tool: call.name, error: e.message });
733
+ }
734
+ }
735
+ else {
736
+ result = { error: `Tool ${call.name} not found or not locally executable.` };
737
+ }
738
+ let resultStr;
739
+ try {
740
+ resultStr = typeof result === 'string' ? result : JSON.stringify(result);
741
+ }
742
+ catch {
743
+ try {
744
+ const seen = new WeakSet();
745
+ resultStr = JSON.stringify(result, (_k, v) => {
746
+ if (typeof v === 'object' && v !== null) {
747
+ if (seen.has(v))
748
+ return '[Circular]';
749
+ seen.add(v);
750
+ }
751
+ return v;
752
+ });
753
+ }
754
+ catch {
755
+ resultStr = String(result);
756
+ }
757
+ }
758
+ const MAX_TOOL_RESULT_CHARS = 500_000;
759
+ if (resultStr.length > MAX_TOOL_RESULT_CHARS) {
760
+ resultStr = resultStr.slice(0, MAX_TOOL_RESULT_CHARS) + `\n...[truncated — ${(resultStr.length - MAX_TOOL_RESULT_CHARS).toLocaleString()} chars removed]`;
761
+ }
762
+ this.messages.push({
763
+ role: 'tool',
764
+ content: resultStr,
765
+ tool_call_id: call.id,
766
+ name: call.name,
767
+ });
768
+ this.emit('tool:result', call.name, result);
769
+ if (this.hooks.length > 0) {
770
+ runHooks(this.hooks, 'PostToolUse', {
771
+ toolName: call.name,
772
+ toolArgs: typeof result === 'string' ? result : JSON.stringify(result),
773
+ }).catch((err) => {
774
+ reqLog.warn('PostToolUse hook failed', { tool: call.name, error: err.message });
775
+ });
776
+ }
777
+ // Auto-verification after file modifications
778
+ const WRITE_TOOL_NAMES = new Set(['write_file', 'edit_file', 'notebook_edit']);
779
+ if (WRITE_TOOL_NAMES.has(call.name) && this.verifyConfig?.enabled) {
780
+ let filePath = null;
781
+ try {
782
+ const parsed = JSON.parse(call.arguments);
783
+ filePath = parsed.filePath || parsed.file_path || parsed.notebook_path || null;
784
+ }
785
+ catch (parseErr) {
786
+ reqLog.warn('Failed to parse tool arguments for auto-verify', { tool: call.name, error: parseErr.message });
787
+ }
788
+ if (filePath) {
789
+ this.changeTracker.track(filePath, call.name);
790
+ this.emit('file:modified', filePath, call.name);
791
+ const verifyResult = await runVerification(filePath, process.cwd(), this.verifyConfig);
792
+ if (verifyResult) {
793
+ if (verifyResult.passed) {
794
+ this.changeTracker.markVerified(filePath, true);
795
+ this.verifyFailCounts.delete(filePath);
796
+ this.emit('verify:pass', verifyResult);
797
+ }
798
+ else {
799
+ this.changeTracker.markVerified(filePath, false);
800
+ this.emit('verify:fail', verifyResult);
801
+ const failCount = (this.verifyFailCounts.get(filePath) ?? 0) + 1;
802
+ this.verifyFailCounts.set(filePath, failCount);
803
+ if (this.verifyFailCounts.size > 100) {
804
+ const first = this.verifyFailCounts.keys().next().value;
805
+ if (first !== undefined)
806
+ this.verifyFailCounts.delete(first);
807
+ }
808
+ if (failCount <= VERIFY_DEFAULTS.maxFailRetries) {
809
+ this.messages.push({
810
+ role: 'system',
811
+ content: `[AUTO-VERIFY FAILED] ${verifyResult.checker} found errors in ${filePath}:\n\n${verifyResult.output}\n\nFix the errors above before proceeding.`,
812
+ });
813
+ reqLog.info('Auto-verify failed, injecting fix hint', { file: filePath, checker: verifyResult.checker, attempt: failCount });
814
+ }
815
+ else {
816
+ reqLog.warn('Auto-verify: max retries reached, skipping further injection', { file: filePath });
817
+ }
818
+ }
819
+ }
820
+ }
821
+ }
822
+ }
823
+ if (this._aborted)
824
+ return '[interrupted]';
825
+ continue;
826
+ }
827
+ return fullText;
828
+ }
829
+ catch (err) {
830
+ this._abortController = null;
831
+ this.recordModelFailure(currentModel);
832
+ if (this.shouldFallbackToNext(err) && retryAttempt < this.config.maxRetries && turnCount < this.config.maxSteps) {
833
+ retryAttempt++;
834
+ const delay = backoffDelay(retryAttempt);
835
+ reqLog.warn(`Retrying after ${Math.round(delay)}ms (attempt ${retryAttempt}/${this.config.maxRetries})`, {
836
+ model: currentModel,
837
+ error: err.message,
838
+ });
839
+ await sleep(delay);
840
+ currentModel = this.pickModel();
841
+ continue;
842
+ }
843
+ throw err;
844
+ }
845
+ }
846
+ throw new Error(`Agent exhausted maximum steps (${this.config.maxSteps}) without producing a final response`);
847
+ }
848
+ }
849
+ export function createAgent(config) {
850
+ return new Agent(config);
851
+ }
852
+ export { normalizeArgs, snakeToCamel, transformToolsForApi, withTimeout };