Project Files
src / tools / secondaryAgent.ts
/**
* @file secondaryAgent.ts
* Secondary agent delegation tool: consult_secondary_agent.
* Manual REST-level agent loop with JSON tool call parsing.
*/
import { tool, type Tool } from "@lmstudio/sdk";
import { writeFile, readFile, readdir, mkdir, rm } from "fs/promises";
import { join, dirname, isAbsolute } from "path";
import { z } from "zod";
import { validatePath, createSafeToolImplementation, type ToolContext } from "./shared";
import { getSharedInstances } from "../memory/toolsProvider";
import { resolveProjectMemoryTarget, buildProjectMemoryTags } from "../memory/projectMemory";
// --- Helpers ---
let _cachedSecondaryModel: string | null = null;
let _cachedEndpoint: string | null = null;
async function detectSecondaryModel(endpoint: string): Promise<string> {
if (_cachedSecondaryModel && _cachedEndpoint === endpoint) return _cachedSecondaryModel;
try {
const res = await fetch(`${endpoint}/models`, { signal: AbortSignal.timeout(5_000) });
if (!res.ok) return "local-model";
const data = await res.json();
const models: Array<{ id: string }> = data?.data ?? [];
_cachedSecondaryModel = models.length >= 2 ? models[1].id : (models[0]?.id ?? "local-model");
_cachedEndpoint = endpoint;
return _cachedSecondaryModel;
} catch { return "local-model"; }
}
/** Parse a tool call from model output (supports 3 JSON formats). */
function parseToolCall(content: string): { tool: string; args: Record<string, any> } | null {
const trimmed = content.trim();
const jsonMatch = trimmed.match(/\{[\s\S]*\}/);
if (!jsonMatch) return null;
try {
const parsed = JSON.parse(jsonMatch[0]);
if (parsed.tool && parsed.args) return parsed;
if (parsed.name && parsed.arguments) {
const args = parsed.arguments;
if (parsed.name === "save_file") {
if (args.path && !args.file_name) args.file_name = args.path;
if (args.data && !args.content) args.content = args.data;
}
return { tool: parsed.name, args };
}
const toolNameMatch = trimmed.match(/to=([a-zA-Z0-9_.]+)/);
if (toolNameMatch) {
let toolName = toolNameMatch[1].replace(/^functions\./, "");
let args = parsed;
if (toolName === "save_file") {
if (Array.isArray(args)) args = { files: args };
if (args.path && !args.file_name) args.file_name = args.path;
if (args.data && !args.content) args.content = args.data;
}
return { tool: toolName, args };
}
} catch { /* JSON parsing failed */ }
return null;
}
const REFUSAL_KEYWORDS = [
"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",
];
function isRefusal(content: string): boolean {
return REFUSAL_KEYWORDS.some(kw => content.toLowerCase().includes(kw));
}
// --- Sub-agent tool dispatch ---
interface DispatchContext {
cwd: string;
allowFileSystem: boolean;
allowWeb: boolean;
allowCode: boolean;
originalRunJavascript: (p: { javascript: string }) => Promise<{ stdout: string; stderr: string }>;
originalRunPython: (p: { python: string }) => Promise<{ stdout: string; stderr: string }>;
pluginConfig: any;
}
async function dispatchTool(
tc: { tool: string; args: Record<string, any> },
dctx: DispatchContext,
filesModified: string[],
): Promise<string> {
// --- File System ---
if (dctx.allowFileSystem) {
if (tc.tool === "read_file" && tc.args?.file_name) {
return await readFile(validatePath(dctx.cwd, tc.args.file_name), "utf-8");
}
if (tc.tool === "list_directory") {
return JSON.stringify(await readdir(dctx.cwd));
}
if (tc.tool === "save_file") {
if (Array.isArray(tc.args?.files)) {
const saved: string[] = [];
for (const f of tc.args.files) {
const fName = f.file_name || f.name || f.path;
const fContent = f.content || f.data;
if (fName && fContent) {
try {
const fpath = validatePath(dctx.cwd, fName);
await mkdir(dirname(fpath), { recursive: true });
await writeFile(fpath, fContent, "utf-8");
filesModified.push(fName); saved.push(fName);
} catch { /* continue */ }
}
}
return saved.length > 0 ? `Success: Saved ${saved.length} files: ${saved.join(", ")}` : "Error: No valid files in batch.";
}
const fileName = tc.args?.file_name || tc.args?.name || tc.args?.path;
const content = tc.args?.content || tc.args?.data;
if (fileName && content) {
const fpath = validatePath(dctx.cwd, fileName);
await mkdir(dirname(fpath), { recursive: true });
await writeFile(fpath, content, "utf-8");
filesModified.push(fileName);
return `Success: File saved to ${fpath}`;
}
return "Error: Missing 'file_name' or 'content'.";
}
if (tc.tool === "replace_text_in_file" && tc.args?.file_name && tc.args?.old_string && tc.args?.new_string) {
const fpath = validatePath(dctx.cwd, tc.args.file_name);
const content = await readFile(fpath, "utf-8");
if (!content.includes(tc.args.old_string)) return "Error: 'old_string' not found exactly.";
const count = content.split(tc.args.old_string).length - 1;
if (count > 1) return `Error: Found ${count} occurrences. Be more specific.`;
await writeFile(fpath, content.replace(tc.args.old_string, tc.args.new_string), "utf-8");
filesModified.push(tc.args.file_name);
return "Success: Text replaced.";
}
if (tc.tool === "delete_files_by_pattern" && tc.args?.pattern) {
if (tc.args.pattern.length > 100) throw new Error("Pattern too complex");
const regex = new RegExp(tc.args.pattern);
const start = Date.now();
regex.test("safe_test_string_for_redos_check_1234567890_safe_test_string_for_redos_check_1234567890");
if (Date.now() - start > 100) throw new Error("Pattern too slow");
const files = await readdir(dctx.cwd);
const deleted: string[] = [];
for (const file of files) {
if (regex.test(file)) { await rm(join(dctx.cwd, file), { force: true }); deleted.push(file); }
}
return `Deleted ${deleted.length} files: ${deleted.join(", ")}`;
}
}
// --- Web ---
if (dctx.allowWeb) {
if (tc.tool === "wikipedia_search" && tc.args?.query) {
try {
const res = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(tc.args.query)}`);
if (res.ok) { const d = await res.json(); return JSON.stringify({ title: d.title, extract: d.extract }); }
return "Wikipedia: no results found.";
} catch { return "Wikipedia search failed."; }
}
if (tc.tool === "web_search" && tc.args?.query) {
const { search, SafeSearchType } = await import("duck-duck-scrape");
const r = await search(tc.args.query, { safeSearch: SafeSearchType.OFF });
return JSON.stringify(r.results.slice(0, 3));
}
if (tc.tool === "fetch_web_content" && tc.args?.url) {
let html = (await (await fetch(tc.args.url)).text());
// Strip scripts, styles, nav, footer, and HTML tags — keep only readable text
html = html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<nav[\s\S]*?<\/nav>/gi, "")
.replace(/<footer[\s\S]*?<\/footer>/gi, "")
.replace(/<header[\s\S]*?<\/header>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&[a-z]+;/gi, " ")
.replace(/\s+/g, " ")
.trim();
return html.substring(0, 5000);
}
}
// --- Code ---
if (dctx.allowCode) {
if (tc.tool === "run_python" && tc.args?.python) {
const res = await dctx.originalRunPython({ python: tc.args.python });
return res.stderr ? `Error: ${res.stderr}` : res.stdout;
}
if (tc.tool === "run_javascript" && tc.args?.javascript) {
const res = await dctx.originalRunJavascript({ javascript: tc.args.javascript });
return res.stderr ? `Error: ${res.stderr}` : res.stdout;
}
}
// --- Memory ---
if (tc.tool === "remember" && tc.args?.content) {
try {
const storagePath = dctx.pluginConfig.get("memoryStoragePath") || "";
const memCfg = { activeProject: dctx.pluginConfig.get("activeProject") || "" };
const { db: memDb, engine: memEngine } = await getSharedInstances(storagePath);
const target = resolveProjectMemoryTarget(memCfg, { scope: tc.args.scope, project: tc.args.project });
const tags = target.scope === "project" && target.project
? buildProjectMemoryTags(tc.args.tags || [], target.project)
: (tc.args.tags || []);
const id = memDb.store(tc.args.content, tc.args.category || "general", tags, 1.0, "sub-agent", null, target.scope, target.project);
memEngine.indexMemory(id, tc.args.content, tags, tc.args.category || "general");
return `Memory stored (id: ${id}, scope: ${target.scope}${target.project ? `, project: ${target.project}` : ""})`;
} catch (err: any) { return `Memory store failed: ${err.message}`; }
}
if (tc.tool === "recall" && tc.args?.query) {
try {
const storagePath = dctx.pluginConfig.get("memoryStoragePath") || "";
const { engine: memEngine } = await getSharedInstances(storagePath);
const result = await memEngine.retrieve(tc.args.query, tc.args.limit || 5, 30);
return JSON.stringify(result.memories.map(m => ({
content: m.content, category: m.category, tags: m.tags,
relevance: "compositeScore" in m ? Math.round((m as any).compositeScore * 100) : 50,
})));
} catch (err: any) { return `Memory recall failed: ${err.message}`; }
}
return "Error: Tool not found/allowed.";
}
// --- Auto-save code blocks from response ---
async function autoSaveCodeBlocks(
finalContent: string,
cwd: string,
filesModified: string[],
): Promise<string> {
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], lang = (match[1] || "txt").toLowerCase(), code = match[2];
const index = match.index || 0;
let handledAsBatch = false;
// Smart JSON Unpacking
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 writeFile(fpath, fContent, "utf-8");
filesModified.push(fName); processedFiles.add(fName); extractedCount++;
}
}
if (extractedCount > 0) {
handledAsBatch = true;
finalContent = finalContent.slice(0, index) + `\n[System: Extracted ${extractedCount} files from JSON block.]\n` + finalContent.slice(index + fullBlock.length);
}
}
} catch { /* not JSON batch */ }
}
if (!handledAsBatch && code.trim().length > 50) {
const lookback = finalContent.substring(Math.max(0, index - 500), index);
const EXT_PAT = /(?:tsx|ts|jsx|js|html|css|json|md|py|sh|java|rs|go|sql|yaml|yml|c|cpp|h|hpp|txt)/;
const nameMatch = lookback.match(new RegExp(`(?:\`|\\*\\*|###|filename:|file:)[\\s\\S]*?([\\w\\-\\/\\\\.]+\\.(?:${EXT_PAT.source}))`, 'i'));
let fileName = nameMatch?.[1]?.trim() || "";
if (!fileName) {
const firstLine = code.split('\n')[0].trim();
const commentMatch = firstLine.match(new RegExp(`^(?:\\/\\/|#|<!--|;)\\s*(?:filename:|file:)?\\s*([\\w\\-\\/\\\\.]+\\.(?:${EXT_PAT.source}))`, '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 = validatePath(cwd, fileName);
await mkdir(dirname(fpath), { recursive: true });
await writeFile(fpath, code, "utf-8");
filesModified.push(fileName); processedFiles.add(fileName);
finalContent = finalContent.slice(0, index) + `\n[System: File '${fileName}' created successfully.]\n` + finalContent.slice(index + fullBlock.length);
} catch { /* skip */ }
}
}
return finalContent;
}
// --- Main export ---
export interface SecondaryAgentConfig {
pluginConfig: any;
originalRunJavascript: (p: { javascript: string }) => Promise<{ stdout: string; stderr: string }>;
originalRunPython: (p: { python: string }) => Promise<{ stdout: string; stderr: string }>;
}
export function createSecondaryAgentTool(
ctx: ToolContext,
config: SecondaryAgentConfig,
): Tool {
const { pluginConfig } = config;
const _saFS = pluginConfig.get("subAgentAllowFileSystem");
const _saWeb = pluginConfig.get("subAgentAllowWeb");
const _saCode = pluginConfig.get("subAgentAllowCode");
const _saCaps: string[] = ["memory (remember/recall)", "summarization"];
if (_saFS) _saCaps.push("file reading");
if (_saWeb) _saCaps.push("web search");
if (_saCode) _saCaps.push("code execution");
const _saNo: string[] = [];
if (!_saCode) _saNo.push("coding", "file creation");
if (!_saWeb) _saNo.push("web search");
if (!_saFS) _saNo.push("file operations");
const desc = `Delegate an auxiliary task to a secondary (lighter) model. Capabilities: ${_saCaps.join(", ")}.`
+ (_saNo.length > 0 ? ` Do NOT delegate ${_saNo.join(" or ")} — handle those yourself.` : "");
return tool({
name: "consult_secondary_agent",
description: desc,
parameters: {
task: z.string().describe("The task to delegate."),
agent_role: z.string().optional().describe("Key from 'Sub-Agent Profiles' config. 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 its enabled tools. Default: false."),
},
implementation: createSafeToolImplementation(
async ({ task, agent_role = "general", context = "", allow_tools = false }) => {
const endpoint = pluginConfig.get("secondaryAgentEndpoint");
const modelId = await detectSecondaryModel(endpoint);
const subAgentProfilesStr = pluginConfig.get("subAgentProfiles");
const debugMode = pluginConfig.get("enableDebugMode");
const autoSave = pluginConfig.get("subAgentAutoSave");
const showFullCode = pluginConfig.get("showFullCodeOutput");
const allowFileSystem = pluginConfig.get("subAgentAllowFileSystem");
const allowWeb = pluginConfig.get("subAgentAllowWeb");
const allowCode = pluginConfig.get("subAgentAllowCode");
const runAgentLoop = async (
role: string, taskPrompt: string, contextData: string,
loopLimit: number = 8, forceTools: boolean = false, workingDir: string,
) => {
let systemPrompt = "You are a helpful assistant.";
// Load instructions file if present
try {
const instructions = await readFile(join(workingDir, "SUB_AGENT_INSTRUCTIONS.md"), "utf-8");
if (instructions.trim()) systemPrompt = instructions;
} catch { /* ignore */ }
systemPrompt += `\n\n## Current Workspace\nYour current working directory is:\n\n${workingDir}\nAlways assume relative paths are from this directory.`;
// Append profile
try {
const profiles = JSON.parse(subAgentProfilesStr);
if (profiles[role]) systemPrompt += `\n\n## Your Persona\n${profiles[role]}`;
else if (role === "reviewer") systemPrompt += `\n\n## Your Persona\nYou are a Senior Code Reviewer. Analyze code, find bugs/issues, and FIX them using 'save_file'.`;
} catch { /* ignore */ }
// Append tools info
let toolsReminder = "";
const toolsEnabled = allow_tools || forceTools;
if (toolsEnabled) {
const allowedTools: string[] = [];
if (allowFileSystem) allowedTools.push("read_file", "list_directory", "save_file", "replace_text_in_file", "delete_files_by_pattern");
if (allowWeb) allowedTools.push("wikipedia_search", "web_search", "fetch_web_content");
if (allowCode) allowedTools.push("run_python", "run_javascript");
allowedTools.push("remember", "recall");
if (allowedTools.length > 0) {
systemPrompt += `\n\n## Allowed Tools\nYou have access to: ${allowedTools.join(", ")}.\n`;
toolsReminder = `\n\n[SYSTEM REMINDER: You have access to tools: ${allowedTools.join(", ")}. USE A TOOL if needed.]`;
}
}
const msgList = [
{ role: "system", content: systemPrompt },
{ role: "user", content: `Task: ${taskPrompt}\n\nContext: ${contextData}${toolsReminder}` },
];
let loops = 0, finalContent = "";
const filesModified: string[] = [];
const dctx: DispatchContext = {
cwd: workingDir, allowFileSystem, allowWeb, allowCode,
originalRunJavascript: config.originalRunJavascript,
originalRunPython: config.originalRunPython,
pluginConfig,
};
while (loops < loopLimit) {
try {
const response = await fetch(`${endpoint}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelId, messages: msgList, temperature: 0.7, stream: false }),
signal: AbortSignal.timeout(120_000),
});
if (!response.ok) return { error: `API Error: ${response.status}`, filesModified };
const data = await response.json();
let content = data.choices[0].message.content;
content = content.replace(/<\|.*?\|>/g, "").trim();
if (!toolsEnabled) return { response: content, filesModified };
if (isRefusal(content)) {
msgList.push({ role: "assistant", content }, { role: "system", content: "SYSTEM ERROR: You HAVE access to tools. USE THEM." });
loops++; continue;
}
const toolCall = parseToolCall(content);
if (toolCall?.tool) {
msgList.push({ role: "assistant", content });
let toolResult: string;
try { toolResult = await dispatchTool(toolCall, dctx, filesModified); }
catch (err: any) { toolResult = `Error: ${err.message}`; }
msgList.push({ role: "user", content: `Tool Output: ${toolResult}` });
loops++;
} else {
if (content.includes("TASK_COMPLETED") || loops >= loopLimit - 1) {
finalContent = content; break;
}
msgList.push({ role: "assistant", content }, { role: "system", content: "SYSTEM NOTICE: You did not call a tool. If finished, output 'TASK_COMPLETED'. Otherwise USE A TOOL." });
loops++;
}
} catch (err: any) { return { error: err.message, filesModified }; }
// Prevent unbounded memory growth — keep system prompt + original task
if (msgList.length > 20) {
const sysMsg = msgList[0];
const originalTask = msgList[1];
msgList.splice(0, msgList.length, sysMsg, originalTask, ...msgList.slice(-17));
}
}
// Auto-save code blocks
if (autoSave && allowFileSystem && finalContent) {
finalContent = await autoSaveCodeBlocks(finalContent, workingDir, filesModified);
}
return { response: finalContent, filesModified };
};
// --- 1. Primary Agent Loop ---
const primaryResult = await runAgentLoop(agent_role, task, context, 8, false, ctx.cwd);
if (primaryResult.error) return { error: primaryResult.error };
let finalResponse = primaryResult.response;
// --- 2. Auto-Debug Loop ---
if (debugMode && allowCode && 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 { /* skip */ }
}
if (debugContext.length > 8000) debugContext = debugContext.substring(0, 8000) + "\n\n[... truncated]";
const debugResult = await runAgentLoop("reviewer", `Review the code in these files: ${filesToCheck}. Check for bugs, syntax errors, or logic flaws. Fix them.`, debugContext, 5, true, ctx.cwd);
finalResponse += "\n\n--- Auto-Debug Report ---\n" + (debugResult.response || "Debug pass completed.");
if (debugResult.filesModified.length > 0) finalResponse += `\n(Reviewer fixed: ${debugResult.filesModified.join(", ")})`;
}
// Append file list
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 content = await readFile(fpath, "utf-8");
finalResponse += `\n**${f}**\n\`\`\`${f.split('.').pop() || 'txt'}\n${content}\n\`\`\`\n`;
} catch { /* skip */ }
}
}
}
if (!showFullCode) {
finalResponse = finalResponse.replace(/```[\s\S]*?```/g, "\n[System: Code Block Hidden. The code has been handled by the sub-agent.]\n");
}
return { response: finalResponse, generated_files: primaryResult.filesModified };
},
pluginConfig.get("enableSecondaryAgent"),
"consult_secondary_agent",
),
});
}