import { tool, type Tool } from "@lmstudio/sdk";
import { z } from "zod";
import { rm, writeFile, readdir, readFile, stat, mkdir, appendFile, rename as fsRename, unlink as fsUnlink } from "fs/promises";
import { join, isAbsolute, dirname, relative } from "path";
import { homedir } from "os";
import type { ToolContext } from "./context";
import { validatePath, extractLikelyFilePath, createSafeToolImplementation, ragLocalFiles, safeFetch, type ToolCtxLike } from "./helpers";
import { rankFuzzyMatches } from "../fuzzySearch";
import { extractHandoffMessage } from "../handoffMessage";
import { parseSubAgentResponseMessage, type ParsedToolCall } from "../subAgentToolCallParser";
import { validateToolCall } from "../toolCallValidator";
import { executeBrowserActions } from "../browserActions";
import { runPythonImpl, runJavascriptImpl } from "./codeTools";
// ── N.16: Shared steering state ───────────────────────────────────────────────
// Messages queued here are injected as system messages at the start of the
// next sub-agent turn. cancelSubAgentRequested signals the loop to exit
// cleanly after its current turn completes.
const pendingSubAgentMessages: string[] = [];
let cancelSubAgentRequested = false;
let subAgentRunning = false;
// ── Role → implied tool categories ───────────────────────────────────────────
// When the main model doesn't pass allow_tools:true, these defaults kick in
// so that role-appropriate tools are available without extra parameters.
// Individual tools are still gated by the user's subAgentAllow* config flags.
const ROLE_TOOL_DEFAULTS: Record<string, { filesystem?: true; web?: true; code?: true }> = {
coder: { filesystem: true, code: true },
reviewer: { filesystem: true },
researcher: { web: true },
debugger: { filesystem: true, code: true },
tester: { filesystem: true, code: true },
documenter: { filesystem: true },
planner: { filesystem: true },
data_analyst: { filesystem: true, code: true },
general: { filesystem: true },
};
const LAST_RESULT_PATH = join(homedir(), ".lm-studio-toolbox", "last_sub_agent_result.json");
const MAX_SUB_AGENT_OUTPUT_CHARS = 30_000;
/** Timeout (ms) applied to all external web fetch calls inside the sub-agent. */
const WEB_FETCH_TIMEOUT_MS = 15_000;
/** Atomic write: write to temp then rename, so a crash never leaves partial content. */
async function atomicWrite(filePath: string, content: string): Promise<void> {
const tmp = `${filePath}.__tmp`;
try {
await writeFile(tmp, content, "utf-8");
await fsRename(tmp, filePath);
} catch (e) {
await fsUnlink(tmp).catch(() => {});
throw e;
}
}
/** Maximum automatic retries on transient network errors before surfacing the error. */
const MAX_ENDPOINT_RETRIES = 2;
/** Base delay for exponential backoff on transient endpoint failures (1s → 2s → 4s). */
const ENDPOINT_RETRY_BASE_MS = 1_000;
/** Maximum retries on HTTP 429 (rate-limited) responses. */
const MAX_RATE_LIMIT_RETRIES = 3;
/** Fallback wait when a 429 response has no Retry-After header. */
const RATE_LIMIT_FALLBACK_MS = 5_000;
/**
* Strip HTML tags, scripts, styles and decode common entities so that
* fetch_web_content returns readable plain text instead of raw markup.
*/
function htmlToPlainText(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/ /g, " ")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/[ \t]{2,}/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function createSubAgentTools(ctx: ToolContext): Tool[] {
if (!ctx.enableSecondary) return [];
const consultTool = tool({
name: "consult_secondary_agent",
description: "Delegate a task to a specialised sub-agent model. Choose the agent_role that best matches the work:\n coder → write/edit/refactor code, saves files automatically\n reviewer → audit code for bugs and security issues\n researcher → gather and summarise information from the web\n debugger → diagnose failing tests or runtime errors\n tester → write and run tests for existing code\n documenter → write or update docs, READMEs, changelogs\n planner → decompose a complex task into an ordered plan (no code)\n data_analyst → query databases and transform data\n general → anything else\nFile-writing roles (coder, reviewer, debugger, tester, documenter, planner) have filesystem access enabled automatically. The sub-agent saves files to disk and returns their paths — do NOT repeat work already done. If [GENERATED_FILES] is in the response, those files exist on disk. Call get_sub_agent_result to re-read the last result without running a new session.",
parameters: {
task: z.string(),
agent_role: z.string().optional().describe("Key from 'Sub-Agent Profiles' config (e.g., 'coder', 'reviewer', 'researcher', 'debugger', 'tester', 'documenter', 'planner', 'data_analyst'). Default: 'general'."),
context: z.string().optional().describe("Additional context or data for the agent."),
allow_tools: z.boolean().optional().describe("If true, the secondary agent can use tools like Web Search, File System, and Code Execution. Default: false."),
chain: z.array(z.string()).optional().describe("Optional list of additional roles to run in sequence after the primary role, each receiving the previous output as context. Example: ['tester', 'reviewer'] runs the tester then the reviewer on the coder's output."),
readonly: z.boolean().optional().describe("If true, the sub-agent cannot write, modify, or delete files. Use for research or review roles that should only read. Default: false."),
},
implementation: createSafeToolImplementation(
async ({ task, agent_role = "general", context = "", allow_tools = false, chain = [], readonly = false }, toolCtx: ToolCtxLike) => {
// Resolve config
let endpoint: string = ctx.pluginConfig.get("secondaryAgentEndpoint");
let modelId: string = ctx.pluginConfig.get("secondaryModelId");
if (ctx.pluginConfig.get("useMainModelForSubAgent")) {
endpoint = "http://localhost:1234/v1";
modelId = "local-model";
}
const subAgentProfilesStr: string = ctx.pluginConfig.get("subAgentProfiles");
const subAgentTemperature: number = ctx.pluginConfig.get("subAgentTemperature") ?? 0.4;
const subAgentTimeLimitSec: number = ctx.pluginConfig.get("subAgentTimeLimit") ?? 600;
const subAgentLoopLimit: number = Math.max(1, ctx.pluginConfig.get("subAgentLoopLimit") ?? 8);
const debugMode: boolean = ctx.pluginConfig.get("enableDebugMode");
const subAgentDebugLogging: boolean = ctx.pluginConfig.get("enableSubAgentDebugLogging");
const autoSave: boolean = ctx.pluginConfig.get("subAgentAutoSave");
const showFullCode: boolean = ctx.pluginConfig.get("showFullCodeOutput");
const showExecutionLog: boolean = ctx.pluginConfig.get("subAgentShowExecutionLog") !== false; // default on
const allowFileSystem: boolean = ctx.pluginConfig.get("subAgentAllowFileSystem");
const allowWeb: boolean = ctx.pluginConfig.get("subAgentAllowWeb");
const allowCode: boolean = ctx.pluginConfig.get("subAgentAllowCode");
const allowSubAgentBrowserControl: boolean = ctx.pluginConfig.get("subAgentAllowBrowserControl");
// ── Pre-flight: verify endpoint is reachable ───────────────────────────
try {
const pingUrl = `${endpoint}/models`;
const pingRes = await fetch(pingUrl, { method: "GET", signal: AbortSignal.timeout(4_000) });
// 200 or 404 both mean the server is up; anything else (e.g. 503) is a problem
if (!pingRes.ok && pingRes.status !== 404) {
return { error: `Secondary agent endpoint returned HTTP ${pingRes.status}. Check that LM Studio is running and the model '${modelId}' is loaded at ${endpoint}.`, status: "failed" };
}
} catch (pingErr) {
const msg = pingErr instanceof Error ? pingErr.message : String(pingErr);
return { error: `Cannot reach secondary agent endpoint (${endpoint}): ${msg}. Verify LM Studio is running with a model loaded. If using LM Link, ensure the remote host is reachable.`, status: "failed" };
}
// ── Role-implied tool access ───────────────────────────────────────────
// Roles have sensible tool defaults so the main model doesn't need to
// pass allow_tools:true explicitly for every productive task.
const roleDefaults = ROLE_TOOL_DEFAULTS[agent_role] ?? {};
const effectiveAllowFileSystem = allowFileSystem && (allow_tools || roleDefaults.filesystem === true);
const effectiveAllowWeb = allowWeb && (allow_tools || roleDefaults.web === true);
const effectiveAllowCode = allowCode && (allow_tools || roleDefaults.code === true);
// ── Agent loop ─────────────────────────────────────────────────────────
const runAgentLoop = async (
role: string,
taskPrompt: string,
contextData: string,
loopLimit = 8,
forceTools = false,
cwd: string,
deadlineMs: number = Date.now() + subAgentTimeLimitSec * 1000,
readonlyMode = false,
) => {
let currentSystemPrompt = "You are a helpful assistant.";
try {
const instructions = await readFile(join(cwd, "SUB_AGENT_INSTRUCTIONS.md"), "utf-8");
if (instructions.trim()) currentSystemPrompt = instructions;
} catch { /* not required */ }
try {
const projectInfo = await readFile(join(cwd, "toolbox_info.md"), "utf-8");
if (projectInfo.trim()) currentSystemPrompt += `\n\n## Current Project Info (toolbox_info.md)\n${projectInfo}`;
} catch { /* not required */ }
currentSystemPrompt += `\n\n## Current Workspace\nYour current working directory is:\n\n${cwd}\nAlways assume relative paths are from this directory.`;
try {
const profiles = JSON.parse(subAgentProfilesStr);
if (profiles[role]) {
currentSystemPrompt += `\n\n## Your Persona\n${profiles[role]}`;
} else if (role === "reviewer") {
currentSystemPrompt += `\n\n## Your Persona\nYou 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.`;
}
} catch { /* invalid JSON in profiles */ }
let toolsReminder = "";
// forceTools is used by the auto-debug reviewer pass; effectiveAllow* incorporates role defaults
const useFS = effectiveAllowFileSystem || forceTools;
const useWeb = effectiveAllowWeb || forceTools;
const useCode = effectiveAllowCode || forceTools;
const toolsEnabled = useFS || useWeb || useCode;
const allowedTools: string[] = []; // populated below; accessible in tool-dispatch fallthrough
if (toolsEnabled) {
if (useFS) {
// J.4: readonly mode — only expose read tools, no writes or deletes
allowedTools.push("read_file", "read_file_range", "list_directory", "search_in_file", "find_files", "fuzzy_find_local_files", "rag_local_files");
if (!readonlyMode) allowedTools.push("save_file", "append_file", "replace_text_in_file", "delete_files_by_pattern");
}
if (useWeb) allowedTools.push("wikipedia_search", "web_search", "fetch_web_content", "rag_web_content");
if (useWeb && allowSubAgentBrowserControl && ctx.allowBrowserControl) allowedTools.push("browser_session_open", "browser_session_control", "browser_session_close");
if (useCode && !readonlyMode) allowedTools.push("run_python", "run_javascript", "run_test_command");
if (allowedTools.length > 0) {
const readonlyNote = readonlyMode ? " (READ-ONLY mode: you may not save, modify, or delete files)" : "";
const toolsList = allowedTools.join(", ");
currentSystemPrompt += `\n\n## Allowed Tools${readonlyNote}\nYou have access to the following tools via JSON output: ${toolsList}.\nFormat tool calls exactly as: {"tool": "tool_name", "args": {"arg_name": "value"}}`;
toolsReminder = `\n\n[SYSTEM REMINDER: You have access to tools: ${toolsList}. If you need information you don't have, USE A TOOL. Format: {"tool": "tool_name", "args": {...}}]`;
}
}
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.\n\n## Task Completion & Early Exit\nIf you have successfully completed your task, output 'TASK_COMPLETED'.\nIf you cannot complete the task, output 'TASK_FAILED' to abort early.`;
const msgList: { role: string; content: string }[] = [
{ role: "system", content: currentSystemPrompt },
{ role: "user", content: `Task: ${taskPrompt}\n\nContext: ${contextData}${toolsReminder}` },
];
let loops = 0;
let noToolCallCount = 0;
let executedToolCallCount = 0;
let finalContent = "";
const filesModified: string[] = [];
let handoffMessage = "";
// M.2: token tracking — accumulate across all turns in this loop run
let totalPromptTokens = 0;
let totalCompletionTokens = 0;
const loopStartMs = Date.now();
// H: per-turn execution log — accumulated and appended to the final response
// so the main model can see the sub-agent's full execution path.
type TurnLogEntry = { turn: number; tool: string; keyArg: string; brief: string; errorDetail?: string };
const turnLog: TurnLogEntry[] = [];
const extractKeyArg = (tool: string, args: Record<string, any>): string => {
const f = String(args.file_name || args.path || args.name || "");
if (["read_file","read_file_range","search_in_file","save_file","replace_text_in_file",
"multi_replace_text","append_file","insert_at_line","delete_lines_in_file",
"delete_path","move_file","copy_file","get_file_metadata"].includes(tool)) return f;
if (["web_search","duckduckgo_search","wikipedia_search","rag_local_files"].includes(tool))
return String(args.query || "").substring(0, 60);
if (tool === "search_directory" || tool === "search_in_file")
return String(args.pattern || args.query || "").substring(0, 60);
if (tool === "fetch_web_content" || tool === "rag_web_content")
return String(args.url || "").substring(0, 60);
if (tool === "run_test_command") return String(args.command || "").substring(0, 60);
if (tool === "run_python") return "python";
if (tool === "run_javascript") return "js";
if (tool === "list_directory") return f || ".";
if (["find_files","fuzzy_find_local_files","delete_files_by_pattern"].includes(tool))
return String(args.pattern || args.query || "").substring(0, 60);
return (f || String(args.query || args.pattern || "").substring(0, 60));
};
const summarizeResult = (tool: string, result: string): { brief: string; errorDetail?: string } => {
const r = (result || "").trim();
const isErr = r.startsWith("Error:") || r.startsWith("TOOL_VALIDATION_ERROR:");
if (isErr) return { brief: "error", errorDetail: r.substring(0, 200) };
if (tool === "run_test_command") {
if (r.startsWith("PASSED")) return { brief: "PASSED" };
return { brief: "FAILED", errorDetail: r.split("\n").slice(0, 3).join(" ").substring(0, 200) };
}
if (tool === "list_directory") {
try { const p = JSON.parse(r); return { brief: `${p.length} entries` }; } catch { return { brief: "success" }; }
}
if (tool === "read_file" || tool === "read_file_range") {
const lines = r.split("\n").length;
return { brief: `${lines} line${lines === 1 ? "" : "s"}${r.startsWith("[FILE TRUNCATED") ? " (truncated)" : ""}` };
}
if (tool === "save_file") {
const m = r.match(/Saved (\d+) files/);
return { brief: m ? `saved ${m[1]} files` : "saved" };
}
if (tool === "find_files" || tool === "fuzzy_find_local_files") {
try { const p = JSON.parse(r); return { brief: `${p.length} result${p.length === 1 ? "" : "s"}` }; } catch { return { brief: "results" }; }
}
if (tool === "search_directory" || tool === "search_in_file") {
if (r.includes("No matches found")) return { brief: "no matches" };
const c = r.split("\n").filter((l: string) => l.trim()).length;
return { brief: `${c} match${c === 1 ? "" : "es"}` };
}
if (r.startsWith("Success:")) return { brief: "success" };
return { brief: r.replace(/\n/g, " ").substring(0, 60) + (r.length > 60 ? "…" : "") };
};
const suggestedReadPath = useFS ? extractLikelyFilePath(`${taskPrompt}\n${contextData}`) : null;
while (loops < loopLimit) {
// ── N.16: inject pending steering messages ────────────────────────
if (pendingSubAgentMessages.length > 0) {
const injections = pendingSubAgentMessages.splice(0);
for (const msg of injections) {
msgList.push({ role: "system", content: msg });
}
if (subAgentDebugLogging) console.log(`[Sub-Agent] Injected ${injections.length} steering message(s).`);
}
// ── N.16: honour cancel request ───────────────────────────────────
if (cancelSubAgentRequested) {
cancelSubAgentRequested = false;
return { error: "Sub-agent run cancelled by user request.", filesModified, status: "cancelled", turnLog };
}
// ── Wall-clock deadline check (enforces subAgentTimeLimit config) ──
const remainingMs = deadlineMs - Date.now();
if (remainingMs <= 0) {
if (subAgentDebugLogging) console.log(`[Sub-Agent] Time limit of ${subAgentTimeLimitSec}s exceeded after ${loops} loop(s).`);
// J.1: partial-progress recovery — surface whatever was done so far
const progressSummary = finalContent
? `${finalContent.substring(0, 500)}${finalContent.length > 500 ? "…" : ""}`
: "No output produced before timeout.";
return {
error: `Sub-agent time limit (${subAgentTimeLimitSec}s) exceeded after ${loops} turn(s). Partial progress: ${progressSummary}`,
filesModified,
status: "timeout",
turnLog,
};
}
// M.1: live status visible in LM Studio's UI sidebar on every turn
toolCtx?.status?.(`Sub-agent turn ${loops + 1}/${loopLimit} [${role}]${executedToolCallCount > 0 ? ` — ${executedToolCallCount} tool call(s) executed` : ""}`);
// J.1: progress heartbeat — also log to debug output
console.log(`[Sub-agent: Turn ${loops + 1}/${loopLimit}, role: ${role}]`);
// J.1: retry helper — retries on transient network errors and HTTP 429s.
// Network errors: exponential backoff (1s→2s→4s), up to MAX_ENDPOINT_RETRIES.
// Rate-limit (429): respects Retry-After header (seconds or HTTP-date),
// falls back to RATE_LIMIT_FALLBACK_MS, up to MAX_RATE_LIMIT_RETRIES.
const fetchWithRetry = async (): Promise<Response> => {
let lastErr: Error = new Error("Unknown fetch error");
let rateLimitAttempts = 0;
for (let attempt = 0; attempt <= MAX_ENDPOINT_RETRIES; attempt++) {
const attemptRemaining = deadlineMs - Date.now();
if (attemptRemaining <= 0) throw new Error(`Sub-agent time limit (${subAgentTimeLimitSec}s) exceeded.`);
try {
const response = await fetch(`${endpoint}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelId, messages: msgList, temperature: subAgentTemperature, stream: false }),
signal: AbortSignal.timeout(attemptRemaining),
});
if (response.status === 429 && rateLimitAttempts < MAX_RATE_LIMIT_RETRIES) {
rateLimitAttempts++;
const retryAfterHeader = response.headers.get("Retry-After");
let waitMs = RATE_LIMIT_FALLBACK_MS;
if (retryAfterHeader) {
const seconds = Number(retryAfterHeader);
if (!isNaN(seconds)) {
waitMs = seconds * 1_000;
} else {
// HTTP-date format
const retryDate = Date.parse(retryAfterHeader);
if (!isNaN(retryDate)) waitMs = Math.max(0, retryDate - Date.now());
}
}
const deadlineRemaining = deadlineMs - Date.now() - 500;
if (deadlineRemaining <= 0) return response; // no time left — surface the 429
waitMs = Math.min(waitMs, deadlineRemaining);
if (waitMs > 0) {
console.log(`[Sub-Agent] Rate-limited (429), waiting ${(waitMs / 1000).toFixed(1)}s before retry ${rateLimitAttempts}/${MAX_RATE_LIMIT_RETRIES}…`);
await new Promise(r => setTimeout(r, waitMs));
}
attempt--; // don't consume a network-error retry slot
continue;
}
return response;
} catch (err) {
lastErr = err instanceof Error ? err : new Error(String(err));
const isTransient = err instanceof TypeError || (err as any)?.code === "ECONNREFUSED";
if (!isTransient || attempt === MAX_ENDPOINT_RETRIES) throw lastErr;
// Exponential backoff: 1s → 2s → 4s, capped at remaining deadline
const backoffMs = Math.min(ENDPOINT_RETRY_BASE_MS * Math.pow(2, attempt), deadlineMs - Date.now());
console.log(`[Sub-Agent] Endpoint unreachable (attempt ${attempt + 1}/${MAX_ENDPOINT_RETRIES + 1}), retrying in ${(backoffMs / 1000).toFixed(1)}s… Check that LM Studio is running and the secondary model is loaded.`);
await new Promise(r => setTimeout(r, Math.max(0, backoffMs)));
}
}
throw lastErr;
};
try {
const response = await fetchWithRetry();
if (!response.ok) {
const errorBody = (await response.text().catch(() => "")).replace(/\s+/g, " ").trim().substring(0, 600);
if (subAgentDebugLogging) console.log(`[Sub-Agent] API error status=${response.status} body=${errorBody}`);
return { error: `API Error: ${response.status}${errorBody ? ` - ${errorBody}` : ""}`, filesModified };
}
const data = await response.json();
// M.2: accumulate token usage — LM Studio's OpenAI endpoint returns data.usage
if (data?.usage) {
totalPromptTokens += data.usage.prompt_tokens ?? 0;
totalCompletionTokens += data.usage.completion_tokens ?? 0;
}
const message = data?.choices?.[0]?.message;
const parsedMessage = parseSubAgentResponseMessage(message);
const content = parsedMessage.content;
const toolCall: ParsedToolCall | null = parsedMessage.toolCall;
if (subAgentDebugLogging) {
const rawContent = (typeof message === "string" ? message : JSON.stringify(message)) ?? "";
console.log(`[Sub-Agent] RAW: ${rawContent.substring(0, 1000)}`);
console.log(`[Sub-Agent] source=${parsedMessage.toolCallSource} hasToolCall=${Boolean(toolCall)} preview=${content.substring(0, 200)}`);
}
finalContent = content;
// ── No-tools mode: return immediately ─────────────────────────
if (!toolsEnabled) {
const extracted = extractHandoffMessage(content);
const looksLikePureToolCall = extracted.response.trimStart().startsWith("{") && parsedMessage.toolCall !== null && extracted.response.trim().length < 500;
const safeResponse = looksLikePureToolCall
? "[Sub-agent did not produce a prose response. It attempted a tool call but tools are disabled for this invocation.]"
: extracted.response;
// M.2: include token/timing footer even for the no-tools early return
const elapsedSecEarly = ((Date.now() - loopStartMs) / 1000).toFixed(1);
const totalTokensEarly = totalPromptTokens + totalCompletionTokens;
const footerEarly = totalTokensEarly > 0
? `\n\n[Sub-agent: 1 turn, ~${Math.round(totalTokensEarly / 1000)}k tokens (${Math.round(totalPromptTokens / 1000)}k prompt + ${Math.round(totalCompletionTokens / 1000)}k completion), ${elapsedSecEarly}s elapsed]`
: `\n\n[Sub-agent: 1 turn, ${elapsedSecEarly}s elapsed]`;
return { response: safeResponse + footerEarly, filesModified, handoff_message: extracted.handoffMessage, turnLog };
}
// ── Refusal detection ──────────────────────────────────────────
const trimmed = content.trim();
if (!toolCall && trimmed) {
const refusalKeywords = ["i cannot browse", "i don't have access", "i can't access", "unable to browse", "real-time news", "no internet access", "as an ai", "i do not have the ability", "cannot access the internet"];
if (refusalKeywords.some(kw => trimmed.toLowerCase().includes(kw))) {
msgList.push({ role: "assistant", content });
msgList.push({ role: "system", content: "SYSTEM ERROR: You HAVE access to tools. USE THEM." });
loops++;
continue;
}
}
// ── Tool call handling ─────────────────────────────────────────
if (toolCall?.tool) {
// finish_task is the model's explicit termination signal — treat it
// like TASK_COMPLETED rather than falling through to "Tool not found".
if (toolCall.tool === "finish_task") {
if (content.trim()) finalContent = content;
break;
}
noToolCallCount = 0;
executedToolCallCount++;
msgList.push({ role: "assistant", content });
// Use undefined (not "") so empty legitimate output isn't mistaken for "no result"
let toolResult: string | undefined = undefined;
const args = toolCall.args || {};
const validationError = validateToolCall(toolCall.tool, args);
if (validationError) {
toolResult = `TOOL_VALIDATION_ERROR: ${validationError}`;
} else {
try {
// File system tools
if (useFS) {
if (toolCall.tool === "read_file" && args.file_name) {
const fpath = validatePath(cwd, args.file_name);
const readContent = await readFile(fpath, "utf-8");
toolResult = readContent.length > MAX_SUB_AGENT_OUTPUT_CHARS
? `[FILE TRUNCATED — showing first ${MAX_SUB_AGENT_OUTPUT_CHARS.toLocaleString()} of ${readContent.length.toLocaleString()} chars. Use read_file_range to read specific line ranges.]\n\n${readContent.substring(0, MAX_SUB_AGENT_OUTPUT_CHARS)}`
: readContent;
} else if (toolCall.tool === "read_file_range" && args.file_name) {
const fpath = validatePath(cwd, args.file_name);
const lines = (await readFile(fpath, "utf-8")).split("\n");
const start = Math.max(1, Number(args.start_line ?? 1));
const end = Math.min(Number(args.end_line ?? lines.length), lines.length);
const selected = lines.slice(start - 1, end);
toolResult = selected.map((l, i) => `${start + i}: ${l}`).join("\n");
} else if (toolCall.tool === "search_in_file" && args.file_name && args.pattern) {
const fpath = validatePath(cwd, args.file_name);
const fileLines = (await readFile(fpath, "utf-8")).split("\n");
const caseSensitive = args.case_sensitive !== false;
const useRegex = args.use_regex === true;
let regex: RegExp | null = null;
if (useRegex) {
try {
// ReDoS pre-check: compile and run against a known-safe string
const candidate = new RegExp(args.pattern, caseSensitive ? "" : "i");
const t0 = Date.now();
candidate.test("safe_redos_probe_string_1234567890_abcdefghij");
if (Date.now() - t0 > 100) { toolResult = "Error: Regex pattern is too slow (possible ReDoS). Simplify the pattern."; }
else regex = candidate;
} catch (regexErr) {
toolResult = `Error: Invalid regular expression: ${regexErr instanceof Error ? regexErr.message : String(regexErr)}`;
}
}
if (toolResult === undefined) {
const hits: string[] = [];
for (let i = 0; i < fileLines.length; i++) {
const line = fileLines[i];
const match = regex
? regex.test(line)
: caseSensitive ? line.includes(args.pattern) : line.toLowerCase().includes(args.pattern.toLowerCase());
if (match) hits.push(`${i + 1}: ${line}`);
if (hits.length >= 100) break;
}
toolResult = hits.length > 0 ? hits.join("\n") : "No matches found.";
}
} else if (toolCall.tool === "find_files" && args.pattern) {
const lowerPat = String(args.pattern).toLowerCase();
const depthLimit = Math.min(Number(args.max_depth ?? 5), 8);
const found: string[] = [];
async function scanDir(dir: string, depth: number) {
if (depth > depthLimit || found.length >= 100) return;
for (const entry of await readdir(dir, { withFileTypes: true })) {
if (["node_modules", ".git", "dist", ".lmstudio"].includes(entry.name)) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) await scanDir(full, depth + 1);
else if (entry.isFile() && entry.name.toLowerCase().includes(lowerPat)) found.push(relative(cwd, full));
}
}
await scanDir(cwd, 0);
toolResult = JSON.stringify(found);
} else if (toolCall.tool === "append_file" && args.file_name && args.content !== undefined) {
const fpath = validatePath(cwd, args.file_name);
await mkdir(dirname(fpath), { recursive: true });
await appendFile(fpath, args.content, "utf-8");
filesModified.push(args.file_name);
toolResult = `Success: Content appended to ${args.file_name}`;
} else if (toolCall.tool === "list_directory") {
const listPath = args?.path ? validatePath(cwd, args.path) : cwd;
const entries = await readdir(listPath, { withFileTypes: true });
const enriched = await Promise.all(entries.map(async (e) => {
const fullPath = join(listPath, e.name);
const isDir = e.isDirectory();
let size: number | undefined;
try { if (!isDir) size = (await stat(fullPath)).size; } catch { /* ignore */ }
return { name: e.name, type: isDir ? "directory" : "file", ...(size !== undefined ? { size_bytes: size } : {}) };
}));
toolResult = JSON.stringify(enriched);
} else if (toolCall.tool === "save_file") {
if (Array.isArray(args.files)) {
const savedList: string[] = [];
for (const fileObj of args.files) {
const fName = fileObj.file_name || fileObj.name || fileObj.path;
const fContent = fileObj.content || fileObj.data;
if (fName && fContent) {
const fpath = validatePath(cwd, fName);
await mkdir(dirname(fpath), { recursive: true });
await atomicWrite(fpath, fContent);
filesModified.push(fName);
savedList.push(fName);
}
}
toolResult = savedList.length > 0 ? `Success: Saved ${savedList.length} files: ${savedList.join(", ")}` : "Error: No valid files found in batch.";
} else {
const fileName = args.file_name || args.name || args.path;
const fileContent = args.content || args.data;
if (fileName && fileContent) {
const fpath = validatePath(cwd, fileName);
await mkdir(dirname(fpath), { recursive: true });
await atomicWrite(fpath, fileContent);
filesModified.push(fileName);
toolResult = `Success: File saved to ${fpath}`;
} else {
toolResult = "Error: Missing 'file_name' (or 'name', 'path') or 'content' (or 'data') arguments.";
}
}
} else if (toolCall.tool === "replace_text_in_file" && args.file_name && args.old_string && args.new_string) {
const fpath = validatePath(cwd, args.file_name);
const fc = await readFile(fpath, "utf-8");
if (!fc.includes(args.old_string)) {
toolResult = "Error: 'old_string' not found exactly in the file. Check whitespace, line endings, and ensure the string matches the file content character-for-character.";
} else {
// Replace the first occurrence and report if more exist, rather than erroring
const count = fc.split(args.old_string).length - 1;
const replaced = fc.replace(args.old_string, args.new_string); // replaces first occurrence only
await atomicWrite(fpath, replaced);
filesModified.push(args.file_name);
toolResult = count > 1
? `Success: Replaced the first of ${count} occurrences. Call again to replace the next occurrence, or use a more specific old_string to target a different one.`
: "Success: Text replaced.";
}
} else if (toolCall.tool === "delete_files_by_pattern" && args.pattern) {
// NOTE: only matches files in the immediate workspace root (not recursive)
if (args.pattern.length > 100) throw new Error("Pattern too complex");
const regex = new RegExp(args.pattern);
const start = Date.now();
regex.test("safe_test_string_for_redos_check_1234567890");
if (Date.now() - start > 100) throw new Error("Pattern too complex/slow");
const files = await readdir(cwd);
const deleted: string[] = [];
for (const file of files) {
if (regex.test(file)) { await rm(validatePath(cwd, file), { force: true }); deleted.push(file); }
}
toolResult = deleted.length > 0
? `Deleted ${deleted.length} file(s) from workspace root: ${deleted.join(", ")}. Note: only searches the top-level directory — use find_files + individual deletes for subdirectory patterns.`
: `No files matched pattern '${args.pattern}' in workspace root. Note: this tool only searches the top-level directory.`;
} else if (toolCall.tool === "fuzzy_find_local_files" && args.query) {
const targetDir = validatePath(cwd, args?.path || ".");
const maxResults = Math.min(Math.max(Number(args?.max_results ?? 5), 1), 20);
const entries = await readdir(targetDir, { recursive: true, withFileTypes: true });
const files = entries
.filter(e => e.isFile())
.map(e => relative(targetDir, join((e as any).parentPath ?? (e as any).path, e.name)).replace(/\\/g, "/"));
toolResult = JSON.stringify(rankFuzzyMatches(args.query, files, maxResults).map(item => ({ path: item.value, score: item.score })));
} else if (toolCall.tool === "rag_local_files" && args.query) {
if (!ctx.client) {
toolResult = "Error: LM Studio client unavailable for RAG.";
} else {
const targetDir = validatePath(cwd, args.path || ".");
const results = await ragLocalFiles({
query: args.query, targetDir, filePattern: args.file_pattern || "",
client: ctx.client, embeddingModelName: ctx.embeddingModelName,
});
toolResult = JSON.stringify(results.map(r => ({
file: r.file, score: r.score.toFixed(3), content: r.content,
})));
}
}
}
// Web tools
if (useWeb && toolResult === undefined) {
if (toolCall.tool === "wikipedia_search") {
const lang = args.lang || "en";
const q = args.query || "";
const wikiSignal = AbortSignal.timeout(Math.min(WEB_FETCH_TIMEOUT_MS, deadlineMs - Date.now()));
const searchData = await (await fetch(`https://${lang}.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json`, { signal: wikiSignal })).json();
if (searchData.query?.search?.length) {
const item = searchData.query.search[0];
const pageData = await (await fetch(`https://${lang}.wikipedia.org/w/api.php?action=query&prop=extracts&exintro&explaintext&pageids=${item.pageid}&format=json`, { signal: AbortSignal.timeout(Math.min(WEB_FETCH_TIMEOUT_MS, deadlineMs - Date.now())) })).json();
const page = pageData.query.pages[item.pageid];
toolResult = page.extract.substring(0, 3000);
} else {
toolResult = "No Wikipedia articles found.";
}
} else if (toolCall.tool === "web_search" || toolCall.tool === "duckduckgo_search") {
const { search, SafeSearchType } = await import("duck-duck-scrape");
const searchTimeoutMs = Math.min(WEB_FETCH_TIMEOUT_MS, deadlineMs - Date.now());
const searchResult = await Promise.race([
search(args.query, { safeSearch: SafeSearchType.OFF }),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`web_search timed out after ${searchTimeoutMs}ms`)), searchTimeoutMs)
),
]);
toolResult = JSON.stringify(searchResult.results.slice(0, 5));
} else if (toolCall.tool === "fetch_web_content" && args.url) {
// Use safeFetch for SSRF protection (blocks private IPs, file://, etc.)
const sfRes = await safeFetch(args.url, { timeoutMs: Math.min(WEB_FETCH_TIMEOUT_MS, deadlineMs - Date.now()) });
const plainText = htmlToPlainText(await sfRes.text());
toolResult = plainText.length > 8000
? `${plainText.substring(0, 8000)}\n... (truncated)`
: plainText;
} else if (allowSubAgentBrowserControl && ctx.allowBrowserControl && toolCall.tool === "browser_session_open" && args.url) {
if (ctx.browserSession) { await ctx.browserSession.browser.close().catch(() => {}); ctx.browserSession = null; }
const puppeteer = await import("puppeteer");
const browser = await puppeteer.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
const page = await browser.newPage();
await page.goto(args.url, { waitUntil: "networkidle0", timeout: 30000 });
if (args.wait_for_selector) await page.waitForSelector(args.wait_for_selector, { timeout: 15000 });
ctx.browserSession = { browser, page, currentUrl: page.url() };
const pageText = args.include_page_text !== false ? await page.evaluate(() => (document.body as HTMLElement).innerText || "") : undefined;
toolResult = JSON.stringify({ session_active: true, url: page.url(), title: await page.title(), text_content: pageText, text_length: pageText ? pageText.length : 0 });
} else if (allowSubAgentBrowserControl && ctx.allowBrowserControl && toolCall.tool === "browser_session_control") {
if (!ctx.browserSession) {
toolResult = "Error: No active browser session.";
} else {
const beforeUrl = ctx.browserSession.page.url();
const actionLog = await executeBrowserActions(ctx.browserSession.page, args.actions || []);
const afterUrl = ctx.browserSession.page.url();
const urlChanged = beforeUrl !== afterUrl;
ctx.browserSession.currentUrl = afterUrl;
const output: Record<string, unknown> = { session_active: true, actions_executed: actionLog, url: afterUrl, url_changed: urlChanged };
if (args.read_page !== false) {
output.title = await ctx.browserSession.page.title();
if (urlChanged || args.full_read) {
const textContent = await ctx.browserSession.page.evaluate(() => (document.body as HTMLElement).innerText || "");
output.text_content = textContent;
} else {
output.note = "Full page text omitted (URL unchanged). Set full_read=true to force full output.";
}
}
if (args.screenshot_path) {
await ctx.browserSession.page.screenshot({ path: validatePath(cwd, args.screenshot_path), fullPage: !!args.full_page_screenshot });
output.screenshot_saved = true;
}
toolResult = JSON.stringify(output);
}
} else if (allowSubAgentBrowserControl && ctx.allowBrowserControl && toolCall.tool === "browser_session_close") {
if (ctx.browserSession) { await ctx.browserSession.browser.close().catch(() => {}); ctx.browserSession = null; }
toolResult = JSON.stringify({ session_active: false, message: "Browser session closed." });
}
}
// Code tools
if (useCode && toolResult === undefined) {
if (toolCall.tool === "run_test_command" && args.command) {
// Run the test command synchronously and return full output
const { spawn } = await import("child_process");
const testResult = await new Promise<{ passed: boolean; stdout: string; stderr: string; exit_code: number | null }>((resolve) => {
const child = spawn(args.command, { cwd, shell: true, env: { ...process.env, CI: "true" } });
let stdout = ""; let stderr = "";
child.stdout.on("data", d => { stdout += d.toString(); });
child.stderr.on("data", d => { stderr += d.toString(); });
child.on("close", code => resolve({ passed: code === 0, exit_code: code, stdout: stdout.trim(), stderr: stderr.trim() }));
child.on("error", err => resolve({ passed: false, exit_code: null, stdout: "", stderr: err.message }));
});
const summary = testResult.passed ? "PASSED" : `FAILED (exit ${testResult.exit_code})`;
const combined = [testResult.stdout, testResult.stderr].filter(Boolean).join("\n");
toolResult = `${summary}\n${combined}`.substring(0, MAX_SUB_AGENT_OUTPUT_CHARS);
} else if (toolCall.tool === "run_python" && args.python) {
const res = await runPythonImpl({ python: args.python, cwd });
// Return both streams: stdout may contain useful output even when stderr has warnings
if (res.stderr && res.stdout) {
toolResult = `${res.stdout}\n[stderr]: ${res.stderr}`;
} else if (res.stderr) {
toolResult = `Error (stderr): ${res.stderr}`;
} else {
toolResult = res.stdout;
}
} else if (toolCall.tool === "run_javascript" && args.javascript) {
const res = await runJavascriptImpl({ javascript: args.javascript, cwd });
if (res.stderr && res.stdout) {
toolResult = `${res.stdout}\n[stderr]: ${res.stderr}`;
} else if (res.stderr) {
toolResult = `Error (stderr): ${res.stderr}`;
} else {
toolResult = res.stdout;
}
}
}
// RAG web content (fetch + embed + score — available when web is allowed)
if (useWeb && toolResult === undefined && toolCall.tool === "rag_web_content" && args.url && args.query) {
if (!ctx.client) {
toolResult = "Error: LM Studio client unavailable for RAG.";
} else {
// safeFetch provides SSRF protection (blocks private IPs, file://, etc.)
const res = await safeFetch(args.url, { timeoutMs: Math.min(WEB_FETCH_TIMEOUT_MS, deadlineMs - Date.now()) });
const plainText = htmlToPlainText(await res.text());
const { performRagOnText } = await import("./helpers");
const topChunks = await performRagOnText(plainText, args.query, ctx.client, ctx.embeddingModelName);
toolResult = topChunks.map((c, i) => `[${i + 1}] (score ${c.score.toFixed(3)})\n${c.chunk}`).join("\n\n");
}
}
if (toolResult === undefined) toolResult = `Error: Tool not found or not allowed. Allowed tools: ${allowedTools.join(", ")}.`;
} catch (err: any) {
toolResult = `Error: ${err.message}`;
}
}
// H: record this turn in the execution log
const keyArg = extractKeyArg(toolCall.tool, args);
const { brief, errorDetail } = summarizeResult(toolCall.tool, toolResult ?? "");
turnLog.push({
turn: loops + 1,
tool: toolCall.tool,
keyArg,
brief,
...(errorDetail !== undefined ? { errorDetail } : {}),
});
// Enrich the live status message with what just happened
const keyArgStr = keyArg ? `(${keyArg})` : "";
toolCtx?.status?.(`Sub-agent [${role}] turn ${loops + 1}/${loopLimit} — ${toolCall.tool}${keyArgStr} → ${brief}`);
msgList.push({ role: "user", content: `Tool Output: ${toolResult ?? ""}` });
loops++;
} else {
// ── No tool call ───────────────────────────────────────────────
const shouldAutoFallbackRead =
toolsEnabled && useFS &&
executedToolCallCount === 0 && noToolCallCount === 0 &&
typeof suggestedReadPath === "string" && suggestedReadPath.length > 0;
if (shouldAutoFallbackRead) {
try {
const autoReadPath = validatePath(cwd, suggestedReadPath!);
const autoReadStats = await stat(autoReadPath);
if (!autoReadStats.isFile()) throw new Error(`Not a file: ${autoReadPath}`);
const autoReadContent = await readFile(autoReadPath, "utf-8");
const bounded = autoReadContent.length > 30000
? `[FILE TRUNCATED — showing first 30,000 of ${autoReadContent.length.toLocaleString()} chars. Use read_file_range for specific sections.]\n\n${autoReadContent.substring(0, 30000)}`
: autoReadContent;
if (trimmed.length > 0) msgList.push({ role: "assistant", content });
msgList.push({ role: "user", content: `Tool Output: AUTO_FALLBACK read_file(${suggestedReadPath})\n${bounded}` });
executedToolCallCount++;
loops++;
continue;
} catch {
try {
const autoFiles = (await readdir(cwd)).slice(0, 200);
if (trimmed.length > 0) msgList.push({ role: "assistant", content });
msgList.push({ role: "user", content: `Tool Output: AUTO_FALLBACK list_directory(.)\n${JSON.stringify(autoFiles)}` });
executedToolCallCount++;
loops++;
continue;
} catch { /* ignore */ }
}
}
const planningLikeText = /(?:\bI(?:'ll| will)\b|\blet me\b|\bnext\b|\bfirst\b)/i.test(trimmed);
const shouldTreatAsFinalResponse = trimmed.length >= 120 && !planningLikeText;
noToolCallCount++;
// J.1: stall detection — after 3 consecutive turns with no tool call
// and no termination signal, return a structured stall error so the
// main agent can retry with a narrower scope rather than getting silence.
if (noToolCallCount >= 3) {
if (subAgentDebugLogging) console.log(`[Sub-Agent] Stalled after ${loops + 1} turn(s) — no tool calls for 3 consecutive turns.`);
const lastOutput = (content || finalContent).substring(0, 300);
return {
error: `Sub-agent stalled after ${loops + 1} turn(s) without completing the task. Last output: "${lastOutput}". Try splitting the task into smaller steps, or provide more specific context.`,
filesModified,
status: "stalled" as const,
turnLog,
};
}
// TASK_FAILED is an explicit abort signal — surface it as an error so the main model
// gets a clear signal rather than a garbled response containing "TASK_FAILED".
if (content.includes("TASK_FAILED")) {
const failReason = content.replace(/TASK_FAILED/g, "").trim().substring(0, 300) || "Sub-agent reported it could not complete the task.";
return { error: `Sub-agent failed: ${failReason}`, filesModified, status: "failed", turnLog };
}
if (content.includes("TASK_COMPLETED") || shouldTreatAsFinalResponse || loops >= loopLimit - 1) break;
if (content.trim().length > 0) msgList.push({ role: "assistant", content });
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.";
if (toolsEnabled) {
if (useFS && suggestedReadPath && noToolCallCount <= 3) {
const escapedPath = suggestedReadPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
reminder += `\nSuggested next step: {"tool":"read_file","args":{"file_name":"${escapedPath}"}}`;
} else if (useFS && noToolCallCount <= 3) {
reminder += `\nSuggested next step: {"tool":"list_directory","args":{}}`;
}
}
msgList.push({ role: "system", content: reminder });
loops++;
}
} catch (err: any) {
return { error: err.message, filesModified, turnLog };
}
// Prevent unbounded context growth — keep system prompt + task message + recent turns
if (msgList.length > 20) {
const systemMsg = msgList[0];
const taskMsg = msgList[1]; // preserve the original task so the model doesn't forget its goal
const recentMsgs = msgList.slice(-16);
msgList.length = 0;
msgList.push(systemMsg, taskMsg, ...recentMsgs);
}
}
if (finalContent) {
const extracted = extractHandoffMessage(finalContent);
finalContent = extracted.response;
handoffMessage = extracted.handoffMessage || "";
}
// ── Auto-Save code blocks ──────────────────────────────────────────
if (autoSave && useFS && finalContent) {
const codeBlockRegex = /```\s*(\w+)?\s*([\s\S]*?)```/g;
const matches = Array.from(finalContent.matchAll(codeBlockRegex));
const processedFiles = new Set<string>();
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const fullBlock = match[0];
const lang = (match[1] || "txt").toLowerCase();
const code = match[2];
const index = match.index || 0;
let handledAsBatch = false;
if (lang === "json") {
try {
const parsed = JSON.parse(code);
if (Array.isArray(parsed)) {
let extractedCount = 0;
for (const item of parsed) {
const fName = item.path || item.file_name || item.name;
const fContent = item.content || item.data || item.code;
if (fName && typeof fName === "string" && fContent && typeof fContent === "string") {
const fpath = validatePath(cwd, fName);
await mkdir(dirname(fpath), { recursive: true });
await atomicWrite(fpath, fContent);
filesModified.push(fName);
processedFiles.add(fName);
extractedCount++;
}
}
if (extractedCount > 0) {
handledAsBatch = true;
finalContent = finalContent.slice(0, index) + `\n[System: Successfully extracted and saved ${extractedCount} files from JSON block.]\n` + finalContent.slice(index + fullBlock.length);
}
}
} catch { /* not a batch JSON block */ }
}
if (!handledAsBatch && code.trim().length > 50) {
const lookback = finalContent.substring(Math.max(0, index - 500), index);
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);
let fileName = nameMatch ? nameMatch[1].trim() : "";
if (!fileName) {
const firstLine = code.split("\n")[0].trim();
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);
if (commentMatch) fileName = commentMatch[1].trim();
}
const isShell = ["bash", "sh", "cmd", "powershell", "console", "zsh", "terminal"].includes(lang);
if ((isShell && !fileName) || !fileName || processedFiles.has(fileName)) continue;
try {
const fpath = join(cwd, fileName);
await mkdir(dirname(fpath), { recursive: true });
await atomicWrite(fpath, code);
filesModified.push(fileName);
processedFiles.add(fileName);
finalContent = finalContent.slice(0, index) + `\n[System: File '${fileName}' created successfully.]\n` + finalContent.slice(index + fullBlock.length);
} catch (e) {
console.error(`Failed to auto-save file ${fileName}:`, e);
}
}
}
}
// ── Auto-Update Project Info ───────────────────────────────────────
if (filesModified.length > 0 && useFS) {
const infoPath = join(cwd, "toolbox_info.md");
const logEntry = `\n- **[${new Date().toISOString()}]** Task: "${taskPrompt.substring(0, 50)}..." | Modified: ${filesModified.join(", ")}`;
try {
await appendFile(infoPath, logEntry, "utf-8");
} catch {
try { await writeFile(infoPath, `# Project History\n${logEntry}`, "utf-8"); } catch { /* ignore */ }
}
}
// M.2: append token/timing footer so the main agent can see the cost
const elapsedSec = ((Date.now() - loopStartMs) / 1000).toFixed(1);
const totalTokens = totalPromptTokens + totalCompletionTokens;
const tokenFooter = totalTokens > 0
? `\n\n[Sub-agent: ${loops} turn(s), ~${Math.round(totalTokens / 1000)}k tokens (${Math.round(totalPromptTokens / 1000)}k prompt + ${Math.round(totalCompletionTokens / 1000)}k completion), ${elapsedSec}s elapsed]`
: `\n\n[Sub-agent: ${loops} turn(s), ${elapsedSec}s elapsed]`;
// If tools were available but no files were produced and response is empty, give the main model a clear signal
const hasOutput = (finalContent || "").trim().length > 0 || filesModified.length > 0;
const noOutputNote = !hasOutput && toolsEnabled
? "\n\n[Sub-agent produced no output. If files were expected, check that subAgentAllowFileSystem is enabled in settings and the model supports JSON tool-call format.]"
: "";
// H: format the execution log — brief for success turns, auto-expand on errors,
// full detail in debug mode. Gated by subAgentShowExecutionLog (default on).
// turn_log is always populated on the return value regardless of this setting.
let execLog = "";
if (showExecutionLog && turnLog.length > 0) {
const lines = turnLog.map(e => {
const keyStr = e.keyArg ? `(${e.keyArg})` : "";
const base = ` ${e.turn}. ${e.tool}${keyStr} → ${e.brief}`;
const showDetail = e.errorDetail !== undefined && (subAgentDebugLogging || e.brief === "error" || e.brief === "FAILED");
return showDetail ? `${base}\n ${e.errorDetail}` : base;
});
execLog = `\n\n[Execution log: ${turnLog.length} tool call${turnLog.length === 1 ? "" : "s"}]\n${lines.join("\n")}`;
}
return { response: (finalContent || "") + execLog + tokenFooter + noOutputNote, filesModified, handoff_message: handoffMessage || undefined, status: "completed" as const, turnLog };
};
// ── Primary agent loop ───────────────────────────────────────────────
// Shared deadline: primary + chain + debug reviewer all share this budget.
const sharedDeadlineMs = Date.now() + subAgentTimeLimitSec * 1000;
subAgentRunning = true;
let primaryResult: Awaited<ReturnType<typeof runAgentLoop>>;
try {
primaryResult = await runAgentLoop(agent_role, task, context, subAgentLoopLimit, false, ctx.cwd, sharedDeadlineMs, readonly);
} finally {
subAgentRunning = false;
}
if (primaryResult.error) return { error: primaryResult.error, status: primaryResult.status, turn_log: primaryResult.turnLog };
let finalResponse = primaryResult.response || "";
let handoffMessage = primaryResult.handoff_message;
const generatedFiles = [...(primaryResult.filesModified ?? [])];
// Accumulate turn logs from primary + any chain roles for the top-level field
const allTurnLog = [...(primaryResult.turnLog ?? [])];
// ── J.3 Role chaining ────────────────────────────────────────────────
// Each role in `chain` receives the previous role's output + modified
// files as its context, sharing the same wall-clock deadline.
if (chain.length > 0) {
let chainContext = finalResponse;
for (const chainRole of chain) {
// Summarise files modified so far for the next role's context
const filesSoFar = generatedFiles.length > 0
? `\n\nFiles modified so far: ${generatedFiles.join(", ")}`
: "";
const chainResult = await runAgentLoop(
chainRole,
task,
`Previous role (${agent_role}) output:\n${chainContext}${filesSoFar}`,
subAgentLoopLimit, allow_tools, ctx.cwd, sharedDeadlineMs, readonly,
);
if (chainResult.error) {
finalResponse += `\n\n--- Chain role '${chainRole}' failed: ${chainResult.error} ---`;
break;
}
const chainResponse = chainResult.response || "";
finalResponse += `\n\n--- Role: ${chainRole} ---\n${chainResponse}`;
generatedFiles.push(...(chainResult.filesModified ?? []));
allTurnLog.push(...(chainResult.turnLog ?? []));
chainContext = chainResponse;
if (!handoffMessage && chainResult.handoff_message) handoffMessage = chainResult.handoff_message;
}
}
// ── Auto-Debug loop ──────────────────────────────────────────────────
if (debugMode && (primaryResult.filesModified ?? []).length > 0) {
const filesToCheck = primaryResult.filesModified!.join(", ");
let debugContext = "Here is the content of the created files:\n";
for (const f of primaryResult.filesModified!) {
try { debugContext += `\n--- ${f} ---\n${await readFile(join(ctx.cwd, f), "utf-8")}\n`; } catch { /* ignore */ }
}
const debugResult = await runAgentLoop(
"reviewer",
`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.`,
debugContext, 5, true, ctx.cwd, sharedDeadlineMs,
);
finalResponse += "\n\n--- Auto-Debug Report ---\n" + (debugResult.response || "Debug pass completed.");
if ((debugResult.filesModified ?? []).length > 0) {
finalResponse += `\n(The reviewer fixed these files: ${debugResult.filesModified!.join(", ")})`;
}
if (!handoffMessage && debugResult.handoff_message) handoffMessage = debugResult.handoff_message;
}
// ── Append file list for main agent ──────────────────────────────────
if ((primaryResult.filesModified ?? []).length > 0) {
const fullPaths = primaryResult.filesModified!.map(f => isAbsolute(f) ? f : join(ctx.cwd, f));
finalResponse += `\n\n[GENERATED_FILES]: ${fullPaths.join(", ")}`;
if (showFullCode) {
finalResponse += `\n\n### Generated Code Content:\n`;
for (const f of primaryResult.filesModified!) {
try {
const fpath = isAbsolute(f) ? f : join(ctx.cwd, f);
const fc = await readFile(fpath, "utf-8");
finalResponse += `\n**${f}**\n\`\`\`${f.split(".").pop() || "txt"}\n${fc}\n\`\`\`\n`;
} catch { /* ignore */ }
}
}
}
if (!showFullCode && (primaryResult.filesModified ?? []).length > 0) {
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");
}
// ── Persist last result so the main model can retrieve it later ──────────
const persistedResult = {
timestamp: new Date().toISOString(),
task: task.substring(0, 300),
agent_role,
status: "completed",
response: finalResponse.substring(0, 8000),
generated_files: generatedFiles,
handoff_message: handoffMessage,
};
writeFile(LAST_RESULT_PATH, JSON.stringify(persistedResult, null, 2), "utf-8").catch(() => {});
return { response: finalResponse, generated_files: generatedFiles, handoff_message: handoffMessage, status: "completed", turn_log: allTurnLog };
},
ctx.enableSecondary,
"consult_secondary_agent"
),
});
// ── N.16: interrupt_sub_agent ─────────────────────────────────────────────
const interruptTool = tool({
name: "interrupt_sub_agent",
description: "Queue a steering message or cancellation for the current or next sub-agent run. Messages are injected as system messages at the start of the next agent turn. If no sub-agent is running, the message is held and injected at the start of the next consult_secondary_agent call.",
parameters: {
message: z.string().describe("Correction or steering instruction to inject (e.g. 'Stop working on auth and focus on the database layer instead.')."),
cancel: z.boolean().optional().default(false).describe("If true, also signals the sub-agent to stop after its current turn and return partial results."),
},
implementation: async ({ message, cancel = false }) => {
pendingSubAgentMessages.push(`[User steering]: ${message}`);
if (cancel) cancelSubAgentRequested = true;
return {
success: true,
queued_message: message,
cancel_requested: cancel,
sub_agent_currently_running: subAgentRunning,
note: subAgentRunning
? "Message will be injected at the start of the next sub-agent turn."
: "Message queued — will be injected at the start of the next consult_secondary_agent call.",
};
},
});
// ── get_sub_agent_result ─────────────────────────────────────────────────
const getResultTool = tool({
name: "get_sub_agent_result",
description: "Retrieve the full response from the most recent sub-agent run without starting a new session. Use this if you need to re-read the sub-agent's output after your context was refreshed, or to check what files were produced.",
parameters: {},
implementation: async () => {
try {
const raw = await readFile(LAST_RESULT_PATH, "utf-8");
return JSON.parse(raw);
} catch {
return { error: "No previous sub-agent result found. Call consult_secondary_agent first." };
}
},
});
return [consultTool, interruptTool, getResultTool];
}