@semalt-ai/code 1.7.0 → 1.8.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.
package/lib/agent.js CHANGED
@@ -1,60 +1,533 @@
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(' ');
18
-
19
- const reply = await chatStream(messages, { model });
20
- if (!reply) break;
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;
368
+
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
+ }
374
+
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
+ }
399
+ }
400
+
401
+ if (lastApiErr) {
402
+ if (cb.onError) cb.onError(lastApiErr);
403
+ break;
404
+ }
21
405
 
22
- messages.push({ role: 'assistant', content: reply });
406
+ const reply = result ? result.content : '';
407
+ const usage = result ? result.usage : null;
408
+ metrics.endTurn(usage, model);
409
+
410
+ if (debug) {
411
+ const header = `\n───── raw ai response (iteration ${iteration + 1}) ─────\n`;
412
+ const footer = `\n───── end raw response ─────\n`;
413
+ process.stderr.write(header + (reply || '(empty)') + footer);
414
+ }
415
+
416
+ if (cb.onMetricsUpdate) {
417
+ cb.onMetricsUpdate({
418
+ totalTokens: metrics.totalTokens(),
419
+ contextTokens: metrics.contextTokens(),
420
+ turns: metrics.turns.length,
421
+ });
422
+ }
423
+
424
+ const limitStatus = metrics.tokenLimitStatus();
425
+ if (limitStatus !== null && limitStatus.pct >= 85) {
426
+ const warnMsg = `Context at ${limitStatus.pct}% of limit (${limitStatus.used}/${limitStatus.limit} tokens). Consider /compact.`;
427
+ if (cb.onError) {
428
+ cb.onError({ message: warnMsg, isWarning: true });
429
+ } else {
430
+ process.stdout.write(
431
+ `\n ${THEME.warn}⚠ ${warnMsg}${THEME.reset}\n`
432
+ );
433
+ }
434
+ }
435
+
436
+ if (!reply) break;
23
437
 
24
438
  const toolCalls = extractToolCalls(reply);
439
+ const cleanedReply = cleanAssistantContent(reply);
440
+
441
+ messages.push({ role: 'assistant', content: cleanedReply });
442
+ // When showThink is off and the turn has tool calls, suppress the text bubble —
443
+ // pre-tool reasoning is noise, tool result bubbles already convey what happened.
444
+ const displayReply = (!showThink && toolCalls.length > 0) ? '' : cleanedReply;
445
+ if (cb.onAssistantMessage) cb.onAssistantMessage(displayReply);
446
+
25
447
  if (toolCalls.length === 0) break;
448
+ if (isAborted()) break;
26
449
 
27
- console.log(`\n ${FG_TEAL}◆${RST} ${FG_GRAY}Found ${toolCalls.length} action(s) to execute${RST}`);
450
+ if (!cb.onToolStart) {
451
+ process.stdout.write(`\n ${FG_TEAL}◆${RST} ${FG_GRAY}Found ${toolCalls.length} action(s) to execute${RST}\n`);
452
+ }
28
453
 
29
454
  const results = [];
30
455
  let aborted = false;
31
456
 
32
457
  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.`);
458
+ if (isAborted()) { aborted = true; break; }
459
+
460
+ const tag = call[0] || 'unknown';
461
+ const arg = call[1] || '';
462
+ const toolStart = Date.now();
463
+
464
+ if (cb.onToolStart) cb.onToolStart(tag, arg);
465
+
466
+ try {
467
+ if (tag === 'shell') {
468
+ const shellResult = await agentExecShell(arg);
469
+ const ms = Date.now() - toolStart;
470
+ if (shellResult.stderr === 'Permission denied by user') {
471
+ const resultStr = `Command \`${arg}\`: Permission denied by user.`;
472
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
473
+ results.push(resultStr);
474
+ aborted = true;
475
+ break;
476
+ } else {
477
+ let out = shellResult.stdout;
478
+ if (shellResult.stderr) out += `\nSTDERR: ${shellResult.stderr}`;
479
+ const resultStr = `Command \`${arg}\`:\nExit code: ${shellResult.exit_code}\n${out}`;
480
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
481
+ results.push(resultStr);
482
+ }
483
+ continue;
484
+ }
485
+
486
+ const fileResult = await agentExecFile(...call);
487
+ const ms = Date.now() - toolStart;
488
+
489
+ if (fileResult.error === 'Permission denied') {
490
+ const resultStr = `${tag} ${call[1] || ''}: Permission denied by user.`;
491
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
492
+ results.push(resultStr);
37
493
  aborted = true;
494
+ break;
38
495
  } 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}`);
496
+ const resultStr = formatFileResult(call, fileResult);
497
+ if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
498
+ results.push(resultStr);
42
499
  }
43
- continue;
500
+ } catch (err) {
501
+ const ms = Date.now() - toolStart;
502
+ if (cb.onToolEnd) cb.onToolEnd(tag, `Error: ${err.message}`, ms);
503
+ if (cb.onError) {
504
+ cb.onError({ message: `Tool error (${tag}): ${err.message}`, isWarning: true });
505
+ } else {
506
+ process.stdout.write(`\n ${THEME.warn}⚠ Tool error (${tag}): ${err.message}${THEME.reset}\n`);
507
+ }
508
+ logToolCall(tag, { args: call.slice(1) }, false, 'error');
509
+ results.push(`${tag}: Error — ${err.message}`);
44
510
  }
511
+ }
45
512
 
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;
513
+ if (aborted) {
514
+ const warnMsg = isAborted()
515
+ ? 'Agent interrupted.'
516
+ : 'Action denied — stopping.';
517
+ if (cb.onError) {
518
+ cb.onError({ message: warnMsg, isWarning: true });
519
+ } else {
520
+ process.stdout.write(`\n ${FG_YELLOW}⚠${RST} ${FG_GRAY}${warnMsg}${RST}`);
51
521
  }
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]}`);
522
+ // Push whatever results accumulated before the denial so the LLM has
523
+ // context if the user asks to continue.
524
+ if (results.length > 0) {
525
+ messages.push({
526
+ role: 'user',
527
+ content: `Tool execution results (partial — stopped after user denied an action):\n\n${results.join('\n\n')}`,
528
+ });
57
529
  }
530
+ break;
58
531
  }
59
532
 
60
533
  const feedback = results.join('\n\n');
@@ -62,13 +535,9 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
62
535
  role: 'user',
63
536
  content: `Tool execution results:\n\n${feedback}\n\nContinue with the task. If everything is done, summarize what was accomplished.`,
64
537
  });
65
-
66
- if (aborted) {
67
- console.log(`\n ${FG_YELLOW}⚠${RST} ${FG_GRAY}Some actions were denied. Continuing with partial results.${RST}`);
68
- }
69
538
  }
70
539
 
71
- return messages;
540
+ return { messages, metrics };
72
541
  }
73
542
 
74
543
  return {