src / toolsProvider.ts
import { text, tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { configSchematics } from "./config";
import { z } from "zod";
import { promises as fs } from "fs";
import { spawn } from "child_process";
import path from "path";
import dns from "dns/promises";
import net from "net";
import crypto from "crypto";
const MAX_FETCH_BYTES = 2_000_000;
const MAX_RETURN_CHARS = 16_000;
const MAX_FILE_BYTES = 2_000_000;
const DEFAULT_USER_AGENT =
"Mozilla/5.0 (compatible; LMStudioChatGPTAgentTools/0.12; +https://lmstudio.ai)";
type SearchResult = { title: string; url: string; snippet?: string; source?: string; published?: string };
type MemoryRecord = { id: string; ts: string; topic?: string; content: string; tags?: string[] };
type FilePermissions = {
read?: boolean;
write?: boolean;
overwrite?: boolean;
delete?: boolean;
allowedRoots?: string[];
writeableRoots?: string[];
allowedWriteExtensions?: string[];
blockedWriteExtensions?: string[];
maxWriteFileSizeBytes?: number;
};
type CommandPermissions = {
enabled?: boolean;
mode?: "off" | "readOnly" | "developer" | "allowlist" | "full";
timeoutSeconds?: number;
maxOutputChars?: number;
allowShellOperators?: boolean;
allowNetworkCommands?: boolean;
allowInstallCommands?: boolean;
allowedExact?: string[];
allowedPrefixes?: string[];
blockedPatterns?: string[];
};
type PythonPermissions = {
enabled?: boolean;
workspaceOnly?: boolean;
timeoutSeconds?: number;
maxOutputChars?: number;
blockedImports?: string[];
blockedPatterns?: string[];
};
type PluginSettings = {
braveSearchApiKey?: string;
workspace?: string;
allowLocalUrls?: boolean;
// Backward-compatible legacy booleans. Prefer permissions.* below.
allowFileRead?: boolean;
allowFileWrite?: boolean;
allowFileOverwrite?: boolean;
allowFileDelete?: boolean;
allowCommands?: boolean;
allowPython?: boolean;
pythonExe?: string;
comfyuiUrl?: string;
maxFetchBytes?: number;
maxReturnChars?: number;
maxFileBytes?: number;
permissions?: {
web?: boolean;
localUrls?: boolean;
files?: FilePermissions;
commands?: CommandPermissions;
python?: PythonPermissions;
};
};
let SETTINGS: PluginSettings = {};
function linesToArray(value: unknown): string[] {
if (typeof value !== "string") return [];
return value.split(/\r?\n/).map(v => v.trim()).filter(Boolean);
}
function loadSettingsFromSidebar(ctl: ToolsProviderController): PluginSettings {
const c = ctl.getPluginConfig(configSchematics);
return {
workspace: c.get("workspace"),
braveSearchApiKey: c.get("braveSearchApiKey"),
allowLocalUrls: c.get("localUrls"),
pythonExe: c.get("pythonExe"),
comfyuiUrl: c.get("comfyuiUrl"),
permissions: {
web: c.get("webEnabled"),
localUrls: c.get("localUrls"),
files: {
read: c.get("filesRead"),
write: c.get("filesWrite"),
overwrite: c.get("filesOverwrite"),
delete: c.get("filesDelete"),
allowedRoots: linesToArray(c.get("filesAllowedRoots")),
writeableRoots: linesToArray(c.get("filesWriteableRoots")),
allowedWriteExtensions: linesToArray(c.get("filesAllowedWriteExtensions")),
blockedWriteExtensions: linesToArray(c.get("filesBlockedWriteExtensions")),
maxWriteFileSizeBytes: c.get("filesMaxWriteBytes"),
},
commands: {
enabled: c.get("commandsEnabled"),
mode: c.get("commandsMode") as CommandPermissions["mode"],
timeoutSeconds: c.get("commandsTimeoutSeconds"),
maxOutputChars: c.get("commandsMaxOutputChars"),
allowShellOperators: c.get("commandsAllowShellOperators"),
allowNetworkCommands: c.get("commandsAllowNetwork"),
allowInstallCommands: c.get("commandsAllowInstall"),
allowedExact: linesToArray(c.get("commandsAllowedExact")),
allowedPrefixes: linesToArray(c.get("commandsAllowedPrefixes")),
blockedPatterns: linesToArray(c.get("commandsBlockedPatterns")),
},
python: {
enabled: c.get("pythonEnabled"),
workspaceOnly: c.get("pythonWorkspaceOnly"),
timeoutSeconds: c.get("pythonTimeoutSeconds"),
maxOutputChars: c.get("pythonMaxOutputChars"),
blockedImports: linesToArray(c.get("pythonBlockedImports")),
blockedPatterns: linesToArray(c.get("pythonBlockedPatterns")),
},
},
};
}
// LM Studio can load a tools provider in a prediction process that is not
// attached to a working directory. Avoid ctl.getWorkingDirectory() unless
// it is wrapped, and fall back to the plugin project folder.
const PLUGIN_DIR = process.cwd();
function safeWorkingDirectory(ctl: ToolsProviderController) {
try {
const wd = ctl.getWorkingDirectory();
if (typeof wd === "string" && wd.trim()) return wd;
} catch {}
return PLUGIN_DIR;
}
function boolFromAny(v: unknown): boolean | undefined {
if (typeof v === "boolean") return v;
if (typeof v === "string") return ["1", "true", "yes", "on"].includes(v.toLowerCase());
return undefined;
}
function settingBool(key: keyof PluginSettings, envName: string) {
const fromFile = boolFromAny(SETTINGS[key]);
if (fromFile !== undefined) return fromFile;
return ["1", "true", "yes", "on"].includes((process.env[envName] ?? "").toLowerCase());
}
function settingString(key: keyof PluginSettings, envName: string, fallback?: string) {
const fromFile = SETTINGS[key];
if (typeof fromFile === "string" && fromFile.trim()) return fromFile;
return process.env[envName] || fallback;
}
function envTrue(name: string) {
const map: Record<string, keyof PluginSettings> = {
ALLOW_LOCAL_URLS: "allowLocalUrls",
ALLOW_FILE_WRITE: "allowFileWrite",
ALLOW_COMMANDS: "allowCommands",
};
return settingBool(map[name] ?? (name as keyof PluginSettings), name);
}
function nowIso() { return new Date().toISOString(); }
function clip(s: string, max = MAX_RETURN_CHARS) {
if (s.length <= max) return s;
return s.slice(0, max) + `\n\n[trimmed ${s.length - max} characters]`;
}
function stripHtml(html: string) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<noscript[\s\S]*?<\/noscript>/gi, " ")
.replace(/<svg[\s\S]*?<\/svg>/gi, " ")
.replace(/<br\s*\/?\s*>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<\/h[1-6]>/gi, "\n")
.replace(/<\/li>/gi, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/ /g, " ")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/\n\s+/g, "\n")
.replace(/[ \t]+/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function htmlDecode(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'");
}
function isPrivateIp(ip: string) {
if (net.isIP(ip) === 4) {
const [a, b] = ip.split(".").map(Number);
return a === 10 || a === 127 || a === 0 || (a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) || (a === 169 && b === 254);
}
if (net.isIP(ip) === 6) {
const lower = ip.toLowerCase();
return lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd") || lower.startsWith("fe80");
}
return false;
}
async function assertSafeUrl(rawUrl: string) {
const u = new URL(rawUrl);
if (!["http:", "https:"].includes(u.protocol)) throw new Error("Only http/https URLs are allowed.");
if (localUrlsEnabled()) return;
if (u.hostname === "localhost" || u.hostname.endsWith(".local")) {
throw new Error("Local/private URLs are blocked. Set ALLOW_LOCAL_URLS=true to allow them.");
}
const records = await dns.lookup(u.hostname, { all: true });
if (records.some(r => isPrivateIp(r.address))) {
throw new Error("Local/private network addresses are blocked. Set ALLOW_LOCAL_URLS=true to allow them.");
}
}
async function fetchText(url: string, timeoutMs = 15_000) {
await assertSafeUrl(url);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: {
"user-agent": DEFAULT_USER_AGENT,
accept: "text/html,text/plain,application/json,application/xml,text/xml,*/*;q=0.5",
},
});
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
const reader = res.body?.getReader();
if (!reader) return await res.text();
let received = 0;
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
received += value.length;
if (received > MAX_FETCH_BYTES) break;
chunks.push(value);
}
return new TextDecoder().decode(Buffer.concat(chunks));
} finally {
clearTimeout(timeout);
}
}
async function braveSearch(query: string, count: number, freshness?: string): Promise<SearchResult[]> {
const key = settingString("braveSearchApiKey", "BRAVE_SEARCH_API_KEY");
if (!key) return [];
const url = new URL("https://api.search.brave.com/res/v1/web/search");
url.searchParams.set("q", query);
url.searchParams.set("count", String(Math.min(Math.max(count, 1), 10)));
if (freshness && freshness !== "any") url.searchParams.set("freshness", freshness);
const res = await fetch(url, {
headers: { "X-Subscription-Token": key, Accept: "application/json", "user-agent": DEFAULT_USER_AGENT },
});
if (!res.ok) throw new Error(`Brave Search failed: HTTP ${res.status}`);
const json: any = await res.json();
return (json.web?.results ?? []).slice(0, count).map((r: any) => ({
title: stripHtml(r.title ?? "Untitled"), url: r.url, snippet: stripHtml(r.description ?? ""),
source: r.profile?.name, published: r.age,
}));
}
async function duckDuckGoHtmlSearch(query: string, count: number): Promise<SearchResult[]> {
const url = `https://duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
const html = await fetchText(url, 15_000);
const results: SearchResult[] = [];
const resultBlocks = html.split(/<div class="result results_links/gi).slice(1);
for (const block of resultBlocks) {
const linkMatch = block.match(/<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
const snippetMatch = block.match(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/i) ||
block.match(/<div[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/div>/i);
if (!linkMatch) continue;
let href = htmlDecode(linkMatch[1]);
try { const parsed = new URL(href); const uddg = parsed.searchParams.get("uddg"); if (uddg) href = decodeURIComponent(uddg); } catch {}
results.push({ title: stripHtml(linkMatch[2]), url: href, snippet: snippetMatch ? stripHtml(snippetMatch[1]) : undefined, source: "DuckDuckGo" });
if (results.length >= count) break;
}
return results;
}
function workspaceRoot(ctl: ToolsProviderController) {
const fallback = safeWorkingDirectory(ctl);
return path.resolve(settingString("workspace", "LMS_TOOLS_WORKSPACE", fallback) || fallback);
}
const DEFAULT_ALLOWED_WRITE_EXTENSIONS = [".txt", ".md", ".json", ".jsonl", ".csv", ".log", ".yaml", ".yml"];
const DEFAULT_BLOCKED_WRITE_EXTENSIONS = [".exe", ".bat", ".cmd", ".ps1", ".vbs", ".js", ".mjs", ".cjs", ".msi", ".dll", ".scr", ".reg", ".lnk"];
const DEFAULT_BLOCKED_COMMAND_PATTERNS = [
"del ", "erase ", "rmdir ", "rd ", "remove-item", " rm ", "format", "diskpart", "shutdown", "restart-computer",
"stop-computer", "reg ", "set-executionpolicy", "netsh", "powershell -enc", "encodedcommand", "invoke-webrequest",
"curl ", "wget ", "start-process", "schtasks", " sc ", "bcdedit", "takeown", "icacls", "cipher",
"robocopy", "xcopy", "npm install", "npm i ", "yarn add", "pnpm add", "pip install", "choco ", "winget ", "msiexec"
];
const READONLY_EXACT_COMMANDS = [
"whoami", "hostname", "ver", "node --version", "npm --version", "python --version", "git --version", "nvidia-smi"
];
const READONLY_PREFIX_COMMANDS = [
"dir", "type", "findstr", "where", "git status", "git log", "git diff", "git branch", "git remote -v", "cmd /c dir", "cmd /c echo", "cmd /c type"
];
const DEVELOPER_PREFIX_COMMANDS = [...READONLY_PREFIX_COMMANDS, "npm list", "npm outdated", "npm run", "node", "tsc", "npx tsc"];
function normalizeSlashes(pth: string) { return pth.replace(/\\/g, "/"); }
function lowerTrim(s: string) { return s.trim().replace(/\s+/g, " ").toLowerCase(); }
function commandLine(command: string, args: string[] = []) { return lowerTrim([command, ...args].join(" ")); }
function containsShellOperators(s: string) { return /(&&|\|\||[|<>;&`])/.test(s); }
function localUrlsEnabled() {
const p = SETTINGS.permissions?.localUrls;
if (typeof p === "boolean") return p;
return envTrue("ALLOW_LOCAL_URLS");
}
function webEnabled() {
const p = SETTINGS.permissions?.web;
return typeof p === "boolean" ? p : true;
}
function filePermissions() { return SETTINGS.permissions?.files ?? {}; }
function commandPermissions() { return SETTINGS.permissions?.commands ?? {}; }
function pythonPermissions() { return SETTINGS.permissions?.python ?? {}; }
function fileReadEnabled() {
const p = filePermissions();
if (typeof p.read === "boolean") return p.read;
const legacy = boolFromAny(SETTINGS.allowFileRead);
return legacy ?? true;
}
function fileWriteEnabled() {
const p = filePermissions();
if (typeof p.write === "boolean") return p.write;
return envTrue("ALLOW_FILE_WRITE");
}
function fileOverwriteEnabled() {
const p = filePermissions();
if (typeof p.overwrite === "boolean") return p.overwrite;
return boolFromAny(SETTINGS.allowFileOverwrite) ?? false;
}
function commandEnabled() {
const p = commandPermissions();
if (typeof p.enabled === "boolean") return p.enabled;
return envTrue("ALLOW_COMMANDS");
}
function pythonEnabled() {
const p = pythonPermissions();
if (typeof p.enabled === "boolean") return p.enabled;
const legacy = boolFromAny(SETTINGS.allowPython);
return legacy ?? commandEnabled();
}
function configuredCommandTimeoutMs(requested?: number) {
const seconds = commandPermissions().timeoutSeconds ?? 20;
return Math.min(Math.max(requested ?? seconds * 1000, 1000), 60_000);
}
function configuredPythonTimeoutMs(requested?: number) {
const seconds = pythonPermissions().timeoutSeconds ?? 20;
return Math.min(Math.max(requested ?? seconds * 1000, 1000), 60_000);
}
function effectiveCommandMode(): "off" | "readOnly" | "developer" | "allowlist" | "full" {
if (!commandEnabled()) return "off";
return commandPermissions().mode ?? "allowlist";
}
function matchesAny(line: string, patterns: string[]) {
const lower = lowerTrim(line);
return patterns.some(p => lower.includes(p.toLowerCase()));
}
function commandAllowlists() {
const p = commandPermissions();
const mode = effectiveCommandMode();
let exact = p.allowedExact ?? [];
let prefixes = p.allowedPrefixes ?? [];
if (mode === "readOnly") {
exact = [...READONLY_EXACT_COMMANDS, ...exact];
prefixes = [...READONLY_PREFIX_COMMANDS, ...prefixes];
} else if (mode === "developer") {
exact = [...READONLY_EXACT_COMMANDS, ...exact];
prefixes = [...DEVELOPER_PREFIX_COMMANDS, ...prefixes];
}
return { exact: exact.map(lowerTrim), prefixes: prefixes.map(lowerTrim) };
}
function validateCommandSafety(command: string, args: string[] = []) {
const p = commandPermissions();
const mode = effectiveCommandMode();
if (mode === "off") throw new Error("Command execution disabled. Enable permissions.commands.enabled and choose a mode.");
const line = commandLine(command, args);
if (!p.allowShellOperators && containsShellOperators(line)) throw new Error("Command rejected: shell operators/pipes/redirection are disabled.");
const blocked = [...DEFAULT_BLOCKED_COMMAND_PATTERNS, ...(p.blockedPatterns ?? [])];
if (matchesAny(` ${line} `, blocked)) throw new Error(`Command rejected by blocked pattern policy: ${line}`);
if (p.allowNetworkCommands === false && /\b(curl|wget|invoke-webrequest|ssh|scp|ftp|telnet|nc|netcat)\b/i.test(line)) throw new Error("Command rejected: network-capable commands are disabled.");
if (p.allowInstallCommands === false && /\b(npm\s+(install|i)|pip\s+install|winget|choco|msiexec|yarn\s+add|pnpm\s+add)\b/i.test(line)) throw new Error("Command rejected: install/package commands are disabled.");
if (mode === "full") return { allowed: true, mode, line };
const { exact, prefixes } = commandAllowlists();
if (exact.includes(line) || prefixes.some(prefix => line === prefix || line.startsWith(prefix + " "))) return { allowed: true, mode, line };
throw new Error(`Command rejected: not in allowlist for mode '${mode}': ${line}`);
}
function validatePythonSafety(code: string) {
if (!pythonEnabled()) throw new Error("Python execution disabled. Enable permissions.python.enabled.");
const p = pythonPermissions();
const blockedImports = p.blockedImports ?? ["subprocess", "os", "shutil", "socket", "requests", "urllib", "ctypes", "winreg"];
for (const mod of blockedImports) {
const re = new RegExp(`(^|\\n)\\s*(import\\s+${mod.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b|from\\s+${mod.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+import\\b)`, "i");
if (re.test(code)) throw new Error(`Python rejected: blocked import '${mod}'.`);
}
const blockedPatterns = p.blockedPatterns ?? ["__import__", "eval(", "exec(", "compile(", "open(", "input(", "globals(", "locals(", "getattr(", "setattr(", "delattr("];
if (matchesAny(code, blockedPatterns)) throw new Error("Python rejected by blocked pattern policy.");
return true;
}
function allowedWriteExtensions() { return filePermissions().allowedWriteExtensions ?? DEFAULT_ALLOWED_WRITE_EXTENSIONS; }
function blockedWriteExtensions() { return filePermissions().blockedWriteExtensions ?? DEFAULT_BLOCKED_WRITE_EXTENSIONS; }
function validateWriteTarget(root: string, relativePath: string, content: string, overwrite?: boolean) {
if (!fileWriteEnabled()) throw new Error("File writing disabled. Enable permissions.files.write or set allowFileWrite=true.");
const filePath = resolveInside(root, relativePath);
const normalized = normalizeSlashes(filePath).toLowerCase();
const ext = path.extname(filePath).toLowerCase();
const maxBytes = filePermissions().maxWriteFileSizeBytes ?? 1_048_576;
if (Buffer.byteLength(content, "utf-8") > maxBytes) throw new Error(`File write rejected: content exceeds ${maxBytes} bytes.`);
if (blockedWriteExtensions().map(e => e.toLowerCase()).includes(ext)) throw new Error(`File write rejected: extension '${ext}' is blocked.`);
const allowedExt = allowedWriteExtensions().map(e => e.toLowerCase());
if (allowedExt.length && !allowedExt.includes(ext)) throw new Error(`File write rejected: extension '${ext}' is not in allowedWriteExtensions.`);
const writeable = filePermissions().writeableRoots ?? filePermissions().allowedRoots ?? [root];
const okRoot = writeable.map(r => normalizeSlashes(path.resolve(r)).toLowerCase()).some(r => normalized === r || normalized.startsWith(r + "/"));
if (!okRoot) throw new Error("File write rejected: path is outside writeable roots.");
if (overwrite && !fileOverwriteEnabled()) throw new Error("File overwrite rejected: permissions.files.overwrite is false.");
return filePath;
}
function resolveInside(root: string, userPath: string) {
const resolved = path.resolve(root, userPath || ".");
const rootResolved = path.resolve(root);
if (resolved !== rootResolved && !resolved.startsWith(rootResolved + path.sep)) throw new Error("Path escapes the configured workspace.");
return resolved;
}
function safeCalculate(expression: string) {
const trimmed = expression.trim();
if (!/^[0-9+\-*/().,%\s^]+$/.test(trimmed)) throw new Error("Expression contains unsupported characters. Use numbers and basic arithmetic only.");
const jsExpr = trimmed.replace(/\^/g, "**").replace(/(\d+(?:\.\d+)?)\s*%/g, "($1/100)");
const result = Function(`"use strict"; return (${jsExpr});`)();
if (typeof result !== "number" || !Number.isFinite(result)) throw new Error("Expression did not produce a finite number.");
return result;
}
async function walkFiles(root: string, rel: string, maxFiles: number, maxDepth: number, out: string[]) {
if (out.length >= maxFiles || maxDepth < 0) return;
const dir = resolveInside(root, rel);
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
if (out.length >= maxFiles) break;
if (e.name.startsWith(".git") || e.name === "node_modules" || e.name === "dist") continue;
const childRel = path.join(rel, e.name);
if (e.isDirectory()) await walkFiles(root, childRel, maxFiles, maxDepth - 1, out);
else if (e.isFile()) out.push(childRel);
}
}
async function maybeReadTextFile(filePath: string, maxBytes: number) {
const stat = await fs.stat(filePath);
if (!stat.isFile() || stat.size > maxBytes) return null;
const buf = await fs.readFile(filePath);
if (buf.includes(0)) return null;
return buf.toString("utf-8");
}
function splitCommandLine(line: string): string[] {
const out: string[] = [];
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(line)) !== null) out.push(m[1] ?? m[2] ?? m[3]);
return out;
}
function buildCommandInvocation(command: string, args: string[] = []) {
const trimmed = command.trim();
const providedArgs = args.filter(a => a !== undefined && a !== null).map(a => String(a));
const parts = providedArgs.length === 0 && /\s/.test(trimmed) ? splitCommandLine(trimmed) : [trimmed, ...providedArgs];
const exe = parts[0];
const exeArgs = parts.slice(1);
const line = commandLine(exe, exeArgs);
if (process.platform === "win32") {
// Windows built-ins such as dir/type/ver/echo are not standalone executables,
// and using cmd.exe also fixes PATH resolution differences in LM Studio's plugin process.
return { exe: "cmd.exe", args: ["/d", "/s", "/c", line], line };
}
return { exe, args: exeArgs, line };
}
function spawnWithTimeout(command: string, args: string[], cwd: string, timeoutMs: number, input?: string) {
return new Promise<{ code: number | null; stdout: string; stderr: string; timed_out: boolean; invoked?: any }>((resolve) => {
const invocation = buildCommandInvocation(command, args);
let child;
try {
child = spawn(invocation.exe, invocation.args, { cwd, shell: false, windowsHide: true });
} catch (e: any) {
resolve({ code: null, stdout: "", stderr: `Failed to spawn command: ${e?.message ?? String(e)}`, timed_out: false, invoked: invocation });
return;
}
let stdout = ""; let stderr = ""; let timed_out = false; let settled = false;
const finish = (payload: { code: number | null; stdout: string; stderr: string; timed_out: boolean; invoked?: any }) => {
if (settled) return; settled = true; clearTimeout(timer); resolve(payload);
};
const timer = setTimeout(() => { timed_out = true; try { child.kill(); } catch {} }, timeoutMs);
child.stdout?.on("data", d => { stdout += d.toString(); if (stdout.length > 80_000) child.kill(); });
child.stderr?.on("data", d => { stderr += d.toString(); if (stderr.length > 80_000) child.kill(); });
child.on("error", (e: any) => finish({ code: null, stdout: clip(stdout, 30_000), stderr: clip(stderr + `\nSpawn error: ${e?.message ?? String(e)}`, 30_000), timed_out, invoked: invocation }));
child.on("close", code => finish({ code, stdout: clip(stdout, 30_000), stderr: clip(stderr, 30_000), timed_out, invoked: invocation }));
if (input) child.stdin?.write(input);
child.stdin?.end();
});
}
async function memoryPath(ctl: ToolsProviderController) {
const root = workspaceRoot(ctl);
await fs.mkdir(path.join(root, ".lmstudio-agent-tools"), { recursive: true });
return path.join(root, ".lmstudio-agent-tools", "memory.jsonl");
}
export async function toolsProvider(ctl: ToolsProviderController) {
SETTINGS = loadSettingsFromSidebar(ctl);
const settingsSource = "LM Studio right-sidebar configuration";
await fs.mkdir(workspaceRoot(ctl), { recursive: true });
const tools: Tool[] = [];
tools.push(tool({
name: "read_tool_settings",
description: text`Read the active settings supplied by LM Studio's right sidebar. Use when the user asks what tool settings are active, or when diagnosing why another tool may not work.`,
parameters: {},
implementation: async () => ({
settings_source: settingsSource,
effective: {
workspace: workspaceRoot(ctl),
webEnabled: webEnabled(),
allowLocalUrls: localUrlsEnabled(),
files: {
read: fileReadEnabled(),
write: fileWriteEnabled(),
overwrite: fileOverwriteEnabled(),
allowedWriteExtensions: allowedWriteExtensions(),
blockedWriteExtensions: blockedWriteExtensions(),
maxWriteFileSizeBytes: filePermissions().maxWriteFileSizeBytes ?? 1_048_576,
},
commands: {
enabled: commandEnabled(),
mode: effectiveCommandMode(),
timeoutSeconds: commandPermissions().timeoutSeconds ?? 20,
allowShellOperators: commandPermissions().allowShellOperators ?? false,
allowNetworkCommands: commandPermissions().allowNetworkCommands ?? false,
allowInstallCommands: commandPermissions().allowInstallCommands ?? false,
allowlist: commandAllowlists(),
},
python: {
enabled: pythonEnabled(),
pythonExe: settingString("pythonExe", "PYTHON_EXE", "python"),
timeoutSeconds: pythonPermissions().timeoutSeconds ?? 20,
blockedImports: pythonPermissions().blockedImports ?? ["subprocess", "os", "shutil", "socket", "requests", "urllib", "ctypes", "winreg"],
},
comfyuiUrl: settingString("comfyuiUrl", "COMFYUI_URL", "http://127.0.0.1:8188"),
hasBraveSearchApiKey: !!settingString("braveSearchApiKey", "BRAVE_SEARCH_API_KEY"),
},
sidebar_config_values: SETTINGS,
}),
}));
tools.push(tool({
name: "decide_tool_use",
description: text`AUTO-ROUTER. Use this at the start of any non-trivial request when you are unsure whether tools are needed. It returns a recommended tool sequence and reasons. Especially use for: current facts, software/docs, troubleshooting, logs, local files, URLs, math, ComfyUI, code execution, or anything that might need verification. Skip only for obvious stable knowledge, casual chat, pure rewriting, or simple creative writing.`,
parameters: {
user_request: z.string().describe("The user's full request, including relevant context."),
available_context: z.string().optional().describe("Brief notes about conversation context that may affect tool choice."),
},
implementation: async ({ user_request, available_context }) => {
const q = user_request.toLowerCase();
const reasons: string[] = [];
const recommended_tools: string[] = [];
const push = (toolName: string, reason: string) => { if (!recommended_tools.includes(toolName)) recommended_tools.push(toolName); reasons.push(`${toolName}: ${reason}`); };
const hasUrl = /https?:\/\//i.test(user_request);
const asksCurrent = /\b(latest|current|today|now|recent|new|newest|price|pricing|available|release|version|update|docs?|documentation|search|web|internet|news|law|regulation|compatible|compatibility|driver|download|where can i find)\b/i.test(user_request);
const asksFiles = /\b(file|folder|directory|workspace|read|search files|log|config|json|txt|csv|pdf|uploaded|local)\b/i.test(user_request);
const asksMath = /\b(calculate|math|how many|amps|watts|volts|percent|ratio|convert|equals?)\b/i.test(user_request) || /\d+\s*[+\-*/^]\s*\d+/.test(user_request);
const asksComfy = /\b(comfyui|workflow|prompt queue|queue prompt|generate image|image workflow)\b/i.test(user_request);
const asksCodeRun = /\b(run|execute|test this|parse|analyze this data|script|python|powershell|cmd|terminal|command)\b/i.test(user_request);
const asksMemory = /\b(remember|save this|note that|what did you remember|memory notes|recall)\b/i.test(user_request);
const complex = user_request.length > 220 || /\b(plan|troubleshoot|debug|diagnose|compare|recommend|best|setup|configure|install|fix|why)\b/i.test(user_request);
if (complex) push("plan_answer", "request looks complex, diagnostic, comparative, or multi-step");
if (hasUrl) push("open_url", "request contains a URL that should be opened/read");
if (asksCurrent) push("web_search", "request likely depends on current or source-backed public information");
if (asksFiles) push("workspace_info/search_files/read_text_file", "request appears to involve local workspace files or logs");
if (asksMath) push("calculate", "request includes arithmetic or unit-style calculation");
if (asksComfy) push("comfyui_status/submit_comfyui_prompt/comfyui_history", "request involves ComfyUI or image workflow automation");
if (asksCodeRun) push("run_python/run_command", "request may need local execution; only use if enabled and safe");
if (asksMemory) push("remember_note/search_memory_notes", "request involves storing or retrieving local memory notes");
let decision: "answer_directly" | "use_tools" | "ask_clarifying_question" = recommended_tools.length ? "use_tools" : "answer_directly";
if (/\b(should i|can you|make it|set it up)\b/i.test(user_request) && q.length < 35) decision = "ask_clarifying_question";
return {
decision,
recommended_tools,
reasons,
available_context: available_context ?? "",
policy: {
use_tools_when: [
"facts may have changed recently",
"user provides a URL",
"user asks about local files/logs/configs",
"exact calculation is useful",
"ComfyUI/local service status or queueing is needed",
"running code/commands would materially improve the answer and is enabled",
],
do_not_use_tools_when: ["simple stable explanations", "casual chat", "pure rewriting/translation", "creative writing with no factual dependency"],
final_step: "After tool use, synthesize; do not dump raw tool output unless requested."
}
};
},
}));
tools.push(tool({
name: "plan_answer",
description: text`Create a short answer plan before responding. Prefer to call this automatically for complex, ambiguous, multi-step, troubleshooting, setup, comparison, recommendation, source-sensitive, or tool-heavy questions. This does not search; it tells you what to do next.`,
parameters: {
user_request: z.string(),
answer_mode: z.enum(["direct", "reason", "search", "read_url", "files", "code", "image_or_comfyui", "mixed"]),
must_verify_freshness: z.boolean().optional(),
brief_plan: z.string().describe("A concise plan, usually 1-4 steps."),
},
implementation: async (p) => ({ ...p, reminder: "Use the fewest tools needed. After tools, synthesize and cite URLs when available." }),
}));
tools.push(tool({
name: "web_search",
description: text`Search the public web. Call automatically when the answer may depend on current/recent info, software documentation, model releases, product/pricing/availability, laws/rules, drivers, compatibility, niche facts, or when the user asks for sources, links, downloads, examples, docs, or verification. Returns title, URL, snippet, source, and publication hint when available.`,
parameters: {
query: z.string(),
max_results: z.number().int().min(1).max(10).optional(),
freshness: z.enum(["any", "pd", "pw", "pm", "py"]).optional().describe("Brave freshness: pd=past day, pw=past week, pm=past month, py=past year. Ignored by fallback."),
},
implementation: async ({ query, max_results, freshness }) => {
if (!webEnabled()) throw new Error("Web tools are disabled in the LM Studio sidebar settings.");
const count = max_results ?? 6;
let provider = "brave";
let results = await braveSearch(query, count, freshness ?? "any");
if (results.length === 0) { provider = "duckduckgo-html-fallback"; results = await duckDuckGoHtmlSearch(query, count); }
return { provider, query, count: results.length, results };
},
}));
tools.push(tool({
name: "open_url",
description: text`Fetch a web page or JSON endpoint and extract readable text. Use after web_search when snippets are insufficient, or when the user gives a URL. Local/private URLs are blocked unless ALLOW_LOCAL_URLS=true.`,
parameters: { url: z.string().url(), max_chars: z.number().int().min(500).max(50000).optional() },
implementation: async ({ url, max_chars }) => {
if (!webEnabled()) throw new Error("Web tools are disabled in the LM Studio sidebar settings.");
const raw = await fetchText(url);
const trimmed = raw.trim();
const body = trimmed.startsWith("{") || trimmed.startsWith("[") ? trimmed : stripHtml(raw);
return { url, retrieved_at: nowIso(), text: clip(body, max_chars ?? MAX_RETURN_CHARS) };
},
}));
tools.push(tool({
name: "open_top_search_results",
description: text`Search and open the top few results in one call. Use when a sourced answer needs more than snippets. Returns clipped page text for each successfully opened result.`,
parameters: { query: z.string(), open_count: z.number().int().min(1).max(4).optional(), freshness: z.enum(["any", "pd", "pw", "pm", "py"]).optional() },
implementation: async ({ query, open_count, freshness }) => {
if (!webEnabled()) throw new Error("Web tools are disabled in the LM Studio sidebar settings.");
const count = open_count ?? 3;
let results = await braveSearch(query, Math.max(count, 4), freshness ?? "any");
let provider = "brave";
if (!results.length) { provider = "duckduckgo-html-fallback"; results = await duckDuckGoHtmlSearch(query, Math.max(count, 4)); }
const pages = [] as any[];
for (const r of results.slice(0, count)) {
try { const raw = await fetchText(r.url); pages.push({ ...r, text: clip(stripHtml(raw), 9000) }); }
catch (e: any) { pages.push({ ...r, error: String(e?.message ?? e) }); }
}
return { provider, query, pages };
},
}));
tools.push(tool({
name: "calculate",
description: text`Calculate exact arithmetic. Supports numbers, +, -, *, /, parentheses, decimals, percentages, and ^ for exponentiation. Not a general JavaScript execution tool.`,
parameters: { expression: z.string() },
implementation: async ({ expression }) => ({ expression, result: safeCalculate(expression) }),
}));
tools.push(tool({
name: "current_datetime",
description: text`Get current date/time. Use for relative dates, schedules, freshness checks, or time-zone-sensitive questions.`,
parameters: { time_zone: z.string().optional().describe("IANA timezone, for example America/Los_Angeles") },
implementation: async ({ time_zone }) => {
const now = new Date();
return { iso_utc: now.toISOString(), local: now.toLocaleString("en-US", { timeZone: time_zone || undefined }), time_zone: time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone };
},
}));
tools.push(tool({
name: "workspace_info",
description: text`Show the file workspace root and which high-risk capabilities are enabled. Use before file operations if unsure.`,
parameters: {},
implementation: async () => ({
workspace: workspaceRoot(ctl),
file_write_enabled: fileWriteEnabled(),
file_overwrite_enabled: fileOverwriteEnabled(),
commands_enabled: commandEnabled(),
command_mode: effectiveCommandMode(),
python_enabled: pythonEnabled(),
local_urls_enabled: localUrlsEnabled(),
comfyui_url: settingString("comfyuiUrl", "COMFYUI_URL", "http://127.0.0.1:8188"),
settings_source: settingsSource,
sidebar_config_values: SETTINGS,
}),
}));
tools.push(tool({
name: "list_directory",
description: text`List files/folders inside the configured workspace. Use when the user asks about local files. Set LMS_TOOLS_WORKSPACE to point this at a project folder.`,
parameters: { relative_path: z.string().default("."), max_entries: z.number().int().min(1).max(500).optional() },
implementation: async ({ relative_path, max_entries }) => {
if (!fileReadEnabled()) throw new Error("File reading disabled by permissions.files.read=false.");
const root = workspaceRoot(ctl); const dir = resolveInside(root, relative_path || ".");
const entries = await fs.readdir(dir, { withFileTypes: true });
return { root, relative_path, entries: entries.slice(0, max_entries ?? 200).map(e => ({ name: e.name, type: e.isDirectory() ? "directory" : "file" })) };
},
}));
tools.push(tool({
name: "read_text_file",
description: text`Read a UTF-8 text file inside the workspace. Use for source code, logs, config files, markdown, JSON, etc. Will refuse large/binary files.`,
parameters: { relative_path: z.string(), max_chars: z.number().int().min(500).max(100000).optional() },
implementation: async ({ relative_path, max_chars }) => {
if (!fileReadEnabled()) throw new Error("File reading disabled by permissions.files.read=false.");
const root = workspaceRoot(ctl); const filePath = resolveInside(root, relative_path); const stat = await fs.stat(filePath);
if (!stat.isFile()) throw new Error("Path is not a file.");
if (stat.size > MAX_FILE_BYTES) throw new Error(`File too large (${stat.size} bytes).`);
const data = await maybeReadTextFile(filePath, MAX_FILE_BYTES); if (data === null) throw new Error("File appears binary or unreadable as text.");
return { relative_path, size_bytes: stat.size, text: clip(data, max_chars ?? 25_000) };
},
}));
tools.push(tool({
name: "search_files",
description: text`Search text files in the workspace by keyword/regex. Useful for finding config keys, errors, functions, filenames, or snippets in a project/log folder.`,
parameters: {
query: z.string(),
relative_path: z.string().default("."),
mode: z.enum(["keyword", "regex"]).optional(),
max_results: z.number().int().min(1).max(100).optional(),
max_files: z.number().int().min(1).max(2000).optional(),
},
implementation: async ({ query, relative_path, mode, max_results, max_files }) => {
if (!fileReadEnabled()) throw new Error("File reading disabled by permissions.files.read=false.");
const root = workspaceRoot(ctl); const files: string[] = []; await walkFiles(root, relative_path || ".", max_files ?? 500, 8, files);
const results: any[] = []; const useRegex = (mode ?? "keyword") === "regex"; const regex = useRegex ? new RegExp(query, "i") : null;
for (const rel of files) {
if (results.length >= (max_results ?? 50)) break;
const full = resolveInside(root, rel); const txt = await maybeReadTextFile(full, 500_000).catch(() => null); if (!txt) continue;
const lines = txt.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const ok = regex ? regex.test(lines[i]) : lines[i].toLowerCase().includes(query.toLowerCase());
if (ok) { results.push({ file: rel, line: i + 1, text: clip(lines[i], 300) }); break; }
}
}
return { root, query, searched_files: files.length, results };
},
}));
tools.push(tool({
name: "write_text_file",
description: text`Create a text file inside the workspace using scoped permissions. Requires permissions.files.write=true or legacy allowFileWrite=true. Extension, size, overwrite, and writeable-root restrictions are enforced. Use only when the user explicitly asks to create/edit a file.`,
parameters: { relative_path: z.string(), content: z.string(), overwrite: z.boolean().optional() },
implementation: async ({ relative_path, content, overwrite }) => {
const root = workspaceRoot(ctl);
const filePath = validateWriteTarget(root, relative_path, content, overwrite);
await fs.mkdir(path.dirname(filePath), { recursive: true });
const exists = await fs.stat(filePath).then(() => true).catch((e: any) => { if (e.code === "ENOENT") return false; throw e; });
if (exists && !overwrite) throw new Error("File exists. Pass overwrite=true, and enable permissions.files.overwrite, to overwrite.");
await fs.writeFile(filePath, content, "utf-8");
return { relative_path, bytes_written: Buffer.byteLength(content), overwritten: exists, policy: { file_write_enabled: fileWriteEnabled(), overwrite_enabled: fileOverwriteEnabled(), allowed_extensions: allowedWriteExtensions() } };
},
}));
tools.push(tool({
name: "run_command",
description: text`Run a local command in the workspace using scoped permissions. Requires permissions.commands.enabled=true. Commands are filtered by mode, allowlists, blocked patterns, shell-operator policy, timeout, and output limits. Prefer safe/read-only diagnostics.`,
parameters: { command: z.string(), args: z.array(z.string()).optional(), timeout_ms: z.number().int().min(1000).max(60000).optional() },
implementation: async ({ command, args, timeout_ms }) => {
const invocation = buildCommandInvocation(command, args ?? []);
const safety = validateCommandSafety(invocation.line, []);
const result = await spawnWithTimeout(command, args ?? [], workspaceRoot(ctl), configuredCommandTimeoutMs(timeout_ms));
const maxChars = commandPermissions().maxOutputChars ?? 12000;
return { ...result, stdout: clip(result.stdout, maxChars), stderr: clip(result.stderr, maxChars), policy: safety };
},
}));
tools.push(tool({
name: "run_python",
description: text`Run a short Python script in the workspace using scoped Python permissions. Use for calculations, parsing logs, JSON validation, and data processing. Static safety filters block risky imports and patterns; this is a safety gate, not a perfect sandbox.`,
parameters: { code: z.string(), timeout_ms: z.number().int().min(1000).max(60000).optional() },
implementation: async ({ code, timeout_ms }) => {
validatePythonSafety(code);
const result = await spawnWithTimeout(settingString("pythonExe", "PYTHON_EXE", "python") || "python", ["-I", "-c", code], workspaceRoot(ctl), configuredPythonTimeoutMs(timeout_ms));
const maxChars = pythonPermissions().maxOutputChars ?? 12000;
return { ...result, stdout: clip(result.stdout, maxChars), stderr: clip(result.stderr, maxChars), policy: { enabled: pythonEnabled(), timeout_ms: configuredPythonTimeoutMs(timeout_ms), blockedImports: pythonPermissions().blockedImports ?? ["subprocess", "os", "shutil", "socket", "requests", "urllib", "ctypes", "winreg"] } };
},
}));
tools.push(tool({
name: "remember_note",
description: text`Store a short local memory note in the workspace memory file. Use only when the user asks you to remember/save something or when it is clearly useful long-term.`,
parameters: { content: z.string(), topic: z.string().optional(), tags: z.array(z.string()).optional() },
implementation: async ({ content, topic, tags }) => {
const rec: MemoryRecord = { id: crypto.randomUUID(), ts: nowIso(), topic, content, tags };
await fs.appendFile(await memoryPath(ctl), JSON.stringify(rec) + "\n", "utf-8");
return { saved: true, record: rec };
},
}));
tools.push(tool({
name: "search_memory_notes",
description: text`Search local memory notes saved by remember_note. Use when the user asks what was remembered or references prior saved local preferences.`,
parameters: { query: z.string().optional(), max_results: z.number().int().min(1).max(50).optional() },
implementation: async ({ query, max_results }) => {
const p = await memoryPath(ctl); let textData = ""; try { textData = await fs.readFile(p, "utf-8"); } catch {}
const records = textData.split(/\r?\n/).filter(Boolean).map(l => { try { return JSON.parse(l) as MemoryRecord; } catch { return null; } }).filter(Boolean) as MemoryRecord[];
const q = query?.toLowerCase();
const filtered = q ? records.filter(r => `${r.topic ?? ""} ${r.content} ${(r.tags ?? []).join(" ")}`.toLowerCase().includes(q)) : records;
return { count: filtered.length, results: filtered.slice(-(max_results ?? 10)).reverse() };
},
}));
tools.push(tool({
name: "comfyui_status",
description: text`Check whether ComfyUI is reachable and return system stats/object info if available. Requires ALLOW_LOCAL_URLS=true for local ComfyUI URLs.`,
parameters: {},
implementation: async () => {
const base = (settingString("comfyuiUrl", "COMFYUI_URL", "http://127.0.0.1:8188") || "http://127.0.0.1:8188").replace(/\/$/, ""); await assertSafeUrl(base + "/system_stats");
const res = await fetch(base + "/system_stats"); const stats = await res.json();
return { comfyui_url: base, stats };
},
}));
tools.push(tool({
name: "submit_comfyui_prompt",
description: text`Submit a ComfyUI API-format workflow JSON object to ComfyUI's /prompt endpoint. Use only when the user explicitly asks to generate/queue a ComfyUI workflow. Requires ALLOW_LOCAL_URLS=true.`,
parameters: { prompt: z.record(z.any()).describe("ComfyUI API workflow JSON object."), client_id: z.string().optional() },
implementation: async ({ prompt, client_id }) => {
const base = (settingString("comfyuiUrl", "COMFYUI_URL", "http://127.0.0.1:8188") || "http://127.0.0.1:8188").replace(/\/$/, ""); const url = `${base}/prompt`; await assertSafeUrl(url);
const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt, client_id: client_id ?? "lmstudio-agent-tools" }) });
const body = await res.text(); if (!res.ok) throw new Error(`ComfyUI HTTP ${res.status}: ${body}`);
return JSON.parse(body);
},
}));
tools.push(tool({
name: "comfyui_history",
description: text`Fetch ComfyUI history for a prompt_id, or recent history if no prompt_id is given. Requires ALLOW_LOCAL_URLS=true.`,
parameters: { prompt_id: z.string().optional(), max_chars: z.number().int().min(500).max(50000).optional() },
implementation: async ({ prompt_id, max_chars }) => {
const base = (settingString("comfyuiUrl", "COMFYUI_URL", "http://127.0.0.1:8188") || "http://127.0.0.1:8188").replace(/\/$/, ""); const url = `${base}/history${prompt_id ? `/${encodeURIComponent(prompt_id)}` : ""}`; await assertSafeUrl(url);
const res = await fetch(url); const body = await res.text(); if (!res.ok) throw new Error(`ComfyUI HTTP ${res.status}: ${body}`);
return { url, json: clip(body, max_chars ?? 20_000) };
},
}));
return tools;
}