banana-code 1.2.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/LICENSE +21 -0
- package/README.md +246 -0
- package/banana.js +5464 -0
- package/lib/agenticRunner.js +1884 -0
- package/lib/borderRenderer.js +41 -0
- package/lib/commandRunner.js +205 -0
- package/lib/completer.js +286 -0
- package/lib/config.js +301 -0
- package/lib/contextBuilder.js +324 -0
- package/lib/diffViewer.js +295 -0
- package/lib/fileManager.js +224 -0
- package/lib/historyManager.js +124 -0
- package/lib/hookManager.js +1143 -0
- package/lib/imageHandler.js +268 -0
- package/lib/inlineComplete.js +192 -0
- package/lib/interactivePicker.js +254 -0
- package/lib/lmStudio.js +226 -0
- package/lib/markdownRenderer.js +423 -0
- package/lib/mcpClient.js +288 -0
- package/lib/modelRegistry.js +350 -0
- package/lib/monkeyModels.js +97 -0
- package/lib/oauthOpenAI.js +167 -0
- package/lib/parser.js +134 -0
- package/lib/promptManager.js +96 -0
- package/lib/providerClients.js +1014 -0
- package/lib/providerManager.js +130 -0
- package/lib/providerStore.js +413 -0
- package/lib/statusBar.js +283 -0
- package/lib/streamHandler.js +306 -0
- package/lib/subAgentManager.js +406 -0
- package/lib/tokenCounter.js +132 -0
- package/lib/visionAnalyzer.js +163 -0
- package/lib/watcher.js +138 -0
- package/models.json +57 -0
- package/package.json +42 -0
- package/prompts/base.md +23 -0
- package/prompts/code-agent-glm.md +16 -0
- package/prompts/code-agent-gptoss.md +25 -0
- package/prompts/code-agent-nemotron.md +17 -0
- package/prompts/code-agent-qwen.md +20 -0
- package/prompts/code-agent.md +70 -0
- package/prompts/plan.md +44 -0
|
@@ -0,0 +1,1884 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agentic Runner for Banana Code v4
|
|
3
|
+
* Tool-calling loop - extracted from the AI Router's agentic endpoint.
|
|
4
|
+
* Runs locally, no middleware needed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const { spawn } = require('child_process');
|
|
11
|
+
const McpClient = require('./mcpClient');
|
|
12
|
+
|
|
13
|
+
// Shared MCP client - set from outside via setMcpClient()
|
|
14
|
+
let mcpClient = null;
|
|
15
|
+
function getMcpClient() {
|
|
16
|
+
if (!mcpClient) mcpClient = new McpClient();
|
|
17
|
+
return mcpClient;
|
|
18
|
+
}
|
|
19
|
+
function setMcpClient(client) {
|
|
20
|
+
mcpClient = client;
|
|
21
|
+
mcpToolNameCache = null;
|
|
22
|
+
mcpToolNameCacheAt = 0;
|
|
23
|
+
mcpHealthy = null;
|
|
24
|
+
mcpHealthCheckAt = 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// MCP tool-name cache (server advertises dynamic tool catalogs)
|
|
28
|
+
let mcpToolNameCache = null;
|
|
29
|
+
let mcpToolNameCacheAt = 0;
|
|
30
|
+
const MCP_TOOL_CACHE_TTL_MS = 30 * 1000;
|
|
31
|
+
|
|
32
|
+
// MCP health state (cached for 60s to avoid repeated slow checks)
|
|
33
|
+
let mcpHealthy = null; // null = unknown, true/false = cached result
|
|
34
|
+
let mcpHealthCheckAt = 0;
|
|
35
|
+
const MCP_HEALTH_CACHE_TTL_MS = 60 * 1000;
|
|
36
|
+
|
|
37
|
+
async function checkMcpHealth() {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
if (mcpHealthy !== null && (now - mcpHealthCheckAt) < MCP_HEALTH_CACHE_TTL_MS) {
|
|
40
|
+
return mcpHealthy;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const client = getMcpClient();
|
|
44
|
+
if (typeof client.isConnected === 'function') {
|
|
45
|
+
mcpHealthy = await client.isConnected();
|
|
46
|
+
} else {
|
|
47
|
+
// If no health check method, assume healthy and let individual calls fail
|
|
48
|
+
mcpHealthy = true;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
mcpHealthy = false;
|
|
52
|
+
}
|
|
53
|
+
mcpHealthCheckAt = now;
|
|
54
|
+
return mcpHealthy;
|
|
55
|
+
}
|
|
56
|
+
const DEBUG_DISABLED_VALUES = new Set(['0', 'false', 'off', 'no']);
|
|
57
|
+
|
|
58
|
+
function isDebugEnabled() {
|
|
59
|
+
const raw = (process.env.BANANA_DEBUG || '').trim().toLowerCase();
|
|
60
|
+
if (!raw) return false;
|
|
61
|
+
return !DEBUG_DISABLED_VALUES.has(raw);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveDebugPath() {
|
|
65
|
+
const configured = (process.env.BANANA_DEBUG_PATH || '').trim();
|
|
66
|
+
if (configured) return configured;
|
|
67
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
68
|
+
return path.join(os.homedir(), '.banana', 'logs', `banana-${day}.log`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function appendDebugLog(line) {
|
|
72
|
+
if (!isDebugEnabled()) return;
|
|
73
|
+
try {
|
|
74
|
+
const debugPath = resolveDebugPath();
|
|
75
|
+
fs.mkdirSync(path.dirname(debugPath), { recursive: true });
|
|
76
|
+
fs.appendFileSync(debugPath, line, 'utf-8');
|
|
77
|
+
} catch {
|
|
78
|
+
// Best-effort logging only.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Error Enrichment ─────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function enrichToolError(functionName, args, result) {
|
|
85
|
+
if (!result || !result.error) return result;
|
|
86
|
+
const errStr = typeof result.error === 'string' ? result.error : JSON.stringify(result.error);
|
|
87
|
+
|
|
88
|
+
// Categorize errors with actionable guidance
|
|
89
|
+
const patterns = [
|
|
90
|
+
{ test: /Unknown tool|unknown tool/i, category: 'PERMANENT', retryable: false,
|
|
91
|
+
guidance: `Tool "${args?.tool || functionName}" does not exist. Do not retry.` },
|
|
92
|
+
{ test: /ECONNREFUSED|ECONNRESET|unreachable|socket hang up/i, category: 'TRANSIENT', retryable: false,
|
|
93
|
+
guidance: 'Service is unreachable. Proceed without this tool.' },
|
|
94
|
+
{ test: /timed? ?out|ETIMEDOUT|deadline exceeded/i, category: 'TRANSIENT', retryable: false,
|
|
95
|
+
guidance: 'Request timed out. Move on to other work.' },
|
|
96
|
+
{ test: /not found|ENOENT|no such file/i, category: 'PERMANENT', retryable: false,
|
|
97
|
+
guidance: 'Resource not found. Check the path or name.' },
|
|
98
|
+
{ test: /401|403|unauthorized|forbidden|expired|invalid.*token/i, category: 'PERMANENT', retryable: false,
|
|
99
|
+
guidance: 'Authentication/authorization issue. Skip this service.' },
|
|
100
|
+
{ test: /5\d{2}|internal server error|bad gateway|service unavailable/i, category: 'TRANSIENT', retryable: true,
|
|
101
|
+
guidance: 'Server error. May resolve on retry.' }
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
for (const p of patterns) {
|
|
105
|
+
if (p.test.test(errStr)) {
|
|
106
|
+
return { ...result, category: p.category, retryable: p.retryable, guidance: p.guidance };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Transient Retry ──────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const TRANSIENT_ERROR_RE = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket hang up|5\d{2}|bad gateway|service unavailable|internal server error/i;
|
|
115
|
+
|
|
116
|
+
async function withRetry(fn, { maxRetries = 2, baseDelay = 1000, signal } = {}) {
|
|
117
|
+
let lastErr;
|
|
118
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
119
|
+
try {
|
|
120
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
121
|
+
return await fn();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err?.name === 'AbortError') throw err;
|
|
124
|
+
lastErr = err;
|
|
125
|
+
const errMsg = err?.message || String(err);
|
|
126
|
+
// Only retry transient errors
|
|
127
|
+
if (!TRANSIENT_ERROR_RE.test(errMsg) || attempt >= maxRetries) {
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
const delay = baseDelay * Math.pow(2, attempt); // 1s, 2s
|
|
131
|
+
appendDebugLog(` [retry] attempt ${attempt + 1}/${maxRetries} after ${delay}ms: ${errMsg.slice(0, 200)}\n`);
|
|
132
|
+
// Abort-aware delay
|
|
133
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
134
|
+
await new Promise((resolve, reject) => {
|
|
135
|
+
let settled = false;
|
|
136
|
+
const timer = setTimeout(() => { settled = true; cleanup(); resolve(); }, delay);
|
|
137
|
+
let onAbort;
|
|
138
|
+
const cleanup = () => { if (signal && onAbort) signal.removeEventListener('abort', onAbort); };
|
|
139
|
+
if (signal) {
|
|
140
|
+
onAbort = () => { if (!settled) { settled = true; clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); } };
|
|
141
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
throw lastErr;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const LEGACY_MCP_TOOL_MAP = {
|
|
150
|
+
list_tasks: { wrapper: 'tasks', action: 'list_tasks' },
|
|
151
|
+
create_task: { wrapper: 'tasks', action: 'create_task' },
|
|
152
|
+
update_task: { wrapper: 'tasks', action: 'update_task' },
|
|
153
|
+
complete_task: { wrapper: 'tasks', action: 'complete_task' },
|
|
154
|
+
delete_task: { wrapper: 'tasks', action: 'delete_task' },
|
|
155
|
+
sync_tasks: { wrapper: 'tasks', action: 'sync_tasks' },
|
|
156
|
+
urgent_tasks: { wrapper: 'tasks', action: 'urgent_tasks' },
|
|
157
|
+
process_inbox: { wrapper: 'tasks', action: 'process_inbox' },
|
|
158
|
+
list_events: { wrapper: 'calendar', action: 'list_events' },
|
|
159
|
+
get_event: { wrapper: 'calendar', action: 'get_event' },
|
|
160
|
+
create_event: { wrapper: 'calendar', action: 'create_event' },
|
|
161
|
+
update_event: { wrapper: 'calendar', action: 'update_event' },
|
|
162
|
+
delete_event: { wrapper: 'calendar', action: 'delete_event' },
|
|
163
|
+
get_freebusy: { wrapper: 'calendar', action: 'get_freebusy' },
|
|
164
|
+
gmail_summary: { wrapper: 'gmail', action: 'gmail_summary' },
|
|
165
|
+
gmail_get_messages: { wrapper: 'gmail', action: 'gmail_get_messages' },
|
|
166
|
+
gmail_search: { wrapper: 'gmail', action: 'gmail_search' },
|
|
167
|
+
gmail_get_message: { wrapper: 'gmail', action: 'gmail_get_message' },
|
|
168
|
+
gmail_send: { wrapper: 'gmail', action: 'gmail_send' },
|
|
169
|
+
gmail_reply: { wrapper: 'gmail', action: 'gmail_reply' },
|
|
170
|
+
gmail_archive: { wrapper: 'gmail', action: 'gmail_archive' },
|
|
171
|
+
gmail_archive_batch: { wrapper: 'gmail', action: 'gmail_archive_batch' },
|
|
172
|
+
gmail_list_accounts: { wrapper: 'gmail', action: 'gmail_list_accounts' },
|
|
173
|
+
gmail_add_account: { wrapper: 'gmail', action: 'gmail_add_account' },
|
|
174
|
+
gmail_complete_auth: { wrapper: 'gmail', action: 'gmail_complete_auth' },
|
|
175
|
+
gmail_remove_account: { wrapper: 'gmail', action: 'gmail_remove_account' },
|
|
176
|
+
search_memory: { wrapper: 'memory', action: 'search_memory' },
|
|
177
|
+
save_memory: { wrapper: 'memory', action: 'save_memory' },
|
|
178
|
+
recent_memories: { wrapper: 'memory', action: 'recent_memories' },
|
|
179
|
+
slack_list_channels: { wrapper: 'slack', action: 'slack_list_channels' },
|
|
180
|
+
slack_list_users: { wrapper: 'slack', action: 'slack_list_users' },
|
|
181
|
+
slack_read_channel: { wrapper: 'slack', action: 'slack_read_channel' },
|
|
182
|
+
slack_read_dms: { wrapper: 'slack', action: 'slack_read_dms' },
|
|
183
|
+
slack_read_thread: { wrapper: 'slack', action: 'slack_read_thread' },
|
|
184
|
+
slack_search_messages: { wrapper: 'slack', action: 'slack_search_messages' },
|
|
185
|
+
slack_send_message: { wrapper: 'slack', action: 'slack_send_message' },
|
|
186
|
+
slack_send_dm: { wrapper: 'slack', action: 'slack_send_dm' }
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const LEGACY_PREFIX_MAP = {
|
|
190
|
+
gmail: 'gmail',
|
|
191
|
+
slack: 'slack',
|
|
192
|
+
drive: 'drive',
|
|
193
|
+
docs: 'docs',
|
|
194
|
+
sheets: 'sheets',
|
|
195
|
+
monday: 'monday',
|
|
196
|
+
stripe: 'stripe',
|
|
197
|
+
twilio: 'twilio',
|
|
198
|
+
telegram: 'telegram',
|
|
199
|
+
operly: 'operly',
|
|
200
|
+
whisper: 'whisper',
|
|
201
|
+
roam: 'roam',
|
|
202
|
+
eliteteam: 'eliteteam',
|
|
203
|
+
namecheap: 'namecheap',
|
|
204
|
+
vercel: 'vercel',
|
|
205
|
+
github: 'github'
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
async function getAvailableMcpToolNames(client, forceRefresh = false) {
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
if (!forceRefresh && mcpToolNameCache && (now - mcpToolNameCacheAt) < MCP_TOOL_CACHE_TTL_MS) {
|
|
211
|
+
return mcpToolNameCache;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const tools = await client.listTools();
|
|
215
|
+
mcpToolNameCache = new Set((tools || []).map(t => t?.name).filter(Boolean));
|
|
216
|
+
mcpToolNameCacheAt = now;
|
|
217
|
+
return mcpToolNameCache;
|
|
218
|
+
} catch {
|
|
219
|
+
// Fall back to stale cache if available; otherwise empty set.
|
|
220
|
+
return mcpToolNameCache || new Set();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function legacyToolToWrapperInvocation(toolName, args = {}) {
|
|
225
|
+
const name = String(toolName || '').trim();
|
|
226
|
+
if (!name) return null;
|
|
227
|
+
const lower = name.toLowerCase();
|
|
228
|
+
|
|
229
|
+
const mapped = LEGACY_MCP_TOOL_MAP[lower];
|
|
230
|
+
if (mapped) {
|
|
231
|
+
return {
|
|
232
|
+
tool: mapped.wrapper,
|
|
233
|
+
args: { action: mapped.action, params: args || {} }
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const prefixMatch = lower.match(/^([a-z]+)_/);
|
|
238
|
+
if (prefixMatch) {
|
|
239
|
+
const wrapper = LEGACY_PREFIX_MAP[prefixMatch[1]];
|
|
240
|
+
if (wrapper) {
|
|
241
|
+
return {
|
|
242
|
+
tool: wrapper,
|
|
243
|
+
args: { action: lower, params: args || {} }
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function callMcpWithCompatibility(client, toolName, args = {}) {
|
|
252
|
+
const available = await getAvailableMcpToolNames(client);
|
|
253
|
+
const requested = String(toolName || '').trim();
|
|
254
|
+
if (!requested) {
|
|
255
|
+
throw new Error('No MCP tool name provided');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (available.has(requested)) {
|
|
259
|
+
return await client.callTool(requested, args || {});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const wrapped = legacyToolToWrapperInvocation(requested, args || {});
|
|
263
|
+
if (wrapped) {
|
|
264
|
+
if (available.has(wrapped.tool)) {
|
|
265
|
+
return await client.callTool(wrapped.tool, wrapped.args);
|
|
266
|
+
}
|
|
267
|
+
// Try anyway in case listTools is stale/partial on this session.
|
|
268
|
+
return await client.callTool(wrapped.tool, wrapped.args);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return await client.callTool(requested, args || {});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Tool definitions (OpenAI-compatible format)
|
|
275
|
+
const TOOLS = [
|
|
276
|
+
{
|
|
277
|
+
type: 'function',
|
|
278
|
+
function: {
|
|
279
|
+
name: 'read_file',
|
|
280
|
+
description: 'Read the contents of a file. Can read any file on the system, not just inside the project. Use relative paths for project files or absolute paths (e.g. C:\\path\\to\\file) for files elsewhere. Supports optional start_line/end_line for reading specific sections of large files.',
|
|
281
|
+
parameters: {
|
|
282
|
+
type: 'object',
|
|
283
|
+
properties: {
|
|
284
|
+
path: {
|
|
285
|
+
type: 'string',
|
|
286
|
+
description: 'Relative path for project files (e.g., "src/App.tsx") or absolute path for any file (e.g., "C:\\\\Users\\\\user\\\\other-project\\\\file.ts")'
|
|
287
|
+
},
|
|
288
|
+
start_line: {
|
|
289
|
+
type: 'integer',
|
|
290
|
+
description: 'Line number to start reading from (1-based). Use with end_line to read specific sections of large files.'
|
|
291
|
+
},
|
|
292
|
+
end_line: {
|
|
293
|
+
type: 'integer',
|
|
294
|
+
description: 'Line number to stop reading at (inclusive). Use with start_line to read specific sections of large files.'
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
required: ['path']
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
type: 'function',
|
|
303
|
+
function: {
|
|
304
|
+
name: 'list_files',
|
|
305
|
+
description: 'List files and directories in a given path. Can list any directory on the system, not just inside the project. Use relative paths for project directories or absolute paths for directories elsewhere.',
|
|
306
|
+
parameters: {
|
|
307
|
+
type: 'object',
|
|
308
|
+
properties: {
|
|
309
|
+
path: {
|
|
310
|
+
type: 'string',
|
|
311
|
+
description: 'Relative path for project dirs (e.g., "src") or absolute path for any dir (e.g., "C:\\\\Projects\\\\my-app")'
|
|
312
|
+
},
|
|
313
|
+
recursive: {
|
|
314
|
+
type: 'boolean',
|
|
315
|
+
description: 'Whether to list files recursively (default: false)'
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
required: ['path']
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
type: 'function',
|
|
324
|
+
function: {
|
|
325
|
+
name: 'search_code',
|
|
326
|
+
description: 'Search for a pattern in files. Path can be a directory (searches recursively) or a single file. Can search anywhere on the system. Use relative paths for project files or absolute paths for anywhere else.',
|
|
327
|
+
parameters: {
|
|
328
|
+
type: 'object',
|
|
329
|
+
properties: {
|
|
330
|
+
pattern: {
|
|
331
|
+
type: 'string',
|
|
332
|
+
description: 'The search pattern (supports regex)'
|
|
333
|
+
},
|
|
334
|
+
path: {
|
|
335
|
+
type: 'string',
|
|
336
|
+
description: 'The directory or file to search in (default: "."). Can be an absolute path like "C:\\\\other-project\\\\file.js"'
|
|
337
|
+
},
|
|
338
|
+
file_pattern: {
|
|
339
|
+
type: 'string',
|
|
340
|
+
description: 'File glob pattern to filter (e.g., "*.ts", "*.tsx")'
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
required: ['pattern']
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
type: 'function',
|
|
349
|
+
function: {
|
|
350
|
+
name: 'create_file',
|
|
351
|
+
description: 'Create a new file or overwrite an existing file with the given content.',
|
|
352
|
+
parameters: {
|
|
353
|
+
type: 'object',
|
|
354
|
+
properties: {
|
|
355
|
+
path: {
|
|
356
|
+
type: 'string',
|
|
357
|
+
description: 'The relative path to the file to create (e.g., "src/components/Button.tsx")'
|
|
358
|
+
},
|
|
359
|
+
content: {
|
|
360
|
+
type: 'string',
|
|
361
|
+
description: 'The full content to write to the file'
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
required: ['path', 'content']
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
type: 'function',
|
|
370
|
+
function: {
|
|
371
|
+
name: 'edit_file',
|
|
372
|
+
description: 'Edit an existing file by replacing its entire content.',
|
|
373
|
+
parameters: {
|
|
374
|
+
type: 'object',
|
|
375
|
+
properties: {
|
|
376
|
+
path: {
|
|
377
|
+
type: 'string',
|
|
378
|
+
description: 'The relative path to the file to edit'
|
|
379
|
+
},
|
|
380
|
+
content: {
|
|
381
|
+
type: 'string',
|
|
382
|
+
description: 'The new full content of the file'
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
required: ['path', 'content']
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
type: 'function',
|
|
391
|
+
function: {
|
|
392
|
+
name: 'run_command',
|
|
393
|
+
description: 'Run a shell command in the project directory. Use non-interactive flags (e.g., --yes, -y).',
|
|
394
|
+
parameters: {
|
|
395
|
+
type: 'object',
|
|
396
|
+
properties: {
|
|
397
|
+
command: {
|
|
398
|
+
type: 'string',
|
|
399
|
+
description: 'The shell command to run (e.g., "npm install axios")'
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
required: ['command']
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
type: 'function',
|
|
408
|
+
function: {
|
|
409
|
+
name: 'get_tasks',
|
|
410
|
+
description: 'Get tasks. Filter by status (not_started, in_progress, completed) or project name. Leave filters empty to get all urgent/upcoming tasks.',
|
|
411
|
+
parameters: {
|
|
412
|
+
type: 'object',
|
|
413
|
+
properties: {
|
|
414
|
+
status: { type: 'string', description: 'Filter by status: not_started, in_progress, completed, blocked' },
|
|
415
|
+
project: { type: 'string', description: 'Filter by project name' },
|
|
416
|
+
limit: { type: 'number', description: 'Max tasks to return (default 20)' }
|
|
417
|
+
},
|
|
418
|
+
required: []
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
type: 'function',
|
|
424
|
+
function: {
|
|
425
|
+
name: 'create_task',
|
|
426
|
+
description: 'Create a new task.',
|
|
427
|
+
parameters: {
|
|
428
|
+
type: 'object',
|
|
429
|
+
properties: {
|
|
430
|
+
title: { type: 'string', description: 'Task title' },
|
|
431
|
+
project: { type: 'string', description: 'Project name' },
|
|
432
|
+
due_date: { type: 'string', description: 'Due date in YYYY-MM-DD format' },
|
|
433
|
+
priority: { type: 'string', description: 'low, medium, high, or urgent' },
|
|
434
|
+
description: { type: 'string', description: 'Optional task description' }
|
|
435
|
+
},
|
|
436
|
+
required: ['title', 'project', 'due_date']
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
type: 'function',
|
|
442
|
+
function: {
|
|
443
|
+
name: 'get_calendar',
|
|
444
|
+
description: 'Get upcoming calendar events.',
|
|
445
|
+
parameters: {
|
|
446
|
+
type: 'object',
|
|
447
|
+
properties: {
|
|
448
|
+
maxResults: { type: 'number', description: 'Max events to return (default 10)' },
|
|
449
|
+
timeMin: { type: 'string', description: 'Start time ISO 8601 (default: now)' },
|
|
450
|
+
timeMax: { type: 'string', description: 'End time ISO 8601' }
|
|
451
|
+
},
|
|
452
|
+
required: []
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
type: 'function',
|
|
458
|
+
function: {
|
|
459
|
+
name: 'get_email_summary',
|
|
460
|
+
description: 'Get a summary of recent unread emails.',
|
|
461
|
+
parameters: {
|
|
462
|
+
type: 'object',
|
|
463
|
+
properties: {},
|
|
464
|
+
required: []
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
type: 'function',
|
|
470
|
+
function: {
|
|
471
|
+
name: 'search_memory',
|
|
472
|
+
description: 'Search saved memories and context notes.',
|
|
473
|
+
parameters: {
|
|
474
|
+
type: 'object',
|
|
475
|
+
properties: {
|
|
476
|
+
query: { type: 'string', description: 'Search query' }
|
|
477
|
+
},
|
|
478
|
+
required: ['query']
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
type: 'function',
|
|
484
|
+
function: {
|
|
485
|
+
name: 'deep_research',
|
|
486
|
+
description: 'Conduct in-depth research using Perplexity Sonar Deep Research. Use this for any question requiring thorough, factual research. Returns comprehensive, source-backed results. ALWAYS prefer this over answering from memory for factual questions about people, events, technology, etc.',
|
|
487
|
+
parameters: {
|
|
488
|
+
type: 'object',
|
|
489
|
+
properties: {
|
|
490
|
+
query: { type: 'string', description: 'The research query' },
|
|
491
|
+
focus_areas: { type: 'array', items: { type: 'string' }, description: 'Optional focus areas to narrow the research' }
|
|
492
|
+
},
|
|
493
|
+
required: ['query']
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
type: 'function',
|
|
499
|
+
function: {
|
|
500
|
+
name: 'web_search',
|
|
501
|
+
description: 'Quick web search using Brave Search API. Use for simple lookups, current events, or quick fact-checks. For deeper research, use deep_research instead.',
|
|
502
|
+
parameters: {
|
|
503
|
+
type: 'object',
|
|
504
|
+
properties: {
|
|
505
|
+
query: { type: 'string', description: 'The search query (max 400 chars)' },
|
|
506
|
+
count: { type: 'number', description: 'Number of results (1-20, default 10)' }
|
|
507
|
+
},
|
|
508
|
+
required: ['query']
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
type: 'function',
|
|
514
|
+
function: {
|
|
515
|
+
name: 'banana_help',
|
|
516
|
+
description: 'Get detailed documentation about Banana Code features. Use this when a user asks how to do something in Banana, or when you need to guide them through a feature.',
|
|
517
|
+
parameters: {
|
|
518
|
+
type: 'object',
|
|
519
|
+
properties: {
|
|
520
|
+
topic: {
|
|
521
|
+
type: 'string',
|
|
522
|
+
description: 'The topic to look up: "overview", "hooks", "models", "commands", "project-instructions", "agents"'
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
required: []
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
type: 'function',
|
|
531
|
+
function: {
|
|
532
|
+
name: 'call_mcp',
|
|
533
|
+
description: 'GENERIC TOOL WRAPPER - call ANY external service by name. Works with both direct MCP tool names and wrapper tools. Wrapper pattern: tool="gmail", args={"action":"gmail_summary","params":{}} (same for tasks, calendar, slack, github, etc). Legacy names like "gmail_summary" are auto-routed for compatibility.',
|
|
534
|
+
parameters: {
|
|
535
|
+
type: 'object',
|
|
536
|
+
properties: {
|
|
537
|
+
tool: { type: 'string', description: 'The MCP tool name (e.g., "gmail" or legacy "gmail_summary")' },
|
|
538
|
+
args: { type: 'object', description: 'Tool arguments as a JSON object' }
|
|
539
|
+
},
|
|
540
|
+
required: ['tool']
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
];
|
|
545
|
+
|
|
546
|
+
// Read-only subset of tools for plan mode
|
|
547
|
+
const READ_ONLY_TOOL_NAMES = new Set(['read_file', 'list_files', 'search_code', 'banana_help', 'ask_human']);
|
|
548
|
+
const READ_ONLY_TOOLS = TOOLS.filter(t => READ_ONLY_TOOL_NAMES.has(t.function.name));
|
|
549
|
+
|
|
550
|
+
const IGNORE_PATTERNS = ['node_modules', '.git', '.next', 'dist', 'build', '.banana'];
|
|
551
|
+
const MAX_ITERATIONS = 30;
|
|
552
|
+
const WRITE_TOOL_NAMES = new Set(['create_file', 'edit_file', 'run_command']);
|
|
553
|
+
const CONTEXT_TRIM_THRESHOLD = 0.60; // 60% of context limit - start trimming early
|
|
554
|
+
const CONTEXT_TRIM_KEEP_RECENT = 6; // Keep last N messages intact
|
|
555
|
+
const CONTEXT_CRITICAL_THRESHOLD = 0.85; // 85% - drop old messages entirely
|
|
556
|
+
|
|
557
|
+
function estimateTokenCount(messages) {
|
|
558
|
+
return Math.ceil(JSON.stringify(messages).length / 3.5);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Truncate a single string field if it exceeds maxLen.
|
|
563
|
+
* Returns true if truncation happened.
|
|
564
|
+
*/
|
|
565
|
+
function truncateString(obj, key, maxLen) {
|
|
566
|
+
if (typeof obj[key] === 'string' && obj[key].length > maxLen) {
|
|
567
|
+
const originalLen = obj[key].length;
|
|
568
|
+
obj[key] = obj[key].substring(0, maxLen) + '\n[trimmed - ' + Math.round(originalLen / 1000) + 'k chars removed]';
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function trimContextIfNeeded(messages, contextLimit) {
|
|
575
|
+
if (!contextLimit || contextLimit <= 0) return;
|
|
576
|
+
let estimated = estimateTokenCount(messages);
|
|
577
|
+
if (estimated < contextLimit * CONTEXT_TRIM_THRESHOLD) return;
|
|
578
|
+
|
|
579
|
+
const protectedStart = Math.max(0, messages.length - CONTEXT_TRIM_KEEP_RECENT);
|
|
580
|
+
let trimmed = 0;
|
|
581
|
+
|
|
582
|
+
// Pass 1: Truncate old tool results (role: 'tool' for OpenAI format)
|
|
583
|
+
for (let i = 1; i < protectedStart; i++) {
|
|
584
|
+
const msg = messages[i];
|
|
585
|
+
if (msg.role === 'tool' && truncateString(msg, 'content', 200)) trimmed++;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Pass 2: Truncate tool_use arguments in old assistant messages (the real token hog)
|
|
589
|
+
// When the model calls create_file with a 12K-token HTML file, the args string is massive
|
|
590
|
+
for (let i = 1; i < protectedStart; i++) {
|
|
591
|
+
const msg = messages[i];
|
|
592
|
+
if (msg.role !== 'assistant') continue;
|
|
593
|
+
// OpenAI format: msg.tool_calls[].function.arguments (string)
|
|
594
|
+
if (Array.isArray(msg.tool_calls)) {
|
|
595
|
+
for (const tc of msg.tool_calls) {
|
|
596
|
+
if (tc.function && typeof tc.function.arguments === 'string' && tc.function.arguments.length > 500) {
|
|
597
|
+
// Parse, keep tool name + path but remove large content
|
|
598
|
+
try {
|
|
599
|
+
const args = JSON.parse(tc.function.arguments);
|
|
600
|
+
const summary = { _trimmed: true };
|
|
601
|
+
if (args.path) summary.path = args.path;
|
|
602
|
+
if (args.command) summary.command = typeof args.command === 'string' ? args.command.substring(0, 200) : args.command;
|
|
603
|
+
if (args.query) summary.query = typeof args.query === 'string' ? args.query.substring(0, 100) : args.query;
|
|
604
|
+
if (args.tool) summary.tool = args.tool;
|
|
605
|
+
tc.function.arguments = JSON.stringify(summary);
|
|
606
|
+
trimmed++;
|
|
607
|
+
} catch { /* not JSON, truncate raw */
|
|
608
|
+
tc.function.arguments = tc.function.arguments.substring(0, 200) + '[trimmed]';
|
|
609
|
+
trimmed++;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// Anthropic format: msg.content[] where type === 'tool_use', input is object
|
|
615
|
+
if (Array.isArray(msg.content)) {
|
|
616
|
+
for (const block of msg.content) {
|
|
617
|
+
if (block.type === 'tool_use' && block.input) {
|
|
618
|
+
const inputStr = JSON.stringify(block.input);
|
|
619
|
+
if (inputStr.length > 500) {
|
|
620
|
+
const summary = { _trimmed: true };
|
|
621
|
+
if (block.input.path) summary.path = block.input.path;
|
|
622
|
+
if (block.input.command) summary.command = typeof block.input.command === 'string' ? block.input.command.substring(0, 200) : block.input.command;
|
|
623
|
+
if (block.input.query) summary.query = typeof block.input.query === 'string' ? block.input.query.substring(0, 100) : block.input.query;
|
|
624
|
+
if (block.input.tool) summary.tool = block.input.tool;
|
|
625
|
+
block.input = summary;
|
|
626
|
+
trimmed++;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Pass 3: Truncate tool_result blocks in user messages (Anthropic format)
|
|
634
|
+
for (let i = 1; i < protectedStart; i++) {
|
|
635
|
+
const msg = messages[i];
|
|
636
|
+
if (msg.role !== 'user' || !Array.isArray(msg.content)) continue;
|
|
637
|
+
for (const block of msg.content) {
|
|
638
|
+
if (block.type === 'tool_result' && typeof block.content === 'string' && block.content.length > 300) {
|
|
639
|
+
block.content = block.content.substring(0, 200) + '\n[trimmed]';
|
|
640
|
+
trimmed++;
|
|
641
|
+
}
|
|
642
|
+
// Nested content array in tool_result
|
|
643
|
+
if (block.type === 'tool_result' && Array.isArray(block.content)) {
|
|
644
|
+
for (const inner of block.content) {
|
|
645
|
+
if (inner.type === 'text' && typeof inner.text === 'string' && inner.text.length > 300) {
|
|
646
|
+
inner.text = inner.text.substring(0, 200) + '\n[trimmed]';
|
|
647
|
+
trimmed++;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (trimmed > 0) {
|
|
655
|
+
const afterTrim = estimateTokenCount(messages);
|
|
656
|
+
appendDebugLog(` [context trim] Truncated ${trimmed} items (${estimated} -> ${afterTrim} estimated tokens, limit ${contextLimit})\n`);
|
|
657
|
+
estimated = afterTrim;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Pass 4: If still critical, drop oldest non-system messages entirely
|
|
661
|
+
if (estimated > contextLimit * CONTEXT_CRITICAL_THRESHOLD && messages.length > CONTEXT_TRIM_KEEP_RECENT + 2) {
|
|
662
|
+
let dropped = 0;
|
|
663
|
+
const dropTarget = contextLimit * 0.70; // Drop until 70% to leave headroom
|
|
664
|
+
while (messages.length > CONTEXT_TRIM_KEEP_RECENT + 2) {
|
|
665
|
+
messages.splice(1, 1); // remove message at index 1 (right after system)
|
|
666
|
+
dropped++;
|
|
667
|
+
// Re-estimate every 2 drops to avoid over-dropping
|
|
668
|
+
if (dropped % 2 === 0) {
|
|
669
|
+
estimated = estimateTokenCount(messages);
|
|
670
|
+
if (estimated < dropTarget) break;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
estimated = estimateTokenCount(messages);
|
|
674
|
+
appendDebugLog(` [context trim] Dropped ${dropped} old messages (now ${estimated} estimated tokens, limit ${contextLimit})\n`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ─── Tool Executors ─────────────────────────────────────────────────────────
|
|
679
|
+
|
|
680
|
+
function executeReadFile(projectDir, filePath, startLine, endLine) {
|
|
681
|
+
try {
|
|
682
|
+
const fullPath = path.resolve(projectDir, filePath);
|
|
683
|
+
if (!fs.existsSync(fullPath)) {
|
|
684
|
+
return { error: `File not found: ${filePath}` };
|
|
685
|
+
}
|
|
686
|
+
const stat = fs.statSync(fullPath);
|
|
687
|
+
if (stat.isDirectory()) {
|
|
688
|
+
return { error: `${filePath} is a directory, not a file` };
|
|
689
|
+
}
|
|
690
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
691
|
+
const lines = content.split('\n');
|
|
692
|
+
const totalLines = lines.length;
|
|
693
|
+
|
|
694
|
+
// Line-range reading
|
|
695
|
+
if (startLine != null || endLine != null) {
|
|
696
|
+
const start = Math.max(1, startLine || 1);
|
|
697
|
+
const end = Math.min(totalLines, endLine || totalLines);
|
|
698
|
+
if (start > end) {
|
|
699
|
+
return { error: `start_line (${start}) must be less than or equal to end_line (${end})` };
|
|
700
|
+
}
|
|
701
|
+
const sliced = lines.slice(start - 1, end);
|
|
702
|
+
const numbered = sliced.map((line, i) => `${start + i}: ${line}`).join('\n');
|
|
703
|
+
const maxLength = 20000;
|
|
704
|
+
if (numbered.length > maxLength) {
|
|
705
|
+
return {
|
|
706
|
+
content: numbered.substring(0, maxLength),
|
|
707
|
+
truncated: true,
|
|
708
|
+
totalLines,
|
|
709
|
+
startLine: start,
|
|
710
|
+
endLine: end,
|
|
711
|
+
hint: `Requested range still exceeds display limit. Try a narrower range (e.g., start_line=${start}, end_line=${start + 200}).`
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
return { content: numbered, totalLines, startLine: start, endLine: end };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Full file reading with improved truncation message
|
|
718
|
+
const maxLength = 20000;
|
|
719
|
+
if (content.length > maxLength) {
|
|
720
|
+
return {
|
|
721
|
+
content: content.substring(0, maxLength),
|
|
722
|
+
truncated: true,
|
|
723
|
+
totalLength: content.length,
|
|
724
|
+
totalLines,
|
|
725
|
+
hint: `File is ${totalLines} lines (${content.length} chars). Only the first portion was returned. Use start_line/end_line to read specific sections, or use search_code to find the content you need.`
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
return { content };
|
|
729
|
+
} catch (err) {
|
|
730
|
+
if (err?.name === 'AbortError') throw err;
|
|
731
|
+
return { error: err.message };
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function executeListFiles(projectDir, dirPath, recursive = false) {
|
|
736
|
+
try {
|
|
737
|
+
const fullPath = path.resolve(projectDir, dirPath || '.');
|
|
738
|
+
if (!fs.existsSync(fullPath)) {
|
|
739
|
+
return { error: `Directory not found: ${dirPath}` };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const results = [];
|
|
743
|
+
function listDir(dir, prefix = '') {
|
|
744
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
745
|
+
for (const entry of entries) {
|
|
746
|
+
if (IGNORE_PATTERNS.includes(entry.name)) continue;
|
|
747
|
+
if (entry.name.startsWith('.') && entry.name !== '.env.example') continue;
|
|
748
|
+
|
|
749
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
750
|
+
if (entry.isDirectory()) {
|
|
751
|
+
results.push({ name: relativePath, type: 'directory' });
|
|
752
|
+
if (recursive && results.length < 500) {
|
|
753
|
+
listDir(path.join(dir, entry.name), relativePath);
|
|
754
|
+
}
|
|
755
|
+
} else {
|
|
756
|
+
results.push({ name: relativePath, type: 'file' });
|
|
757
|
+
}
|
|
758
|
+
if (results.length >= 500) break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
listDir(fullPath);
|
|
762
|
+
// Compact format: just paths with type indicator (/ suffix for dirs)
|
|
763
|
+
const compact = results.map(f => f.type === 'directory' ? f.name + '/' : f.name);
|
|
764
|
+
return { files: compact, count: results.length, ...(results.length >= 500 ? { truncated: true, limit: 500 } : {}) };
|
|
765
|
+
} catch (err) {
|
|
766
|
+
if (err?.name === 'AbortError') throw err;
|
|
767
|
+
return { error: err.message };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function executeSearchCode(projectDir, pattern, searchPath = '.', filePattern = '*', options = {}) {
|
|
772
|
+
const signal = options.signal;
|
|
773
|
+
try {
|
|
774
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
775
|
+
const fullPath = path.resolve(projectDir, searchPath);
|
|
776
|
+
|
|
777
|
+
const results = [];
|
|
778
|
+
const regex = new RegExp(pattern, 'i');
|
|
779
|
+
let fileCount = 0;
|
|
780
|
+
|
|
781
|
+
// Handle single file search (fixes ENOTDIR when path is a file)
|
|
782
|
+
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
|
783
|
+
try {
|
|
784
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
785
|
+
const lines = content.split('\n');
|
|
786
|
+
const matches = [];
|
|
787
|
+
lines.forEach((line, idx) => {
|
|
788
|
+
if (regex.test(line)) {
|
|
789
|
+
matches.push({ line: idx + 1, content: line.trim().substring(0, 200) });
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
if (matches.length > 0) {
|
|
793
|
+
return { results: [{ file: path.relative(projectDir, fullPath), matches: matches.slice(0, 20) }] };
|
|
794
|
+
}
|
|
795
|
+
return { results: [] };
|
|
796
|
+
} catch {
|
|
797
|
+
return { error: `Could not read file: ${searchPath}` };
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function searchDir(dir) {
|
|
802
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
803
|
+
if (results.length >= 50) return;
|
|
804
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
805
|
+
for (const entry of entries) {
|
|
806
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
807
|
+
if (IGNORE_PATTERNS.includes(entry.name)) continue;
|
|
808
|
+
if (entry.name.startsWith('.')) continue;
|
|
809
|
+
const entryPath = path.join(dir, entry.name);
|
|
810
|
+
|
|
811
|
+
if (entry.isDirectory()) {
|
|
812
|
+
await searchDir(entryPath);
|
|
813
|
+
} else if (entry.isFile()) {
|
|
814
|
+
if (filePattern !== '*') {
|
|
815
|
+
const ext = path.extname(entry.name);
|
|
816
|
+
if (!filePattern.includes(ext) && !filePattern.includes(entry.name)) continue;
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
const content = fs.readFileSync(entryPath, 'utf-8');
|
|
820
|
+
const lines = content.split('\n');
|
|
821
|
+
const matches = [];
|
|
822
|
+
lines.forEach((line, idx) => {
|
|
823
|
+
if (regex.test(line)) {
|
|
824
|
+
matches.push({ line: idx + 1, content: line.trim().substring(0, 200) });
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
if (matches.length > 0) {
|
|
828
|
+
results.push({ file: path.relative(projectDir, entryPath), matches: matches.slice(0, 5) });
|
|
829
|
+
}
|
|
830
|
+
} catch {
|
|
831
|
+
// Skip binary or unreadable files
|
|
832
|
+
}
|
|
833
|
+
// Yield to event loop every 25 files so spinner animation stays alive
|
|
834
|
+
fileCount++;
|
|
835
|
+
if (fileCount % 25 === 0) {
|
|
836
|
+
await new Promise(r => setImmediate(r));
|
|
837
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (results.length >= 50) break;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
await searchDir(fullPath);
|
|
844
|
+
return { results };
|
|
845
|
+
} catch (err) {
|
|
846
|
+
if (err?.name === 'AbortError') throw err;
|
|
847
|
+
return { error: err.message };
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function executeCreateFile(projectDir, filePath, content) {
|
|
852
|
+
try {
|
|
853
|
+
const fullPath = path.resolve(projectDir, filePath);
|
|
854
|
+
const resolvedProject = path.resolve(projectDir);
|
|
855
|
+
if (!fullPath.startsWith(resolvedProject)) {
|
|
856
|
+
return { error: 'Access denied: path outside project directory' };
|
|
857
|
+
}
|
|
858
|
+
// Create parent directories if needed
|
|
859
|
+
const dir = path.dirname(fullPath);
|
|
860
|
+
if (!fs.existsSync(dir)) {
|
|
861
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
862
|
+
}
|
|
863
|
+
const existed = fs.existsSync(fullPath);
|
|
864
|
+
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
865
|
+
return { success: true, path: filePath, action: existed ? 'updated' : 'created' };
|
|
866
|
+
} catch (err) {
|
|
867
|
+
return { error: err.message };
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function executeEditFile(projectDir, filePath, content) {
|
|
872
|
+
try {
|
|
873
|
+
const fullPath = path.resolve(projectDir, filePath);
|
|
874
|
+
const resolvedProject = path.resolve(projectDir);
|
|
875
|
+
if (!fullPath.startsWith(resolvedProject)) {
|
|
876
|
+
return { error: 'Access denied: path outside project directory' };
|
|
877
|
+
}
|
|
878
|
+
if (!fs.existsSync(fullPath)) {
|
|
879
|
+
return { error: `File not found: ${filePath}. Use create_file to create new files.` };
|
|
880
|
+
}
|
|
881
|
+
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
882
|
+
return { success: true, path: filePath, action: 'updated' };
|
|
883
|
+
} catch (err) {
|
|
884
|
+
return { error: err.message };
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function executeRunCommand(projectDir, command, options = {}) {
|
|
889
|
+
const signal = options.signal;
|
|
890
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
891
|
+
|
|
892
|
+
// Basic safety check - block destructive commands
|
|
893
|
+
const dangerous = /\b(rm\s+-rf|del\s+\/[sqf]|format\s+[a-z]:)\b/i;
|
|
894
|
+
if (dangerous.test(command)) {
|
|
895
|
+
return { error: 'Blocked: command appears destructive. Use a safer alternative.' };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
899
|
+
|
|
900
|
+
return await new Promise((resolve, reject) => {
|
|
901
|
+
const isWindows = process.platform === 'win32';
|
|
902
|
+
const shell = isWindows ? 'cmd.exe' : '/bin/sh';
|
|
903
|
+
const shellArgs = isWindows ? ['/c', command] : ['-c', command];
|
|
904
|
+
|
|
905
|
+
const child = spawn(shell, shellArgs, {
|
|
906
|
+
cwd: projectDir,
|
|
907
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
908
|
+
windowsHide: true
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
let stdout = '';
|
|
912
|
+
let stderr = '';
|
|
913
|
+
let done = false;
|
|
914
|
+
let timeout = null;
|
|
915
|
+
let abortHandler = null;
|
|
916
|
+
let killTimer = null;
|
|
917
|
+
|
|
918
|
+
const finish = (fn, value) => {
|
|
919
|
+
if (done) return;
|
|
920
|
+
done = true;
|
|
921
|
+
if (timeout) clearTimeout(timeout);
|
|
922
|
+
if (killTimer) clearTimeout(killTimer);
|
|
923
|
+
if (signal && abortHandler) signal.removeEventListener('abort', abortHandler);
|
|
924
|
+
fn(value);
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
child.stdout.on('data', (chunk) => {
|
|
928
|
+
stdout += chunk.toString();
|
|
929
|
+
});
|
|
930
|
+
child.stderr.on('data', (chunk) => {
|
|
931
|
+
stderr += chunk.toString();
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
child.on('error', (err) => {
|
|
935
|
+
const raw = (stdout + stderr);
|
|
936
|
+
finish(resolve, {
|
|
937
|
+
error: err.message,
|
|
938
|
+
output: raw.substring(0, 10000),
|
|
939
|
+
...(raw.length > 10000 ? { truncated: true, totalLength: raw.length } : {})
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
child.on('close', (code) => {
|
|
944
|
+
const output = stdout + stderr;
|
|
945
|
+
if (code === 0) {
|
|
946
|
+
const limit = 15000;
|
|
947
|
+
finish(resolve, {
|
|
948
|
+
success: true,
|
|
949
|
+
output: output.substring(0, limit),
|
|
950
|
+
...(output.length > limit ? { truncated: true, totalLength: output.length } : {})
|
|
951
|
+
});
|
|
952
|
+
} else {
|
|
953
|
+
const limit = 10000;
|
|
954
|
+
finish(resolve, {
|
|
955
|
+
error: `Command failed with exit code ${code}`,
|
|
956
|
+
output: output.substring(0, limit),
|
|
957
|
+
exitCode: code,
|
|
958
|
+
...(output.length > limit ? { truncated: true, totalLength: output.length } : {})
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
timeout = setTimeout(() => {
|
|
964
|
+
child.kill('SIGTERM');
|
|
965
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 1000);
|
|
966
|
+
const raw = (stdout + stderr);
|
|
967
|
+
finish(resolve, {
|
|
968
|
+
error: `Command timed out after ${timeoutMs}ms`,
|
|
969
|
+
output: raw.substring(0, 10000),
|
|
970
|
+
exitCode: 124,
|
|
971
|
+
...(raw.length > 10000 ? { truncated: true, totalLength: raw.length } : {})
|
|
972
|
+
});
|
|
973
|
+
}, timeoutMs);
|
|
974
|
+
|
|
975
|
+
if (signal) {
|
|
976
|
+
abortHandler = () => {
|
|
977
|
+
child.kill('SIGTERM');
|
|
978
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 500);
|
|
979
|
+
finish(reject, new DOMException('Aborted', 'AbortError'));
|
|
980
|
+
};
|
|
981
|
+
if (signal.aborted) {
|
|
982
|
+
abortHandler();
|
|
983
|
+
} else {
|
|
984
|
+
signal.addEventListener('abort', abortHandler, { once: true });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
async function executeMcpTool(toolName, args, options = {}) {
|
|
991
|
+
const signal = options.signal;
|
|
992
|
+
// Health gate: fail fast if MCP is unreachable instead of waiting 60s per tool
|
|
993
|
+
const healthy = await checkMcpHealth();
|
|
994
|
+
if (!healthy) {
|
|
995
|
+
return {
|
|
996
|
+
error: 'MCP server is unreachable. Proceed without external services. Do NOT retry MCP tools until the server is back.',
|
|
997
|
+
category: 'TRANSIENT',
|
|
998
|
+
retryable: false,
|
|
999
|
+
guidance: 'The MCP server is down. Skip MCP-dependent steps and work with what you have.'
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
const client = getMcpClient();
|
|
1003
|
+
try {
|
|
1004
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
1005
|
+
|
|
1006
|
+
// Determine the MCP call to make
|
|
1007
|
+
const makeMcpCall = async () => {
|
|
1008
|
+
switch (toolName) {
|
|
1009
|
+
case 'get_tasks':
|
|
1010
|
+
return callMcpWithCompatibility(client, 'list_tasks', {
|
|
1011
|
+
status: args.status,
|
|
1012
|
+
project: args.project,
|
|
1013
|
+
limit: args.limit || 20
|
|
1014
|
+
});
|
|
1015
|
+
case 'create_task':
|
|
1016
|
+
return callMcpWithCompatibility(client, 'create_task', args);
|
|
1017
|
+
case 'get_calendar':
|
|
1018
|
+
return callMcpWithCompatibility(client, 'list_events', {
|
|
1019
|
+
maxResults: args.maxResults || 10,
|
|
1020
|
+
timeMin: args.timeMin,
|
|
1021
|
+
timeMax: args.timeMax
|
|
1022
|
+
});
|
|
1023
|
+
case 'get_email_summary':
|
|
1024
|
+
return callMcpWithCompatibility(client, 'gmail_summary', {});
|
|
1025
|
+
case 'search_memory':
|
|
1026
|
+
return callMcpWithCompatibility(client, 'search_memory', { query: args.query });
|
|
1027
|
+
case 'deep_research':
|
|
1028
|
+
return callMcpWithCompatibility(client, 'deep_research', {
|
|
1029
|
+
query: args.query,
|
|
1030
|
+
...(args.focus_areas ? { focus_areas: args.focus_areas } : {})
|
|
1031
|
+
});
|
|
1032
|
+
case 'web_search':
|
|
1033
|
+
return callMcpWithCompatibility(client, 'web_search', {
|
|
1034
|
+
query: args.query,
|
|
1035
|
+
...(args.count ? { count: args.count } : {})
|
|
1036
|
+
});
|
|
1037
|
+
case 'call_mcp':
|
|
1038
|
+
return callMcpWithCompatibility(client, args.tool, args.args || {});
|
|
1039
|
+
default:
|
|
1040
|
+
throw new Error(`Unknown MCP tool: ${toolName}`);
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
// Wrap with retry for transient failures (2 retries, 1s/2s backoff)
|
|
1045
|
+
const result = await withRetry(makeMcpCall, { maxRetries: 2, baseDelay: 1000, signal });
|
|
1046
|
+
|
|
1047
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
1048
|
+
// Parse JSON strings to avoid double-encoding when serialized later
|
|
1049
|
+
let parsed = result;
|
|
1050
|
+
try { parsed = JSON.parse(result); } catch {}
|
|
1051
|
+
return { result: parsed, _mcp: true };
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
if (err?.name === 'AbortError') throw err;
|
|
1054
|
+
// Mark MCP as unhealthy on connection errors so subsequent calls fail fast
|
|
1055
|
+
if (TRANSIENT_ERROR_RE.test(err.message)) {
|
|
1056
|
+
mcpHealthy = false;
|
|
1057
|
+
mcpHealthCheckAt = Date.now();
|
|
1058
|
+
}
|
|
1059
|
+
return { error: `MCP error: ${err.message}` };
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Banana Code docs directory (co-located with the CLI source)
|
|
1064
|
+
const BANANA_DOCS_DIR = path.join(__dirname, '..', 'docs');
|
|
1065
|
+
const VALID_HELP_TOPICS = new Set(['overview', 'hooks', 'models', 'commands', 'project-instructions', 'agents']);
|
|
1066
|
+
|
|
1067
|
+
function executeBananaHelp(topic) {
|
|
1068
|
+
const normalized = (topic || '').trim().toLowerCase();
|
|
1069
|
+
if (!normalized) {
|
|
1070
|
+
// Return the overview with the topic list
|
|
1071
|
+
try {
|
|
1072
|
+
return { content: fs.readFileSync(path.join(BANANA_DOCS_DIR, 'overview.md'), 'utf-8') };
|
|
1073
|
+
} catch {
|
|
1074
|
+
return { error: 'Help docs not found.' };
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (!VALID_HELP_TOPICS.has(normalized)) {
|
|
1078
|
+
return { error: `Unknown topic "${topic}". Valid topics: ${[...VALID_HELP_TOPICS].join(', ')}` };
|
|
1079
|
+
}
|
|
1080
|
+
try {
|
|
1081
|
+
const content = fs.readFileSync(path.join(BANANA_DOCS_DIR, `${normalized}.md`), 'utf-8');
|
|
1082
|
+
return { content };
|
|
1083
|
+
} catch {
|
|
1084
|
+
return { error: `Help doc for "${topic}" not found.` };
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
async function executeTool(projectDir, toolName, args, options = {}) {
|
|
1089
|
+
const signal = options.signal;
|
|
1090
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
1091
|
+
switch (toolName) {
|
|
1092
|
+
case 'read_file':
|
|
1093
|
+
return executeReadFile(projectDir, args.path, args.start_line, args.end_line);
|
|
1094
|
+
case 'list_files':
|
|
1095
|
+
return executeListFiles(projectDir, args.path, args.recursive);
|
|
1096
|
+
case 'search_code':
|
|
1097
|
+
return executeSearchCode(projectDir, args.pattern, args.path, args.file_pattern, { signal });
|
|
1098
|
+
case 'create_file':
|
|
1099
|
+
return executeCreateFile(projectDir, args.path, args.content);
|
|
1100
|
+
case 'edit_file':
|
|
1101
|
+
return executeEditFile(projectDir, args.path, args.content);
|
|
1102
|
+
case 'banana_help':
|
|
1103
|
+
return executeBananaHelp(args.topic);
|
|
1104
|
+
case 'run_command':
|
|
1105
|
+
return executeRunCommand(projectDir, args.command, { signal });
|
|
1106
|
+
default:
|
|
1107
|
+
return { error: `Unknown tool: ${toolName}` };
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const MCP_TOOLS = new Set(['get_tasks', 'create_task', 'get_calendar', 'get_email_summary', 'search_memory', 'deep_research', 'web_search', 'call_mcp']);
|
|
1112
|
+
|
|
1113
|
+
// ─── SSE Stream Parser ───────────────────────────────────────────────────────
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Consume an SSE stream response and call onToken for each text chunk.
|
|
1117
|
+
* Strips <think>...</think> blocks before firing tokens.
|
|
1118
|
+
* Returns the full assembled content string (think blocks removed).
|
|
1119
|
+
*/
|
|
1120
|
+
// Strip model control tokens like <|start|>, <|channel|>, <|constrain|>, etc.
|
|
1121
|
+
const CONTROL_TOKEN_RE = /<\|[^|]*\|>/g;
|
|
1122
|
+
function stripControlTokens(text) {
|
|
1123
|
+
const cleaned = text.replace(CONTROL_TOKEN_RE, '');
|
|
1124
|
+
return cleaned.replace(/^\s+$/, '');
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// ─── Repetition Detection ─────────────────────────────────────────────────────
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Detects degenerate model output where the same phrase repeats endlessly.
|
|
1131
|
+
* Returns true if the recent output is stuck in a repetition loop.
|
|
1132
|
+
*/
|
|
1133
|
+
function detectRepetition(text, windowSize = 500) {
|
|
1134
|
+
if (text.length < windowSize) return false;
|
|
1135
|
+
const tail = text.slice(-windowSize);
|
|
1136
|
+
// Check for a repeating substring (10-80 chars) that fills 80%+ of the window
|
|
1137
|
+
for (let len = 10; len <= 80; len++) {
|
|
1138
|
+
const phrase = tail.slice(-len);
|
|
1139
|
+
let count = 0;
|
|
1140
|
+
let pos = 0;
|
|
1141
|
+
while ((pos = tail.indexOf(phrase, pos)) !== -1) {
|
|
1142
|
+
count++;
|
|
1143
|
+
pos += phrase.length;
|
|
1144
|
+
}
|
|
1145
|
+
if (count >= Math.floor(windowSize / len) * 0.7) return true;
|
|
1146
|
+
}
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
async function consumeStream(response, onToken) {
|
|
1151
|
+
const reader = response.body.getReader();
|
|
1152
|
+
const decoder = new TextDecoder();
|
|
1153
|
+
let fullContent = '';
|
|
1154
|
+
let buffer = '';
|
|
1155
|
+
let thinkBuffer = ''; // accumulates text inside a think block
|
|
1156
|
+
let inThink = false;
|
|
1157
|
+
let repetitionDetected = false;
|
|
1158
|
+
|
|
1159
|
+
const flush = (text) => {
|
|
1160
|
+
const clean = stripControlTokens(text);
|
|
1161
|
+
if (!clean) return;
|
|
1162
|
+
fullContent += clean;
|
|
1163
|
+
if (!repetitionDetected) {
|
|
1164
|
+
onToken(clean);
|
|
1165
|
+
// Check for repetition every 200 chars
|
|
1166
|
+
if (fullContent.length % 200 < clean.length && detectRepetition(fullContent)) {
|
|
1167
|
+
repetitionDetected = true;
|
|
1168
|
+
onToken('\n\n[Repetition detected - output truncated]');
|
|
1169
|
+
appendDebugLog(` [repetition-guard] Streaming output truncated at ${fullContent.length} chars\n`);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
while (true) {
|
|
1175
|
+
const { done, value } = await reader.read();
|
|
1176
|
+
if (done) break;
|
|
1177
|
+
|
|
1178
|
+
// If repetition detected, still consume the stream to avoid backpressure, but don't process
|
|
1179
|
+
if (repetitionDetected) continue;
|
|
1180
|
+
|
|
1181
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1182
|
+
const lines = buffer.split('\n');
|
|
1183
|
+
buffer = lines.pop();
|
|
1184
|
+
|
|
1185
|
+
for (const line of lines) {
|
|
1186
|
+
const trimmed = line.trim();
|
|
1187
|
+
if (!trimmed || trimmed === 'data: [DONE]') continue;
|
|
1188
|
+
if (!trimmed.startsWith('data: ')) continue;
|
|
1189
|
+
|
|
1190
|
+
try {
|
|
1191
|
+
const json = JSON.parse(trimmed.slice(6));
|
|
1192
|
+
const delta = json.choices?.[0]?.delta;
|
|
1193
|
+
if (!delta?.content) continue;
|
|
1194
|
+
|
|
1195
|
+
let chunk = delta.content;
|
|
1196
|
+
|
|
1197
|
+
// Process character by character to handle think blocks spanning chunks
|
|
1198
|
+
let out = '';
|
|
1199
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
1200
|
+
if (!inThink) {
|
|
1201
|
+
// Check for opening tag
|
|
1202
|
+
const remaining = chunk.slice(i);
|
|
1203
|
+
if (remaining.startsWith('<think>')) {
|
|
1204
|
+
inThink = true;
|
|
1205
|
+
thinkBuffer = '';
|
|
1206
|
+
i += 6; // skip '<think>'
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
out += chunk[i];
|
|
1210
|
+
} else {
|
|
1211
|
+
// Inside think block - check for closing tag
|
|
1212
|
+
thinkBuffer += chunk[i];
|
|
1213
|
+
if (thinkBuffer.endsWith('</think>')) {
|
|
1214
|
+
inThink = false;
|
|
1215
|
+
thinkBuffer = '';
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (out) flush(out);
|
|
1221
|
+
} catch {
|
|
1222
|
+
// Skip malformed chunks
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
return fullContent;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// ─── Agentic Loop ───────────────────────────────────────────────────────────
|
|
1231
|
+
|
|
1232
|
+
class AgenticRunner {
|
|
1233
|
+
constructor(lmStudio, options = {}) {
|
|
1234
|
+
this.lmStudio = lmStudio;
|
|
1235
|
+
this.onToolCall = options.onToolCall || (() => {});
|
|
1236
|
+
this.onToolResult = options.onToolResult || (() => {});
|
|
1237
|
+
this.onToken = options.onToken || (() => {}); // called per token on final response
|
|
1238
|
+
this.onContent = options.onContent || (() => {}); // called with full final content
|
|
1239
|
+
this.onReasoning = options.onReasoning || (() => {}); // called with reasoning text when thinking
|
|
1240
|
+
this.onWarning = options.onWarning || (() => {});
|
|
1241
|
+
this.onIntermediateContent = options.onIntermediateContent || (() => {}); // model narration between tool rounds
|
|
1242
|
+
this.onFileWrite = options.onFileWrite || null; // callback(path, action) after file write
|
|
1243
|
+
this.onCommandComplete = options.onCommandComplete || null; // callback(command, result) after command
|
|
1244
|
+
this.onContextUpdate = options.onContextUpdate || null; // callback(promptTokens, contextLimit) after each API call
|
|
1245
|
+
this.customToolExecutors = options.customToolExecutors || null; // Map<string, async fn>
|
|
1246
|
+
this.customTools = options.customTools || []; // Extra tool definitions to append
|
|
1247
|
+
this._lastWrittenFiles = null;
|
|
1248
|
+
this.totalTokens = 0;
|
|
1249
|
+
this.totalPromptTokens = 0;
|
|
1250
|
+
this.totalCompletionTokens = 0;
|
|
1251
|
+
this.lastTurnTokens = 0;
|
|
1252
|
+
this.lastTurnMessagesEstimate = 0;
|
|
1253
|
+
this.totalCacheReadTokens = 0;
|
|
1254
|
+
this.totalCacheCreationTokens = 0;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Simulate streaming by emitting content in small word-sized chunks.
|
|
1259
|
+
* Makes non-streaming responses render progressively like real streaming.
|
|
1260
|
+
*/
|
|
1261
|
+
getWrittenFiles() {
|
|
1262
|
+
return [...(this._lastWrittenFiles || [])];
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
async emitStreaming(content) {
|
|
1266
|
+
// Truncate degenerate repetitive output before emitting
|
|
1267
|
+
if (content.length > 500 && detectRepetition(content)) {
|
|
1268
|
+
// Find where the repetition starts and truncate there
|
|
1269
|
+
const truncAt = Math.min(content.length, 500);
|
|
1270
|
+
content = content.slice(0, truncAt) + '\n\n[Repetition detected - output truncated]';
|
|
1271
|
+
appendDebugLog(` [repetition-guard] emitStreaming truncated at ${truncAt} chars\n`);
|
|
1272
|
+
}
|
|
1273
|
+
// Split into small chunks (by words, preserving whitespace)
|
|
1274
|
+
const chunks = content.match(/\S+\s*/g) || [content];
|
|
1275
|
+
for (const chunk of chunks) {
|
|
1276
|
+
this.onToken(chunk);
|
|
1277
|
+
// Tiny yield to let the event loop render each chunk
|
|
1278
|
+
await new Promise(r => setTimeout(r, 8));
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Run the agentic loop.
|
|
1284
|
+
* Tool-call iterations use non-streaming chat().
|
|
1285
|
+
* The final (no-tool) response streams token-by-token via onToken.
|
|
1286
|
+
*
|
|
1287
|
+
* @param {Array} messages - The message array (system + history + user)
|
|
1288
|
+
* @param {string} projectDir - The project directory for tool execution
|
|
1289
|
+
* @param {Object} options - Model options (model, temperature, etc.)
|
|
1290
|
+
* @returns {string} - The final assistant response content
|
|
1291
|
+
*/
|
|
1292
|
+
/**
|
|
1293
|
+
* Inject a steering message into the live agentic loop without restarting.
|
|
1294
|
+
* The message will be appended before the next API call.
|
|
1295
|
+
*/
|
|
1296
|
+
injectSteer(text) {
|
|
1297
|
+
this._pendingSteers = this._pendingSteers || [];
|
|
1298
|
+
this._pendingSteers.push(text);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
async run(messages, projectDir, options = {}) {
|
|
1302
|
+
let iterations = 0;
|
|
1303
|
+
const toolCallHistory = []; // Track tool calls for loop detection
|
|
1304
|
+
const failedMcpTools = new Set(); // Track MCP tools that returned "Unknown tool" errors
|
|
1305
|
+
let readOnlyStreak = 0; // Consecutive iterations with only read-only tool calls
|
|
1306
|
+
let loopWarningCount = 0; // How many times loop detection has fired
|
|
1307
|
+
const writtenFiles = [];
|
|
1308
|
+
this._lastWrittenFiles = null;
|
|
1309
|
+
this._pendingSteers = [];
|
|
1310
|
+
this.totalTokens = 0; // Track cumulative tokens across iterations
|
|
1311
|
+
this.totalPromptTokens = 0;
|
|
1312
|
+
this.totalCompletionTokens = 0;
|
|
1313
|
+
this.lastTurnTokens = 0;
|
|
1314
|
+
this.lastTurnMessagesEstimate = 0;
|
|
1315
|
+
this.totalCacheReadTokens = 0;
|
|
1316
|
+
this.totalCacheCreationTokens = 0;
|
|
1317
|
+
|
|
1318
|
+
const logRunTotals = (phase) => {
|
|
1319
|
+
// Estimate context from actual messages when API doesn't return usage
|
|
1320
|
+
this.lastTurnMessagesEstimate = Math.ceil(JSON.stringify(messages).length / 3.5);
|
|
1321
|
+
appendDebugLog(
|
|
1322
|
+
`run_totals phase=${phase} ` +
|
|
1323
|
+
`iterations=${iterations} ` +
|
|
1324
|
+
`prompt=${this.totalPromptTokens} ` +
|
|
1325
|
+
`completion=${this.totalCompletionTokens} ` +
|
|
1326
|
+
`total=${this.totalTokens} ` +
|
|
1327
|
+
`cache_read=${this.totalCacheReadTokens} ` +
|
|
1328
|
+
`cache_create=${this.totalCacheCreationTokens} ` +
|
|
1329
|
+
`messagesEstimate=${this.lastTurnMessagesEstimate}\n`
|
|
1330
|
+
);
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
// Discover available MCP tools and inject into call_mcp description
|
|
1334
|
+
try {
|
|
1335
|
+
const client = getMcpClient();
|
|
1336
|
+
const mcpToolNames = await getAvailableMcpToolNames(client);
|
|
1337
|
+
if (mcpToolNames && mcpToolNames.size > 0) {
|
|
1338
|
+
const toolList = [...mcpToolNames].sort().join(', ');
|
|
1339
|
+
// Patch call_mcp description for this run (on a clone to avoid mutating the shared TOOLS array)
|
|
1340
|
+
const baseTools = options.tools || TOOLS;
|
|
1341
|
+
const callMcpIdx = baseTools.findIndex(t => t.function?.name === 'call_mcp');
|
|
1342
|
+
if (callMcpIdx >= 0) {
|
|
1343
|
+
const orig = baseTools[callMcpIdx];
|
|
1344
|
+
const baseDesc = orig.function.description.replace(/\.\s*Available MCP tools:.*$/, '');
|
|
1345
|
+
baseTools[callMcpIdx] = {
|
|
1346
|
+
...orig,
|
|
1347
|
+
function: { ...orig.function, description: `${baseDesc}. Available MCP tools: ${toolList}` }
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
appendDebugLog(`[MCP discovery] ${mcpToolNames.size} tools available: ${toolList.slice(0, 300)}\n`);
|
|
1351
|
+
}
|
|
1352
|
+
} catch (err) {
|
|
1353
|
+
appendDebugLog(`[MCP discovery] Failed: ${err.message}\n`);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
while (iterations < MAX_ITERATIONS) {
|
|
1357
|
+
iterations++;
|
|
1358
|
+
|
|
1359
|
+
// Check if aborted before each iteration
|
|
1360
|
+
if (options.signal?.aborted) {
|
|
1361
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Trim context if approaching limit
|
|
1365
|
+
const contextLimit = options.contextLimit || 0;
|
|
1366
|
+
if (contextLimit > 0) {
|
|
1367
|
+
trimContextIfNeeded(messages, contextLimit);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Inject any pending steer messages before the API call
|
|
1371
|
+
if (this._pendingSteers && this._pendingSteers.length > 0) {
|
|
1372
|
+
for (const steerText of this._pendingSteers) {
|
|
1373
|
+
messages.push({
|
|
1374
|
+
role: 'user',
|
|
1375
|
+
content: `## Steering Message\n\nAdditional guidance for this turn:\n\n${steerText}`
|
|
1376
|
+
});
|
|
1377
|
+
appendDebugLog(` [steer injected]: ${steerText.slice(0, 200)}\n`);
|
|
1378
|
+
}
|
|
1379
|
+
this._pendingSteers = [];
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Merge custom tools into the tool list
|
|
1383
|
+
const baseTools = options.tools || TOOLS;
|
|
1384
|
+
const mergedTools = this.customTools.length > 0
|
|
1385
|
+
? [...baseTools, ...this.customTools]
|
|
1386
|
+
: baseTools;
|
|
1387
|
+
|
|
1388
|
+
// Use non-streaming for tool-call iterations (need full response to parse tool_calls)
|
|
1389
|
+
appendDebugLog(`\n--- sending iteration ${iterations} (${messages.length} msgs, model=${options.model || 'default'}) ---\n`);
|
|
1390
|
+
const chatOpts = {
|
|
1391
|
+
model: options.model,
|
|
1392
|
+
temperature: options.temperature ?? 0.6,
|
|
1393
|
+
topP: options.topP,
|
|
1394
|
+
repeatPenalty: options.repeatPenalty,
|
|
1395
|
+
reasoningEffort: options.reasoningEffort,
|
|
1396
|
+
maxTokens: options.maxTokens ?? 32000,
|
|
1397
|
+
tools: mergedTools,
|
|
1398
|
+
toolChoice: 'auto',
|
|
1399
|
+
thinking: (options.thinkingBudget && options.thinkingBudget > 0) ? options.thinkingBudget : false,
|
|
1400
|
+
signal: options.signal
|
|
1401
|
+
};
|
|
1402
|
+
const data = await withRetry(
|
|
1403
|
+
() => this.lmStudio.chat(messages, chatOpts),
|
|
1404
|
+
{ maxRetries: 1, baseDelay: 2000, signal: options.signal }
|
|
1405
|
+
);
|
|
1406
|
+
|
|
1407
|
+
const choice = data.choices[0];
|
|
1408
|
+
const assistantMessage = choice.message;
|
|
1409
|
+
|
|
1410
|
+
// Track token usage from API response
|
|
1411
|
+
const usage = data.usage || {};
|
|
1412
|
+
const promptTokens = Number(usage.prompt_tokens ?? usage.input_tokens ?? 0);
|
|
1413
|
+
const completionTokens = Number(usage.completion_tokens ?? usage.output_tokens ?? 0);
|
|
1414
|
+
const totalTokens = Number(usage.total_tokens ?? (promptTokens + completionTokens));
|
|
1415
|
+
const cacheReadTokens = Number(usage.cache_read_input_tokens || 0);
|
|
1416
|
+
const cacheCreationTokens = Number(
|
|
1417
|
+
usage.cache_creation_input_tokens
|
|
1418
|
+
|| usage.cache_creation?.ephemeral_5m_input_tokens
|
|
1419
|
+
|| usage.cache_creation?.ephemeral_1h_input_tokens
|
|
1420
|
+
|| 0
|
|
1421
|
+
);
|
|
1422
|
+
if (totalTokens > 0) {
|
|
1423
|
+
this.totalTokens += totalTokens;
|
|
1424
|
+
this.lastTurnTokens = promptTokens > 0 ? promptTokens : totalTokens;
|
|
1425
|
+
}
|
|
1426
|
+
if (promptTokens > 0 || completionTokens > 0) {
|
|
1427
|
+
this.totalPromptTokens += promptTokens;
|
|
1428
|
+
this.totalCompletionTokens += completionTokens;
|
|
1429
|
+
}
|
|
1430
|
+
if (cacheReadTokens > 0 || cacheCreationTokens > 0) {
|
|
1431
|
+
this.totalCacheReadTokens += cacheReadTokens;
|
|
1432
|
+
this.totalCacheCreationTokens += cacheCreationTokens;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Live context update so the status bar reflects current state
|
|
1436
|
+
if (this.onContextUpdate && this.lastTurnTokens > 0) {
|
|
1437
|
+
this.onContextUpdate(this.lastTurnTokens, options.contextLimit || 0);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// DEBUG: log raw response to understand model behavior
|
|
1441
|
+
const reasoning = assistantMessage.reasoning || assistantMessage.reasoning_content || '';
|
|
1442
|
+
appendDebugLog(
|
|
1443
|
+
`\n=== iteration ${iterations} ===\n` +
|
|
1444
|
+
`finish_reason: ${choice.finish_reason}\n` +
|
|
1445
|
+
`usage: prompt=${promptTokens} completion=${completionTokens} total=${totalTokens} cache_read=${cacheReadTokens} cache_create=${cacheCreationTokens}\n` +
|
|
1446
|
+
`tool_calls: ${JSON.stringify(assistantMessage.tool_calls?.map(t => ({ name: t.function.name, args: t.function.arguments })), null, 2)}\n` +
|
|
1447
|
+
(reasoning ? `reasoning: ${reasoning.slice(0, 500)}\n` : '') +
|
|
1448
|
+
`content: ${(assistantMessage.content || '').slice(0, 500)}\n`
|
|
1449
|
+
);
|
|
1450
|
+
|
|
1451
|
+
// Fallback: detect Harmony-format tool calls leaked into content
|
|
1452
|
+
// GPT-OSS sometimes outputs <|channel|>functions.tool_name as text instead of proper tool_calls
|
|
1453
|
+
if ((!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) && assistantMessage.content) {
|
|
1454
|
+
const harmonyMatch = assistantMessage.content.match(/<\|channel\|>functions\.(\w+).*?<\|message\|>\s*(\{[\s\S]*)/);
|
|
1455
|
+
if (harmonyMatch) {
|
|
1456
|
+
const toolName = harmonyMatch[1];
|
|
1457
|
+
let argsStr = harmonyMatch[2].trim();
|
|
1458
|
+
// Clean up: find the JSON object
|
|
1459
|
+
try {
|
|
1460
|
+
const args = JSON.parse(argsStr);
|
|
1461
|
+
assistantMessage.tool_calls = [{
|
|
1462
|
+
type: 'function',
|
|
1463
|
+
id: String(Date.now()),
|
|
1464
|
+
function: { name: toolName, arguments: JSON.stringify(args) }
|
|
1465
|
+
}];
|
|
1466
|
+
// Strip the Harmony tokens from content
|
|
1467
|
+
assistantMessage.content = assistantMessage.content.replace(/<\|channel\|>[\s\S]*/, '').trim() || null;
|
|
1468
|
+
} catch { /* couldn't parse args, skip */ }
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Check if model wants to call tools
|
|
1473
|
+
// Some models use finish_reason "tool_calls", others use "stop" or "function_call"
|
|
1474
|
+
// but still include tool_calls in the message. Check for the array itself.
|
|
1475
|
+
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
|
|
1476
|
+
// Add assistant message to history, preserving the reasoning field
|
|
1477
|
+
// GPT-OSS requires the reasoning/CoT from prior tool calls to be passed back.
|
|
1478
|
+
// Other models ignore it harmlessly.
|
|
1479
|
+
const historyMsg = {
|
|
1480
|
+
role: 'assistant',
|
|
1481
|
+
content: assistantMessage.content || null,
|
|
1482
|
+
tool_calls: assistantMessage.tool_calls
|
|
1483
|
+
};
|
|
1484
|
+
if (assistantMessage.reasoning) {
|
|
1485
|
+
historyMsg.reasoning = assistantMessage.reasoning;
|
|
1486
|
+
}
|
|
1487
|
+
if (assistantMessage.reasoning_content) {
|
|
1488
|
+
historyMsg.reasoning_content = assistantMessage.reasoning_content;
|
|
1489
|
+
}
|
|
1490
|
+
messages.push(historyMsg);
|
|
1491
|
+
|
|
1492
|
+
// Surface model narration between tool rounds
|
|
1493
|
+
if (assistantMessage.content) {
|
|
1494
|
+
const narration = stripControlTokens(assistantMessage.content).trim();
|
|
1495
|
+
if (narration) this.onIntermediateContent(narration);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
let mcpDataSummaries = [];
|
|
1499
|
+
let failedResearchTools = [];
|
|
1500
|
+
|
|
1501
|
+
// ─── Execute one tool call and return its result + metadata ───
|
|
1502
|
+
const executeOneToolCall = async (toolCall) => {
|
|
1503
|
+
const functionName = toolCall.function.name;
|
|
1504
|
+
let args;
|
|
1505
|
+
try {
|
|
1506
|
+
args = JSON.parse(toolCall.function.arguments);
|
|
1507
|
+
} catch {
|
|
1508
|
+
args = {};
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// Track tool calls for loop detection
|
|
1512
|
+
const trackingName = (functionName === 'call_mcp' && args.tool) ? args.tool : functionName;
|
|
1513
|
+
const trackingEntry = { signature: `${trackingName}:${JSON.stringify(args)}`, name: trackingName, hadError: false };
|
|
1514
|
+
toolCallHistory.push(trackingEntry);
|
|
1515
|
+
|
|
1516
|
+
this.onToolCall(functionName, args);
|
|
1517
|
+
|
|
1518
|
+
const startTime = Date.now();
|
|
1519
|
+
|
|
1520
|
+
// Execute tool
|
|
1521
|
+
let result;
|
|
1522
|
+
const customExec = this.customToolExecutors || options.customToolExecutors;
|
|
1523
|
+
if (customExec?.has?.(functionName)) {
|
|
1524
|
+
result = await customExec.get(functionName)(args, { signal: options.signal });
|
|
1525
|
+
} else if (options.readOnly && !READ_ONLY_TOOL_NAMES.has(functionName)) {
|
|
1526
|
+
result = { error: `Tool "${functionName}" is blocked in plan mode. Only ${[...READ_ONLY_TOOL_NAMES].join(', ')} are available.` };
|
|
1527
|
+
} else if (MCP_TOOLS.has(functionName)) {
|
|
1528
|
+
result = await executeMcpTool(functionName, args, { signal: options.signal });
|
|
1529
|
+
} else {
|
|
1530
|
+
result = await executeTool(projectDir, functionName, args, { signal: options.signal });
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const elapsed = Date.now() - startTime;
|
|
1534
|
+
|
|
1535
|
+
// Enrich errors with category/guidance for the AI
|
|
1536
|
+
if (result.error) {
|
|
1537
|
+
result = enrichToolError(functionName, args, result);
|
|
1538
|
+
trackingEntry.hadError = true;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
this.onToolResult(functionName, !result.error, result, elapsed);
|
|
1542
|
+
|
|
1543
|
+
appendDebugLog(` tool: ${functionName} (${elapsed}ms) -> ${JSON.stringify(result).slice(0, 200)}\n`);
|
|
1544
|
+
|
|
1545
|
+
// Track written files for reporting
|
|
1546
|
+
if ((functionName === 'create_file' || functionName === 'edit_file') && result.success) {
|
|
1547
|
+
writtenFiles.push(result.path);
|
|
1548
|
+
if (this.onFileWrite) this.onFileWrite(result.path, functionName);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Track command execution for hooks
|
|
1552
|
+
if (functionName === 'run_command' && !result.error) {
|
|
1553
|
+
if (this.onCommandComplete) this.onCommandComplete(args.command, result);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
return { toolCall, functionName, args, result };
|
|
1557
|
+
};
|
|
1558
|
+
|
|
1559
|
+
// ─── Partition into parallel-safe and serial sets ───
|
|
1560
|
+
const SERIAL_TOOLS = new Set(['create_file', 'edit_file', 'run_command']);
|
|
1561
|
+
const parallelCalls = [];
|
|
1562
|
+
const serialCalls = [];
|
|
1563
|
+
for (const tc of assistantMessage.tool_calls) {
|
|
1564
|
+
const name = tc.function.name;
|
|
1565
|
+
if (SERIAL_TOOLS.has(name)) {
|
|
1566
|
+
serialCalls.push(tc);
|
|
1567
|
+
} else {
|
|
1568
|
+
parallelCalls.push(tc);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// Execute parallel batch first (read-only and MCP calls)
|
|
1573
|
+
const allResults = [];
|
|
1574
|
+
if (parallelCalls.length > 1) {
|
|
1575
|
+
appendDebugLog(` [parallel] Running ${parallelCalls.length} tools in parallel\n`);
|
|
1576
|
+
const settled = await Promise.allSettled(
|
|
1577
|
+
parallelCalls.map(tc => executeOneToolCall(tc))
|
|
1578
|
+
);
|
|
1579
|
+
for (const outcome of settled) {
|
|
1580
|
+
if (outcome.status === 'fulfilled') {
|
|
1581
|
+
allResults.push(outcome.value);
|
|
1582
|
+
} else {
|
|
1583
|
+
// Should not happen (executeOneToolCall catches errors), but handle gracefully
|
|
1584
|
+
appendDebugLog(` [parallel] Unexpected rejection: ${outcome.reason}\n`);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
} else if (parallelCalls.length === 1) {
|
|
1588
|
+
allResults.push(await executeOneToolCall(parallelCalls[0]));
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// Execute serial batch sequentially
|
|
1592
|
+
for (const tc of serialCalls) {
|
|
1593
|
+
if (options.signal?.aborted) {
|
|
1594
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
1595
|
+
}
|
|
1596
|
+
allResults.push(await executeOneToolCall(tc));
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Push results to messages in original tool_call order (API requires matching order)
|
|
1600
|
+
const resultMap = new Map(allResults.map(r => [r.toolCall.id, r]));
|
|
1601
|
+
for (const tc of assistantMessage.tool_calls) {
|
|
1602
|
+
const r = resultMap.get(tc.id);
|
|
1603
|
+
if (!r) continue;
|
|
1604
|
+
const { functionName, args, result } = r;
|
|
1605
|
+
const effectiveName = (functionName === 'call_mcp' && args.tool) ? args.tool : functionName;
|
|
1606
|
+
|
|
1607
|
+
let resultContent;
|
|
1608
|
+
if (result._mcp) {
|
|
1609
|
+
const clean = { ...result };
|
|
1610
|
+
delete clean._mcp;
|
|
1611
|
+
resultContent = JSON.stringify(clean);
|
|
1612
|
+
if (!result.error) {
|
|
1613
|
+
const preview = JSON.stringify(clean.result).slice(0, 500);
|
|
1614
|
+
mcpDataSummaries.push(`${effectiveName}: ${preview}`);
|
|
1615
|
+
}
|
|
1616
|
+
} else {
|
|
1617
|
+
resultContent = JSON.stringify(result);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
if (result.error && (effectiveName === 'deep_research' || effectiveName === 'web_search')) {
|
|
1621
|
+
failedResearchTools.push(effectiveName);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
if (functionName === 'call_mcp' && result.error) {
|
|
1625
|
+
const errorStr = typeof result.error === 'string' ? result.error : JSON.stringify(result.error);
|
|
1626
|
+
if (errorStr.includes('Unknown tool')) {
|
|
1627
|
+
const failedToolName = args.tool || effectiveName;
|
|
1628
|
+
failedMcpTools.add(failedToolName);
|
|
1629
|
+
appendDebugLog(` [MCP] Unknown tool detected: ${failedToolName}, adding to failed set\n`);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
messages.push({
|
|
1633
|
+
role: 'tool',
|
|
1634
|
+
tool_call_id: tc.id,
|
|
1635
|
+
content: resultContent
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// After all tool results, inject a single consolidated system nudge
|
|
1640
|
+
const nudgeParts = [];
|
|
1641
|
+
|
|
1642
|
+
if (mcpDataSummaries.length > 0) {
|
|
1643
|
+
nudgeParts.push('Present the tool data above to the user. Do NOT say you cannot access it.');
|
|
1644
|
+
const hasSearchResults = mcpDataSummaries.some(s => s.startsWith('deep_research:') || s.startsWith('web_search:'));
|
|
1645
|
+
if (hasSearchResults) {
|
|
1646
|
+
nudgeParts.push('Search results: ONLY state facts from results above. Do NOT extrapolate or invent details. Say "could not confirm" for gaps.');
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
if (failedResearchTools.length > 0) {
|
|
1651
|
+
nudgeParts.push(`Research tools FAILED: ${failedResearchTools.join(', ')}. Tell user which lookups failed. Do NOT fill gaps from training data.`);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
if (failedMcpTools.size > 0) {
|
|
1655
|
+
nudgeParts.push(`Non-existent MCP tools (do NOT retry): ${[...failedMcpTools].join(', ')}`);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
if (nudgeParts.length > 0) {
|
|
1659
|
+
messages.push({
|
|
1660
|
+
role: 'system',
|
|
1661
|
+
content: nudgeParts.join('\n')
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// Track read-only streaks (iterations with no writes or commands)
|
|
1666
|
+
const thisIterToolNames = assistantMessage.tool_calls.map(t => t.function.name);
|
|
1667
|
+
const hadWriteAction = thisIterToolNames.some(n => WRITE_TOOL_NAMES.has(n));
|
|
1668
|
+
if (hadWriteAction) {
|
|
1669
|
+
readOnlyStreak = 0;
|
|
1670
|
+
} else {
|
|
1671
|
+
readOnlyStreak++;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// Read-only streak nudge: after 5 consecutive read-only iterations, nudge to act
|
|
1675
|
+
if (readOnlyStreak === 5) {
|
|
1676
|
+
appendDebugLog(` [READ-ONLY NUDGE] ${readOnlyStreak} consecutive read-only iterations\n`);
|
|
1677
|
+
this.onWarning('5 consecutive read-only iterations. Nudging to start implementing.');
|
|
1678
|
+
messages.push({
|
|
1679
|
+
role: 'system',
|
|
1680
|
+
content: 'NOTICE: You have done 5 consecutive read/search iterations without writing any code or running commands. ' +
|
|
1681
|
+
'You likely have enough context now. Start implementing changes. ' +
|
|
1682
|
+
'If you are genuinely blocked, use ask_human to tell the user what you need.'
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// Loop detection: inject nudge or hard-break after all tool results
|
|
1687
|
+
// Only count calls that had errors - successful calls to the same tool are legitimate
|
|
1688
|
+
if (toolCallHistory.length >= 3) {
|
|
1689
|
+
const last = toolCallHistory[toolCallHistory.length - 1];
|
|
1690
|
+
const recent6 = toolCallHistory.slice(-6);
|
|
1691
|
+
const identicalCount = recent6.filter(e => e.signature === last.signature).length;
|
|
1692
|
+
const lastToolName = last.name;
|
|
1693
|
+
const recent8 = toolCallHistory.slice(-8);
|
|
1694
|
+
// Count how many of those same-name calls had errors
|
|
1695
|
+
const sameToolErrorCount = recent8.filter(e => e.name === lastToolName && e.hadError).length;
|
|
1696
|
+
|
|
1697
|
+
// Trigger on: identical calls 3+ times, OR same tool with errors 4+ times in 8 calls
|
|
1698
|
+
if (identicalCount >= 3 || sameToolErrorCount >= 4) {
|
|
1699
|
+
loopWarningCount++;
|
|
1700
|
+
const loopNudge = identicalCount >= 3
|
|
1701
|
+
? `WARNING: You have called ${lastToolName} with the same arguments ${identicalCount} times. This approach is not working.`
|
|
1702
|
+
: `WARNING: You have called ${lastToolName} ${sameToolErrorCount} times with errors in the last 8 calls. This tool is failing repeatedly. Consider a different approach.`;
|
|
1703
|
+
|
|
1704
|
+
// Hard break on second loop detection (first one was a nudge)
|
|
1705
|
+
if (loopWarningCount >= 2) {
|
|
1706
|
+
appendDebugLog(` [LOOP BREAKER] Forcing stop after ${loopWarningCount} loop warnings, ${iterations} iterations\n`);
|
|
1707
|
+
this.onWarning(`Loop detected: ${lastToolName} called repeatedly without progress. Stopping after ${iterations} iterations.`);
|
|
1708
|
+
messages.push({
|
|
1709
|
+
role: 'system',
|
|
1710
|
+
content: `STOP. You are stuck in a loop calling ${lastToolName} repeatedly. ` +
|
|
1711
|
+
`You MUST provide your response NOW with whatever information you have gathered so far. ` +
|
|
1712
|
+
`Do NOT call any more tools. Summarize what you found and what you could not resolve.`
|
|
1713
|
+
});
|
|
1714
|
+
// Force one more iteration without tools to get the final response
|
|
1715
|
+
const finalData = await this.lmStudio.chat(messages, {
|
|
1716
|
+
model: options.model,
|
|
1717
|
+
temperature: options.temperature ?? 0.6,
|
|
1718
|
+
maxTokens: options.maxTokens ?? 32000,
|
|
1719
|
+
signal: options.signal
|
|
1720
|
+
});
|
|
1721
|
+
const finalContent = stripControlTokens(finalData.choices?.[0]?.message?.content || '');
|
|
1722
|
+
this._lastWrittenFiles = [...writtenFiles];
|
|
1723
|
+
logRunTotals('loop-break');
|
|
1724
|
+
const loopResponse = finalContent || 'I got stuck in a loop and could not complete the task. Please try rephrasing your request.';
|
|
1725
|
+
await this.emitStreaming(loopResponse);
|
|
1726
|
+
this.onContent(loopResponse);
|
|
1727
|
+
return loopResponse;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
messages.push({
|
|
1731
|
+
role: 'system',
|
|
1732
|
+
content: `${loopNudge} Try a different strategy:\n` +
|
|
1733
|
+
`- For large files: use read_file with start_line/end_line to read specific sections\n` +
|
|
1734
|
+
`- For search: try a different pattern, or use search_code with the file path directly\n` +
|
|
1735
|
+
`- For commands: run_command uses cmd.exe on Windows (not PowerShell). Use findstr, type, dir.\n` +
|
|
1736
|
+
`- Consider explaining the blocker to the user via ask_human instead of retrying`
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// Hard break if 8+ consecutive read-only iterations with no progress
|
|
1742
|
+
if (readOnlyStreak >= 8) {
|
|
1743
|
+
appendDebugLog(` [NO-PROGRESS BREAKER] ${readOnlyStreak} consecutive read-only iterations\n`);
|
|
1744
|
+
this.onWarning(`No write actions for ${readOnlyStreak} consecutive iterations. Stopping.`);
|
|
1745
|
+
messages.push({
|
|
1746
|
+
role: 'system',
|
|
1747
|
+
content: 'STOP. You have spent too many iterations reading files and searching without making any changes. ' +
|
|
1748
|
+
'Provide your response NOW. Summarize what you found and what changes are needed.'
|
|
1749
|
+
});
|
|
1750
|
+
const npData = await this.lmStudio.chat(messages, {
|
|
1751
|
+
model: options.model,
|
|
1752
|
+
temperature: options.temperature ?? 0.6,
|
|
1753
|
+
maxTokens: options.maxTokens ?? 32000,
|
|
1754
|
+
signal: options.signal
|
|
1755
|
+
});
|
|
1756
|
+
const npContent = stripControlTokens(npData.choices?.[0]?.message?.content || '');
|
|
1757
|
+
this._lastWrittenFiles = [...writtenFiles];
|
|
1758
|
+
logRunTotals('no-progress-break');
|
|
1759
|
+
const npResponse = npContent || 'I spent too many iterations researching without making progress. Please try a more specific request.';
|
|
1760
|
+
await this.emitStreaming(npResponse);
|
|
1761
|
+
this.onContent(npResponse);
|
|
1762
|
+
return npResponse;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
appendDebugLog(` writtenFiles: ${writtenFiles}\n`);
|
|
1766
|
+
this._lastWrittenFiles = [...writtenFiles];
|
|
1767
|
+
|
|
1768
|
+
// Continue the loop - let the model decide when it's done
|
|
1769
|
+
|
|
1770
|
+
} else {
|
|
1771
|
+
// Final response - no more tool calls.
|
|
1772
|
+
// The non-streaming chat() call already returned content. Use it directly
|
|
1773
|
+
// instead of making a redundant streaming call that may return empty/truncated.
|
|
1774
|
+
let existingContent = stripControlTokens(assistantMessage.content || '');
|
|
1775
|
+
|
|
1776
|
+
// Extract inline <think>/<thinking> blocks from content (Qwen3.5 embeds reasoning in content)
|
|
1777
|
+
let inlineReasoning = '';
|
|
1778
|
+
existingContent = existingContent.replace(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/gi, (_, r) => {
|
|
1779
|
+
inlineReasoning += r.trim();
|
|
1780
|
+
return '';
|
|
1781
|
+
});
|
|
1782
|
+
// Also handle orphan: content starts with reasoning text followed by </think>
|
|
1783
|
+
// IMPORTANT: Only strip if </think> appears at the very start or after only whitespace,
|
|
1784
|
+
// meaning the entire content up to that point is reasoning (no real answer before it).
|
|
1785
|
+
// The original greedy /^[\s\S]*?<\/think>/ was too aggressive - it matched and wiped
|
|
1786
|
+
// the full response when a model appended </think> to the end of its answer (e.g.,
|
|
1787
|
+
// "We moved Kim's dashboard to this url</think>"), leaving existingContent empty and
|
|
1788
|
+
// causing Banana to silently stop producing output.
|
|
1789
|
+
const orphanMatch = existingContent.match(/^([\s\S]*?)<\/think(?:ing)?>\s*/i);
|
|
1790
|
+
if (orphanMatch) {
|
|
1791
|
+
const beforeClose = orphanMatch[1];
|
|
1792
|
+
const afterClose = existingContent.slice(orphanMatch[0].length).trim();
|
|
1793
|
+
// Only treat as orphan reasoning if there is actual content AFTER the </think> tag.
|
|
1794
|
+
// If nothing follows it, the text before it is the real answer - don't strip it.
|
|
1795
|
+
if (afterClose) {
|
|
1796
|
+
inlineReasoning += beforeClose.trim();
|
|
1797
|
+
existingContent = afterClose;
|
|
1798
|
+
} else {
|
|
1799
|
+
// No content after </think> - the text before is the real answer.
|
|
1800
|
+
// Just strip the trailing </think> tag so it doesn't appear in output.
|
|
1801
|
+
existingContent = existingContent.replace(/<\/think(?:ing)?>\s*$/i, '');
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
existingContent = existingContent.trim();
|
|
1805
|
+
|
|
1806
|
+
const reasoning = assistantMessage.reasoning || assistantMessage.reasoning_content || inlineReasoning;
|
|
1807
|
+
|
|
1808
|
+
// If the model already produced content in this iteration, use it directly
|
|
1809
|
+
if (existingContent) {
|
|
1810
|
+
if (reasoning) {
|
|
1811
|
+
this.onReasoning(stripControlTokens(reasoning));
|
|
1812
|
+
}
|
|
1813
|
+
await this.emitStreaming(existingContent);
|
|
1814
|
+
this.onContent(existingContent);
|
|
1815
|
+
logRunTotals('final-content');
|
|
1816
|
+
return existingContent;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// Model returned reasoning but no visible content - still got a real response.
|
|
1820
|
+
// Don't make a redundant second call; use reasoning as the response.
|
|
1821
|
+
if (reasoning) {
|
|
1822
|
+
this.onReasoning(stripControlTokens(reasoning));
|
|
1823
|
+
// Some models put the actual answer in reasoning when content is empty.
|
|
1824
|
+
// Return a minimal acknowledgment rather than an empty response.
|
|
1825
|
+
const fallback = '(Response was in reasoning only - see thinking output above)';
|
|
1826
|
+
await this.emitStreaming(fallback);
|
|
1827
|
+
this.onContent(fallback);
|
|
1828
|
+
logRunTotals('final-reasoning-fallback');
|
|
1829
|
+
return fallback;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// Truly empty response (no content AND no reasoning) - generate a final response.
|
|
1833
|
+
// This should be rare; it means the model returned nothing useful at all.
|
|
1834
|
+
if (options.thinkingBudget && options.thinkingBudget > 0) {
|
|
1835
|
+
const thinkData = await this.lmStudio.chat(messages, {
|
|
1836
|
+
model: options.model,
|
|
1837
|
+
temperature: options.temperature ?? 0.6,
|
|
1838
|
+
topP: options.topP,
|
|
1839
|
+
repeatPenalty: options.repeatPenalty,
|
|
1840
|
+
reasoningEffort: options.reasoningEffort,
|
|
1841
|
+
maxTokens: options.maxTokens ?? 32000,
|
|
1842
|
+
thinking: options.thinkingBudget,
|
|
1843
|
+
signal: options.signal
|
|
1844
|
+
});
|
|
1845
|
+
const thinkMsg = thinkData.choices[0]?.message;
|
|
1846
|
+
const thinkReasoning = thinkMsg?.reasoning;
|
|
1847
|
+
const content = stripControlTokens(thinkMsg?.content || '');
|
|
1848
|
+
|
|
1849
|
+
if (thinkReasoning) this.onReasoning(stripControlTokens(thinkReasoning));
|
|
1850
|
+
await this.emitStreaming(content);
|
|
1851
|
+
this.onContent(content);
|
|
1852
|
+
logRunTotals('final-think-pass');
|
|
1853
|
+
return content;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// Normal streaming final response
|
|
1857
|
+
const streamResponse = await this.lmStudio.chatStream(messages, {
|
|
1858
|
+
model: options.model,
|
|
1859
|
+
temperature: options.temperature ?? 0.6,
|
|
1860
|
+
topP: options.topP,
|
|
1861
|
+
repeatPenalty: options.repeatPenalty,
|
|
1862
|
+
reasoningEffort: options.reasoningEffort,
|
|
1863
|
+
maxTokens: options.maxTokens ?? 32000,
|
|
1864
|
+
thinking: false,
|
|
1865
|
+
signal: options.signal
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
const content = await consumeStream(streamResponse, (token) => {
|
|
1869
|
+
this.onToken(token);
|
|
1870
|
+
});
|
|
1871
|
+
|
|
1872
|
+
this.onContent(content);
|
|
1873
|
+
logRunTotals('final-stream');
|
|
1874
|
+
return content;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
this.onWarning('Max tool iterations reached');
|
|
1879
|
+
logRunTotals('max-iterations');
|
|
1880
|
+
return '';
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
module.exports = { AgenticRunner, TOOLS, READ_ONLY_TOOLS, executeTool, setMcpClient };
|