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.
Files changed (81) hide show
  1. package/README.md +193 -0
  2. package/bin/otherwise.js +5 -0
  3. package/frontend/404.html +84 -0
  4. package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
  5. package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
  6. package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  7. package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
  8. package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
  9. package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
  10. package/frontend/assets/index-BLux5ps4.js +21 -0
  11. package/frontend/assets/index-Blh8_TEM.js +5272 -0
  12. package/frontend/assets/index-BpQ1PuKu.js +18 -0
  13. package/frontend/assets/index-Df737c8w.css +1 -0
  14. package/frontend/assets/index-xaYHL6wb.js +113 -0
  15. package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
  16. package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
  17. package/frontend/assets/transformers-tULNc5V3.js +31 -0
  18. package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
  19. package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
  20. package/frontend/assets/worker-2d5ABSLU.js +31 -0
  21. package/frontend/banner.png +0 -0
  22. package/frontend/favicon.svg +3 -0
  23. package/frontend/google55e5ec47ee14a5f8.html +1 -0
  24. package/frontend/index.html +234 -0
  25. package/frontend/manifest.json +17 -0
  26. package/frontend/pdf.worker.min.mjs +21 -0
  27. package/frontend/robots.txt +5 -0
  28. package/frontend/sitemap.xml +27 -0
  29. package/package.json +81 -0
  30. package/src/agent/index.js +1066 -0
  31. package/src/agent/location.js +51 -0
  32. package/src/agent/prompt.js +548 -0
  33. package/src/agent/tools.js +4372 -0
  34. package/src/browser/detect.js +68 -0
  35. package/src/browser/session.js +1109 -0
  36. package/src/config.js +137 -0
  37. package/src/email/client.js +503 -0
  38. package/src/index.js +557 -0
  39. package/src/inference/anthropic.js +113 -0
  40. package/src/inference/google.js +373 -0
  41. package/src/inference/index.js +81 -0
  42. package/src/inference/ollama.js +383 -0
  43. package/src/inference/openai.js +140 -0
  44. package/src/inference/openrouter.js +378 -0
  45. package/src/inference/xai.js +200 -0
  46. package/src/logBridge.js +9 -0
  47. package/src/models.js +146 -0
  48. package/src/remote/client.js +225 -0
  49. package/src/scheduler/cron.js +243 -0
  50. package/src/server.js +3876 -0
  51. package/src/storage/db.js +1135 -0
  52. package/src/storage/supabase.js +364 -0
  53. package/src/tunnel/cloudflare.js +241 -0
  54. package/src/ui/components/App.jsx +687 -0
  55. package/src/ui/components/BrowserSelect.jsx +111 -0
  56. package/src/ui/components/FilePicker.jsx +472 -0
  57. package/src/ui/components/Header.jsx +444 -0
  58. package/src/ui/components/HelpPanel.jsx +173 -0
  59. package/src/ui/components/HistoryPanel.jsx +158 -0
  60. package/src/ui/components/MessageList.jsx +235 -0
  61. package/src/ui/components/ModelSelector.jsx +304 -0
  62. package/src/ui/components/PromptInput.jsx +515 -0
  63. package/src/ui/components/StreamingResponse.jsx +134 -0
  64. package/src/ui/components/ThinkingIndicator.jsx +365 -0
  65. package/src/ui/components/ToolExecution.jsx +714 -0
  66. package/src/ui/components/index.js +82 -0
  67. package/src/ui/context/TerminalContext.jsx +150 -0
  68. package/src/ui/context/index.js +13 -0
  69. package/src/ui/hooks/index.js +16 -0
  70. package/src/ui/hooks/useChatState.js +675 -0
  71. package/src/ui/hooks/useCommands.js +280 -0
  72. package/src/ui/hooks/useFileAttachments.js +216 -0
  73. package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
  74. package/src/ui/hooks/useNotifications.js +185 -0
  75. package/src/ui/hooks/useTerminalSize.js +151 -0
  76. package/src/ui/hooks/useWebSocket.js +273 -0
  77. package/src/ui/index.js +94 -0
  78. package/src/ui/ink-runner.js +22 -0
  79. package/src/ui/utils/formatters.js +424 -0
  80. package/src/ui/utils/index.js +6 -0
  81. 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 };