osai-agent 4.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 (86) hide show
  1. package/LICENSE +7 -0
  2. package/package.json +72 -0
  3. package/src/agent/context.js +141 -0
  4. package/src/agent/loop/context-summary.js +196 -0
  5. package/src/agent/loop/directory-utils.js +102 -0
  6. package/src/agent/loop/local.js +196 -0
  7. package/src/agent/loop/loop-detection.js +288 -0
  8. package/src/agent/loop/stream-parser.js +515 -0
  9. package/src/agent/loop/tool-executor.js +470 -0
  10. package/src/agent/loop/verification.js +263 -0
  11. package/src/agent/loop/websocket.js +80 -0
  12. package/src/agent/prompt.js +259 -0
  13. package/src/agent/react-loop.js +697 -0
  14. package/src/agent/subagent.js +263 -0
  15. package/src/commands/config.js +53 -0
  16. package/src/commands/connect.js +190 -0
  17. package/src/commands/devices.js +121 -0
  18. package/src/commands/login.js +77 -0
  19. package/src/commands/logout.js +31 -0
  20. package/src/commands/mcp.js +258 -0
  21. package/src/commands/provider.js +633 -0
  22. package/src/commands/register.js +74 -0
  23. package/src/commands/run.js +150 -0
  24. package/src/commands/search.js +64 -0
  25. package/src/commands/session.js +57 -0
  26. package/src/commands/skills.js +54 -0
  27. package/src/commands/stop-subagent.js +58 -0
  28. package/src/index.js +208 -0
  29. package/src/llm/direct.js +317 -0
  30. package/src/memory/store.js +215 -0
  31. package/src/mock-readline.js +27 -0
  32. package/src/parser/dependencies.js +71 -0
  33. package/src/parser/markdown.js +505 -0
  34. package/src/parser/stream.js +96 -0
  35. package/src/prompts/modes/CODING.js +160 -0
  36. package/src/prompts/modes/GENERAL.js +105 -0
  37. package/src/prompts/modes/NETWORK.js +69 -0
  38. package/src/prompts/modes/SSH.js +53 -0
  39. package/src/prompts/systemPrompt.js +85 -0
  40. package/src/safety/check.js +210 -0
  41. package/src/services/crypto.js +78 -0
  42. package/src/services/executor.js +68 -0
  43. package/src/services/history.js +58 -0
  44. package/src/services/server-url.js +11 -0
  45. package/src/services/session.js +194 -0
  46. package/src/services/ssh.js +176 -0
  47. package/src/services/websocket.js +112 -0
  48. package/src/skills/loader.js +231 -0
  49. package/src/tools/browser.js +434 -0
  50. package/src/tools/local.js +1254 -0
  51. package/src/tools/mcp-client.js +209 -0
  52. package/src/tools/registry.js +132 -0
  53. package/src/tools/search-providers.js +237 -0
  54. package/src/tools/ssh.js +74 -0
  55. package/src/ui/App.js +2031 -0
  56. package/src/ui/animation.js +47 -0
  57. package/src/ui/components/AskUserDialog.js +33 -0
  58. package/src/ui/components/ConfirmationDialog.js +45 -0
  59. package/src/ui/components/DiffView.js +201 -0
  60. package/src/ui/components/Header.js +157 -0
  61. package/src/ui/components/HistoryPicker.js +130 -0
  62. package/src/ui/components/InputShell.js +22 -0
  63. package/src/ui/components/MessageHistory.js +1200 -0
  64. package/src/ui/components/ModalPanel.js +40 -0
  65. package/src/ui/components/ModePicker.js +161 -0
  66. package/src/ui/components/PlanDialog.js +48 -0
  67. package/src/ui/components/ProviderMenu.js +1095 -0
  68. package/src/ui/components/SavePicker.js +106 -0
  69. package/src/ui/components/SelectMenu.js +194 -0
  70. package/src/ui/components/SlashMenu.js +168 -0
  71. package/src/ui/components/SubagentPanel.js +138 -0
  72. package/src/ui/components/TextInputSafe.js +117 -0
  73. package/src/ui/components/TodoPanel.js +54 -0
  74. package/src/ui/components/ToolExecution.js +261 -0
  75. package/src/ui/components/TranscriptViewport.js +99 -0
  76. package/src/ui/diff.js +249 -0
  77. package/src/ui/h.js +7 -0
  78. package/src/ui/mouse-scroll.js +63 -0
  79. package/src/ui/slash-picker.js +58 -0
  80. package/src/ui/terminal.js +41 -0
  81. package/src/ui/theme.js +5 -0
  82. package/src/ui/welcome.js +12 -0
  83. package/src/utils/constants.js +231 -0
  84. package/src/utils/helpers.js +154 -0
  85. package/src/utils/logger.js +81 -0
  86. package/src/utils/sound.js +33 -0
@@ -0,0 +1,697 @@
1
+ // =============================================================================
2
+ // OS AI Agent — Agent Loop (v4.0 — Coding Mode + Todos + Web Tools)
3
+ // =============================================================================
4
+ // Core agent loop: GOAL -> PLAN -> EXECUTE -> OBSERVE -> REFLECT -> REPEAT
5
+ // Supports GENERAL, NETWORK, and CODING modes with full tool dispatch.
6
+ // =============================================================================
7
+
8
+ import {
9
+ executeLocal, readFile, writeFile, editFile, listDir, detectOS, getSystemInfo, resolvePath,
10
+ searchInFiles, appendFile, deleteFile, createDir, treeView, runScript,
11
+ moveFile, copyFile, getFileInfo, fetchUrl, webSearch,
12
+ todoAdd, todoComplete, todoUpdate, todoList, todoClear, todoStore,
13
+ globFiles, grepFiles, gitOp, readFileRange, askUserQuestion, diagPostEdit,
14
+ getDependencies, reloadTodos,
15
+ } from '../tools/local.js';
16
+ import { executeSSH, closeAllConnections } from '../tools/ssh.js';
17
+ import { browse as browserBrowse, searchAndBrowse as browserSearch, extractStructuredData as browserExtract } from '../tools/browser.js';
18
+ import { checkSafety } from '../safety/check.js';
19
+ import { memory } from '../memory/store.js';
20
+ import { mcpClientManager } from '../tools/mcp-client.js';
21
+ import inquirer from 'inquirer';
22
+ import chalk from 'chalk';
23
+ import Conf from 'conf';
24
+ import WebSocket from 'ws';
25
+ import path from 'path';
26
+ import fs from 'fs';
27
+ import { DEFAULTS, TOOLS, COMPLETION_SIGNALS, SAFETY_TIERS, MODES, EXECUTION_MODES, READ_ONLY_TOOLS, CODING_TOOLS, CRITICAL_ENV_FILENAMES, CRITICAL_ENV_FILENAME_PATTERNS, CRITICAL_ENV_PATH_SEGMENTS, CRITICAL_ENV_EXCLUDE, MAX_ITERATIONS, AUTO_CONTINUE_LIMIT, READ_FRESHNESS_INTERACTIONS, TOOL_SEQUENCE_WINDOW_SIZE, CONTEXT_SUMMARY_TAG, SUBAGENT_MAX_ITERATIONS } from '../utils/constants.js';
28
+ import { discoverSkills } from '../skills/loader.js';
29
+ import { logger } from '../utils/logger.js';
30
+ import { sleep, cancellableSleep } from '../utils/helpers.js';
31
+
32
+ import contextSummaryMethods from './loop/context-summary.js';
33
+ import loopDetectionMethods from './loop/loop-detection.js';
34
+ import streamParserMethods from './loop/stream-parser.js';
35
+ import verificationMethods from './loop/verification.js';
36
+ import directoryUtilsMethods from './loop/directory-utils.js';
37
+ import toolExecutorMethods from './loop/tool-executor.js';
38
+ import websocketMethods from './loop/websocket.js';
39
+ import localMethods from './loop/local.js';
40
+
41
+ export class AgentLoop {
42
+ constructor({
43
+ device, mode, server, token, executionMode,
44
+ onMarkdown, onThought, onToolStart, onToolResult, onObservation,
45
+ onError, onComplete, onTodos, onBlocked, onThinkingStart, onThinkingEnd,
46
+ onBadge, onConfirmPrompt, onAskUserPrompt, onPlanPrompt,
47
+ onStats, onConnectionChange, onUpdateLastText, readline, noConfirm = false,
48
+ isSubagent = false, maxIterations = null, local = false,
49
+ }) {
50
+ this.device = device;
51
+ this.mode = mode || MODES.GENERAL;
52
+ this.executionMode = executionMode || EXECUTION_MODES.EXEC;
53
+ this.local = local;
54
+ this.server = server;
55
+ this.token = token;
56
+ this.conversationHistory = [];
57
+ this.iteration = 0;
58
+ this.autoContinueCount = 0;
59
+ this.ws = null;
60
+ this.pendingCommands = null;
61
+ this.currentTaskId = null;
62
+ this.displayedContent = new Set();
63
+ this.readline = readline;
64
+ this.noConfirm = noConfirm;
65
+ this.isSubagent = isSubagent;
66
+ this._maxIterations = maxIterations || (isSubagent ? SUBAGENT_MAX_ITERATIONS : MAX_ITERATIONS);
67
+ this._subagentActive = false;
68
+ this._subagentState = null;
69
+ this._lastSubagentResult = null;
70
+ this.abortController = null;
71
+ this.startTime = null;
72
+ this.totalTokensUsed = 0;
73
+ this.commandsExecuted = 0;
74
+ this._cancelled = false;
75
+ this._escapeFirstPress = false;
76
+ this._isThinking = false;
77
+ this._lastCompletionSignal = null;
78
+ this._lastDisplayOutput = null;
79
+ this._lastToolResult = null;
80
+ this._lastToolSignature = '';
81
+ this._sameToolCallStreak = 0;
82
+ this._lastToolResultFingerprint = '';
83
+ this._sameToolResultStreak = 0;
84
+ this._consecutiveFailedToolStreak = 0;
85
+ this._searchStreak = 0;
86
+ this._readFileCallCount = new Map();
87
+ this._localCmdPathCount = new Map();
88
+ this._readFiles = new Set();
89
+ this._sessionReadFiles = new Set();
90
+ this._readFileState = new Map();
91
+ this._readFileOps = 0;
92
+ this._blockedWritesWithoutRead = 0;
93
+ this._toolInteractionCounter = 0;
94
+ this._verificationStateLoaded = false;
95
+ this._lastCompactInteraction = null;
96
+ this._codingRootPath = this._normalizeTrackedPath(process.cwd()) || path.normalize(process.cwd());
97
+ reloadTodos();
98
+ this._approvedExternalDirs = new Set();
99
+ this._toolSequenceWindow = [];
100
+ this._summaryInProgress = false;
101
+ this._summaryTimer = null;
102
+ this._lastSummaryAtTokenCount = 0;
103
+
104
+ this._config = new Conf({ projectName: 'osai-agent' });
105
+ this._userOS = this._config.get('os') || detectOS();
106
+
107
+ this.onMarkdown = onMarkdown || (() => {});
108
+ this.onThought = onThought || (() => {});
109
+ this.onToolStart = onToolStart || (() => {});
110
+ this.onToolResult = onToolResult || (() => {});
111
+ this.onObservation = onObservation || (() => {});
112
+ this.onError = onError || (() => {});
113
+ this.onComplete = onComplete || (() => {});
114
+ this.onTodos = onTodos || (() => {});
115
+ this.onThinkingStart = onThinkingStart || (() => {});
116
+ this.onThinkingEnd = onThinkingEnd || (() => {});
117
+ this.onBadge = onBadge || (() => {});
118
+ this.onConfirmPrompt = onConfirmPrompt || (() => {});
119
+ this.onAskUserPrompt = onAskUserPrompt || (() => {});
120
+ this.onPlanPrompt = onPlanPrompt || (() => {});
121
+ this.onStats = onStats || (() => {});
122
+ this.onConnectionChange = onConnectionChange || (() => {});
123
+ this.onUpdateLastText = onUpdateLastText || (() => {});
124
+ this.onStats(0);
125
+ }
126
+
127
+ /**
128
+ * Set up Escape key listener to cancel active subagent.
129
+ * Listens for the Esc key (code 27) on stdin and cancels the subagent if active.
130
+ */
131
+ _setupEscapeKeyListener() {
132
+ if (!this.readline || !this.readline.input) return;
133
+
134
+ const onKeyPress = (char, key) => {
135
+ if (!key) return;
136
+ if (key.name === 'escape' || (key.code && key.code === 'Escape') || char === '\u001b') {
137
+ if (this._subagentActive && this._subagentLoop) {
138
+ if (this._escapeFirstPress) {
139
+ this._subagentLoop.cancel();
140
+ this.cancel();
141
+ this._escapeFirstPress = false;
142
+ } else {
143
+ this._subagentLoop.cancel();
144
+ this._escapeFirstPress = true;
145
+ }
146
+ } else {
147
+ this.cancel();
148
+ }
149
+ }
150
+ };
151
+
152
+ // Set raw mode to capture Esc key without needing Enter
153
+ this.readline.input.setRawMode(true);
154
+ this.readline.input.on('keypress', onKeyPress);
155
+
156
+ // Store the listener to clean up later
157
+ this._escapeKeyListener = onKeyPress;
158
+ }
159
+
160
+ /**
161
+ * Clean up Escape key listener and restore stdin settings.
162
+ */
163
+ _cleanupEscapeKeyListener() {
164
+ if (!this.readline || !this.readline.input) return;
165
+ if (this._escapeKeyListener) {
166
+ this.readline.input.off('keypress', this._escapeKeyListener);
167
+ this._escapeKeyListener = null;
168
+ }
169
+ // Restore normal mode
170
+ try { this.readline.input.setRawMode(false); } catch {}
171
+ }
172
+
173
+ getStats() {
174
+ return {
175
+ iterations: this.iteration,
176
+ commandsExecuted: this.commandsExecuted,
177
+ estimatedTokens: this.totalTokensUsed,
178
+ elapsedMs: this.startTime ? Date.now() - this.startTime : 0,
179
+ conversationLength: this.conversationHistory.length,
180
+ mode: this.mode,
181
+ executionMode: this.executionMode,
182
+ todoStats: todoStore.getStats(this.mode),
183
+ verification: {
184
+ filesReadOps: this._readFileOps,
185
+ filesReadUnique: this._readFiles.size,
186
+ blockedWritesWithoutRead: this._blockedWritesWithoutRead,
187
+ interactionIndex: this._toolInteractionCounter,
188
+ freshnessWindow: READ_FRESHNESS_INTERACTIONS,
189
+ },
190
+ };
191
+ }
192
+
193
+ setConversationHistory(messages = []) {
194
+ if (!Array.isArray(messages)) return;
195
+ const normalized = messages
196
+ .filter((m) => m && typeof m.content === 'string' && typeof m.role === 'string')
197
+ .map((m) => ({ role: m.role, content: m.content }))
198
+ .filter((m) => (m.role === 'user' || m.role === 'assistant' || m.role === 'system') && m.content.trim().length > 0);
199
+ this.conversationHistory = normalized;
200
+ const first = this.conversationHistory[0];
201
+ if (first?.role === 'system' && first.content.startsWith(CONTEXT_SUMMARY_TAG)) {
202
+ this._lastSummaryAtTokenCount = 0;
203
+ }
204
+ this._trimContext();
205
+ this.totalTokensUsed = this._estimateTokens();
206
+ this.onStats(this.totalTokensUsed);
207
+ }
208
+
209
+ getConversationHistory() {
210
+ return this.conversationHistory.map((m) => ({ role: m.role, content: m.content }));
211
+ }
212
+
213
+ _shouldCancel() {
214
+ return this._cancelled || !!this.abortController?.signal?.aborted;
215
+ }
216
+
217
+ async _cancellableSleep(ms) {
218
+ return cancellableSleep(ms, () => this._shouldCancel());
219
+ }
220
+
221
+ cancel() {
222
+ this._cancelled = true;
223
+ if (this.abortController) {
224
+ try { this.abortController.abort(); } catch {}
225
+ }
226
+
227
+ if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentTaskId) {
228
+ try {
229
+ this.ws.send(JSON.stringify({
230
+ type: 'CANCEL',
231
+ taskId: this.currentTaskId,
232
+ timestamp: Date.now()
233
+ }));
234
+ logger.info('Cancellation signal sent to server');
235
+ } catch (error) {
236
+ logger.warn('Failed to send cancellation signal to server', { error: error.message });
237
+ }
238
+ }
239
+
240
+ }
241
+
242
+ async cleanup() {
243
+ if (this.ws) { try { this.ws.close(); } catch {} this.ws = null; }
244
+ if (this.mode === MODES.NETWORK) closeAllConnections();
245
+ }
246
+
247
+ // --- Main Loop ---
248
+
249
+ async run(instruction) {
250
+ if (this._cancelled) this._cancelled = false;
251
+ this._escapeFirstPress = false;
252
+
253
+ // Yield immédiat pour laisser Ink afficher "streaming" avant tout traitement
254
+ await new Promise(r => setTimeout(r, 0));
255
+
256
+ await this._loadVerificationState();
257
+
258
+ this._appendConversationMessage('user', instruction);
259
+ this.iteration = 0;
260
+ this.autoContinueCount = 0;
261
+ this.pendingCommands = null;
262
+ this.currentTaskId = null;
263
+ this.displayedContent.clear();
264
+ this._lastToolSignature = '';
265
+ this._sameToolCallStreak = 0;
266
+ this._lastToolResultFingerprint = '';
267
+ this._sameToolResultStreak = 0;
268
+ this._consecutiveFailedToolStreak = 0;
269
+ this._searchStreak = 0;
270
+ this._readFileCallCount = new Map();
271
+ this.startTime = Date.now();
272
+ this.abortController = new AbortController();
273
+
274
+ if (!this.local) {
275
+ try {
276
+ await this._connectWebSocket();
277
+ } catch (err) {
278
+ this.onConnectionChange(false);
279
+ logger.warn(`WebSocket connection failed, continuing via HTTP: ${err.message}`);
280
+ this.ws = null;
281
+ }
282
+ } else {
283
+ this.onConnectionChange(true);
284
+ }
285
+
286
+ // Set up Escape key listener to cancel active subagent
287
+ this._setupEscapeKeyListener();
288
+
289
+ try { await memory.recordAction({ instruction, mode: this.mode, os: this._userOS }); } catch {}
290
+
291
+ // Load configured MCP servers (non-blocking)
292
+ const mcpConfig = this._config.get('mcpServers', {});
293
+ if (Object.keys(mcpConfig).length > 0) {
294
+ mcpClientManager.loadFromConfig(mcpConfig).catch(err => {
295
+ logger.warn('MCP server loading error', { error: err.message });
296
+ });
297
+ }
298
+
299
+ this._trimContext();
300
+
301
+ let iterationRetrying = false;
302
+
303
+ while (this.iteration < this._maxIterations && !this._cancelled) {
304
+ this.iteration++;
305
+ logger.debug(`Iteration ${this.iteration}/${this._maxIterations}`);
306
+
307
+ if (iterationRetrying) { this.onUpdateLastText(''); iterationRetrying = false; }
308
+
309
+ try {
310
+ const shouldContinue = await this._executeIteration();
311
+ if (!shouldContinue) break;
312
+
313
+ if (this._lastCompletionSignal === COMPLETION_SIGNALS.INCOMPLETE) {
314
+ if (this.autoContinueCount < AUTO_CONTINUE_LIMIT) {
315
+ this.autoContinueCount++;
316
+ logger.debug(`Auto-continuing (${this.autoContinueCount}/${AUTO_CONTINUE_LIMIT})`);
317
+ this._appendConversationMessage('user', `Continue with the next step. But FIRST, review your progress:
318
+ - Have you searched/grepped 3+ times without editing? STOP, make an edit now.
319
+ - Have you read the same file more than once? Use content already in context.
320
+ - Are you stuck in a READ → EDIT → READ cycle? Change approach entirely.
321
+ - Output [BLOCKED] if you cannot proceed, explaining why.`);
322
+ this._trimContext();
323
+
324
+ this.totalTokensUsed = this._estimateTokens();
325
+ this.onStats(this.totalTokensUsed);
326
+ continue;
327
+ } else {
328
+ this.onMarkdown('\n_The agent needs more steps. Type "continue" or "yes" to proceed, or describe a new task._\n');
329
+ break;
330
+ }
331
+ }
332
+ if (this._lastCompletionSignal === COMPLETION_SIGNALS.DONE || this._lastCompletionSignal === COMPLETION_SIGNALS.BLOCKED) break;
333
+ this._trimContext();
334
+ } catch (error) {
335
+ if (this._shouldCancel()) break;
336
+ try { await memory.recordFailure({ error: error.message, iteration: this.iteration, mode: this.mode }); } catch {}
337
+ if (this.iteration <= 1 && !this._shouldCancel()) {
338
+ logger.info('Retrying iteration after error...');
339
+ iterationRetrying = true;
340
+ this.onMarkdown(`\n⏳ Retrying after error: ${error.message}\n`);
341
+ this.iteration--;
342
+ const slept = await this._cancellableSleep(DEFAULTS.API_RETRY_DELAY);
343
+ if (!slept || this._shouldCancel()) break;
344
+ continue;
345
+ }
346
+ this.onError(error.message);
347
+ break;
348
+ }
349
+ }
350
+
351
+ if (this.iteration >= this._maxIterations && !this._cancelled) {
352
+ this.onError(this.isSubagent
353
+ ? 'Subagent reached maximum iterations without completion'
354
+ : 'Maximum iterations reached without completion');
355
+ }
356
+
357
+ try {
358
+ await memory.recordResult({
359
+ status: this._cancelled ? 'cancelled' : (this._lastCompletionSignal || 'unknown'),
360
+ iterations: this.iteration, commandsExecuted: this.commandsExecuted, mode: this.mode,
361
+ verification: {
362
+ filesReadOps: this._readFileOps,
363
+ filesReadUnique: this._readFiles.size,
364
+ blockedWritesWithoutRead: this._blockedWritesWithoutRead,
365
+ interactionIndex: this._toolInteractionCounter,
366
+ freshnessWindow: READ_FRESHNESS_INTERACTIONS,
367
+ },
368
+ });
369
+ } catch {}
370
+
371
+ this.onTodos(todoStore.list(this.mode));
372
+ this.onComplete({
373
+ ...this.getStats(),
374
+ completionSignal: this._lastCompletionSignal,
375
+ status: this._cancelled ? 'cancelled' : 'completed',
376
+ });
377
+ }
378
+
379
+ // --- Iteration Execution ---
380
+
381
+ async _executeIteration() {
382
+ this._lastCompletionSignal = null;
383
+
384
+ if (this.pendingCommands && this.pendingCommands.length > 0) {
385
+ const commands = this.pendingCommands;
386
+ this.pendingCommands = null;
387
+ const toolCall = commands[0];
388
+ const result = await this._executeTool(toolCall);
389
+ const fullOutput = result.success ? result.output : result.error;
390
+ const aiSafeOutput = fullOutput.length > DEFAULTS.MAX_TOOL_RESULT_LENGTH
391
+ ? fullOutput.slice(0, DEFAULTS.MAX_TOOL_RESULT_LENGTH) + '\n...[output truncated]'
392
+ : fullOutput;
393
+
394
+ if (toolCall.tool === TOOLS.READ_FILE && toolCall.path) {
395
+ await this._trackReadFile(toolCall, result);
396
+ }
397
+
398
+ const pcToolName = toolCall.tool || TOOLS.LOCAL_CMD;
399
+ const pcTarget = toolCall.path || toolCall.cmd || toolCall.url || toolCall.query || '';
400
+ this._toolSequenceWindow.push({ tool: pcToolName, target: pcTarget });
401
+ if (this._toolSequenceWindow.length > TOOL_SEQUENCE_WINDOW_SIZE) {
402
+ this._toolSequenceWindow.shift();
403
+ }
404
+
405
+ if (this._detectRepeatedToolLoop(toolCall, result, aiSafeOutput) || this._detectCrossToolLoop()) {
406
+ this._lastCompletionSignal = COMPLETION_SIGNALS.BLOCKED;
407
+ const msg = this._buildLoopBlockedMessage(toolCall);
408
+ this.onBlocked(msg);
409
+ this._appendConversationMessage('assistant', msg);
410
+ return false;
411
+ }
412
+ const toolResultContent = this._buildToolResultMessage(toolCall, result, aiSafeOutput);
413
+ this._appendConversationMessage('user', toolResultContent);
414
+ return true;
415
+ }
416
+
417
+ const response = await this._callWorker();
418
+ if (!response) return false;
419
+
420
+ const { toolCalls } = await this._parseResponse(response);
421
+
422
+ // Yield pour laisser Ink render les derniers chunks de streaming avant d'exécuter les tools
423
+ await new Promise(r => setTimeout(r, 0));
424
+
425
+ if (this._isThinking) {
426
+ this._isThinking = false;
427
+ this.onThinkingEnd();
428
+ }
429
+
430
+ if (toolCalls.length === 0) {
431
+ if (this.isSubagent && !this._shouldCancel()) {
432
+ const signal = this._lastCompletionSignal;
433
+ const hasUsedTools = this.commandsExecuted > 0;
434
+
435
+ if (!hasUsedTools || signal !== COMPLETION_SIGNALS.DONE) {
436
+ this._lastCompletionSignal = COMPLETION_SIGNALS.INCOMPLETE;
437
+ const correctivePrompt = hasUsedTools
438
+ ? `Subagent continuation required: no valid tool call was detected. If your exploration is truly complete, respond with [DONE] and a concise findings summary. Otherwise, emit exactly one allowed read-only tool call now. Do not stop just because a previous path required confirmation; skip restricted files and continue with non-sensitive alternatives.`
439
+ : `Subagent continuation required: you have not used any tools yet. Emit exactly one allowed read-only tool call now to inspect the project. Do not answer from assumptions, and do not stop just because a path might require confirmation; skip restricted files and continue with non-sensitive alternatives.`;
440
+ this._appendConversationMessage('user', correctivePrompt);
441
+ return true;
442
+ }
443
+ }
444
+ if (!this._shouldCancel()) {
445
+ this._lastCompletionSignal = COMPLETION_SIGNALS.DONE;
446
+ }
447
+ return false;
448
+ }
449
+
450
+ const batchCalls = this._batchFileReads(toolCalls);
451
+
452
+ if (batchCalls.length > 1) {
453
+ const MAX_CONCURRENCY = 4;
454
+ const results = [];
455
+ for (let i = 0; i < batchCalls.length; i += MAX_CONCURRENCY) {
456
+ const chunk = batchCalls.slice(i, i + MAX_CONCURRENCY);
457
+ const chunkResults = await Promise.all(chunk.map(tc => this._executeTool(tc)));
458
+ results.push(...chunkResults);
459
+ }
460
+ for (let i = 0; i < batchCalls.length; i++) {
461
+ const tc = batchCalls[i];
462
+ const result = results[i];
463
+
464
+ this._toolSequenceWindow.push({ tool: TOOLS.READ_FILE, target: tc.path || '' });
465
+ if (this._toolSequenceWindow.length > TOOL_SEQUENCE_WINDOW_SIZE) {
466
+ this._toolSequenceWindow.shift();
467
+ }
468
+
469
+ const fullOutput = result.success ? result.output : result.error;
470
+
471
+ const normPath = this._normalizeTrackedPath(tc.path) || tc.path;
472
+ const isFirstRead = normPath && !this._sessionReadFiles.has(normPath);
473
+ const aiSafeOutput = (isFirstRead || fullOutput.length <= DEFAULTS.MAX_TOOL_RESULT_LENGTH)
474
+ ? fullOutput
475
+ : fullOutput.slice(0, DEFAULTS.MAX_TOOL_RESULT_LENGTH) + '\n...[output truncated]';
476
+
477
+ const toolResultContent = this._buildToolResultMessage(tc, result, aiSafeOutput);
478
+ const labeledContent = i === 0
479
+ ? `[PARALLEL_READ: ${batchCalls.length} files]\n\n${toolResultContent}`
480
+ : toolResultContent;
481
+
482
+ this._appendConversationMessage('user', labeledContent);
483
+ this._lastDisplayOutput = fullOutput;
484
+ this._lastToolResult = result;
485
+
486
+ if (normPath) {
487
+ await this._trackReadFile(tc, result);
488
+ }
489
+ }
490
+ this.onTodos(todoStore.list(this.mode));
491
+ return true;
492
+ }
493
+
494
+ const toolCall = toolCalls[0];
495
+ const result = await this._executeTool(toolCall);
496
+ const fullOutput = result.success ? result.output : result.error;
497
+
498
+ const normalizedSinglePath = this._normalizeTrackedPath(toolCall.path) || toolCall.path;
499
+ const isFirstSingleRead = toolCall.tool === TOOLS.READ_FILE && normalizedSinglePath && !this._sessionReadFiles.has(normalizedSinglePath);
500
+ const aiSafeOutput = (isFirstSingleRead || fullOutput.length <= DEFAULTS.MAX_TOOL_RESULT_LENGTH)
501
+ ? fullOutput
502
+ : fullOutput.slice(0, DEFAULTS.MAX_TOOL_RESULT_LENGTH) + '\n...[output truncated]';
503
+
504
+ if (toolCall.tool === TOOLS.READ_FILE && normalizedSinglePath) {
505
+ await this._trackReadFile(toolCall, result);
506
+ }
507
+
508
+ const toolName = toolCall.tool || TOOLS.LOCAL_CMD;
509
+ const target = toolCall.path || toolCall.cmd || toolCall.url || toolCall.query || '';
510
+ this._toolSequenceWindow.push({ tool: toolName, target });
511
+ if (this._toolSequenceWindow.length > TOOL_SEQUENCE_WINDOW_SIZE) {
512
+ this._toolSequenceWindow.shift();
513
+ }
514
+
515
+ if (this._detectRepeatedToolLoop(toolCall, result, aiSafeOutput) || this._detectCrossToolLoop()) {
516
+ this._lastCompletionSignal = COMPLETION_SIGNALS.BLOCKED;
517
+ const msg = this._buildLoopBlockedMessage(toolCall);
518
+ this.onBlocked(msg);
519
+ this._appendConversationMessage('assistant', msg);
520
+ return false;
521
+ }
522
+
523
+ const toolResultContent = this._buildToolResultMessage(toolCall, result, aiSafeOutput);
524
+ this._lastDisplayOutput = fullOutput;
525
+ this._lastToolResult = result;
526
+ this._appendConversationMessage('user', toolResultContent);
527
+
528
+ this.onTodos(todoStore.list(this.mode));
529
+
530
+ return true;
531
+ }
532
+
533
+ // --- LLM Communication ---
534
+
535
+ async _callWorker() {
536
+ if (this.local) return this._callWorkerLocal();
537
+
538
+ const cleanHistory = [...this.conversationHistory];
539
+ if (cleanHistory.length === 0) throw new Error('No valid conversation history');
540
+
541
+ const lastMessage = cleanHistory[cleanHistory.length - 1];
542
+ const historyWithoutLast = cleanHistory.slice(0, -1);
543
+ let memoryContext = null;
544
+ try { memoryContext = await memory.buildContextSummary(3); } catch {}
545
+
546
+ const estTokens = this._estimateTokens();
547
+ this.totalTokensUsed = estTokens;
548
+ this.onStats(estTokens);
549
+
550
+ const maxAttempts = DEFAULTS.API_RETRY_ATTEMPTS;
551
+ const isRetryableStatus = (status) => status === 408 || status === 429 || status >= 500;
552
+ let retrying = false;
553
+
554
+ let skillsCatalog = [];
555
+ try {
556
+ skillsCatalog = await discoverSkills();
557
+ } catch {}
558
+
559
+ let providerType = 'auto';
560
+ this.currentProvider = null;
561
+ this.currentModel = null;
562
+ try {
563
+ const provRes = await fetch(`${this.server}/api/provider`, {
564
+ headers: { Authorization: `Bearer ${this.token}` }
565
+ });
566
+ if (provRes.ok) {
567
+ const data = await provRes.json();
568
+ if (data.type && data.type !== 'osai') {
569
+ providerType = data.type;
570
+ this.currentProvider = data.type;
571
+ this.currentModel = data.model || null;
572
+ }
573
+ }
574
+ } catch {}
575
+
576
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
577
+ if (this._shouldCancel()) {
578
+ logger.info('Worker call cancelled before attempt');
579
+ return null;
580
+ }
581
+
582
+ try {
583
+ const response = await fetch(`${this.server}/stream`, {
584
+ method: 'POST',
585
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.token}` },
586
+ body: JSON.stringify({
587
+ provider: providerType,
588
+ message: lastMessage.content,
589
+ history: historyWithoutLast,
590
+ os: this._userOS,
591
+ meta: {
592
+ mode: this.mode,
593
+ executionMode: this.executionMode,
594
+ os: this._userOS,
595
+ cwd: process.cwd(), dateTime: new Date().toLocaleString("fr-FR", { timeZone: "UTC" }), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
596
+ iteration: this.iteration,
597
+ mcpTools: mcpClientManager.getToolDescriptions(),
598
+ skills: skillsCatalog.map((s) => ({ name: s.name, description: s.description, source: s.source })),
599
+ isSubagent: this.isSubagent,
600
+ ...(memoryContext ? { memoryContext } : {}),
601
+ },
602
+ }),
603
+ signal: this.abortController?.signal,
604
+ });
605
+
606
+ if (this._shouldCancel()) {
607
+ logger.info('Worker call cancelled after fetch');
608
+ return null;
609
+ }
610
+
611
+ if (!response.ok) {
612
+ const errorText = await response.text();
613
+ let errorMsg = errorText;
614
+ try {
615
+ const parsed = JSON.parse(errorText);
616
+ errorMsg = parsed.error || parsed.message || errorText;
617
+ } catch {}
618
+
619
+ if (!isRetryableStatus(response.status) || attempt >= maxAttempts - 1) {
620
+ this.onError(errorMsg);
621
+ return null;
622
+ }
623
+
624
+ const backoffMs = DEFAULTS.API_RETRY_DELAY * (attempt + 1);
625
+ retrying = true;
626
+ this.onMarkdown(`⏳ Request failed (${response.status}) — retrying in ${Math.ceil(backoffMs / 1000)}s...`);
627
+ const slept = await this._cancellableSleep(backoffMs);
628
+ if (!slept || this._shouldCancel()) return null;
629
+ continue;
630
+ }
631
+
632
+ const contentType = response.headers.get('content-type') || '';
633
+
634
+ if (contentType.includes('application/json')) {
635
+ const json = await response.json();
636
+ if (json.content || json.message) {
637
+ if (retrying) { this.onUpdateLastText(''); retrying = false; }
638
+ const textContent = json.content || json.message || '';
639
+ const cleanText = textContent
640
+ .replace(/^\s*\{(?:\\")?tool(?:\\")?\s*:\s*.*$/gim, '')
641
+ .replace(/<tool\b[^>]*>[\s\S]*?<\/tool\s*>|<tool\b[^>]*\/>/gim, '')
642
+ .trim();
643
+ this._appendConversationMessage('assistant', cleanText || textContent);
644
+ const extractedToolCalls = this._extractToolCalls(textContent);
645
+ this._detectCompletionSignal(textContent);
646
+ return { toolCalls: extractedToolCalls, fullResponse: textContent };
647
+ }
648
+ if (json.error) {
649
+ if (attempt >= maxAttempts - 1) {
650
+ this.onError(json.error);
651
+ return null;
652
+ }
653
+ retrying = true;
654
+ const backoffMs = DEFAULTS.API_RETRY_DELAY * (attempt + 1);
655
+ this.onMarkdown(`⏳ Worker error — retrying in ${Math.ceil(backoffMs / 1000)}s...`);
656
+ const slept = await this._cancellableSleep(backoffMs);
657
+ if (!slept || this._shouldCancel()) return null;
658
+ continue;
659
+ }
660
+ return null;
661
+ }
662
+
663
+ if (retrying) { this.onUpdateLastText(''); retrying = false; }
664
+ return response;
665
+ } catch (error) {
666
+ if (error.name === 'AbortError' || this._shouldCancel()) {
667
+ logger.info('Worker call aborted');
668
+ return null;
669
+ }
670
+ if (attempt >= maxAttempts - 1) {
671
+ this.onError(error.message);
672
+ return null;
673
+ }
674
+ retrying = true;
675
+ const backoffMs = DEFAULTS.API_RETRY_DELAY * (attempt + 1);
676
+ this.onMarkdown(`⏳ Retrying in ${Math.ceil(backoffMs / 1000)}s...`);
677
+ const slept = await this._cancellableSleep(backoffMs);
678
+ if (!slept || this._shouldCancel()) return null;
679
+ }
680
+ }
681
+
682
+ return null;
683
+ }
684
+ }
685
+
686
+ // Assemble prototype methods from extracted modules
687
+ Object.assign(
688
+ AgentLoop.prototype,
689
+ contextSummaryMethods,
690
+ loopDetectionMethods,
691
+ streamParserMethods,
692
+ verificationMethods,
693
+ directoryUtilsMethods,
694
+ toolExecutorMethods,
695
+ websocketMethods,
696
+ localMethods,
697
+ );