@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/.claude/settings.local.json +8 -0
- package/ARCHITECTURE.md +99 -0
- package/CLAUDE.md +349 -0
- package/index.js +69 -7
- package/lib/agent.js +577 -39
- package/lib/api.js +285 -79
- package/lib/args.js +31 -0
- package/lib/audit.js +31 -0
- package/lib/commands.js +1006 -307
- package/lib/config.js +51 -5
- package/lib/constants.js +72 -0
- package/lib/context.js +2 -6
- package/lib/metrics.js +94 -0
- package/lib/permissions.js +180 -49
- package/lib/prompts.js +96 -13
- package/lib/storage.js +96 -0
- package/lib/tools.js +1009 -35
- package/lib/ui/ansi.js +65 -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 +131 -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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
if (
|
|
41
|
-
results.push(
|
|
565
|
+
const resultStr = formatFileResult(call, fileResult);
|
|
566
|
+
if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
|
|
567
|
+
results.push(resultStr);
|
|
42
568
|
}
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 {
|