otherwise-cli 0.1.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/README.md +193 -0
- package/bin/otherwise.js +5 -0
- package/frontend/404.html +84 -0
- package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
- package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
- package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
- package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
- package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
- package/frontend/assets/index-BLux5ps4.js +21 -0
- package/frontend/assets/index-Blh8_TEM.js +5272 -0
- package/frontend/assets/index-BpQ1PuKu.js +18 -0
- package/frontend/assets/index-Df737c8w.css +1 -0
- package/frontend/assets/index-xaYHL6wb.js +113 -0
- package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
- package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
- package/frontend/assets/transformers-tULNc5V3.js +31 -0
- package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
- package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
- package/frontend/assets/worker-2d5ABSLU.js +31 -0
- package/frontend/banner.png +0 -0
- package/frontend/favicon.svg +3 -0
- package/frontend/google55e5ec47ee14a5f8.html +1 -0
- package/frontend/index.html +234 -0
- package/frontend/manifest.json +17 -0
- package/frontend/pdf.worker.min.mjs +21 -0
- package/frontend/robots.txt +5 -0
- package/frontend/sitemap.xml +27 -0
- package/package.json +81 -0
- package/src/agent/index.js +1066 -0
- package/src/agent/location.js +51 -0
- package/src/agent/prompt.js +548 -0
- package/src/agent/tools.js +4372 -0
- package/src/browser/detect.js +68 -0
- package/src/browser/session.js +1109 -0
- package/src/config.js +137 -0
- package/src/email/client.js +503 -0
- package/src/index.js +557 -0
- package/src/inference/anthropic.js +113 -0
- package/src/inference/google.js +373 -0
- package/src/inference/index.js +81 -0
- package/src/inference/ollama.js +383 -0
- package/src/inference/openai.js +140 -0
- package/src/inference/openrouter.js +378 -0
- package/src/inference/xai.js +200 -0
- package/src/logBridge.js +9 -0
- package/src/models.js +146 -0
- package/src/remote/client.js +225 -0
- package/src/scheduler/cron.js +243 -0
- package/src/server.js +3876 -0
- package/src/storage/db.js +1135 -0
- package/src/storage/supabase.js +364 -0
- package/src/tunnel/cloudflare.js +241 -0
- package/src/ui/components/App.jsx +687 -0
- package/src/ui/components/BrowserSelect.jsx +111 -0
- package/src/ui/components/FilePicker.jsx +472 -0
- package/src/ui/components/Header.jsx +444 -0
- package/src/ui/components/HelpPanel.jsx +173 -0
- package/src/ui/components/HistoryPanel.jsx +158 -0
- package/src/ui/components/MessageList.jsx +235 -0
- package/src/ui/components/ModelSelector.jsx +304 -0
- package/src/ui/components/PromptInput.jsx +515 -0
- package/src/ui/components/StreamingResponse.jsx +134 -0
- package/src/ui/components/ThinkingIndicator.jsx +365 -0
- package/src/ui/components/ToolExecution.jsx +714 -0
- package/src/ui/components/index.js +82 -0
- package/src/ui/context/TerminalContext.jsx +150 -0
- package/src/ui/context/index.js +13 -0
- package/src/ui/hooks/index.js +16 -0
- package/src/ui/hooks/useChatState.js +675 -0
- package/src/ui/hooks/useCommands.js +280 -0
- package/src/ui/hooks/useFileAttachments.js +216 -0
- package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
- package/src/ui/hooks/useNotifications.js +185 -0
- package/src/ui/hooks/useTerminalSize.js +151 -0
- package/src/ui/hooks/useWebSocket.js +273 -0
- package/src/ui/index.js +94 -0
- package/src/ui/ink-runner.js +22 -0
- package/src/ui/utils/formatters.js +424 -0
- package/src/ui/utils/index.js +6 -0
- package/src/ui/utils/markdown.js +166 -0
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
import { streamInference, hasRequiredApiKey } from '../inference/index.js';
|
|
2
|
+
import { executeTool } from './tools.js';
|
|
3
|
+
import {
|
|
4
|
+
buildAgentSystemPrompt,
|
|
5
|
+
cleanResponseText,
|
|
6
|
+
validateToolCall,
|
|
7
|
+
VALID_TOOL_NAMES,
|
|
8
|
+
} from './prompt.js';
|
|
9
|
+
import { randomUUID } from 'crypto';
|
|
10
|
+
import { isOllamaReasoningModel } from '../inference/ollama.js';
|
|
11
|
+
|
|
12
|
+
const MAX_TOOL_ITERATIONS = 25;
|
|
13
|
+
|
|
14
|
+
// Agent logging helper - respects SILENT_MODE
|
|
15
|
+
const agentLog = (...args) => {
|
|
16
|
+
if (process.env.SILENT_MODE !== 'true') {
|
|
17
|
+
console.log(...args);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Web-related tools that should be skipped when web search was already done
|
|
22
|
+
const WEB_TOOLS = ['web_search', 'fetch_url'];
|
|
23
|
+
|
|
24
|
+
// Threshold for warning about too many edits to the same file
|
|
25
|
+
const EDIT_WARNING_THRESHOLD = 3;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Serialize a tool call object to XML format
|
|
29
|
+
* @param {{ name: string, args: object }} toolCall - The tool call to serialize
|
|
30
|
+
* @returns {string} - XML string representation
|
|
31
|
+
*/
|
|
32
|
+
function serializeToolCallToXml(toolCall) {
|
|
33
|
+
let xml = `<name>${toolCall.name}</name>\n`;
|
|
34
|
+
|
|
35
|
+
for (const [key, value] of Object.entries(toolCall.args || {})) {
|
|
36
|
+
// Convert value to string representation
|
|
37
|
+
let strValue;
|
|
38
|
+
if (typeof value === 'boolean') {
|
|
39
|
+
strValue = value ? 'true' : 'false';
|
|
40
|
+
} else if (typeof value === 'number') {
|
|
41
|
+
strValue = String(value);
|
|
42
|
+
} else if (value === null || value === undefined) {
|
|
43
|
+
continue; // Skip null/undefined values
|
|
44
|
+
} else {
|
|
45
|
+
strValue = String(value);
|
|
46
|
+
}
|
|
47
|
+
xml += `<${key}>${strValue}</${key}>\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return xml.trimEnd();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Try to repair malformed JSON for edit_file/write_file tools
|
|
55
|
+
* @deprecated JSON format is no longer used - keeping for backwards compatibility only
|
|
56
|
+
* @param {string} jsonText - The malformed JSON text
|
|
57
|
+
* @returns {object|null} - Parsed object or null if repair failed
|
|
58
|
+
*/
|
|
59
|
+
function tryRepairToolCallJson(jsonText) {
|
|
60
|
+
// Extract tool name first
|
|
61
|
+
const nameMatch = jsonText.match(/"name"\s*:\s*"([^"]+)"/);
|
|
62
|
+
if (!nameMatch) return null;
|
|
63
|
+
|
|
64
|
+
const toolName = nameMatch[1];
|
|
65
|
+
|
|
66
|
+
// Only attempt repair for tools that have string content parameters
|
|
67
|
+
if (!['edit_file', 'write_file'].includes(toolName)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
agentLog('[Agent] Attempting JSON repair for:', toolName);
|
|
72
|
+
|
|
73
|
+
// Extract path (should be properly quoted)
|
|
74
|
+
const pathMatch = jsonText.match(/"path"\s*:\s*"([^"]+)"/);
|
|
75
|
+
if (!pathMatch) {
|
|
76
|
+
agentLog('[Agent] JSON repair failed: could not extract path');
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const path = pathMatch[1];
|
|
80
|
+
|
|
81
|
+
if (toolName === 'write_file') {
|
|
82
|
+
// For write_file, extract content
|
|
83
|
+
// Pattern: "content": followed by content until end of args
|
|
84
|
+
const contentStart = jsonText.indexOf('"content"');
|
|
85
|
+
if (contentStart === -1) return null;
|
|
86
|
+
|
|
87
|
+
// Find the colon after "content"
|
|
88
|
+
const colonIdx = jsonText.indexOf(':', contentStart);
|
|
89
|
+
if (colonIdx === -1) return null;
|
|
90
|
+
|
|
91
|
+
// Content starts after the colon (skip whitespace and optional quote)
|
|
92
|
+
let contentBegin = colonIdx + 1;
|
|
93
|
+
while (contentBegin < jsonText.length && /\s/.test(jsonText[contentBegin])) {
|
|
94
|
+
contentBegin++;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check if properly quoted
|
|
98
|
+
const isQuoted = jsonText[contentBegin] === '"';
|
|
99
|
+
if (isQuoted) {
|
|
100
|
+
// Already quoted - let normal JSON.parse handle it
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Extract raw content until closing braces
|
|
105
|
+
// Find the last }} or } that closes the args
|
|
106
|
+
let depth = 0;
|
|
107
|
+
let contentEnd = jsonText.length;
|
|
108
|
+
for (let i = jsonText.length - 1; i >= contentBegin; i--) {
|
|
109
|
+
if (jsonText[i] === '}') {
|
|
110
|
+
depth++;
|
|
111
|
+
if (depth === 2) { // Found the closing of args and outer object
|
|
112
|
+
contentEnd = i;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const rawContent = jsonText.substring(contentBegin, contentEnd).trim();
|
|
119
|
+
|
|
120
|
+
agentLog('[Agent] JSON repair: extracted write_file content, length:', rawContent.length);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
name: toolName,
|
|
124
|
+
args: { path, content: rawContent }
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (toolName === 'edit_file') {
|
|
129
|
+
// For edit_file, extract old_string and new_string
|
|
130
|
+
// This is trickier because we have two unquoted strings
|
|
131
|
+
|
|
132
|
+
const oldStringStart = jsonText.indexOf('"old_string"');
|
|
133
|
+
const newStringStart = jsonText.indexOf('"new_string"');
|
|
134
|
+
|
|
135
|
+
if (oldStringStart === -1 || newStringStart === -1) {
|
|
136
|
+
agentLog('[Agent] JSON repair failed: could not find old_string/new_string markers');
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Find colons after each marker
|
|
141
|
+
const oldColonIdx = jsonText.indexOf(':', oldStringStart);
|
|
142
|
+
const newColonIdx = jsonText.indexOf(':', newStringStart);
|
|
143
|
+
|
|
144
|
+
if (oldColonIdx === -1 || newColonIdx === -1) return null;
|
|
145
|
+
|
|
146
|
+
// Determine order (old_string usually comes first)
|
|
147
|
+
let oldStringContent, newStringContent;
|
|
148
|
+
|
|
149
|
+
if (oldStringStart < newStringStart) {
|
|
150
|
+
// old_string comes first
|
|
151
|
+
// old_string content is between old colon and "new_string"
|
|
152
|
+
let oldBegin = oldColonIdx + 1;
|
|
153
|
+
while (oldBegin < newStringStart && /\s/.test(jsonText[oldBegin])) oldBegin++;
|
|
154
|
+
|
|
155
|
+
// Skip opening quote if present
|
|
156
|
+
if (jsonText[oldBegin] === '"') oldBegin++;
|
|
157
|
+
|
|
158
|
+
// Find end - look for "new_string" or ", "new_string"
|
|
159
|
+
let oldEnd = newStringStart;
|
|
160
|
+
// Walk back to find the actual end (before comma/whitespace)
|
|
161
|
+
while (oldEnd > oldBegin && /[\s,"]/.test(jsonText[oldEnd - 1])) oldEnd--;
|
|
162
|
+
|
|
163
|
+
oldStringContent = jsonText.substring(oldBegin, oldEnd);
|
|
164
|
+
|
|
165
|
+
// new_string content is from new colon to end
|
|
166
|
+
let newBegin = newColonIdx + 1;
|
|
167
|
+
while (newBegin < jsonText.length && /\s/.test(jsonText[newBegin])) newBegin++;
|
|
168
|
+
if (jsonText[newBegin] === '"') newBegin++;
|
|
169
|
+
|
|
170
|
+
// Find end - closing braces
|
|
171
|
+
let depth = 0;
|
|
172
|
+
let newEnd = jsonText.length;
|
|
173
|
+
for (let i = jsonText.length - 1; i >= newBegin; i--) {
|
|
174
|
+
if (jsonText[i] === '}') {
|
|
175
|
+
depth++;
|
|
176
|
+
if (depth === 2) {
|
|
177
|
+
newEnd = i;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Remove trailing quote if present
|
|
183
|
+
while (newEnd > newBegin && /[\s}"]/.test(jsonText[newEnd - 1])) newEnd--;
|
|
184
|
+
|
|
185
|
+
newStringContent = jsonText.substring(newBegin, newEnd);
|
|
186
|
+
} else {
|
|
187
|
+
// new_string comes first (unusual but handle it)
|
|
188
|
+
agentLog('[Agent] JSON repair: unusual order (new_string before old_string)');
|
|
189
|
+
return null; // Too complex to handle reliably
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check for replace_all parameter
|
|
193
|
+
const replaceAllMatch = jsonText.match(/"replace_all"\s*:\s*(true|false)/);
|
|
194
|
+
const replaceAll = replaceAllMatch ? replaceAllMatch[1] === 'true' : false;
|
|
195
|
+
|
|
196
|
+
agentLog('[Agent] JSON repair: extracted edit_file content');
|
|
197
|
+
agentLog('[Agent] JSON repair: old_string length:', oldStringContent.length);
|
|
198
|
+
agentLog('[Agent] JSON repair: new_string length:', newStringContent.length);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
name: toolName,
|
|
202
|
+
args: {
|
|
203
|
+
path,
|
|
204
|
+
old_string: oldStringContent,
|
|
205
|
+
new_string: newStringContent,
|
|
206
|
+
...(replaceAll && { replace_all: replaceAll })
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Parse XML tool call format
|
|
216
|
+
* Format: <tool_call><name>tool</name><param1>value1</param1>...</tool_call>
|
|
217
|
+
* Or with CDATA: <tool_call><name>tool</name><content><![CDATA[...]]></content></tool_call>
|
|
218
|
+
* @param {string} xmlText - The XML content between <tool_call> tags
|
|
219
|
+
* @returns {object|null} - Parsed tool call object or null
|
|
220
|
+
*/
|
|
221
|
+
function parseXmlToolCall(xmlText) {
|
|
222
|
+
// Check if this looks like XML format (has <name> tag, not {"name":)
|
|
223
|
+
if (!xmlText.includes('<name>') || xmlText.trim().startsWith('{')) {
|
|
224
|
+
return null; // Not XML format
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
agentLog('[Agent] Parsing XML-format tool call');
|
|
228
|
+
|
|
229
|
+
// Extract tool name
|
|
230
|
+
const nameMatch = xmlText.match(/<name>\s*([\s\S]*?)\s*<\/name>/);
|
|
231
|
+
if (!nameMatch) {
|
|
232
|
+
agentLog('[Agent] XML parse failed: no <name> tag found');
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const toolName = nameMatch[1].trim();
|
|
237
|
+
const args = {};
|
|
238
|
+
|
|
239
|
+
// Extract all other XML tags as parameters
|
|
240
|
+
// Support both regular content and CDATA sections
|
|
241
|
+
const paramPattern = /<(\w+)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([\s\S]*?))<\/\1>/g;
|
|
242
|
+
|
|
243
|
+
let match;
|
|
244
|
+
while ((match = paramPattern.exec(xmlText)) !== null) {
|
|
245
|
+
const paramName = match[1];
|
|
246
|
+
if (paramName === 'name') continue; // Skip the name tag
|
|
247
|
+
|
|
248
|
+
// CDATA content is in match[2], regular content in match[3]
|
|
249
|
+
const value = match[2] !== undefined ? match[2] : match[3];
|
|
250
|
+
|
|
251
|
+
// Try to parse as boolean or number if appropriate
|
|
252
|
+
const trimmedValue = value.trim();
|
|
253
|
+
if (trimmedValue === 'true') {
|
|
254
|
+
args[paramName] = true;
|
|
255
|
+
} else if (trimmedValue === 'false') {
|
|
256
|
+
args[paramName] = false;
|
|
257
|
+
} else if (/^-?\d+$/.test(trimmedValue)) {
|
|
258
|
+
args[paramName] = parseInt(trimmedValue, 10);
|
|
259
|
+
} else if (/^-?\d+\.\d+$/.test(trimmedValue)) {
|
|
260
|
+
args[paramName] = parseFloat(trimmedValue);
|
|
261
|
+
} else {
|
|
262
|
+
// Keep as string - preserve the exact content (including whitespace for code)
|
|
263
|
+
args[paramName] = value;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
agentLog('[Agent] XML parse success:', toolName, 'with', Object.keys(args).length, 'args');
|
|
268
|
+
|
|
269
|
+
return { name: toolName, args };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Parse a single tool call from text (finds first complete tool_call block)
|
|
274
|
+
* Returns { toolCall, beforeText, afterText, parseError? } or null if no complete tool call found
|
|
275
|
+
* Uses XML format: <tool_call><name>tool_name</name><parameter_name>value</parameter_name></tool_call>
|
|
276
|
+
* Example: <tool_call><name>web_search</name><query>weather in Miami</query></tool_call>
|
|
277
|
+
*/
|
|
278
|
+
function parseFirstToolCall(text) {
|
|
279
|
+
const startTag = '<tool_call>';
|
|
280
|
+
const endTag = '</tool_call>';
|
|
281
|
+
|
|
282
|
+
// CRITICAL FIX: Normalize malformed closing tags (missing >) before parsing
|
|
283
|
+
// The LLM sometimes outputs </tool_call without the closing >
|
|
284
|
+
let normalizedText = text.replace(/<\/tool_call([^>])/g, '</tool_call>$1');
|
|
285
|
+
|
|
286
|
+
const startIdx = normalizedText.indexOf(startTag);
|
|
287
|
+
if (startIdx === -1) return null;
|
|
288
|
+
|
|
289
|
+
const endIdx = normalizedText.indexOf(endTag, startIdx);
|
|
290
|
+
if (endIdx === -1) return null; // Incomplete tool call
|
|
291
|
+
|
|
292
|
+
const beforeText = normalizedText.substring(0, startIdx);
|
|
293
|
+
const toolCallContent = normalizedText.substring(startIdx + startTag.length, endIdx).trim();
|
|
294
|
+
const afterText = normalizedText.substring(endIdx + endTag.length);
|
|
295
|
+
|
|
296
|
+
// Parse XML format: <name>tool_name</name><parameter_name>value</parameter_name>
|
|
297
|
+
const xmlParsed = parseXmlToolCall(toolCallContent);
|
|
298
|
+
if (xmlParsed) {
|
|
299
|
+
return {
|
|
300
|
+
toolCall: xmlParsed,
|
|
301
|
+
beforeText,
|
|
302
|
+
afterText,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// XML parsing failed - log error for debugging
|
|
307
|
+
console.error('[Agent] Failed to parse tool call XML');
|
|
308
|
+
console.error('[Agent] Tool call content:', toolCallContent.substring(0, 200));
|
|
309
|
+
|
|
310
|
+
// Try to extract tool name for debugging
|
|
311
|
+
const nameMatch = toolCallContent.match(/<name>\s*([\s\S]*?)\s*<\/name>/);
|
|
312
|
+
if (nameMatch) {
|
|
313
|
+
console.error('[Agent] Tool name found:', nameMatch[1]);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Return error info so we can notify the user
|
|
317
|
+
return {
|
|
318
|
+
toolCall: null,
|
|
319
|
+
beforeText,
|
|
320
|
+
afterText,
|
|
321
|
+
parseError: {
|
|
322
|
+
message: 'Failed to parse XML tool call',
|
|
323
|
+
toolName: nameMatch ? nameMatch[1] : 'unknown',
|
|
324
|
+
contentPreview: toolCallContent.substring(0, 300),
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Check if text contains a partial (incomplete) tool call at the end
|
|
331
|
+
* This now also detects partial opening tags like <tool, <tool_, <tool_c, etc.
|
|
332
|
+
*/
|
|
333
|
+
function hasPartialToolCall(text) {
|
|
334
|
+
// First check for complete opening tag without closing tag
|
|
335
|
+
const startTag = '<tool_call>';
|
|
336
|
+
const endTag = '</tool_call>';
|
|
337
|
+
|
|
338
|
+
const startIdx = text.lastIndexOf(startTag);
|
|
339
|
+
if (startIdx !== -1) {
|
|
340
|
+
const endIdx = text.indexOf(endTag, startIdx);
|
|
341
|
+
if (endIdx === -1) return true; // Has start but no end = partial
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Also check for partial opening tags at the end of the text
|
|
345
|
+
// This catches cases like "Hello<tool" before it becomes "Hello<tool_call>"
|
|
346
|
+
const partialTags = ['<tool_call', '<tool_cal', '<tool_ca', '<tool_c', '<tool_', '<tool', '<too', '<to', '<t'];
|
|
347
|
+
for (const partial of partialTags) {
|
|
348
|
+
if (text.endsWith(partial)) {
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Also check if text ends with just '<' which could be start of a tag
|
|
354
|
+
if (text.endsWith('<')) {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get safe text to yield (everything before any partial tool call tag)
|
|
363
|
+
* CRITICAL: Must check for <tool_call> ANYWHERE in text, not just at last '<'
|
|
364
|
+
*/
|
|
365
|
+
function getSafeTextLength(text) {
|
|
366
|
+
// CRITICAL FIX: First check if there's ANY <tool_call> tag in the text
|
|
367
|
+
// This catches complete tool_call blocks that shouldn't be yielded
|
|
368
|
+
const toolCallStart = text.indexOf('<tool_call>');
|
|
369
|
+
if (toolCallStart !== -1) {
|
|
370
|
+
// Found a tool_call opening tag - don't yield any content from here onward
|
|
371
|
+
// The parseFirstToolCall function will handle this once the closing tag arrives
|
|
372
|
+
return toolCallStart;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Now check the last '<' for partial tags
|
|
376
|
+
const lastLt = text.lastIndexOf('<');
|
|
377
|
+
if (lastLt === -1) return text.length;
|
|
378
|
+
|
|
379
|
+
// Check if this '<' starts a partial tool_call tag
|
|
380
|
+
const afterLt = text.substring(lastLt);
|
|
381
|
+
|
|
382
|
+
// Check if it could be a partial OPENING tool_call tag
|
|
383
|
+
if ('<tool_call>'.startsWith(afterLt)) {
|
|
384
|
+
// Potential partial tag - yield up to the <
|
|
385
|
+
return lastLt;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// CRITICAL FIX: Also check for incomplete CLOSING tags
|
|
389
|
+
// The LLM might output </tool_call without the final > yet
|
|
390
|
+
// We need to wait for the > before yielding
|
|
391
|
+
if (afterLt.startsWith('</tool_call') && !afterLt.startsWith('</tool_call>')) {
|
|
392
|
+
// Incomplete closing tag (missing >) - yield up to the <
|
|
393
|
+
return lastLt;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Check if it could be a partial closing tag (</t, </to, </too, etc.)
|
|
397
|
+
// BUT only for tool_call, not general HTML tags!
|
|
398
|
+
if ('</tool_call>'.startsWith(afterLt) && afterLt.length >= 3) {
|
|
399
|
+
// Only treat as partial tool_call if it's at least </t (3 chars)
|
|
400
|
+
// This prevents false positives on HTML tags like </td>, </tr>
|
|
401
|
+
// Potential partial closing tag - yield up to the <
|
|
402
|
+
return lastLt;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Not a tool tag, safe to yield everything
|
|
406
|
+
return text.length;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Run the agent with inline tool execution
|
|
411
|
+
* Tools are executed one at a time, inline with text generation
|
|
412
|
+
* @param {string} userMessage - The user's message
|
|
413
|
+
* @param {Array} history - Previous messages in the conversation
|
|
414
|
+
* @param {object} config - Configuration with API keys, model, etc.
|
|
415
|
+
* @param {object} options - Additional options
|
|
416
|
+
* @param {boolean} options.skipWebTools - Skip web-related tools
|
|
417
|
+
* @yields {object} - Chunks to stream to client
|
|
418
|
+
*/
|
|
419
|
+
export async function* runAgent(userMessage, history, config, options = {}) {
|
|
420
|
+
const model = config.model || 'claude-sonnet-4-20250514';
|
|
421
|
+
const { skipWebTools = false, snapshotFn = null, shellUndoFn = null, ragDocuments = [], images: currentTurnImages = [] } = options;
|
|
422
|
+
|
|
423
|
+
// Check for API key
|
|
424
|
+
if (!hasRequiredApiKey(model, config)) {
|
|
425
|
+
yield { type: 'error', message: `No API key configured for model ${model}. Run: otherwise config` };
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Check if this is an Ollama reasoning model (like gpt-oss) that needs CoT preservation
|
|
430
|
+
const isOllamaReasoning = model.startsWith('ollama:') && isOllamaReasoningModel(model.replace('ollama:', ''));
|
|
431
|
+
|
|
432
|
+
// Build system prompt (with RAG documents if any were detected)
|
|
433
|
+
let systemPrompt = buildAgentSystemPrompt(config, { ragDocuments });
|
|
434
|
+
if (skipWebTools) {
|
|
435
|
+
systemPrompt += '\n\nIMPORTANT: Web search has already been performed and the results are included in the user\'s message. Do NOT use web_search or fetch_url tools - the information you need is already provided.';
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Log if RAG documents are being used
|
|
439
|
+
if (ragDocuments.length > 0) {
|
|
440
|
+
agentLog('[Agent] RAG documents enabled:', ragDocuments.map(d => d.name).join(', '));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Build messages array (include images on current user message for vision models)
|
|
444
|
+
const currentUserMessage = currentTurnImages?.length
|
|
445
|
+
? { role: 'user', content: userMessage, images: currentTurnImages }
|
|
446
|
+
: { role: 'user', content: userMessage };
|
|
447
|
+
let messages = [
|
|
448
|
+
...history,
|
|
449
|
+
currentUserMessage,
|
|
450
|
+
];
|
|
451
|
+
|
|
452
|
+
const maxIterations = skipWebTools ? 3 : MAX_TOOL_ITERATIONS;
|
|
453
|
+
let iterationCount = 0;
|
|
454
|
+
let totalToolCalls = 0;
|
|
455
|
+
|
|
456
|
+
// Full response text for building conversation history
|
|
457
|
+
let fullResponseText = '';
|
|
458
|
+
|
|
459
|
+
// Track thinking/reasoning content for models like gpt-oss that need CoT
|
|
460
|
+
let thinkingContent = '';
|
|
461
|
+
|
|
462
|
+
// Track recovery attempts to prevent infinite loops
|
|
463
|
+
let recoveryAttempts = 0;
|
|
464
|
+
const MAX_RECOVERY_ATTEMPTS = 2;
|
|
465
|
+
|
|
466
|
+
// Track edit_file calls by path to detect "many small edits" anti-pattern
|
|
467
|
+
const editFileCountByPath = new Map();
|
|
468
|
+
|
|
469
|
+
// STREAMING DEDUPLICATION: Track current streaming tool state
|
|
470
|
+
// This prevents flooding the frontend with duplicate tool_streaming events
|
|
471
|
+
let currentStreamingTool = null; // { id, name, lastEmittedLength, lastEmittedTime }
|
|
472
|
+
|
|
473
|
+
// Agent loop
|
|
474
|
+
while (iterationCount < maxIterations) {
|
|
475
|
+
iterationCount++;
|
|
476
|
+
|
|
477
|
+
// Buffer for detecting tool calls mid-stream
|
|
478
|
+
let buffer = '';
|
|
479
|
+
let textYielded = ''; // Track what we've already sent to client
|
|
480
|
+
let executedToolInline = false; // Track if we broke due to tool execution
|
|
481
|
+
|
|
482
|
+
// Stream from the model
|
|
483
|
+
try {
|
|
484
|
+
for await (const chunk of streamInference(model, messages, systemPrompt, config)) {
|
|
485
|
+
if (chunk.type === 'text') {
|
|
486
|
+
buffer += chunk.content;
|
|
487
|
+
|
|
488
|
+
// Only parse when we have a complete tool_call block (avoids heavy parse on every chunk)
|
|
489
|
+
const toolParse =
|
|
490
|
+
config.disableTools || !buffer.includes('</tool_call>')
|
|
491
|
+
? null
|
|
492
|
+
: parseFirstToolCall(buffer);
|
|
493
|
+
|
|
494
|
+
if (toolParse) {
|
|
495
|
+
const { toolCall, beforeText, afterText, parseError, wasRepaired } = toolParse;
|
|
496
|
+
|
|
497
|
+
// Yield any text before the tool call that we haven't sent yet
|
|
498
|
+
const newTextBefore = beforeText.substring(textYielded.length);
|
|
499
|
+
if (newTextBefore) {
|
|
500
|
+
yield { type: 'text', content: newTextBefore };
|
|
501
|
+
fullResponseText += newTextBefore;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Handle parse error - notify user and skip this tool call
|
|
505
|
+
if (parseError || !toolCall) {
|
|
506
|
+
console.error('[Agent] Tool call parsing failed completely');
|
|
507
|
+
|
|
508
|
+
// Yield error to frontend so user knows what happened
|
|
509
|
+
yield {
|
|
510
|
+
type: 'tool_parse_error',
|
|
511
|
+
name: parseError?.toolName || 'unknown',
|
|
512
|
+
error: `Failed to parse tool call: ${parseError?.message || 'Unknown error'}`,
|
|
513
|
+
hint: 'The AI generated a malformed tool call. Try rephrasing your request or using a different model.',
|
|
514
|
+
preview: parseError?.contentPreview?.substring(0, 150) || '',
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// Add error to messages so AI knows something went wrong
|
|
518
|
+
const errorMessage = `[SYSTEM ERROR] Your previous tool call for "${parseError?.toolName || 'unknown'}" could not be parsed. The tool was NOT executed. Please try again using the correct XML format. Example for web_search: <tool_call><name>web_search</name><query>your search query</query></tool_call>`;
|
|
519
|
+
|
|
520
|
+
messages.push({ role: 'assistant', content: fullResponseText });
|
|
521
|
+
messages.push({ role: 'user', content: errorMessage });
|
|
522
|
+
|
|
523
|
+
// Reset and continue to let the AI try again
|
|
524
|
+
buffer = afterText;
|
|
525
|
+
textYielded = '';
|
|
526
|
+
fullResponseText = '';
|
|
527
|
+
executedToolInline = true;
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Log if repair was needed (successful)
|
|
532
|
+
if (wasRepaired) {
|
|
533
|
+
agentLog('[Agent] Tool call was repaired from malformed format');
|
|
534
|
+
yield {
|
|
535
|
+
type: 'warning',
|
|
536
|
+
message: `Note: Repaired malformed tool call for ${toolCall.name}.`
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Validate before execute/emit - only emit tool_start with valid, sanitized args
|
|
541
|
+
const validation = validateToolCall({ name: toolCall.name, args: toolCall.args });
|
|
542
|
+
if (!validation.valid) {
|
|
543
|
+
console.error('[Agent] Tool call validation failed:', validation.error);
|
|
544
|
+
yield {
|
|
545
|
+
type: 'tool_parse_error',
|
|
546
|
+
name: toolCall.name,
|
|
547
|
+
error: `Invalid tool call: ${validation.error}`,
|
|
548
|
+
hint: 'The tool name or arguments are invalid. Check the tool format.',
|
|
549
|
+
preview: '',
|
|
550
|
+
};
|
|
551
|
+
messages.push({ role: 'assistant', content: fullResponseText });
|
|
552
|
+
messages.push({ role: 'user', content: `[SYSTEM ERROR] Tool "${toolCall.name}" rejected: ${validation.error}. Please use a valid tool and format.` });
|
|
553
|
+
buffer = afterText;
|
|
554
|
+
textYielded = '';
|
|
555
|
+
fullResponseText = '';
|
|
556
|
+
executedToolInline = true;
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
const sanitizedToolCall = { name: validation.sanitized.name, args: validation.sanitized.args };
|
|
560
|
+
|
|
561
|
+
// Skip web tools if already searched - but provide feedback to the AI
|
|
562
|
+
if (skipWebTools && WEB_TOOLS.includes(sanitizedToolCall.name)) {
|
|
563
|
+
agentLog('[Agent] Skipping web tool (search already done):', sanitizedToolCall.name);
|
|
564
|
+
|
|
565
|
+
// Generate a fake tool result so the AI knows what happened
|
|
566
|
+
const callId = randomUUID();
|
|
567
|
+
const skipResult = sanitizedToolCall.name === 'web_search'
|
|
568
|
+
? 'Web search was already performed. The search results are included in the user message above. Please use that information to answer.'
|
|
569
|
+
: 'URL fetching is not needed. The relevant content was already fetched and included in the user message above. Please use that information.';
|
|
570
|
+
|
|
571
|
+
yield { type: 'tool_start', name: sanitizedToolCall.name, args: sanitizedToolCall.args, callId };
|
|
572
|
+
yield { type: 'tool_result', name: sanitizedToolCall.name, result: skipResult, callId };
|
|
573
|
+
|
|
574
|
+
// Add to messages so the AI sees the result
|
|
575
|
+
const assistantContent = fullResponseText + `<tool_call>\n${serializeToolCallToXml(sanitizedToolCall)}\n</tool_call>`;
|
|
576
|
+
messages.push({ role: 'assistant', content: assistantContent });
|
|
577
|
+
messages.push({ role: 'user', content: `Tool result for ${sanitizedToolCall.name}:\n${skipResult}` });
|
|
578
|
+
|
|
579
|
+
buffer = afterText;
|
|
580
|
+
textYielded = '';
|
|
581
|
+
fullResponseText = '';
|
|
582
|
+
executedToolInline = true;
|
|
583
|
+
break; // Re-prompt the model with the skip message
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Execute the tool INLINE (using validated/sanitized tool call)
|
|
587
|
+
totalToolCalls++;
|
|
588
|
+
const callId = randomUUID(); // Unique ID for correlating start/result
|
|
589
|
+
console.log(`[Agent] Executing tool inline: ${sanitizedToolCall.name} (${callId}) [${totalToolCalls}/${maxIterations}]`);
|
|
590
|
+
|
|
591
|
+
// Clear streaming tool state since we're now executing
|
|
592
|
+
currentStreamingTool = null;
|
|
593
|
+
|
|
594
|
+
// Warn the AI when approaching tool limit
|
|
595
|
+
const remainingTools = maxIterations - iterationCount;
|
|
596
|
+
if (remainingTools <= 3) {
|
|
597
|
+
yield {
|
|
598
|
+
type: 'warning',
|
|
599
|
+
message: `Warning: Only ${remainingTools} tool calls remaining. Please wrap up soon.`
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
yield { type: 'tool_start', name: sanitizedToolCall.name, args: sanitizedToolCall.args, callId };
|
|
604
|
+
|
|
605
|
+
let toolResult;
|
|
606
|
+
let toolError = null;
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
toolResult = await executeTool(sanitizedToolCall.name, sanitizedToolCall.args, config, snapshotFn, shellUndoFn, callId);
|
|
610
|
+
yield { type: 'tool_result', name: sanitizedToolCall.name, result: toolResult, callId };
|
|
611
|
+
} catch (err) {
|
|
612
|
+
toolError = `Error: ${err.message}`;
|
|
613
|
+
yield { type: 'tool_error', name: sanitizedToolCall.name, error: toolError, callId };
|
|
614
|
+
toolResult = toolError;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Add the assistant's text (up to and including tool call) and tool result to messages
|
|
618
|
+
// For Ollama reasoning models (like gpt-oss), include thinking content as context
|
|
619
|
+
let assistantContent = fullResponseText + `<tool_call>\n${serializeToolCallToXml(sanitizedToolCall)}\n</tool_call>`;
|
|
620
|
+
if (isOllamaReasoning && thinkingContent) {
|
|
621
|
+
// Include thinking content as a prefix so the model maintains context
|
|
622
|
+
assistantContent = `<thinking>${thinkingContent}</thinking>\n${assistantContent}`;
|
|
623
|
+
}
|
|
624
|
+
messages.push({ role: 'assistant', content: assistantContent });
|
|
625
|
+
|
|
626
|
+
// Track edit_file calls by path to detect "many small edits" anti-pattern
|
|
627
|
+
if (sanitizedToolCall.name === 'edit_file' && sanitizedToolCall.args?.path) {
|
|
628
|
+
const editPath = sanitizedToolCall.args.path;
|
|
629
|
+
const count = (editFileCountByPath.get(editPath) || 0) + 1;
|
|
630
|
+
editFileCountByPath.set(editPath, count);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Build tool result message with optional warning about tool limit
|
|
634
|
+
let toolResultMessage = `[Tool "${sanitizedToolCall.name}" returned]:\n${toolResult}`;
|
|
635
|
+
|
|
636
|
+
// Warn if making many small edits to the same file
|
|
637
|
+
if (sanitizedToolCall.name === 'edit_file' && sanitizedToolCall.args?.path) {
|
|
638
|
+
const editCount = editFileCountByPath.get(sanitizedToolCall.args.path) || 0;
|
|
639
|
+
if (editCount >= EDIT_WARNING_THRESHOLD) {
|
|
640
|
+
toolResultMessage += `\n\n⚠️ EFFICIENCY WARNING: You've made ${editCount} edit_file calls to this file. Instead of many small edits, use LARGER CHUNKS - include 30-100 lines in old_string and make ALL changes to that section at once. This is faster and won't hit the tool limit.`;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Warn the AI if approaching limit so it can wrap up gracefully
|
|
645
|
+
const toolsRemaining = maxIterations - iterationCount;
|
|
646
|
+
if (toolsRemaining <= 5) {
|
|
647
|
+
toolResultMessage += `\n\n⚠️ WARNING: You have ${toolsRemaining} tool calls remaining. Please finish your current task soon.`;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
messages.push({
|
|
651
|
+
role: 'user',
|
|
652
|
+
content: toolResultMessage
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// Reset for continuation - the model will continue from here
|
|
656
|
+
buffer = '';
|
|
657
|
+
textYielded = '';
|
|
658
|
+
fullResponseText = ''; // Reset since we're starting a new turn
|
|
659
|
+
thinkingContent = ''; // Reset thinking for next iteration
|
|
660
|
+
executedToolInline = true; // Mark that we need to continue the loop
|
|
661
|
+
|
|
662
|
+
// Break out of the streaming loop to get a fresh response
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// No complete tool call yet - yield safe text
|
|
667
|
+
// Use getSafeTextLength to determine how much we can safely yield
|
|
668
|
+
// This prevents partial tags like "<tool" from being sent to the client
|
|
669
|
+
const safeLength = getSafeTextLength(buffer);
|
|
670
|
+
const safeText = buffer.substring(textYielded.length, safeLength);
|
|
671
|
+
|
|
672
|
+
if (safeText) {
|
|
673
|
+
yield { type: 'text', content: safeText };
|
|
674
|
+
fullResponseText += safeText;
|
|
675
|
+
textYielded = buffer.substring(0, safeLength);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Check if we're buffering a partial tool call and emit streaming update
|
|
679
|
+
// This lets the frontend show the tool call UI while it's being written
|
|
680
|
+
const toolCallStart = buffer.indexOf('<tool_call>');
|
|
681
|
+
if (toolCallStart !== -1) {
|
|
682
|
+
const partialToolContent = buffer.substring(toolCallStart + '<tool_call>'.length).trim();
|
|
683
|
+
|
|
684
|
+
// Try to extract tool name from XML format
|
|
685
|
+
const nameMatch = partialToolContent.match(/<name>\s*([\s\S]*?)\s*<\/name>/);
|
|
686
|
+
|
|
687
|
+
// Only emit tool_streaming once we have a complete, valid tool name (avoids malformed streaming)
|
|
688
|
+
if (nameMatch) {
|
|
689
|
+
const partialToolName = nameMatch[1].trim();
|
|
690
|
+
if (!partialToolName || !VALID_TOOL_NAMES.includes(partialToolName)) {
|
|
691
|
+
// Incomplete or invalid name (e.g. "read_fi" or garbage) - don't emit yet
|
|
692
|
+
currentStreamingTool = null;
|
|
693
|
+
} else {
|
|
694
|
+
// Extract what we can from the partial XML
|
|
695
|
+
let partialArgs = {};
|
|
696
|
+
|
|
697
|
+
// Helper to extract XML tag content (handles partial/streaming tags)
|
|
698
|
+
const extractXmlContent = (xmlText, tagName) => {
|
|
699
|
+
// Try complete tag first
|
|
700
|
+
const completeMatch = xmlText.match(new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`));
|
|
701
|
+
if (completeMatch) {
|
|
702
|
+
return completeMatch[1];
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Try partial tag (opened but not closed yet)
|
|
706
|
+
const openTagMatch = xmlText.match(new RegExp(`<${tagName}>([\\s\\S]*)$`));
|
|
707
|
+
if (openTagMatch) {
|
|
708
|
+
return openTagMatch[1];
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return null;
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
// Extract path - handle both complete and partial
|
|
715
|
+
const pathContent = extractXmlContent(partialToolContent, 'path');
|
|
716
|
+
if (pathContent) partialArgs.path = pathContent;
|
|
717
|
+
|
|
718
|
+
// Extract query
|
|
719
|
+
const queryContent = extractXmlContent(partialToolContent, 'query');
|
|
720
|
+
if (queryContent) partialArgs.query = queryContent;
|
|
721
|
+
|
|
722
|
+
// Extract command
|
|
723
|
+
const commandContent = extractXmlContent(partialToolContent, 'command');
|
|
724
|
+
if (commandContent) partialArgs.command = commandContent;
|
|
725
|
+
|
|
726
|
+
// Extract streaming content based on tool type
|
|
727
|
+
let streamingContent = null;
|
|
728
|
+
|
|
729
|
+
// For write_file: stream the content parameter
|
|
730
|
+
if (partialToolName === 'write_file') {
|
|
731
|
+
const content = extractXmlContent(partialToolContent, 'content');
|
|
732
|
+
if (content) {
|
|
733
|
+
streamingContent = content;
|
|
734
|
+
const lineCount = (content.match(/\n/g) || []).length + 1;
|
|
735
|
+
partialArgs._streamingLines = lineCount;
|
|
736
|
+
partialArgs._streamingChars = content.length;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// For edit_file: stream old_string and new_string
|
|
741
|
+
if (partialToolName === 'edit_file') {
|
|
742
|
+
const oldString = extractXmlContent(partialToolContent, 'old_string');
|
|
743
|
+
const newString = extractXmlContent(partialToolContent, 'new_string');
|
|
744
|
+
|
|
745
|
+
if (oldString || newString) {
|
|
746
|
+
partialArgs._streamingOldString = oldString || '';
|
|
747
|
+
partialArgs._streamingNewString = newString || '';
|
|
748
|
+
// Create a preview diff-like content
|
|
749
|
+
streamingContent = `--- ${partialArgs.path || 'file'}\n+++ ${partialArgs.path || 'file'}\n`;
|
|
750
|
+
if (oldString) {
|
|
751
|
+
streamingContent += oldString.split('\n').map(l => `- ${l}`).join('\n') + '\n';
|
|
752
|
+
}
|
|
753
|
+
if (newString) {
|
|
754
|
+
streamingContent += newString.split('\n').map(l => `+ ${l}`).join('\n');
|
|
755
|
+
}
|
|
756
|
+
partialArgs._streamingLines = (streamingContent.match(/\n/g) || []).length + 1;
|
|
757
|
+
partialArgs._streamingChars = (oldString?.length || 0) + (newString?.length || 0);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// For search_files: stream the query
|
|
762
|
+
if (partialToolName === 'search_files') {
|
|
763
|
+
const queryContent = extractXmlContent(partialToolContent, 'query');
|
|
764
|
+
if (queryContent) {
|
|
765
|
+
partialArgs._streamingQuery = queryContent;
|
|
766
|
+
streamingContent = `Searching for: "${queryContent}"`;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// For read_file: just show path info
|
|
771
|
+
if (partialToolName === 'read_file') {
|
|
772
|
+
if (partialArgs.path) {
|
|
773
|
+
streamingContent = `Reading: ${partialArgs.path}`;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// For execute_command/shell: stream the command being typed
|
|
778
|
+
if (partialToolName === 'execute_command' || partialToolName === 'shell' || partialToolName === 'run_command') {
|
|
779
|
+
const command = extractXmlContent(partialToolContent, 'command');
|
|
780
|
+
if (command) {
|
|
781
|
+
partialArgs.command = command;
|
|
782
|
+
streamingContent = command;
|
|
783
|
+
partialArgs._streamingChars = command.length;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// DEDUPLICATION FIX: Only emit tool_streaming when there's meaningful change
|
|
788
|
+
// This prevents flooding the frontend with hundreds of identical events
|
|
789
|
+
const contentLength = streamingContent?.length || partialToolContent.length;
|
|
790
|
+
const now = Date.now();
|
|
791
|
+
|
|
792
|
+
// Check if this is a NEW tool or if we should emit an update
|
|
793
|
+
const isNewTool = !currentStreamingTool || currentStreamingTool.name !== partialToolName;
|
|
794
|
+
const shouldEmit = isNewTool ||
|
|
795
|
+
// Emit every ~300 chars of content change (reduces event volume)
|
|
796
|
+
(contentLength - (currentStreamingTool?.lastEmittedLength || 0)) >= 300 ||
|
|
797
|
+
// Or every 200ms for progress feedback
|
|
798
|
+
(now - (currentStreamingTool?.lastEmittedTime || 0)) >= 200;
|
|
799
|
+
|
|
800
|
+
if (shouldEmit) {
|
|
801
|
+
// Generate stable ID for this tool call (persists across all streaming events)
|
|
802
|
+
const streamingId = isNewTool
|
|
803
|
+
? `streaming-${partialToolName}-${now}`
|
|
804
|
+
: currentStreamingTool.id;
|
|
805
|
+
|
|
806
|
+
// Update tracking state
|
|
807
|
+
currentStreamingTool = {
|
|
808
|
+
id: streamingId,
|
|
809
|
+
name: partialToolName,
|
|
810
|
+
lastEmittedLength: contentLength,
|
|
811
|
+
lastEmittedTime: now,
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
// Sanitize partial args: drop values that look truncated (contain '<' = unclosed tag)
|
|
815
|
+
const sanitizedPartialArgs = {};
|
|
816
|
+
for (const [k, v] of Object.entries(partialArgs)) {
|
|
817
|
+
if (typeof v === 'string' && v.includes('<')) continue; // Truncated/invalid
|
|
818
|
+
sanitizedPartialArgs[k] = v;
|
|
819
|
+
}
|
|
820
|
+
// Emit with stable ID so frontend can match/update existing entry
|
|
821
|
+
yield {
|
|
822
|
+
type: 'tool_streaming',
|
|
823
|
+
streamingId, // CRITICAL: Stable ID for deduplication
|
|
824
|
+
name: partialToolName,
|
|
825
|
+
args: sanitizedPartialArgs,
|
|
826
|
+
streamingContent,
|
|
827
|
+
bufferLength: partialToolContent.length,
|
|
828
|
+
status: streamingContent ? 'streaming' : 'preparing',
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
} else {
|
|
834
|
+
// No tool call in buffer - clear streaming state
|
|
835
|
+
currentStreamingTool = null;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
} else if (chunk.type === 'thinking') {
|
|
839
|
+
// Track thinking content for models like gpt-oss that need CoT preservation
|
|
840
|
+
thinkingContent += chunk.content;
|
|
841
|
+
yield chunk;
|
|
842
|
+
} else if (chunk.type === 'thinking_only') {
|
|
843
|
+
// Ollama reasoning model produced thinking but no response
|
|
844
|
+
// This is handled after the streaming loop via the recovery mechanism
|
|
845
|
+
agentLog('[Agent] Received thinking_only marker - will attempt recovery if tools were called');
|
|
846
|
+
} else if (chunk.type === 'image' || chunk.type === 'image_url') {
|
|
847
|
+
// Pass through image chunks directly from image generation models
|
|
848
|
+
yield chunk;
|
|
849
|
+
} else if (chunk.type === 'usage') {
|
|
850
|
+
// Pass through usage stats from inference providers
|
|
851
|
+
yield chunk;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// If we executed a tool inline, continue to next iteration for the model to respond
|
|
856
|
+
if (executedToolInline) {
|
|
857
|
+
agentLog('[Agent] Tool executed inline, continuing to next iteration...');
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// After streaming completes, check if there's a remaining tool call (skip during recovery)
|
|
862
|
+
const remainingTool = config.disableTools ? null : parseFirstToolCall(buffer);
|
|
863
|
+
if (remainingTool) {
|
|
864
|
+
const { toolCall, beforeText, parseError } = remainingTool;
|
|
865
|
+
|
|
866
|
+
// Yield any remaining text before tool call
|
|
867
|
+
const newTextBefore = beforeText.substring(textYielded.length);
|
|
868
|
+
if (newTextBefore) {
|
|
869
|
+
yield { type: 'text', content: newTextBefore };
|
|
870
|
+
fullResponseText += newTextBefore;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (parseError || !toolCall) {
|
|
874
|
+
yield {
|
|
875
|
+
type: 'tool_parse_error',
|
|
876
|
+
name: parseError?.toolName || 'unknown',
|
|
877
|
+
error: `Failed to parse tool call: ${parseError?.message || 'Unknown error'}`,
|
|
878
|
+
hint: 'The AI generated a malformed tool call at end of stream.',
|
|
879
|
+
preview: parseError?.contentPreview?.substring(0, 150) || '',
|
|
880
|
+
};
|
|
881
|
+
buffer = remainingTool.afterText || '';
|
|
882
|
+
textYielded = '';
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const postValidation = validateToolCall({ name: toolCall.name, args: toolCall.args });
|
|
887
|
+
if (!postValidation.valid) {
|
|
888
|
+
yield {
|
|
889
|
+
type: 'tool_parse_error',
|
|
890
|
+
name: toolCall.name,
|
|
891
|
+
error: `Invalid tool call: ${postValidation.error}`,
|
|
892
|
+
hint: 'Tool name or arguments invalid.',
|
|
893
|
+
preview: '',
|
|
894
|
+
};
|
|
895
|
+
buffer = remainingTool.afterText || '';
|
|
896
|
+
textYielded = '';
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const finalSanitized = { name: postValidation.sanitized.name, args: postValidation.sanitized.args };
|
|
900
|
+
|
|
901
|
+
// Skip web tools if needed - but provide feedback to the AI
|
|
902
|
+
if (skipWebTools && WEB_TOOLS.includes(finalSanitized.name)) {
|
|
903
|
+
agentLog('[Agent] Skipping web tool (post-stream):', finalSanitized.name);
|
|
904
|
+
|
|
905
|
+
const skipCallId = randomUUID();
|
|
906
|
+
const skipResult = finalSanitized.name === 'web_search'
|
|
907
|
+
? 'Web search was already performed. The search results are included in the user message above. Please use that information to answer.'
|
|
908
|
+
: 'URL fetching is not needed. The relevant content was already fetched and included in the user message above. Please use that information.';
|
|
909
|
+
|
|
910
|
+
yield { type: 'tool_start', name: finalSanitized.name, args: finalSanitized.args, callId: skipCallId };
|
|
911
|
+
yield { type: 'tool_result', name: finalSanitized.name, result: skipResult, callId: skipCallId };
|
|
912
|
+
|
|
913
|
+
const assistantContent = fullResponseText + `<tool_call>\n${serializeToolCallToXml(finalSanitized)}\n</tool_call>`;
|
|
914
|
+
messages.push({ role: 'assistant', content: assistantContent });
|
|
915
|
+
messages.push({ role: 'user', content: `Tool result for ${finalSanitized.name}:\n${skipResult}` });
|
|
916
|
+
|
|
917
|
+
fullResponseText = '';
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Execute the tool (validated/sanitized)
|
|
922
|
+
totalToolCalls++;
|
|
923
|
+
const finalCallId = randomUUID();
|
|
924
|
+
console.log(`[Agent] Executing final tool: ${finalSanitized.name} (${finalCallId})`);
|
|
925
|
+
|
|
926
|
+
yield { type: 'tool_start', name: finalSanitized.name, args: finalSanitized.args, callId: finalCallId };
|
|
927
|
+
|
|
928
|
+
let toolResult;
|
|
929
|
+
try {
|
|
930
|
+
toolResult = await executeTool(finalSanitized.name, finalSanitized.args, config, snapshotFn, shellUndoFn, finalCallId);
|
|
931
|
+
yield { type: 'tool_result', name: finalSanitized.name, result: toolResult, callId: finalCallId };
|
|
932
|
+
} catch (err) {
|
|
933
|
+
toolResult = `Error: ${err.message}`;
|
|
934
|
+
yield { type: 'tool_error', name: finalSanitized.name, error: toolResult, callId: finalCallId };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
let assistantMsgContent = fullResponseText + `<tool_call>\n${serializeToolCallToXml(finalSanitized)}\n</tool_call>`;
|
|
938
|
+
if (isOllamaReasoning && thinkingContent) {
|
|
939
|
+
assistantMsgContent = `<thinking>${thinkingContent}</thinking>\n${assistantMsgContent}`;
|
|
940
|
+
}
|
|
941
|
+
messages.push({ role: 'assistant', content: assistantMsgContent });
|
|
942
|
+
messages.push({
|
|
943
|
+
role: 'user',
|
|
944
|
+
content: `[Tool "${finalSanitized.name}" returned]:\n${toolResult}`
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
fullResponseText = '';
|
|
948
|
+
thinkingContent = '';
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// No more tool calls - yield any remaining buffered text
|
|
953
|
+
const remainingText = buffer.substring(textYielded.length);
|
|
954
|
+
if (remainingText) {
|
|
955
|
+
// Clean out any tool call tags (both complete and incomplete)
|
|
956
|
+
let cleanRemaining = remainingText;
|
|
957
|
+
|
|
958
|
+
// CRITICAL FIX: Normalize malformed closing tags (missing >) before processing
|
|
959
|
+
// The LLM sometimes outputs </tool_call without the closing >
|
|
960
|
+
cleanRemaining = cleanRemaining.replace(/<\/tool_call([^>])/g, '</tool_call>$1');
|
|
961
|
+
|
|
962
|
+
// CRITICAL FIX: Strip ALL complete tool_call blocks too
|
|
963
|
+
// This is a safeguard for cases where parseFirstToolCall fails to parse the XML
|
|
964
|
+
// (e.g., malformed XML, unexpected format, etc.)
|
|
965
|
+
// We should NEVER yield tool_call XML as text - it confuses the frontend
|
|
966
|
+
cleanRemaining = cleanRemaining.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, '');
|
|
967
|
+
|
|
968
|
+
// Check if there's an incomplete tool call (opening without closing)
|
|
969
|
+
const lastToolStart = cleanRemaining.lastIndexOf('<tool_call>');
|
|
970
|
+
if (lastToolStart !== -1) {
|
|
971
|
+
const afterStart = cleanRemaining.substring(lastToolStart);
|
|
972
|
+
if (!afterStart.includes('</tool_call>')) {
|
|
973
|
+
// Incomplete tool call - remove from opening tag to end
|
|
974
|
+
cleanRemaining = cleanRemaining.substring(0, lastToolStart);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Also clean partial opening tags at the very end
|
|
979
|
+
cleanRemaining = cleanRemaining.replace(/<tool_call$/g, '')
|
|
980
|
+
.replace(/<tool_cal$/g, '')
|
|
981
|
+
.replace(/<tool_ca$/g, '')
|
|
982
|
+
.replace(/<tool_c$/g, '')
|
|
983
|
+
.replace(/<tool_$/g, '')
|
|
984
|
+
.replace(/<tool$/g, '')
|
|
985
|
+
.replace(/<too$/g, '')
|
|
986
|
+
.replace(/<to$/g, '')
|
|
987
|
+
.replace(/<t$/g, '')
|
|
988
|
+
.replace(/<$/g, '');
|
|
989
|
+
|
|
990
|
+
if (cleanRemaining) {
|
|
991
|
+
yield { type: 'text', content: cleanRemaining };
|
|
992
|
+
fullResponseText += cleanRemaining;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// RECOVERY: If model produced no text response after using tools, nudge it to answer.
|
|
997
|
+
// Common with Ollama models that get stuck in a tool-calling loop or produce only thinking.
|
|
998
|
+
// Disable tools during recovery so the model is forced to produce a text response.
|
|
999
|
+
if (!fullResponseText.trim() && totalToolCalls > 0 && recoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
|
|
1000
|
+
recoveryAttempts++;
|
|
1001
|
+
console.log(`[Agent] Model produced no response after ${totalToolCalls} tool call(s) - recovery attempt ${recoveryAttempts}/${MAX_RECOVERY_ATTEMPTS}`);
|
|
1002
|
+
|
|
1003
|
+
messages.push({
|
|
1004
|
+
role: 'user',
|
|
1005
|
+
content: 'Based on the tool results above, please provide your answer now. Do not use any tools. Respond directly with the information you have.'
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
thinkingContent = '';
|
|
1009
|
+
config.disableTools = true;
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Restore tools after recovery (whether it succeeded or not)
|
|
1014
|
+
if (recoveryAttempts > 0) {
|
|
1015
|
+
config.disableTools = false;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (!fullResponseText.trim() && totalToolCalls > 0 && recoveryAttempts >= MAX_RECOVERY_ATTEMPTS) {
|
|
1019
|
+
console.warn('[Agent] Recovery failed - model did not produce a response after multiple attempts');
|
|
1020
|
+
yield {
|
|
1021
|
+
type: 'text',
|
|
1022
|
+
content: '\n\n*The model gathered information but could not formulate a response. Try asking your question again or use a different model.*\n'
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// No tool calls found in this iteration - we're done (title is set by frontend via backend)
|
|
1027
|
+
return;
|
|
1028
|
+
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
yield { type: 'error', message: `Inference error: ${err.message}` };
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Max iterations reached
|
|
1036
|
+
yield {
|
|
1037
|
+
type: 'warning',
|
|
1038
|
+
message: `Reached maximum tool iterations (${maxIterations}). Stopping.`
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Run agent for a scheduled task
|
|
1044
|
+
*/
|
|
1045
|
+
export async function runScheduledTask(taskDescription, config) {
|
|
1046
|
+
const messages = [];
|
|
1047
|
+
const prompt = `Execute the following scheduled task:\n\n${taskDescription}\n\nComplete this task and report the results.`;
|
|
1048
|
+
|
|
1049
|
+
let fullResponse = '';
|
|
1050
|
+
|
|
1051
|
+
for await (const chunk of runAgent(prompt, messages, config, {})) {
|
|
1052
|
+
if (chunk.type === 'text') {
|
|
1053
|
+
fullResponse += chunk.content;
|
|
1054
|
+
}
|
|
1055
|
+
if (chunk.type === 'tool_start') {
|
|
1056
|
+
console.log(`[Scheduled Task] Executing: ${chunk.name}`);
|
|
1057
|
+
}
|
|
1058
|
+
if (chunk.type === 'tool_result') {
|
|
1059
|
+
console.log(`[Scheduled Task] Result: ${chunk.result?.slice(0, 100)}...`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
return cleanResponseText(fullResponse);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export default { runAgent, runScheduledTask };
|