@semalt-ai/code 1.7.0 → 1.8.1

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/lib/agent.js CHANGED
@@ -1,60 +1,602 @@
1
1
  'use strict';
2
2
 
3
+ const { logToolCall } = require('./audit');
4
+ const { Metrics } = require('./metrics');
5
+ const { SYSTEM_PROMPT } = require('./prompts');
6
+ const { TAG_REGISTRY } = require('./constants');
7
+
8
+ class StreamParser {
9
+ constructor(onToken, onTagOpen, onTagContent, onTagClose) {
10
+ this.onToken = onToken;
11
+ this.onTagOpen = onTagOpen;
12
+ this.onTagContent = onTagContent;
13
+ this.onTagClose = onTagClose;
14
+ this.buffer = '';
15
+ this.insideTag = null;
16
+ this.tagAttrs = {};
17
+ this.tagContent = '';
18
+ }
19
+
20
+ push(chunk) {
21
+ this.buffer += chunk;
22
+ this._process();
23
+ }
24
+
25
+ _process() {
26
+ while (true) {
27
+ if (this.insideTag === null) {
28
+ const ltIdx = this.buffer.indexOf('<');
29
+ if (ltIdx === -1) {
30
+ if (this.buffer) this.onToken(this.buffer);
31
+ this.buffer = '';
32
+ break;
33
+ }
34
+ if (ltIdx > 0) {
35
+ this.onToken(this.buffer.slice(0, ltIdx));
36
+ this.buffer = this.buffer.slice(ltIdx);
37
+ }
38
+ const gtIdx = this.buffer.indexOf('>');
39
+ if (gtIdx === -1) break;
40
+ const tagRaw = this.buffer.slice(1, gtIdx).trim();
41
+ const selfClose = tagRaw.endsWith('/');
42
+ const tagBody = selfClose ? tagRaw.slice(0, -1).trim() : tagRaw;
43
+ const spaceIdx = tagBody.search(/\s/);
44
+ const tagName = (spaceIdx === -1 ? tagBody : tagBody.slice(0, spaceIdx)).toLowerCase();
45
+ const attrStr = spaceIdx === -1 ? '' : tagBody.slice(spaceIdx + 1);
46
+
47
+ const attrs = {};
48
+ const attrRe = /(\w+)="([^"]*)"/g;
49
+ let m;
50
+ while ((m = attrRe.exec(attrStr)) !== null) attrs[m[1]] = m[2];
51
+
52
+ this.buffer = this.buffer.slice(gtIdx + 1);
53
+
54
+ const entry = TAG_REGISTRY[tagName];
55
+ if (!entry) {
56
+ this.onToken('<' + tagRaw + '>');
57
+ continue;
58
+ }
59
+
60
+ this.onTagOpen(tagName, attrs);
61
+
62
+ if (selfClose) {
63
+ this.onTagContent(tagName, '');
64
+ this.onTagClose(tagName, '', attrs);
65
+ } else {
66
+ this.insideTag = tagName;
67
+ this.tagAttrs = attrs;
68
+ this.tagContent = '';
69
+ }
70
+ } else {
71
+ const closing = '</' + this.insideTag + '>';
72
+ const closeIdx = this.buffer.toLowerCase().indexOf(closing);
73
+ if (closeIdx === -1) {
74
+ this.tagContent += this.buffer;
75
+ this.buffer = '';
76
+ break;
77
+ }
78
+ this.tagContent += this.buffer.slice(0, closeIdx);
79
+ this.buffer = this.buffer.slice(closeIdx + closing.length);
80
+ this.onTagContent(this.insideTag, this.tagContent);
81
+ this.onTagClose(this.insideTag, this.tagContent, this.tagAttrs);
82
+ this.insideTag = null;
83
+ this.tagContent = '';
84
+ this.tagAttrs = {};
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ function cleanAssistantContent(raw) {
91
+ let text = raw;
92
+
93
+ // Qwen3-style: response starts with implicit thinking (no opening tag), closed by </tag>.
94
+ // Strip everything from the start up to and including the first orphan closing think tag.
95
+ for (const [tag, entry] of Object.entries(TAG_REGISTRY)) {
96
+ if (entry.type === 'visual') {
97
+ text = text.replace(new RegExp(`^[\\s\\S]*?<\\/${tag}>\\s*`, 'i'), '');
98
+ }
99
+ }
100
+
101
+ for (const [tag, entry] of Object.entries(TAG_REGISTRY)) {
102
+ if (entry.type === 'strip') {
103
+ // Strip only the wrapper tags; keep the inner content
104
+ text = text.replace(new RegExp(`<${tag}[^>]*>`, 'gi'), '');
105
+ text = text.replace(new RegExp(`<\\/${tag}>`, 'gi'), '');
106
+ } else {
107
+ // Strip entire tag block including content (visual / tool)
108
+ text = text.replace(new RegExp(`<${tag}[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi'), '');
109
+ text = text.replace(new RegExp(`<${tag}[^>]*/>`, 'gi'), '');
110
+ // Strip unclosed opening tag and everything after it (truncated streaming)
111
+ text = text.replace(new RegExp(`<${tag}[^>]*>[\\s\\S]*$`, 'gi'), '');
112
+ }
113
+ }
114
+
115
+ text = text.replace(/<\/?[a-zA-Z_][a-zA-Z0-9_]*(\s[^>]*)?>/g, '');
116
+ text = text.replace(/\n{2,}/g, '\n');
117
+
118
+ return text.trim();
119
+ }
120
+
3
121
  function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agentExecFile, ui }) {
4
- const { BOLD, FG_DARK, FG_GRAY, FG_TEAL, FG_YELLOW, RST, getCols } = ui;
122
+ const { BOLD, FG_DARK, FG_GRAY, FG_TEAL, FG_YELLOW, RST, THEME, getCols } = ui;
5
123
 
6
- async function runAgentLoop(messages, model, maxIterations = 10) {
7
- const cols = getCols();
124
+ function formatFileResult(call, result) {
125
+ const [action, ...args] = call;
126
+ if (result.error) return `${action} ${args[0] || ''}: Error — ${result.error}`;
127
+ switch (action) {
128
+ case 'read':
129
+ return `File ${args[0]}:\n${result.content}`;
130
+ case 'write':
131
+ return `Wrote ${result.bytes} bytes to ${args[0]}`;
132
+ case 'append':
133
+ return `Appended ${result.bytes} bytes to ${args[0]}`;
134
+ case 'list_dir':
135
+ return `Directory ${args[0]}:\n${result.items.join('\n')}`;
136
+ case 'search_files':
137
+ return result.files.length
138
+ ? `Files matching "${args[0]}" in ${args[1] || '.'}:\n${result.files.join('\n')}`
139
+ : `No files found matching "${args[0]}" in ${args[1] || '.'}`;
140
+ case 'file_stat':
141
+ return `Stat ${result.path}: size=${result.size_kb} KB, mtime=${result.mtime}, type=${result.type}, mode=${result.mode}`;
142
+ case 'http_get': {
143
+ if (result.chunked) {
144
+ return `HTTP GET ${args[0]} (${result.status_code}) [Part 1/${result.total_parts}]:\n${result.body}\n\n[Response is large and was split into ${result.total_parts} parts. Use <http_get_next key="${args[0]}"/> to retrieve the next part.]`;
145
+ }
146
+ return `HTTP GET ${args[0]} (${result.status_code}):\n${result.body}`;
147
+ }
148
+ case 'http_get_next': {
149
+ if (result.done && !result.body) {
150
+ return `http_get_next "${args[0]}": No more content available.`;
151
+ }
152
+ const more = result.done
153
+ ? ' [Final part]'
154
+ : `\n\n[Use <http_get_next key="${args[0]}"/> to retrieve part ${result.part + 1}/${result.total_parts}.]`;
155
+ return `HTTP content "${args[0]}" [Part ${result.part}/${result.total_parts}]:\n${result.body}${more}`;
156
+ }
157
+ case 'ask_user':
158
+ return `User answered "${result.question}": ${result.answer}`;
159
+ case 'store_memory':
160
+ return `Stored memory key "${result.key}"`;
161
+ case 'recall_memory':
162
+ return result.found
163
+ ? `Memory "${result.key}": ${result.value}`
164
+ : `Memory "${result.key}": not found`;
165
+ case 'list_memories':
166
+ return result.keys.length
167
+ ? `Memory keys:\n${result.keys.join('\n')}`
168
+ : 'No memories stored';
169
+ case 'system_info':
170
+ return `System: ${result.platform}/${result.arch}, host=${result.hostname}, user=${result.user}, mem=${result.free_mem_mb}/${result.total_mem_mb} MB free, node=${result.node_version}, cwd=${result.cwd}`;
171
+ case 'delete_file':
172
+ return `Deleted ${args[0]}`;
173
+ case 'make_dir':
174
+ return `Created directory ${args[0]}`;
175
+ case 'remove_dir':
176
+ return `Removed directory ${args[0]}`;
177
+ case 'get_env':
178
+ return `${args[0]}=${result.value !== null ? result.value : '(not set)'}`;
179
+ case 'set_env':
180
+ return `Set env ${args[0]}=${args[1]}`;
181
+ case 'move_file':
182
+ return `Moved ${args[0]} → ${args[1]}`;
183
+ case 'copy_file':
184
+ return `Copied ${args[0]} → ${args[1]}`;
185
+ case 'edit_file':
186
+ return `Edited line ${args[1]} in ${args[0]}`;
187
+ case 'search_in_file': {
188
+ const matchLines = result.matches.map((m) => ` Line ${m.line}: ${m.content}`).join('\n');
189
+ return `Search in ${args[0]} for "${args[1]}":\n${matchLines || ' (no matches)'}`;
190
+ }
191
+ case 'replace_in_file':
192
+ return `Replaced ${result.count} occurrence(s) in ${args[0]}`;
193
+ case 'download':
194
+ return `Downloaded to ${result.path}`;
195
+ case 'upload':
196
+ return `Uploaded ${result.bytes} bytes to ${args[0]}`;
197
+ default:
198
+ return `${action}: done`;
199
+ }
200
+ }
201
+
202
+ async function executeTool(tag, content, attrs) {
203
+ switch (tag) {
204
+ case 'exec': {
205
+ const r = await agentExecShell(content);
206
+ if (r.stderr === 'Permission denied by user') {
207
+ return `Command \`${content}\`: Permission denied by user.`;
208
+ }
209
+ let out = r.stdout;
210
+ if (r.stderr) out += `\nSTDERR: ${r.stderr}`;
211
+ return `Command \`${content}\`:\nExit code: ${r.exit_code}\n${out}`;
212
+ }
213
+ case 'read_file': {
214
+ const p = attrs.path || content;
215
+ return formatFileResult(['read', p], await agentExecFile('read', p));
216
+ }
217
+ case 'write_file':
218
+ case 'create_file': {
219
+ const p = attrs.path;
220
+ if (!p) return `Error: ${tag} requires a path attribute`;
221
+ return formatFileResult(['write', p], await agentExecFile('write', p, content));
222
+ }
223
+ case 'append_file': {
224
+ const p = attrs.path;
225
+ if (!p) return 'Error: append_file requires a path attribute';
226
+ return formatFileResult(['append', p], await agentExecFile('append', p, content));
227
+ }
228
+ case 'delete_file': {
229
+ const p = attrs.path || content;
230
+ return formatFileResult(['delete_file', p], await agentExecFile('delete_file', p));
231
+ }
232
+ case 'list_dir': {
233
+ const p = attrs.path || content;
234
+ return formatFileResult(['list_dir', p], await agentExecFile('list_dir', p));
235
+ }
236
+ case 'make_dir': {
237
+ const p = attrs.path || content;
238
+ return formatFileResult(['make_dir', p], await agentExecFile('make_dir', p));
239
+ }
240
+ case 'move_file': {
241
+ return formatFileResult(['move_file', attrs.src, attrs.dst], await agentExecFile('move_file', attrs.src, attrs.dst));
242
+ }
243
+ case 'copy_file': {
244
+ return formatFileResult(['copy_file', attrs.src, attrs.dst], await agentExecFile('copy_file', attrs.src, attrs.dst));
245
+ }
246
+ case 'file_stat': {
247
+ const p = attrs.path || content;
248
+ return formatFileResult(['file_stat', p], await agentExecFile('file_stat', p));
249
+ }
250
+ case 'search_files': {
251
+ const pat = attrs.pattern || content;
252
+ const dir = attrs.dir || '.';
253
+ return formatFileResult(['search_files', pat, dir], await agentExecFile('search_files', pat, dir));
254
+ }
255
+ case 'http_get': {
256
+ const url = attrs.url || content;
257
+ const raw = attrs.raw || '';
258
+ return formatFileResult(['http_get', url, raw], await agentExecFile('http_get', url, raw));
259
+ }
260
+ case 'http_get_next': {
261
+ const key = attrs.key || content;
262
+ return formatFileResult(['http_get_next', key], await agentExecFile('http_get_next', key));
263
+ }
264
+ case 'ask_user': {
265
+ const q = attrs.question || content;
266
+ return formatFileResult(['ask_user', q], await agentExecFile('ask_user', q));
267
+ }
268
+ case 'store_memory': {
269
+ const k = attrs.key;
270
+ if (!k) return 'Error: store_memory requires a key attribute';
271
+ return formatFileResult(['store_memory', k], await agentExecFile('store_memory', k, content));
272
+ }
273
+ case 'recall_memory': {
274
+ const k = attrs.key || content;
275
+ return formatFileResult(['recall_memory', k], await agentExecFile('recall_memory', k));
276
+ }
277
+ case 'list_memories': {
278
+ return formatFileResult(['list_memories'], await agentExecFile('list_memories'));
279
+ }
280
+ case 'system_info': {
281
+ return formatFileResult(['system_info'], await agentExecFile('system_info'));
282
+ }
283
+ default:
284
+ return `Error: tool "${tag}" not implemented`;
285
+ }
286
+ }
287
+
288
+ async function handleTag(tag, content, attrs, callbacks, showThink) {
289
+ const entry = TAG_REGISTRY[tag];
290
+ if (!entry) return;
291
+
292
+ if (entry.type === 'visual' && entry.display === 'think_bubble') {
293
+ if (!showThink) return;
294
+ callbacks.onThinkEnd?.(content.trim());
295
+ return;
296
+ }
297
+
298
+ if (entry.type === 'strip') return;
299
+
300
+ // Tool execution happens in the toolCalls loop after streaming; handleTag only handles visual/strip.
301
+ }
302
+
303
+ async function runAgentLoop(messages, model, maxIterations = Infinity, tokenLimit = null, opts = {}) {
304
+ const {
305
+ showThink = false,
306
+ debug = false,
307
+ callbacks = {},
308
+ systemPrompt: overrideSystemPrompt = null,
309
+ systemPromptMode: overrideMode = null,
310
+ getAbortFlag = null,
311
+ } = opts;
312
+ const isAborted = getAbortFlag || (() => false);
313
+ const cb = callbacks;
314
+ const metrics = new Metrics(tokenLimit);
315
+ const activeSystemPrompt = overrideSystemPrompt !== null ? overrideSystemPrompt : SYSTEM_PROMPT;
316
+ const mode = overrideMode || 'system_role';
8
317
 
9
318
  for (let iteration = 0; iteration < maxIterations; iteration++) {
10
- console.log();
11
- console.log(` ${FG_DARK}${'─'.repeat(Math.min(cols, 70) - 4)}${RST}`);
12
- process.stdout.write(` ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`);
13
- if (iteration > 0) process.stdout.write(` ${FG_DARK}(step ${iteration + 1})${RST}`);
14
- console.log();
15
- console.log(` ${FG_DARK}${'─'.repeat(Math.min(cols, 70) - 4)}${RST}`);
16
- console.log();
17
- process.stdout.write(' ');
319
+ if (isAborted()) break;
320
+ const linePrefix = `${FG_TEAL}${BOLD}◆ ${RST}`;
321
+
322
+ metrics.startTurn();
323
+
324
+ if (cb.onThinking) cb.onThinking();
325
+
326
+ // Build messagesWithSystem fresh on every API call; messages[] never stores the system entry
327
+ let messagesWithSystem;
328
+ if (mode === 'first_user') {
329
+ messagesWithSystem = [
330
+ { role: 'user', content: activeSystemPrompt },
331
+ { role: 'assistant', content: 'Understood.' },
332
+ ...messages,
333
+ ];
334
+ } else if (mode === 'prepend') {
335
+ const firstUserIdx = messages.findIndex((m) => m.role === 'user');
336
+ if (firstUserIdx === -1) {
337
+ messagesWithSystem = messages;
338
+ } else {
339
+ messagesWithSystem = messages.map((m, i) =>
340
+ i === firstUserIdx
341
+ ? { ...m, content: `${activeSystemPrompt}\n\n---\n\n${m.content}` }
342
+ : m
343
+ );
344
+ }
345
+ } else {
346
+ // 'system_role' (default)
347
+ messagesWithSystem = [{ role: 'system', content: activeSystemPrompt }, ...messages];
348
+ }
349
+
350
+ // Wire onToken callback: first token triggers onStreamStart
351
+ const parser = new StreamParser(
352
+ (text) => callbacks.onToken?.(text),
353
+ (tag, attrs) => callbacks.onTagOpen?.(tag, attrs),
354
+ (tag, content) => {},
355
+ (tag, content, attrs) => handleTag(tag, content, attrs, callbacks, showThink)
356
+ );
357
+
358
+ let streamStarted = false;
359
+ const wrappedOnToken = cb.onToken
360
+ ? (token) => {
361
+ if (!streamStarted) {
362
+ streamStarted = true;
363
+ if (cb.onStreamStart) cb.onStreamStart();
364
+ }
365
+ parser.push(token);
366
+ }
367
+ : null;
18
368
 
19
- const reply = await chatStream(messages, { model });
20
- if (!reply) break;
369
+ if (debug) {
370
+ const header = `\n───── messages sent to agent (iteration ${iteration + 1}) ─────\n`;
371
+ const footer = `\n───── end messages ─────\n`;
372
+ process.stderr.write(header + JSON.stringify(messagesWithSystem, null, 2) + footer);
373
+ }
21
374
 
22
- messages.push({ role: 'assistant', content: reply });
375
+ const MAX_RETRIES = 3;
376
+ let result = null;
377
+ let lastApiErr = null;
378
+
379
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
380
+ if (attempt === 1) {
381
+ callbacks.onRequestSent?.();
382
+ } else {
383
+ cb.onRetry?.(attempt, MAX_RETRIES);
384
+ await new Promise((r) => setTimeout(r, 1000));
385
+ }
386
+ try {
387
+ result = await chatStream(messagesWithSystem, {
388
+ model,
389
+ linePrefix: wrappedOnToken ? '' : linePrefix,
390
+ showThink,
391
+ onToken: wrappedOnToken,
392
+ silent: !!wrappedOnToken,
393
+ });
394
+ lastApiErr = null;
395
+ break;
396
+ } catch (err) {
397
+ lastApiErr = err;
398
+ if (debug) {
399
+ const header = `\n───── raw http error (iteration ${iteration + 1}, attempt ${attempt}/${MAX_RETRIES}) ─────\n`;
400
+ const footer = `\n───── end raw http error ─────\n`;
401
+ const status = err.statusCode ? `HTTP ${err.statusCode}` : 'network error';
402
+ const headerLines = err.responseHeaders
403
+ ? Object.entries(err.responseHeaders).map(([k, v]) => `${k}: ${v}`).join('\n')
404
+ : '';
405
+ const body = err.rawBody !== undefined ? err.rawBody : (err.stack || err.message || String(err));
406
+ const parts = [status];
407
+ if (headerLines) parts.push(headerLines);
408
+ parts.push(body || '(empty body)');
409
+ process.stderr.write(header + parts.join('\n\n') + footer);
410
+ }
411
+ }
412
+ }
413
+
414
+ if (lastApiErr) {
415
+ if (cb.onError) cb.onError(lastApiErr);
416
+ break;
417
+ }
418
+
419
+ const reply = result ? result.content : '';
420
+ const usage = result ? result.usage : null;
421
+ metrics.endTurn(usage, model);
422
+
423
+ if (debug) {
424
+ const header = `\n───── raw ai response (iteration ${iteration + 1}) ─────\n`;
425
+ const footer = `\n───── end raw response ─────\n`;
426
+ process.stderr.write(header + (reply || '(empty)') + footer);
427
+ }
428
+
429
+ if (cb.onMetricsUpdate) {
430
+ cb.onMetricsUpdate({
431
+ totalTokens: metrics.totalTokens(),
432
+ contextTokens: metrics.contextTokens(),
433
+ turns: metrics.turns.length,
434
+ });
435
+ }
436
+
437
+ const limitStatus = metrics.tokenLimitStatus();
438
+ if (limitStatus !== null && limitStatus.pct >= 85) {
439
+ const warnMsg = `Context at ${limitStatus.pct}% of limit (${limitStatus.used}/${limitStatus.limit} tokens). Consider /compact.`;
440
+ if (cb.onError) {
441
+ cb.onError({ message: warnMsg, isWarning: true });
442
+ } else {
443
+ process.stdout.write(
444
+ `\n ${THEME.warn}⚠ ${warnMsg}${THEME.reset}\n`
445
+ );
446
+ }
447
+ }
448
+
449
+ if (!reply) {
450
+ // Empty reply from the model — stream resolved with no content and no
451
+ // tool_calls. Most common causes: server-side disconnect mid-stream,
452
+ // context-window overflow that slipped past the 400/413 handler, or a
453
+ // model that returns only a stop token. Surface it so the user isn't
454
+ // left staring at an idle prompt.
455
+ if (cb.onError) {
456
+ const hint = iteration > 0 ? ' (after tool execution)' : '';
457
+ cb.onError({ message: `Agent returned an empty response${hint}. The connection to the model may have dropped — try again or /compact if context is large.`, isWarning: true });
458
+ }
459
+ break;
460
+ }
23
461
 
24
462
  const toolCalls = extractToolCalls(reply);
25
- if (toolCalls.length === 0) break;
463
+ const cleanedReply = cleanAssistantContent(reply);
464
+
465
+ // Detect mid-tag truncation: an opening tool tag in the raw reply with
466
+ // no matching close. This happens when the model streams a large
467
+ // `<write_file>…` body and hits max_tokens or a server-side cutoff
468
+ // before the closing tag arrives. cleanAssistantContent strips the
469
+ // unclosed tag + its trailing content, so cleanedReply looks
470
+ // legitimate (just the planning preamble) and extractToolCalls finds
471
+ // zero calls — the loop would break silently and the user sees the
472
+ // planning text followed by nothing. Surface it so the user can retry,
473
+ // shorten the request, or bump max_tokens.
474
+ let truncatedTag = null;
475
+ for (const [tag, entry] of Object.entries(TAG_REGISTRY)) {
476
+ if (entry.type !== 'tool') continue;
477
+ let opens = 0;
478
+ for (const m of reply.matchAll(new RegExp(`<${tag}([^>]*)>`, 'gi'))) {
479
+ // Skip self-closing (`<tag .../>`) — they don't need a matching close.
480
+ if (!m[1].trimEnd().endsWith('/')) opens++;
481
+ }
482
+ if (opens === 0) continue;
483
+ const closes = (reply.match(new RegExp(`<\\/${tag}>`, 'gi')) || []).length;
484
+ if (opens > closes) { truncatedTag = tag; break; }
485
+ }
486
+ if (truncatedTag && cb.onError) {
487
+ cb.onError({ message: `Response truncated mid-<${truncatedTag}> tag — likely hit max_tokens or a server-side cutoff. Try again, shorten the request, or raise the model's max_tokens.`, isWarning: true });
488
+ }
489
+
490
+ messages.push({ role: 'assistant', content: cleanedReply });
491
+ // When showThink is off and the turn has tool calls, suppress the text bubble —
492
+ // pre-tool reasoning is noise, tool result bubbles already convey what happened.
493
+ const displayReply = (!showThink && toolCalls.length > 0) ? '' : cleanedReply;
494
+ if (cb.onAssistantMessage) cb.onAssistantMessage(displayReply);
26
495
 
27
- console.log(`\n ${FG_TEAL}◆${RST} ${FG_GRAY}Found ${toolCalls.length} action(s) to execute${RST}`);
496
+ // If nothing meaningful came back (no text to show, no tools to run) but
497
+ // the reply string wasn't strictly empty, it's usually model wrapper
498
+ // noise or a stripped-only response. Still a dead-end for the user.
499
+ if (toolCalls.length === 0 && !cleanedReply.trim()) {
500
+ if (cb.onError) {
501
+ cb.onError({ message: 'Agent reply had no visible content and no actions — stopping.', isWarning: true });
502
+ }
503
+ break;
504
+ }
505
+
506
+ if (toolCalls.length === 0) {
507
+ // Model narrated next steps but didn't emit a tool tag. Happens when the
508
+ // model ends a plan with "Let me do that for you." and stops. If we just
509
+ // break, the user sees a dangling promise and thinks the connection dropped.
510
+ if (iteration > 0 && /\b(let me|i['’]?ll|i will|i'?m going to|next[, ]|now[, ]? ?(i|we)|going to (create|write|build|add|make|run|do|set up|install))\b/i.test(cleanedReply)) {
511
+ if (cb.onError) {
512
+ cb.onError({ message: 'Agent described next steps but did not emit a tool call. Reply "continue" (or similar) to push it forward, or restart if it keeps stalling.', isWarning: true });
513
+ }
514
+ }
515
+ break;
516
+ }
517
+ if (isAborted()) break;
518
+
519
+ if (!cb.onToolStart) {
520
+ process.stdout.write(`\n ${FG_TEAL}◆${RST} ${FG_GRAY}Found ${toolCalls.length} action(s) to execute${RST}\n`);
521
+ }
28
522
 
29
523
  const results = [];
30
524
  let aborted = false;
31
525
 
32
526
  for (const call of toolCalls) {
33
- if (call[0] === 'shell') {
34
- const result = await agentExecShell(call[1]);
35
- if (result.stderr === 'Permission denied by user') {
36
- results.push(`Command \`${call[1]}\`: Permission denied by user.`);
527
+ if (isAborted()) { aborted = true; break; }
528
+
529
+ const tag = call[0] || 'unknown';
530
+ const arg = call[1] || '';
531
+ const toolStart = Date.now();
532
+
533
+ if (cb.onToolStart) cb.onToolStart(tag, arg);
534
+
535
+ try {
536
+ if (tag === 'shell') {
537
+ const shellResult = await agentExecShell(arg);
538
+ const ms = Date.now() - toolStart;
539
+ if (shellResult.stderr === 'Permission denied by user') {
540
+ const resultStr = `Command \`${arg}\`: Permission denied by user.`;
541
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
542
+ results.push(resultStr);
543
+ aborted = true;
544
+ break;
545
+ } else {
546
+ let out = shellResult.stdout;
547
+ if (shellResult.stderr) out += `\nSTDERR: ${shellResult.stderr}`;
548
+ const resultStr = `Command \`${arg}\`:\nExit code: ${shellResult.exit_code}\n${out}`;
549
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
550
+ results.push(resultStr);
551
+ }
552
+ continue;
553
+ }
554
+
555
+ const fileResult = await agentExecFile(...call);
556
+ const ms = Date.now() - toolStart;
557
+
558
+ if (fileResult.error === 'Permission denied') {
559
+ const resultStr = `${tag} ${call[1] || ''}: Permission denied by user.`;
560
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
561
+ results.push(resultStr);
37
562
  aborted = true;
563
+ break;
38
564
  } else {
39
- let out = result.stdout;
40
- if (result.stderr) out += `\nSTDERR: ${result.stderr}`;
41
- results.push(`Command \`${call[1]}\`:\nExit code: ${result.exit_code}\n${out}`);
565
+ const resultStr = formatFileResult(call, fileResult);
566
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
567
+ results.push(resultStr);
42
568
  }
43
- continue;
569
+ } catch (err) {
570
+ const ms = Date.now() - toolStart;
571
+ if (cb.onToolEnd) cb.onToolEnd(tag, `Error: ${err.message}`, ms);
572
+ if (cb.onError) {
573
+ cb.onError({ message: `Tool error (${tag}): ${err.message}`, isWarning: true });
574
+ } else {
575
+ process.stdout.write(`\n ${THEME.warn}⚠ Tool error (${tag}): ${err.message}${THEME.reset}\n`);
576
+ }
577
+ logToolCall(tag, { args: call.slice(1) }, false, 'error');
578
+ results.push(`${tag}: Error — ${err.message}`);
44
579
  }
580
+ }
45
581
 
46
- if (call[0] === 'read') {
47
- const result = await agentExecFile('read', call[1]);
48
- if (result.error) results.push(`Read ${call[1]}: Error — ${result.error}`);
49
- else results.push(`File ${call[1]}:\n${result.content}`);
50
- continue;
582
+ if (aborted) {
583
+ const warnMsg = isAborted()
584
+ ? 'Agent interrupted.'
585
+ : 'Action denied — stopping.';
586
+ if (cb.onError) {
587
+ cb.onError({ message: warnMsg, isWarning: true });
588
+ } else {
589
+ process.stdout.write(`\n ${FG_YELLOW}⚠${RST} ${FG_GRAY}${warnMsg}${RST}`);
51
590
  }
52
-
53
- if (call[0] === 'write') {
54
- const result = await agentExecFile('write', call[1], call[2]);
55
- if (result.error) results.push(`Write ${call[1]}: Error — ${result.error}`);
56
- else results.push(`Wrote ${result.bytes} bytes to ${call[1]}`);
591
+ // Push whatever results accumulated before the denial so the LLM has
592
+ // context if the user asks to continue.
593
+ if (results.length > 0) {
594
+ messages.push({
595
+ role: 'user',
596
+ content: `Tool execution results (partial — stopped after user denied an action):\n\n${results.join('\n\n')}`,
597
+ });
57
598
  }
599
+ break;
58
600
  }
59
601
 
60
602
  const feedback = results.join('\n\n');
@@ -62,13 +604,9 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
62
604
  role: 'user',
63
605
  content: `Tool execution results:\n\n${feedback}\n\nContinue with the task. If everything is done, summarize what was accomplished.`,
64
606
  });
65
-
66
- if (aborted) {
67
- console.log(`\n ${FG_YELLOW}⚠${RST} ${FG_GRAY}Some actions were denied. Continuing with partial results.${RST}`);
68
- }
69
607
  }
70
608
 
71
- return messages;
609
+ return { messages, metrics };
72
610
  }
73
611
 
74
612
  return {