@semalt-ai/code 1.6.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/.claude/settings.local.json +8 -0
- package/ARCHITECTURE.md +99 -0
- package/CLAUDE.md +349 -0
- package/README.md +16 -2
- package/index.js +79 -7
- package/lib/agent.js +508 -39
- package/lib/api.js +347 -77
- package/lib/args.js +34 -0
- package/lib/audit.js +31 -0
- package/lib/commands.js +1018 -183
- package/lib/config.js +68 -5
- package/lib/constants.js +58 -0
- package/lib/context.js +2 -6
- package/lib/metrics.js +94 -0
- package/lib/permissions.js +180 -49
- package/lib/prompts.js +89 -13
- package/lib/storage.js +96 -0
- package/lib/tools.js +896 -35
- package/lib/ui/ansi.js +64 -0
- package/lib/ui/chat-history.js +217 -0
- package/lib/ui/create-ui.js +474 -0
- package/lib/ui/diff.js +243 -0
- package/lib/ui/input-field.js +1176 -0
- package/lib/ui/layout.js +53 -0
- package/lib/ui/legacy.js +130 -0
- package/lib/ui/status-bar.js +130 -0
- package/lib/ui/stream.js +158 -0
- package/lib/ui/utils.js +45 -0
- package/lib/ui.js +42 -598
- package/package.json +1 -1
- package/path +1 -0
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
|
-
|
|
7
|
-
const
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
if (
|
|
41
|
-
results.push(
|
|
496
|
+
const resultStr = formatFileResult(call, fileResult);
|
|
497
|
+
if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
|
|
498
|
+
results.push(resultStr);
|
|
42
499
|
}
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 {
|