mcp-fs-shell-windows 0.2.20 → 0.2.29

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.
@@ -0,0 +1,752 @@
1
+ // subagent/handler.ts — consult_secondary_agent (MCP filesystem fork).
2
+ //
3
+ // Port of the Beledarian reference implementation (beledarians-lm-studio-tools
4
+ // src/toolsProvider.ts L2465-3236): a delegated agent loop against the
5
+ // LM Studio /v1/chat/completions endpoint (default http://localhost:1234/v1,
6
+ // model "local-model" = the main loaded model). It parses JSON tool calls out
7
+ // of prose (subAgentToolCallParser.ts), validates them (toolCallValidator.ts),
8
+ // executes them against the FORK's own in-process handlers (no JSON-RPC
9
+ // round-trip), auto-saves code blocks from the FINAL response, runs the
10
+ // auto-debug reviewer pass when the primary task modified files, and returns:
11
+ //
12
+ // { response, generated_files, filesModified, handoff_message? }
13
+ // or
14
+ // { error, filesModified? }
15
+ //
16
+ // Environment overrides:
17
+ // MCP_SUBAGENT_ENDPOINT default http://localhost:1234/v1
18
+ // MCP_SUBAGENT_MODEL default local-model
19
+ // MCP_SUBAGENT_CWD sub-agent working dir (default: process.cwd())
20
+ // MCP_SUBAGENT_PROFILES JSON string {role: personaText} (default: built-ins)
21
+ // MCP_SUBAGENT_DEBUG "1" to enable [Sub-Agent] console logging
22
+ //
23
+ // Auth: Bearer token from the API token file (MCP_API_TOKEN_FILE, or the
24
+ // reference machine's overnight token file when unset) when present
25
+ // (keyless fallback when missing) — replicates the patched Beledarian
26
+ // behavior.
27
+ //
28
+ // Allowed-tool mapping (allow_tools=true) -> fork in-process handlers:
29
+ // read_file -> read_files
30
+ // list_directory -> list_directory
31
+ // save_file -> write_new_files (overwrite semantics)
32
+ // replace_text_in_file -> edit_files (replaceAll: false)
33
+ // delete_files_by_pattern -> delete_files_by_pattern
34
+ // rag_local_files -> rag_local_files
35
+ // fuzzy_find_local_files -> fuzzy_find_files
36
+ // search_file_content -> search_regex
37
+ // wikipedia_search -> wikipedia_search
38
+ // web_search / duckduckgo_search-> web_search
39
+ // fetch_web_content -> fetch_web_content
40
+ // rag_web_content -> rag_web_content
41
+ // browser_session_open/control/close -> same names
42
+ // run_python -> run_python (in the sub-agent CWD)
43
+ // run_javascript -> run_javascript
44
+ // finish_task -> termination signal (loop exit, no execution)
45
+ import { readFile, writeFile, appendFile, stat, mkdir, readdir } from "fs/promises";
46
+ import { join, dirname, resolve, relative, isAbsolute } from "path";
47
+ import { parseSubAgentResponseMessage, } from "./subAgentToolCallParser.js";
48
+ import { validateToolCall } from "./toolCallValidator.js";
49
+ import { extractHandoffMessage } from "./handoffMessage.js";
50
+ // The FORK's own in-process handlers (executed directly; no JSON-RPC round-trip)
51
+ import { handleReadFiles } from "../read_files/handler.js";
52
+ import { handleListDirectory } from "../list_directory/handler.js";
53
+ import { handleWriteNewFiles } from "../write_new_files/handler.js";
54
+ import { handleEditFiles } from "../edit_files/handler.js";
55
+ import { handleDeleteFilesByPattern } from "../delete_files_by_pattern/handler.js";
56
+ import { handleRagLocalFiles, handleRagWebContent } from "../rag/handler.js";
57
+ import { handleFuzzyFindFiles } from "../fuzzy_find_files/handler.js";
58
+ import { handleSearchRegex } from "../search_regex/handler.js";
59
+ import { handleWebSearch, handleFetchWebContent, handleWikipediaSearch, } from "../web/handler.js";
60
+ import { handleBrowserSessionOpen, handleBrowserSessionControl, handleBrowserSessionClose, } from "../browser/handler.js";
61
+ import { handleRunPython, handleRunJavascript } from "../compat/handler.js";
62
+ // --- Security Helper (ported from the reference L20-30) ---
63
+ function validatePath(baseDir, requestedPath) {
64
+ const resolved = resolve(baseDir, requestedPath);
65
+ // Use relative pathing to ensure the resolved path stays within baseDir
66
+ const rel = relative(baseDir, resolved);
67
+ if (rel.startsWith("..") || isAbsolute(rel)) {
68
+ throw new Error(`Access Denied: Path '${requestedPath}' is outside the workspace.`);
69
+ }
70
+ return resolved;
71
+ }
72
+ // (ported from the reference L32-62)
73
+ function extractLikelyFilePath(text) {
74
+ const isPlausiblePath = (value) => {
75
+ const candidate = value.trim();
76
+ if (!candidate)
77
+ return false;
78
+ if (/[\r\n]/.test(candidate))
79
+ return false;
80
+ if (candidate.includes("=") && !candidate.includes("\\") && !candidate.includes("/"))
81
+ return false;
82
+ if (/[<>|*?]/.test(candidate))
83
+ return false;
84
+ const extensionMatch = candidate.match(/\.([A-Za-z0-9_-]{1,15})$/);
85
+ if (!extensionMatch)
86
+ return false;
87
+ const extension = extensionMatch[1];
88
+ if (!/[A-Za-z]/.test(extension))
89
+ return false; // reject ".0" and similar numeric pseudo-extensions
90
+ return true;
91
+ };
92
+ const patterns = [
93
+ /['"]([A-Za-z]:\\[^'"\r\n]+)['"]/,
94
+ /\b([A-Za-z]:\\[^\s'"]+(?:\.[A-Za-z0-9_-]+)?)\b/,
95
+ /['"]((?:\.{0,2}[\\/])?[^'"\r\n]+\.[A-Za-z0-9_-]+)['"]/,
96
+ ];
97
+ for (const pattern of patterns) {
98
+ const match = text.match(pattern);
99
+ if (!match?.[1])
100
+ continue;
101
+ const candidate = match[1].replace(/[),.;]+$/, "").trim();
102
+ if (!isPlausiblePath(candidate))
103
+ continue;
104
+ return candidate;
105
+ }
106
+ return null;
107
+ }
108
+ // LM Studio API token file (2026-09-04 auth enablement). When the file exists,
109
+ // requests carry an Authorization: Bearer header; otherwise keyless (works
110
+ // when auth is off). Same file + fallback the patched Beledarian plugin uses.
111
+ // Reference machine's overnight token file (kept for back-compat; on other
112
+ // machines the file is simply absent -> keyless mode).
113
+ const LEGACY_API_TOKEN_FILE = "C:\\Users\\Gerar\\.beledarians-llm-toolbox\\workspace\\overnight\\lm_api_token.txt";
114
+ const API_TOKEN_FILE = process.env.MCP_API_TOKEN_FILE?.trim() || LEGACY_API_TOKEN_FILE;
115
+ // Built-in persona profiles:
116
+ // - 'summarizer'/'coder': the reference's config default (subAgentProfiles,
117
+ // beledarians config.ts L148)
118
+ // - 'reviewer': the reference's inline fallback persona (toolsProvider L2547)
119
+ const BUILT_IN_PROFILES = {
120
+ summarizer: "You are a summarization expert. Summarize the content concisely.",
121
+ coder: "You are a software engineer. Write efficient and safe code.",
122
+ reviewer: "You are a Senior Code Reviewer. Your job is to analyze code, find bugs, security issues, or logic errors, and FIX them.\n\nIMPORTANT: To fix a file, you MUST use the 'save_file' tool with the complete, corrected content. DO NOT use 'container.exec' or diff formats. Just overwrite the file with the fixed version using 'save_file'.",
123
+ };
124
+ // Full allowed-tools set for the fork: the reference gates these behind
125
+ // subAgentAllowFileSystem / subAgentAllowWeb / subAgentAllowCode /
126
+ // subAgentAllowBrowserControl settings (all on except code/browser by
127
+ // default). The fork has no settings UI, so allow_tools=true enables the
128
+ // full mapped set below.
129
+ const FULL_ALLOWED_TOOLS = [
130
+ "read_file", "list_directory", "save_file", "replace_text_in_file",
131
+ "delete_files_by_pattern", "rag_local_files", "fuzzy_find_local_files",
132
+ "search_file_content",
133
+ "wikipedia_search", "web_search", "duckduckgo_search",
134
+ "fetch_web_content", "rag_web_content",
135
+ "browser_session_open", "browser_session_control", "browser_session_close",
136
+ "run_python", "run_javascript",
137
+ ];
138
+ export async function handleConsultSecondaryAgent(task, agentRole, context, allowTools, allowedDirectories) {
139
+ let endpoint = process.env.MCP_SUBAGENT_ENDPOINT || "http://localhost:1234/v1";
140
+ let modelId = process.env.MCP_SUBAGENT_MODEL || "local-model";
141
+ let handoffMessage = undefined;
142
+ let finalResponse = "";
143
+ const subAgentDebugLogging = process.env.MCP_SUBAGENT_DEBUG === "1";
144
+ // Sub-agent working directory (reference: plugin-state currentWorkingDirectory)
145
+ const currentWorkingDirectory = process.env.MCP_SUBAGENT_CWD || process.cwd();
146
+ // Persona profiles: env JSON override, else built-in defaults
147
+ let subAgentProfiles = { ...BUILT_IN_PROFILES };
148
+ const profilesEnv = process.env.MCP_SUBAGENT_PROFILES;
149
+ if (profilesEnv && profilesEnv.trim()) {
150
+ try {
151
+ const parsed = JSON.parse(profilesEnv);
152
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
153
+ subAgentProfiles = parsed;
154
+ }
155
+ }
156
+ catch { /* invalid JSON - keep built-ins */ }
157
+ }
158
+ // The fork has no settings UI; use the reference's config defaults:
159
+ // subAgentAutoSave=true, showFullCodeOutput=false. (The reference's
160
+ // enableDebugMode=false default is overridden here: the auto-debug
161
+ // reviewer pass runs for coding tasks, i.e. whenever the primary task
162
+ // modified files.)
163
+ const autoSave = true;
164
+ const showFullCode = false;
165
+ // LM Studio API auth (enabled 2026-09-04): send an Authorization header when
166
+ // the token file exists; otherwise stay keyless (works when auth is off).
167
+ let apiToken = "";
168
+ try {
169
+ const t = await readFile(API_TOKEN_FILE, "utf-8");
170
+ if (t.trim())
171
+ apiToken = t.trim();
172
+ }
173
+ catch { /* no token file - keyless mode */ }
174
+ const maxSubAgentToolOutputChars = 30000;
175
+ const truncateOutput = (s) => s.length > maxSubAgentToolOutputChars
176
+ ? `${s.substring(0, maxSubAgentToolOutputChars)}\n... (truncated ${s.length - maxSubAgentToolOutputChars} chars)`
177
+ : s;
178
+ // Helper to run an agent loop (ported from the reference runAgentLoop)
179
+ const runAgentLoop = async (role, taskPrompt, contextData, loopLimit = 8, forceTools = false) => {
180
+ let currentSystemPrompt = "You are a helpful assistant.";
181
+ // Load Instructions
182
+ const instructionsPath = join(currentWorkingDirectory, "SUB_AGENT_INSTRUCTIONS.md");
183
+ try {
184
+ const instructions = await readFile(instructionsPath, "utf-8");
185
+ if (instructions.trim())
186
+ currentSystemPrompt = instructions;
187
+ }
188
+ catch { /* Ignore if instructions file doesn't exist */ }
189
+ // Inject Project Info
190
+ const infoPath = join(currentWorkingDirectory, "beledarian_info.md");
191
+ try {
192
+ const projectInfo = await readFile(infoPath, "utf-8");
193
+ if (projectInfo.trim()) {
194
+ currentSystemPrompt += `\n\n## ? Current Project Info (beledarian_info.md)\n${projectInfo}\n`;
195
+ }
196
+ }
197
+ catch { /* Ignore if info file doesn't exist */ }
198
+ // Add current working directory to system prompt for context
199
+ currentSystemPrompt += `\n\n## ? Current Workspace\nYour current working directory is: \n\n${currentWorkingDirectory}\nAlways assume relative paths are from this directory.`;
200
+ // Append specific profile if available
201
+ try {
202
+ if (subAgentProfiles[role]) {
203
+ currentSystemPrompt += `\n\n## Your Persona\n${subAgentProfiles[role]}`;
204
+ }
205
+ }
206
+ catch { /* ignore */ }
207
+ // Append Tools
208
+ let toolsReminder = "";
209
+ const toolsEnabled = allowTools || forceTools;
210
+ if (toolsEnabled) {
211
+ const toolsList = FULL_ALLOWED_TOOLS.join(", ");
212
+ currentSystemPrompt += `\n\n## Allowed Tools\nYou have access to the following tools via JSON output: ${toolsList}.\nRefer to the "Tool Usage" section above for the JSON format.\n`;
213
+ toolsReminder = `\n\n[SYSTEM REMINDER: You have access to tools: ${toolsList}. If you need information you don't have, USE A TOOL. Do not refuse. Format tool calls exactly as: {"tool": "tool_name", "args": {"arg_name": "value"}}]`;
214
+ currentSystemPrompt += `\n\n## Browser Navigation Rule\nFor multi-step browsing/navigation, you MUST use browser_session_open -> browser_session_control -> browser_session_close.\nUse browser_open_page only for one-shot page reads.`;
215
+ }
216
+ currentSystemPrompt += `\n\n## Optional Handoff Message\nIf you want the main agent to relay your findings, include either:\n1) [HANDOFF_MESSAGE]...[/HANDOFF_MESSAGE]\nOR\n2) JSON with a \`handoff_message\` field (optionally with \`response\` or \`final_response\`).`;
217
+ currentSystemPrompt += `\n\n## Task Completion & Early Exit\nIf you have successfully completed your task, output 'TASK_COMPLETED'.\nIf you cannot complete the task (e.g., due to system limitations, missing files, or inaccessible paths), output 'TASK_FAILED' to abort early.`;
218
+ const msgList = [
219
+ { role: "system", content: currentSystemPrompt },
220
+ { role: "user", content: `Task: ${taskPrompt}\n\nContext: ${contextData}${toolsReminder}` },
221
+ ];
222
+ let loops = 0;
223
+ let noToolCallCount = 0;
224
+ let executedToolCallCount = 0;
225
+ let finalContent = "";
226
+ let filesModified = [];
227
+ let handoffMessage = "";
228
+ const suggestedReadPath = toolsEnabled
229
+ ? extractLikelyFilePath(`${taskPrompt}\n${contextData}`)
230
+ : null;
231
+ while (loops < loopLimit) {
232
+ try {
233
+ const subAgentHeaders = { "Content-Type": "application/json" };
234
+ if (apiToken)
235
+ subAgentHeaders["Authorization"] = `Bearer ${apiToken}`;
236
+ const response = await fetch(`${endpoint}/chat/completions`, {
237
+ method: "POST",
238
+ headers: subAgentHeaders,
239
+ body: JSON.stringify({
240
+ model: modelId,
241
+ messages: msgList,
242
+ temperature: 0.7,
243
+ stream: false
244
+ })
245
+ });
246
+ if (!response.ok) {
247
+ const errorBody = await response.text().catch(() => "");
248
+ const compactErrorBody = errorBody.replace(/\s+/g, " ").trim().substring(0, 600);
249
+ if (subAgentDebugLogging) {
250
+ console.log(`[Sub-Agent] API error status=${response.status} body=${compactErrorBody}`);
251
+ }
252
+ const details = compactErrorBody ? ` - ${compactErrorBody}` : "";
253
+ return { error: `API Error: ${response.status}${details}`, filesModified };
254
+ }
255
+ const data = await response.json();
256
+ const message = data?.choices?.[0]?.message;
257
+ const parsedMessage = parseSubAgentResponseMessage(message);
258
+ const content = parsedMessage.content;
259
+ let toolCall = parsedMessage.toolCall;
260
+ if (subAgentDebugLogging) {
261
+ const rawContent = (typeof message === "string" ? message : JSON.stringify(message)) ?? "";
262
+ console.log(`[Sub-Agent] RAW content received: ${rawContent.substring(0, 1000)}...`);
263
+ const preview = content.substring(0, 200);
264
+ console.log(`[Sub-Agent] Parse result source=${parsedMessage.toolCallSource} hasToolCall=${Boolean(toolCall)} preview=${preview}`);
265
+ }
266
+ // Always capture the latest content as the finalContent candidate
267
+ finalContent = content;
268
+ if (!toolsEnabled) {
269
+ const extracted = extractHandoffMessage(content);
270
+ // If the only output is a bare tool-call JSON (model tried to use tools it wasn't
271
+ // given), substitute a clear failure message rather than leaking raw JSON.
272
+ const looksLikePureToolCall = extracted.response.trimStart().startsWith("{") &&
273
+ parsedMessage.toolCall !== null &&
274
+ extracted.response.trim().length < 500;
275
+ const safeResponse = looksLikePureToolCall
276
+ ? "[Sub-agent did not produce a prose response. It attempted a tool call but tools are disabled for this invocation.]"
277
+ : extracted.response;
278
+ return { response: safeResponse, filesModified, handoff_message: extracted.handoffMessage };
279
+ }
280
+ const trimmed = content.trim();
281
+ if (!toolCall && trimmed) {
282
+ const refusalKeywords = [
283
+ "i cannot browse", "i don't have access", "i can't access",
284
+ "unable to browse", "real-time news", "no internet access",
285
+ "as an ai", "i do not have the ability", "cannot access the internet"
286
+ ];
287
+ if (refusalKeywords.some(kw => trimmed.toLowerCase().includes(kw))) {
288
+ msgList.push({ role: "assistant", content: content });
289
+ // role must stay "user": mid-conversation "system" messages break chat templates
290
+ // that require the system message first (e.g. HauhauCS Qwen templates raise)
291
+ msgList.push({ role: "user", content: "SYSTEM ERROR: You HAVE access to tools. USE THEM." });
292
+ loops++;
293
+ continue;
294
+ }
295
+ }
296
+ if (toolCall && toolCall.tool) {
297
+ noToolCallCount = 0;
298
+ executedToolCallCount++;
299
+ msgList.push({ role: "assistant", content: content });
300
+ let toolResult = "";
301
+ let finishNow = false;
302
+ const args = toolCall.args || {};
303
+ // Use shared validator to prevent duplication between prod and tests
304
+ const toolValidationError = validateToolCall(toolCall.tool, args);
305
+ if (toolValidationError) {
306
+ toolResult = `TOOL_VALIDATION_ERROR: ${toolValidationError}`;
307
+ }
308
+ else {
309
+ try {
310
+ // --- File System (fork handler mappings) ---
311
+ if (toolCall.tool === "read_file" && toolCall.args?.file_name) {
312
+ const fpath = validatePath(currentWorkingDirectory, toolCall.args.file_name);
313
+ const readContent = await handleReadFiles([fpath], allowedDirectories);
314
+ toolResult = truncateOutput(readContent);
315
+ }
316
+ else if (toolCall.tool === "list_directory") {
317
+ const dirPath = toolCall.args?.path
318
+ ? validatePath(currentWorkingDirectory, toolCall.args.path)
319
+ : currentWorkingDirectory;
320
+ toolResult = truncateOutput(await handleListDirectory(dirPath, allowedDirectories));
321
+ }
322
+ else if (toolCall.tool === "save_file") {
323
+ // Handle batch files (some models return { files: [...] })
324
+ if (Array.isArray(toolCall.args?.files)) {
325
+ const fileEntries = [];
326
+ for (const fileObj of toolCall.args.files) {
327
+ const fName = fileObj.file_name || fileObj.name || fileObj.path;
328
+ const fContent = fileObj.content || fileObj.data;
329
+ if (fName && fContent) {
330
+ const fpath = validatePath(currentWorkingDirectory, fName);
331
+ fileEntries.push({ path: fpath, content: fContent, overwrite: true });
332
+ }
333
+ }
334
+ if (fileEntries.length > 0) {
335
+ const res = await handleWriteNewFiles(fileEntries, allowedDirectories);
336
+ const savedList = fileEntries
337
+ .filter((f) => res.includes(`Successfully wrote to ${f.path}`))
338
+ .map((f) => {
339
+ const relName = relative(currentWorkingDirectory, f.path);
340
+ return relName.startsWith("..") || isAbsolute(relName) ? f.path : relName;
341
+ });
342
+ for (const name of savedList)
343
+ filesModified.push(name);
344
+ toolResult = savedList.length > 0
345
+ ? `Success: Saved ${savedList.length} files: ${savedList.join(", ")}`
346
+ : `Error: No valid files found in batch.\n${res}`;
347
+ }
348
+ else {
349
+ toolResult = "Error: No valid files found in batch.";
350
+ }
351
+ }
352
+ else {
353
+ // Handle varying argument names (some models use name/data instead of file_name/content)
354
+ const fileName = toolCall.args?.file_name || toolCall.args?.name || toolCall.args?.path;
355
+ const content = toolCall.args?.content || toolCall.args?.data;
356
+ if (fileName && content) {
357
+ const fpath = validatePath(currentWorkingDirectory, fileName);
358
+ const res = await handleWriteNewFiles([{ path: fpath, content, overwrite: true }], allowedDirectories);
359
+ if (res.includes(`Successfully wrote to ${fpath}`)) {
360
+ toolResult = `Success: File saved to ${fpath}`;
361
+ filesModified.push(fileName);
362
+ }
363
+ else {
364
+ toolResult = `Error: ${res}`;
365
+ }
366
+ }
367
+ else {
368
+ toolResult = "Error: Missing 'file_name' (or 'name', 'path') or 'content' (or 'data') arguments.";
369
+ }
370
+ }
371
+ }
372
+ else if (toolCall.tool === "replace_text_in_file" && toolCall.args?.file_name && toolCall.args?.old_string && toolCall.args?.new_string) {
373
+ const fpath = validatePath(currentWorkingDirectory, toolCall.args.file_name);
374
+ const res = await handleEditFiles([{
375
+ path: fpath,
376
+ edits: [{
377
+ oldString: toolCall.args.old_string,
378
+ newString: toolCall.args.new_string,
379
+ replaceAll: false,
380
+ }],
381
+ }], allowedDirectories);
382
+ if (res.includes("1 files edited successfully")) {
383
+ toolResult = "Success: Text replaced.";
384
+ filesModified.push(toolCall.args.file_name);
385
+ }
386
+ else {
387
+ toolResult = `Error: ${res}`;
388
+ }
389
+ }
390
+ else if (toolCall.tool === "delete_files_by_pattern" && toolCall.args?.pattern) {
391
+ if (toolCall.args.pattern.length > 100)
392
+ throw new Error("Pattern too complex");
393
+ const regex = new RegExp(toolCall.args.pattern);
394
+ // ReDoS check
395
+ const start = Date.now();
396
+ regex.test("safe_test_string_for_redos_check_1234567890_safe_test_string_for_redos_check_1234567890");
397
+ if (Date.now() - start > 100)
398
+ throw new Error("Pattern too complex/slow");
399
+ toolResult = truncateOutput(await handleDeleteFilesByPattern(currentWorkingDirectory, toolCall.args.pattern, toolCall.args?.include_directories === true, allowedDirectories));
400
+ }
401
+ else if (toolCall.tool === "rag_local_files") {
402
+ if (toolCall.args?.query) {
403
+ toolResult = truncateOutput(await handleRagLocalFiles(toolCall.args.query, toolCall.args?.path, toolCall.args?.file_pattern, allowedDirectories));
404
+ }
405
+ else {
406
+ toolResult = "Error: 'query' is required.";
407
+ }
408
+ }
409
+ else if (toolCall.tool === "fuzzy_find_local_files" && toolCall.args?.query) {
410
+ const targetDir = toolCall.args?.path
411
+ ? validatePath(currentWorkingDirectory, toolCall.args.path)
412
+ : currentWorkingDirectory;
413
+ const maxResults = Math.min(Math.max(Number(toolCall.args?.max_results ?? 5), 1), 20);
414
+ const scanLimit = Math.min(Math.max(Number(toolCall.args?.scan_limit ?? 20000), 1), 200000);
415
+ toolResult = truncateOutput(await handleFuzzyFindFiles(toolCall.args.query, targetDir, toolCall.args?.recursive !== false, maxResults, scanLimit, allowedDirectories));
416
+ }
417
+ else if (toolCall.tool === "search_file_content" && toolCall.args?.pattern) {
418
+ const targetPath = toolCall.args?.path
419
+ ? validatePath(currentWorkingDirectory, toolCall.args.path)
420
+ : currentWorkingDirectory;
421
+ const maxResults = Math.min(Math.max(Number(toolCall.args?.max_results ?? 100), 1), 500);
422
+ toolResult = truncateOutput(await handleSearchRegex(targetPath, toolCall.args.pattern, Array.isArray(toolCall.args?.file_patterns) ? toolCall.args.file_patterns : [], Array.isArray(toolCall.args?.exclude_patterns) ? toolCall.args.exclude_patterns : [], maxResults, toolCall.args?.case_sensitive === true, 1, allowedDirectories, toolCall.args?.count_only === true));
423
+ // --- Web (fork handler mappings) ---
424
+ }
425
+ else if (toolCall.tool === "wikipedia_search" && toolCall.args?.query) {
426
+ toolResult = truncateOutput(await handleWikipediaSearch(toolCall.args.query, toolCall.args?.lang || "en"));
427
+ }
428
+ else if (toolCall.tool === "web_search" || toolCall.tool === "duckduckgo_search") {
429
+ if (toolCall.args?.query) {
430
+ toolResult = truncateOutput(await handleWebSearch(toolCall.args.query));
431
+ }
432
+ else {
433
+ toolResult = "Error: 'query' is required.";
434
+ }
435
+ }
436
+ else if (toolCall.tool === "fetch_web_content" && toolCall.args?.url) {
437
+ toolResult = truncateOutput(await handleFetchWebContent(toolCall.args.url));
438
+ }
439
+ else if (toolCall.tool === "rag_web_content" && toolCall.args?.url && toolCall.args?.query) {
440
+ toolResult = truncateOutput(await handleRagWebContent(toolCall.args.url, toolCall.args.query));
441
+ // --- Browser (fork handler mappings) ---
442
+ }
443
+ else if (toolCall.tool === "browser_session_open" && toolCall.args?.url) {
444
+ toolResult = truncateOutput(await handleBrowserSessionOpen(toolCall.args.url, toolCall.args?.wait_for_selector, toolCall.args?.include_page_text));
445
+ }
446
+ else if (toolCall.tool === "browser_session_control") {
447
+ toolResult = truncateOutput(await handleBrowserSessionControl(toolCall.args ?? {}, allowedDirectories));
448
+ }
449
+ else if (toolCall.tool === "browser_session_close") {
450
+ toolResult = truncateOutput(await handleBrowserSessionClose());
451
+ // --- Code (fork handler mappings) ---
452
+ }
453
+ else if (toolCall.tool === "run_python" && toolCall.args?.python) {
454
+ toolResult = truncateOutput(await handleRunPython(toolCall.args.python, toolCall.args?.timeout_seconds, currentWorkingDirectory));
455
+ }
456
+ else if (toolCall.tool === "run_javascript" && toolCall.args?.javascript) {
457
+ toolResult = truncateOutput(await handleRunJavascript(toolCall.args.javascript, toolCall.args?.timeout_seconds));
458
+ // finish_task: termination signal, not an executable tool (the
459
+ // reference's validator special-cases it; the loop exits cleanly)
460
+ }
461
+ else if (toolCall.tool === "finish_task") {
462
+ if (toolCall.args?.message && !finalContent) {
463
+ finalContent = String(toolCall.args.message);
464
+ }
465
+ finishNow = true;
466
+ }
467
+ if (!toolResult)
468
+ toolResult = "Error: Tool not found/allowed.";
469
+ }
470
+ catch (err) {
471
+ toolResult = `Error: ${err.message}`;
472
+ }
473
+ }
474
+ if (finishNow)
475
+ break;
476
+ msgList.push({ role: "user", content: `Tool Output: ${toolResult}` });
477
+ loops++;
478
+ }
479
+ else {
480
+ // NO TOOL CALL DETECTED
481
+ const shouldAutoFallbackRead = toolsEnabled &&
482
+ executedToolCallCount === 0 &&
483
+ noToolCallCount === 0 &&
484
+ typeof suggestedReadPath === "string" &&
485
+ suggestedReadPath.length > 0;
486
+ if (shouldAutoFallbackRead) {
487
+ try {
488
+ const autoReadPath = validatePath(currentWorkingDirectory, suggestedReadPath);
489
+ const autoReadStats = await stat(autoReadPath);
490
+ if (!autoReadStats.isFile()) {
491
+ throw new Error(`Not a file: ${autoReadPath}`);
492
+ }
493
+ const autoReadContent = await readFile(autoReadPath, "utf-8");
494
+ const boundedContent = autoReadContent.length > 30000
495
+ ? `${autoReadContent.substring(0, 30000)}\n... (truncated)`
496
+ : autoReadContent;
497
+ if (trimmed.length > 0) {
498
+ msgList.push({ role: "assistant", content: content });
499
+ }
500
+ msgList.push({
501
+ role: "user",
502
+ content: `Tool Output: AUTO_FALLBACK read_file(${suggestedReadPath})\n${boundedContent}`,
503
+ });
504
+ executedToolCallCount++;
505
+ loops++;
506
+ continue;
507
+ }
508
+ catch (error) {
509
+ if (subAgentDebugLogging) {
510
+ console.log(`[Sub-Agent] Auto fallback read_file failed: ${error instanceof Error ? error.message : String(error)}`);
511
+ }
512
+ try {
513
+ const autoFiles = await readdir(currentWorkingDirectory);
514
+ const limitedFiles = autoFiles.slice(0, 200);
515
+ if (trimmed.length > 0) {
516
+ msgList.push({ role: "assistant", content: content });
517
+ }
518
+ msgList.push({
519
+ role: "user",
520
+ content: `Tool Output: AUTO_FALLBACK list_directory(.)\n${JSON.stringify(limitedFiles)}`,
521
+ });
522
+ executedToolCallCount++;
523
+ loops++;
524
+ continue;
525
+ }
526
+ catch {
527
+ // ignore and continue to normal no-tool fallback behavior
528
+ }
529
+ }
530
+ }
531
+ // Check for explicit completion phrase or strict loop limit
532
+ const planningLikeText = /(?:\bI(?:'ll| will)\b|\blet me\b|\bnext\b|\bfirst\b)/i.test(trimmed);
533
+ const shouldTreatAsFinalResponse = trimmed.length >= 120 &&
534
+ !planningLikeText;
535
+ // Increment no-tool counter
536
+ noToolCallCount++;
537
+ if (content.includes("TASK_COMPLETED") || content.includes("TASK_FAILED") || shouldTreatAsFinalResponse || noToolCallCount >= 3 || loops >= loopLimit - 1) {
538
+ break; // Done
539
+ }
540
+ if (content.trim().length > 0) {
541
+ msgList.push({ role: "assistant", content: content });
542
+ }
543
+ let reminder = "SYSTEM NOTICE: You did not call a tool. If you are finished, output 'TASK_COMPLETED'. If you cannot complete the task, output 'TASK_FAILED'. If not, USE A TOOL now and return a single JSON tool-call object only (no prose).";
544
+ if (toolsEnabled) {
545
+ if (suggestedReadPath && noToolCallCount <= 3) {
546
+ const escapedPath = suggestedReadPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
547
+ reminder += `\nSuggested next step: {"tool":"read_file","args":{"file_name":"${escapedPath}"}}`;
548
+ }
549
+ else if (noToolCallCount <= 3) {
550
+ reminder += `\nSuggested next step: {"tool":"list_directory","args":{}}`;
551
+ }
552
+ }
553
+ // role must stay "user" — see note on the SYSTEM ERROR push above
554
+ msgList.push({ role: "user", content: reminder });
555
+ loops++;
556
+ }
557
+ }
558
+ catch (err) {
559
+ return { error: err.message, filesModified };
560
+ }
561
+ // Prevent unbounded memory growth
562
+ if (msgList.length > 20) {
563
+ // Keep system message (index 0) and last 18 messages
564
+ const systemMsg = msgList[0];
565
+ const recentMsgs = msgList.slice(-18);
566
+ msgList.length = 0;
567
+ msgList.push(systemMsg, ...recentMsgs);
568
+ }
569
+ }
570
+ if (finalContent) {
571
+ const extracted = extractHandoffMessage(finalContent);
572
+ finalContent = extracted.response;
573
+ handoffMessage = extracted.handoffMessage || "";
574
+ }
575
+ // --- Auto-Save Logic (ported from the reference) ---
576
+ if (autoSave && finalContent) {
577
+ // Regex matches: ```lang (optional space/newline) code ```
578
+ // Relaxed to not strictly require \n, handling ```html code...
579
+ const codeBlockRegex = /```\s*(\w+)?\s*([\s\S]*?)```/g;
580
+ // Get all matches from the ORIGINAL string
581
+ const matches = Array.from(finalContent.matchAll(codeBlockRegex));
582
+ const processedFiles = new Set();
583
+ // Iterate BACKWARDS to preserve indices for replacement
584
+ for (let i = matches.length - 1; i >= 0; i--) {
585
+ const match = matches[i];
586
+ const fullBlock = match[0];
587
+ const lang = (match[1] || "txt").toLowerCase();
588
+ const code = match[2];
589
+ const index = match.index || 0;
590
+ let handledAsBatch = false;
591
+ // Smart JSON Unpacking
592
+ if (lang === "json") {
593
+ try {
594
+ const parsed = JSON.parse(code);
595
+ if (Array.isArray(parsed)) {
596
+ let extractedCount = 0;
597
+ for (const item of parsed) {
598
+ const fName = item.path || item.file_name || item.name;
599
+ const fContent = item.content || item.data || item.code;
600
+ if (fName && typeof fName === "string" && fContent && typeof fContent === "string") {
601
+ const fpath = validatePath(currentWorkingDirectory, fName);
602
+ await mkdir(dirname(fpath), { recursive: true });
603
+ await writeFile(fpath, fContent, "utf-8");
604
+ filesModified.push(fName);
605
+ processedFiles.add(fName);
606
+ extractedCount++;
607
+ }
608
+ }
609
+ if (extractedCount > 0) {
610
+ handledAsBatch = true;
611
+ const replacement = `\n[System: Successfully extracted and saved ${extractedCount} files from JSON block.]\n`;
612
+ finalContent = finalContent.slice(0, index) + replacement + finalContent.slice(index + fullBlock.length);
613
+ }
614
+ }
615
+ }
616
+ catch {
617
+ // Not valid JSON or not the structure we want, fall through to normal save
618
+ }
619
+ }
620
+ if (!handledAsBatch && code.trim().length > 50) {
621
+ // Lookback in the ORIGINAL string (match.input is safe)
622
+ const lookback = finalContent.substring(Math.max(0, index - 500), index);
623
+ // Regex to find filenames like `### src/App.tsx`, `**App.tsx**`, `filename: App.tsx`
624
+ const nameMatch = lookback.match(/(?:`|\*\*|###|filename:|file:)[\s\S]*?([\w\-\/\\.]+\.(?:tsx|ts|jsx|js|html|css|json|md|py|sh|java|rs|go|sql|yaml|yml|c|cpp|h|hpp|txt))/i);
625
+ let fileName = "";
626
+ if (nameMatch) {
627
+ fileName = nameMatch[1].trim();
628
+ }
629
+ // Fallback: Check the first line of the code block for a filename comment
630
+ // e.g. // src/App.tsx or # filename: utils.py
631
+ if (!fileName) {
632
+ const firstLine = code.split('\n')[0].trim();
633
+ const commentMatch = firstLine.match(/^(?:\/\/|#|<!--|;)\s*(?:filename:|file:)?\s*([\w\-\/\\.]+\.(?:tsx|ts|jsx|js|html|css|json|md|py|sh|java|rs|go|sql|yaml|yml|c|cpp|h|hpp|txt))/i);
634
+ if (commentMatch) {
635
+ fileName = commentMatch[1].trim();
636
+ }
637
+ }
638
+ // Block Shell/Console snippets from being auto-saved as "auto_gen" files
639
+ // unless there is an EXPLICIT filename match above.
640
+ const isShell = ["bash", "sh", "cmd", "powershell", "console", "zsh", "terminal"].includes(lang);
641
+ if (isShell && !fileName) {
642
+ continue;
643
+ }
644
+ // If we didn't find a filename, skip saving this block.
645
+ // This prevents "auto_gen" files from cluttering the workspace.
646
+ if (!fileName) {
647
+ continue;
648
+ }
649
+ // Deduplication: If we already processed this file in this turn, skip saving it again
650
+ // (the LAST occurrence we are processing is the definitive one).
651
+ if (processedFiles.has(fileName)) {
652
+ continue;
653
+ }
654
+ const fpath = join(currentWorkingDirectory, fileName);
655
+ try {
656
+ await mkdir(dirname(fpath), { recursive: true });
657
+ await writeFile(fpath, code, "utf-8");
658
+ filesModified.push(fileName);
659
+ processedFiles.add(fileName);
660
+ // Replace the block in finalContent using string slicing with the original index
661
+ const replacement = `\n[System: File '${fileName}' created successfully.]\n`;
662
+ finalContent = finalContent.slice(0, index) + replacement + finalContent.slice(index + fullBlock.length);
663
+ }
664
+ catch (e) {
665
+ console.error(`Failed to auto-save file ${fileName}:`, e);
666
+ }
667
+ }
668
+ }
669
+ }
670
+ // --- Auto-Update Project Info (ported from the reference) ---
671
+ if (filesModified.length > 0) {
672
+ const infoPath = join(currentWorkingDirectory, "beledarian_info.md");
673
+ const timestamp = new Date().toISOString();
674
+ const logEntry = `\n- **[${timestamp}]** Task: "${taskPrompt.substring(0, 50)}..." | Modified: ${filesModified.join(", ")}`;
675
+ try {
676
+ await appendFile(infoPath, logEntry, "utf-8");
677
+ }
678
+ catch {
679
+ // If append fails, maybe file doesn't exist, try write
680
+ try {
681
+ await writeFile(infoPath, `# Project History\n${logEntry}`, "utf-8");
682
+ }
683
+ catch { /* ignore */ }
684
+ }
685
+ }
686
+ return { response: finalContent, filesModified, handoff_message: handoffMessage || undefined };
687
+ };
688
+ // --- 1. Primary Agent Loop ---
689
+ const primaryResult = await runAgentLoop(agentRole || "general", task, context || "", 8, false);
690
+ if (primaryResult.error)
691
+ return JSON.stringify({ error: primaryResult.error, filesModified: primaryResult.filesModified });
692
+ finalResponse = primaryResult.response || "";
693
+ handoffMessage = primaryResult.handoff_message;
694
+ const generatedFiles = [...primaryResult.filesModified];
695
+ // --- 2. Auto-Debug Loop (ported from the reference; the fork runs it for
696
+ // coding tasks = whenever the primary task modified files) ---
697
+ if (primaryResult.filesModified.length > 0) {
698
+ const filesToCheck = primaryResult.filesModified.join(", ");
699
+ const debugTask = `Review the code in these files: ${filesToCheck}. Check for bugs, syntax errors, or logic flaws. If you find any, use 'save_file' to FIX them. If they are correct, confirm it.`;
700
+ // Read content of modified files to pass as context
701
+ let debugContext = "Here is the content of the created files:\n";
702
+ for (const f of primaryResult.filesModified) {
703
+ try {
704
+ const c = await readFile(join(currentWorkingDirectory, f), "utf-8");
705
+ debugContext += `\n--- ${f} ---\n${c}\n`;
706
+ }
707
+ catch { /* ignore unreadable */ }
708
+ }
709
+ const debugResult = await runAgentLoop("reviewer", debugTask, debugContext, 5, true);
710
+ finalResponse += "\n\n--- Auto-Debug Report ---\n" + (debugResult.response || (debugResult.error ? `Debug pass failed: ${debugResult.error}` : "Debug pass completed."));
711
+ if (debugResult.filesModified.length > 0) {
712
+ finalResponse += `\n(The reviewer fixed these files: ${debugResult.filesModified.join(", ")})`;
713
+ }
714
+ if (!handoffMessage && debugResult.handoff_message) {
715
+ handoffMessage = debugResult.handoff_message;
716
+ }
717
+ }
718
+ // Append generated file list for Main Agent visibility
719
+ if (primaryResult.filesModified.length > 0) {
720
+ const fullPaths = primaryResult.filesModified.map(f => {
721
+ if (isAbsolute(f))
722
+ return f;
723
+ return join(currentWorkingDirectory, f);
724
+ });
725
+ finalResponse += `\n\n[GENERATED_FILES]: ${fullPaths.join(", ")}`;
726
+ if (showFullCode) {
727
+ finalResponse += `\n\n### Generated Code Content:\n`;
728
+ for (const f of primaryResult.filesModified) {
729
+ try {
730
+ const fpath = isAbsolute(f) ? f : join(currentWorkingDirectory, f);
731
+ const content = await readFile(fpath, "utf-8");
732
+ const ext = f.split('.').pop() || 'txt';
733
+ finalResponse += `\n**${f}**\n\`\`\`${ext}\n${content}\n\`\`\`\n`;
734
+ }
735
+ catch { /* ignore unreadable */ }
736
+ }
737
+ }
738
+ }
739
+ // Only hide code blocks when files were actually saved.
740
+ // If nothing was written to disk, leave the raw response intact so the main agent
741
+ // can see what the sub-agent actually did (or didn't do) rather than being misled
742
+ // by a false "code has been handled" success message.
743
+ if (!showFullCode && primaryResult.filesModified.length > 0) {
744
+ finalResponse = finalResponse.replace(/```[\s\S]*?```/g, "\n[System: Code Block Hidden for Brevity. The code has been handled/saved by the sub-agent. Do NOT request it again. Proceed.]\n");
745
+ }
746
+ return JSON.stringify({
747
+ response: finalResponse,
748
+ generated_files: generatedFiles,
749
+ filesModified: primaryResult.filesModified,
750
+ handoff_message: handoffMessage,
751
+ });
752
+ }