src / toolsProvider.ts
src / toolsProvider.ts
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { existsSync } from "fs";
import { mkdir, readdir, readFile, stat, writeFile } from "fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "path";
import { z } from "zod";
import { configSchematics } from "./configSchematics";
import {
activeLoopCount,
describeLoop,
listLoops,
loopExists,
MAX_CONCURRENT_LOOPS,
MAX_RUNS_CEILING,
startLoop,
stopLoop,
} from "./loops";
import { formatResult, runCommand } from "./shell";
import { renderTasks, TaskStore, type Task, type TaskStatus } from "./tasks";
import { validateSyntax } from "./validate";
import { agentTools } from "./tools/agent";
import { bulkTools } from "./tools/bulk";
import { coreTools } from "./tools/core";
import { editTools } from "./tools/edit";
import { subagentTools } from "./tools/subagent";
import { gitTools } from "./tools/git";
import { processTools } from "./tools/process";
import { previewTools } from "./tools/preview";
import { projectTools } from "./tools/project";
import { reasonTools } from "./tools/reason";
import { searchTools } from "./tools/search";
import { symbolTools } from "./tools/symbols";
import { webTools } from "./tools/web";
import {
clamp,
createWorkspace,
firstText,
formatBytes,
IGNORED_DIRS,
resolveInRoot,
} from "./workspace";
const TASK_STATUSES = ["pending", "in_progress", "done", "blocked"] as const;
/**
* The tools a coding model actually reaches for, in rough order of use. With
* every capability enabled the full set runs past fifty tools, which is more
* choices than a small local model can pick between reliably; this is the
* shortlist it sees by default. Anything omitted is still reachable by turning
* the focused set off.
*/
const FOCUSED_TOOLS = new Set([
"project_overview",
"list_directory",
"read_file",
"read_many_files",
"file_outline",
"grep",
"glob_files",
"find_definition",
"find_references",
"think",
"set_tasks",
"get_tasks",
"update_task",
"edit_file",
"multi_edit",
"replace_in_files",
"write_file",
"append_file",
"undo_last_edit",
"verify",
"preview_in_browser",
"preview_errors",
"recent_changes",
"run_command",
"git_status",
"git_diff",
"run_subagent",
"recall",
"remember",
"workspace_status",
]);
export async function toolsProvider(ctl: ToolsProviderController) {
const config = ctl.getPluginConfig(configSchematics);
const configuredRoot = config.get("rootDirectory").trim();
const allowWrite = config.get("allowWrite");
const allowShell = config.get("allowShell");
const maxFileSizeKb = config.get("maxFileSizeKb");
const commandTimeoutSec = config.get("commandTimeoutSec");
const maxRetryAttempts = config.get("maxRetryAttempts");
const allowBackgroundLoops = config.get("allowBackgroundLoops");
const minLoopIntervalSec = config.get("minLoopIntervalSec");
// A chat is not always attached to a folder, and asking an unattached
// prediction for its working directory throws. That must not take the whole
// tool list down with it.
let resolvedRoot = configuredRoot;
if (resolvedRoot === "") {
try {
const attached = ctl.getWorkingDirectory();
if (typeof attached === "string" && attached.trim() !== "") {
resolvedRoot = attached.trim();
}
} catch {
// No folder attached to this chat; handled below.
}
}
if (resolvedRoot === "") {
return [
tool({
name: "workspace_status",
description:
"Reports why the workspace tools are unavailable. Call this if you were asked to work " +
"with files but have no file tools.",
parameters: {},
implementation: async () =>
"No workspace folder is set, so the file, search, edit and shell tools are all " +
"unavailable.\n\nTell the user to fix it in one of two ways:\n" +
"1. Open the workspace-tools plugin settings and set 'Workspace root' to an absolute " +
"path, for example C:\\\\Users\\\\Admin\\\\my-project. This is the reliable option.\n" +
"2. Or attach this chat to a folder, which makes the tools follow that folder.\n\n" +
"Until then, answer from your own knowledge and say plainly that you cannot read or " +
"change any files.",
}),
];
}
const ws = createWorkspace({
root: resolvedRoot,
maxFileSizeKb,
commandTimeoutSec,
allowWrite,
allowShell,
allowNetwork: config.get("allowNetwork"),
allowGitWrite: config.get("allowGitWrite"),
});
const { root, maxBytes, runOptions } = ws;
const tools: Tool[] = [];
tools.push(...coreTools(ws));
if (allowWrite) {
tools.push(
tool({
name: "write_file",
description:
"Write text to a file inside the workspace, creating parent directories as needed. " +
"Overwrites the file if it already exists. Keep a single call under roughly 200 lines -- " +
"for anything larger, write the first part here and add the rest with append_file, " +
"because very large single calls often come out truncated or malformed.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
content: z.string().default("").describe("Full text content to write."),
text: z.string().optional().describe("Alias for content."),
},
implementation: async ({ path, file_path, content, text }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.js\".";
content = firstText(content, text);
const filePath = resolveInRoot(root, target);
const existed = existsSync(filePath);
ctx.status(`${existed ? "Overwriting" : "Creating"} ${relative(root, filePath)}`);
if (existed) {
ctx.warn(`Overwriting existing file ${relative(root, filePath)}.`);
}
await mkdir(resolve(filePath, ".."), { recursive: true });
await writeFile(filePath, content, "utf-8");
const complaint = await validateSyntax(ws, filePath);
return (
`${existed ? "Overwrote" : "Created"} ${relative(root, filePath)} (${formatBytes(
Buffer.byteLength(content, "utf-8"),
)}).` + complaint
);
},
}),
);
}
if (allowShell) {
tools.push(
tool({
name: "run_command",
description:
"Run a shell command with the workspace root as the working directory and return its " +
"stdout and stderr. Use for builds, tests, and git status -- not for long-running " +
"servers, which will hit the timeout.",
parameters: {
command: z.string().describe("The command line to execute."),
},
implementation: async ({ command }, ctx) => {
ctx.status(`Running: ${command}`);
ctx.warn(`Executing shell command in ${root}: ${command}`);
const result = await runCommand(command, runOptions);
return formatResult(result, commandTimeoutSec);
},
}),
);
tools.push(
tool({
name: "run_until_success",
description:
"Run a shell command repeatedly until it exits successfully or the attempt limit is " +
"reached. Use this for flaky tests, a build you expect to pass once a dependency " +
"settles, or waiting on a service to come up. If it still fails at the end, the output " +
"of the last attempt is returned so you can fix the cause and call it again.",
parameters: {
command: z.string().describe("The command line to execute on each attempt."),
max_attempts: z
.number()
.int()
.min(1)
.default(3)
.describe("How many times to try before giving up. Capped by the plugin settings."),
delay_sec: z
.number()
.min(0)
.default(2)
.describe("Seconds to wait between attempts."),
},
implementation: async ({ command, max_attempts, delay_sec }, ctx) => {
const attempts = Math.min(max_attempts, maxRetryAttempts);
if (attempts < max_attempts) {
ctx.warn(`Attempt count capped at the configured maximum of ${maxRetryAttempts}.`);
}
ctx.warn(`Executing shell command in ${root}: ${command}`);
let last = "";
for (let attempt = 1; attempt <= attempts; attempt++) {
ctx.status(`Attempt ${attempt}/${attempts}: ${command}`);
const result = await runCommand(command, runOptions);
last = formatResult(result, commandTimeoutSec);
if (result.ok) {
return `Succeeded on attempt ${attempt} of ${attempts}.\n\n${last}`;
}
if (attempt < attempts && delay_sec > 0) {
await new Promise((done) => setTimeout(done, delay_sec * 1000));
}
}
return `Still failing after ${attempts} attempt(s). Last attempt:\n\n${last}`;
},
}),
);
}
if (allowShell && allowBackgroundLoops) {
tools.push(
tool({
name: "start_loop",
description:
"Start a background loop that re-runs a shell command on a timer and keeps running " +
"between messages. Use it to watch something over time -- a test suite, a log tail, a " +
"health check. Results are buffered; read them later with check_loop.",
parameters: {
label: z
.string()
.describe("Short unique name for this loop, used to check or stop it later."),
command: z.string().describe("The command line to run on each tick."),
interval_sec: z
.number()
.min(1)
.describe("Seconds between runs. Raised to the configured minimum if lower."),
max_runs: z
.number()
.int()
.min(1)
.default(20)
.describe("Stop automatically after this many runs."),
},
implementation: async ({ label, command, interval_sec, max_runs }, ctx) => {
if (loopExists(label)) {
return `Error: a loop named "${label}" already exists. Stop it first or pick another label.`;
}
if (activeLoopCount() >= MAX_CONCURRENT_LOOPS) {
return `Error: ${MAX_CONCURRENT_LOOPS} loops are already running. Stop one before starting another.`;
}
const interval = Math.max(interval_sec, minLoopIntervalSec);
if (interval > interval_sec) {
ctx.warn(`Interval raised to the configured minimum of ${minLoopIntervalSec}s.`);
}
const runs = Math.min(max_runs, MAX_RUNS_CEILING);
ctx.status(`Starting loop "${label}" every ${interval}s`);
ctx.warn(`Background loop "${label}" will run in ${root}: ${command}`);
startLoop({
label,
command,
intervalSec: interval,
maxRuns: runs,
runOptions,
timeoutSec: commandTimeoutSec,
});
return (
`Loop "${label}" started: runs "${command}" every ${interval}s, up to ${runs} times. ` +
`The first run has been kicked off now. Use check_loop("${label}") to read results.`
);
},
}),
);
tools.push(
tool({
name: "check_loop",
description:
"Read the status and recent output of a background loop started with start_loop.",
parameters: {
label: z.string().describe("The loop's label."),
runs: z
.number()
.int()
.min(1)
.max(10)
.default(3)
.describe("How many of the most recent runs to include."),
},
implementation: async ({ label, runs }, ctx) => {
ctx.status(`Checking loop "${label}"`);
return describeLoop(label, runs) ?? `Error: no loop named "${label}".`;
},
}),
);
tools.push(
tool({
name: "list_loops",
description: "List every background loop in this session and its current status.",
parameters: {},
implementation: async (_params, ctx) => {
ctx.status("Listing loops");
return listLoops();
},
}),
);
tools.push(
tool({
name: "stop_loop",
description: "Stop a running background loop.",
parameters: {
label: z.string().describe("The loop's label."),
},
implementation: async ({ label }, ctx) => {
ctx.status(`Stopping loop "${label}"`);
return stopLoop(label, "stopped by request")
? `Loop "${label}" stopped.`
: `Error: no running loop named "${label}".`;
},
}),
);
}
const taskStore = new TaskStore(root, allowWrite);
tools.push(
tool({
name: "set_tasks",
description:
"Create or replace the working checklist for this workspace. Use it at the start of a " +
"multi-step job to plan the steps, then work them one at a time with update_task. " +
"Replaces any existing checklist.",
parameters: {
// Plain strings are what we want, but models often wrap each step in an
// object. Accepting both avoids a rejected call over pure formatting.
tasks: z
.array(
z.union([
z.string(),
z.object({
text: z.string().optional(),
task: z.string().optional(),
description: z.string().optional(),
name: z.string().optional(),
}),
]),
)
.min(1)
.describe("The steps, in the order you intend to do them."),
},
implementation: async ({ tasks: items }, ctx) => {
const texts = items
.map((item) =>
typeof item === "string"
? item
: firstText(item.text, item.task, item.description, item.name),
)
.map((text) => text.trim())
.filter((text) => text !== "");
if (texts.length === 0) {
return "Error: no readable steps. Pass tasks as a list of short strings, e.g. [\"Read the parser\", \"Add the guard\"].";
}
ctx.status(`Writing ${texts.length} task(s)`);
const tasks: Task[] = texts.map((text) => ({ text, status: "pending", note: "" }));
await taskStore.save(tasks);
return `Checklist saved to ${taskStore.storageDescription}:\n\n${renderTasks(tasks)}`;
},
}),
);
tools.push(
tool({
name: "get_tasks",
description:
"Read the current checklist for this workspace, including which steps are done. Call " +
"this when resuming work to see where you left off.",
parameters: {},
implementation: async (_params, ctx) => {
ctx.status("Reading checklist");
return renderTasks(await taskStore.load());
},
}),
);
tools.push(
tool({
name: "update_task",
description:
"Change the status of one checklist item. Mark a step in_progress before starting it and " +
"done when it is finished, so the checklist reflects reality across messages.",
parameters: {
// Every field is optional. A model that emits only a status still gets a
// useful result, which matters because a rejected call costs a whole turn
// and small models mangle multi-parameter calls.
// No .min() here: the sentinel default (0) would fail its own rule and
// reject every call that omitted the number. Range is checked below.
task_number: z
.number()
.int()
.default(0)
.describe("1-based position in the checklist. Omit to update the step you are on."),
task_id: z.number().int().optional().describe("Alias for task_number."),
index: z.number().int().optional().describe("Alias for task_number."),
number: z.number().int().optional().describe("Alias for task_number."),
status: z
.enum(TASK_STATUSES)
.describe("New status: pending, in_progress, done, or blocked."),
note: z
.string()
.default("")
.describe("Optional short note, e.g. why it is blocked or what changed."),
},
implementation: async ({ task_number, task_id, index, number, status, note }, ctx) => {
const tasks = await taskStore.load();
if (tasks.length === 0) {
return "Error: there is no checklist yet. Use set_tasks first.";
}
const given = [task_number, task_id, index, number].find(
(value): value is number => typeof value === "number" && value >= 1,
);
// With no usable number, act on the step in progress, else the first one
// not yet done -- which is what "mark it done" almost always means.
const position =
given ??
(tasks.findIndex((task) => task.status === "in_progress") + 1 ||
tasks.findIndex((task) => task.status !== "done") + 1);
if (position < 1) {
return "Every step is already done. Use set_tasks to start a new checklist.";
}
if (position > tasks.length) {
return `Error: task ${position} does not exist. The checklist has ${tasks.length} item(s).`;
}
const target = tasks[position - 1];
target.status = status as TaskStatus;
if (note.trim() !== "") target.note = note.trim();
ctx.status(`Task ${position} -> ${status}`);
await taskStore.save(tasks);
const inferred = given === undefined ? ` (assumed step ${position}: "${target.text}")` : "";
return `${renderTasks(tasks)}${inferred}`;
},
}),
);
// Capability-gated modules. Each factory returns an empty array when its
// toggle is off, so a disabled capability is genuinely absent from the
// model's tool list rather than merely discouraged.
tools.push(
...projectTools(ws),
...searchTools(ws),
...editTools(ws),
...agentTools(ws),
...gitTools(ws),
...processTools(ws),
...webTools(ws),
);
tools.push(...symbolTools(ws), ...reasonTools(ws), ...bulkTools(ws), ...previewTools(ws));
if (config.get("allowSubagents")) {
tools.push(
...subagentTools(ws, {
client: ctl.client,
abortSignal: ctl.abortSignal,
maxSteps: config.get("subagentMaxSteps"),
modelKey: config.get("subagentModel"),
}),
);
}
if (config.get("focusedToolset")) {
return tools.filter((entry) => FOCUSED_TOOLS.has(entry.name));
}
return tools;
}
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { existsSync } from "fs";
import { mkdir, readdir, readFile, stat, writeFile } from "fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "path";
import { z } from "zod";
import { configSchematics } from "./configSchematics";
import {
activeLoopCount,
describeLoop,
listLoops,
loopExists,
MAX_CONCURRENT_LOOPS,
MAX_RUNS_CEILING,
startLoop,
stopLoop,
} from "./loops";
import { formatResult, runCommand } from "./shell";
import { renderTasks, TaskStore, type Task, type TaskStatus } from "./tasks";
import { validateSyntax } from "./validate";
import { agentTools } from "./tools/agent";
import { bulkTools } from "./tools/bulk";
import { coreTools } from "./tools/core";
import { editTools } from "./tools/edit";
import { subagentTools } from "./tools/subagent";
import { gitTools } from "./tools/git";
import { processTools } from "./tools/process";
import { previewTools } from "./tools/preview";
import { projectTools } from "./tools/project";
import { reasonTools } from "./tools/reason";
import { searchTools } from "./tools/search";
import { symbolTools } from "./tools/symbols";
import { webTools } from "./tools/web";
import {
clamp,
createWorkspace,
firstText,
formatBytes,
IGNORED_DIRS,
resolveInRoot,
} from "./workspace";
const TASK_STATUSES = ["pending", "in_progress", "done", "blocked"] as const;
/**
* The tools a coding model actually reaches for, in rough order of use. With
* every capability enabled the full set runs past fifty tools, which is more
* choices than a small local model can pick between reliably; this is the
* shortlist it sees by default. Anything omitted is still reachable by turning
* the focused set off.
*/
const FOCUSED_TOOLS = new Set([
"project_overview",
"list_directory",
"read_file",
"read_many_files",
"file_outline",
"grep",
"glob_files",
"find_definition",
"find_references",
"think",
"set_tasks",
"get_tasks",
"update_task",
"edit_file",
"multi_edit",
"replace_in_files",
"write_file",
"append_file",
"undo_last_edit",
"verify",
"preview_in_browser",
"preview_errors",
"recent_changes",
"run_command",
"git_status",
"git_diff",
"run_subagent",
"recall",
"remember",
"workspace_status",
]);
export async function toolsProvider(ctl: ToolsProviderController) {
const config = ctl.getPluginConfig(configSchematics);
const configuredRoot = config.get("rootDirectory").trim();
const allowWrite = config.get("allowWrite");
const allowShell = config.get("allowShell");
const maxFileSizeKb = config.get("maxFileSizeKb");
const commandTimeoutSec = config.get("commandTimeoutSec");
const maxRetryAttempts = config.get("maxRetryAttempts");
const allowBackgroundLoops = config.get("allowBackgroundLoops");
const minLoopIntervalSec = config.get("minLoopIntervalSec");
// A chat is not always attached to a folder, and asking an unattached
// prediction for its working directory throws. That must not take the whole
// tool list down with it.
let resolvedRoot = configuredRoot;
if (resolvedRoot === "") {
try {
const attached = ctl.getWorkingDirectory();
if (typeof attached === "string" && attached.trim() !== "") {
resolvedRoot = attached.trim();
}
} catch {
// No folder attached to this chat; handled below.
}
}
if (resolvedRoot === "") {
return [
tool({
name: "workspace_status",
description:
"Reports why the workspace tools are unavailable. Call this if you were asked to work " +
"with files but have no file tools.",
parameters: {},
implementation: async () =>
"No workspace folder is set, so the file, search, edit and shell tools are all " +
"unavailable.\n\nTell the user to fix it in one of two ways:\n" +
"1. Open the workspace-tools plugin settings and set 'Workspace root' to an absolute " +
"path, for example C:\\\\Users\\\\Admin\\\\my-project. This is the reliable option.\n" +
"2. Or attach this chat to a folder, which makes the tools follow that folder.\n\n" +
"Until then, answer from your own knowledge and say plainly that you cannot read or " +
"change any files.",
}),
];
}
const ws = createWorkspace({
root: resolvedRoot,
maxFileSizeKb,
commandTimeoutSec,
allowWrite,
allowShell,
allowNetwork: config.get("allowNetwork"),
allowGitWrite: config.get("allowGitWrite"),
});
const { root, maxBytes, runOptions } = ws;
const tools: Tool[] = [];
tools.push(...coreTools(ws));
if (allowWrite) {
tools.push(
tool({
name: "write_file",
description:
"Write text to a file inside the workspace, creating parent directories as needed. " +
"Overwrites the file if it already exists. Keep a single call under roughly 200 lines -- " +
"for anything larger, write the first part here and add the rest with append_file, " +
"because very large single calls often come out truncated or malformed.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
content: z.string().default("").describe("Full text content to write."),
text: z.string().optional().describe("Alias for content."),
},
implementation: async ({ path, file_path, content, text }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.js\".";
content = firstText(content, text);
const filePath = resolveInRoot(root, target);
const existed = existsSync(filePath);
ctx.status(`${existed ? "Overwriting" : "Creating"} ${relative(root, filePath)}`);
if (existed) {
ctx.warn(`Overwriting existing file ${relative(root, filePath)}.`);
}
await mkdir(resolve(filePath, ".."), { recursive: true });
await writeFile(filePath, content, "utf-8");
const complaint = await validateSyntax(ws, filePath);
return (
`${existed ? "Overwrote" : "Created"} ${relative(root, filePath)} (${formatBytes(
Buffer.byteLength(content, "utf-8"),
)}).` + complaint
);
},
}),
);
}
if (allowShell) {
tools.push(
tool({
name: "run_command",
description:
"Run a shell command with the workspace root as the working directory and return its " +
"stdout and stderr. Use for builds, tests, and git status -- not for long-running " +
"servers, which will hit the timeout.",
parameters: {
command: z.string().describe("The command line to execute."),
},
implementation: async ({ command }, ctx) => {
ctx.status(`Running: ${command}`);
ctx.warn(`Executing shell command in ${root}: ${command}`);
const result = await runCommand(command, runOptions);
return formatResult(result, commandTimeoutSec);
},
}),
);
tools.push(
tool({
name: "run_until_success",
description:
"Run a shell command repeatedly until it exits successfully or the attempt limit is " +
"reached. Use this for flaky tests, a build you expect to pass once a dependency " +
"settles, or waiting on a service to come up. If it still fails at the end, the output " +
"of the last attempt is returned so you can fix the cause and call it again.",
parameters: {
command: z.string().describe("The command line to execute on each attempt."),
max_attempts: z
.number()
.int()
.min(1)
.default(3)
.describe("How many times to try before giving up. Capped by the plugin settings."),
delay_sec: z
.number()
.min(0)
.default(2)
.describe("Seconds to wait between attempts."),
},
implementation: async ({ command, max_attempts, delay_sec }, ctx) => {
const attempts = Math.min(max_attempts, maxRetryAttempts);
if (attempts < max_attempts) {
ctx.warn(`Attempt count capped at the configured maximum of ${maxRetryAttempts}.`);
}
ctx.warn(`Executing shell command in ${root}: ${command}`);
let last = "";
for (let attempt = 1; attempt <= attempts; attempt++) {
ctx.status(`Attempt ${attempt}/${attempts}: ${command}`);
const result = await runCommand(command, runOptions);
last = formatResult(result, commandTimeoutSec);
if (result.ok) {
return `Succeeded on attempt ${attempt} of ${attempts}.\n\n${last}`;
}
if (attempt < attempts && delay_sec > 0) {
await new Promise((done) => setTimeout(done, delay_sec * 1000));
}
}
return `Still failing after ${attempts} attempt(s). Last attempt:\n\n${last}`;
},
}),
);
}
if (allowShell && allowBackgroundLoops) {
tools.push(
tool({
name: "start_loop",
description:
"Start a background loop that re-runs a shell command on a timer and keeps running " +
"between messages. Use it to watch something over time -- a test suite, a log tail, a " +
"health check. Results are buffered; read them later with check_loop.",
parameters: {
label: z
.string()
.describe("Short unique name for this loop, used to check or stop it later."),
command: z.string().describe("The command line to run on each tick."),
interval_sec: z
.number()
.min(1)
.describe("Seconds between runs. Raised to the configured minimum if lower."),
max_runs: z
.number()
.int()
.min(1)
.default(20)
.describe("Stop automatically after this many runs."),
},
implementation: async ({ label, command, interval_sec, max_runs }, ctx) => {
if (loopExists(label)) {
return `Error: a loop named "${label}" already exists. Stop it first or pick another label.`;
}
if (activeLoopCount() >= MAX_CONCURRENT_LOOPS) {
return `Error: ${MAX_CONCURRENT_LOOPS} loops are already running. Stop one before starting another.`;
}
const interval = Math.max(interval_sec, minLoopIntervalSec);
if (interval > interval_sec) {
ctx.warn(`Interval raised to the configured minimum of ${minLoopIntervalSec}s.`);
}
const runs = Math.min(max_runs, MAX_RUNS_CEILING);
ctx.status(`Starting loop "${label}" every ${interval}s`);
ctx.warn(`Background loop "${label}" will run in ${root}: ${command}`);
startLoop({
label,
command,
intervalSec: interval,
maxRuns: runs,
runOptions,
timeoutSec: commandTimeoutSec,
});
return (
`Loop "${label}" started: runs "${command}" every ${interval}s, up to ${runs} times. ` +
`The first run has been kicked off now. Use check_loop("${label}") to read results.`
);
},
}),
);
tools.push(
tool({
name: "check_loop",
description:
"Read the status and recent output of a background loop started with start_loop.",
parameters: {
label: z.string().describe("The loop's label."),
runs: z
.number()
.int()
.min(1)
.max(10)
.default(3)
.describe("How many of the most recent runs to include."),
},
implementation: async ({ label, runs }, ctx) => {
ctx.status(`Checking loop "${label}"`);
return describeLoop(label, runs) ?? `Error: no loop named "${label}".`;
},
}),
);
tools.push(
tool({
name: "list_loops",
description: "List every background loop in this session and its current status.",
parameters: {},
implementation: async (_params, ctx) => {
ctx.status("Listing loops");
return listLoops();
},
}),
);
tools.push(
tool({
name: "stop_loop",
description: "Stop a running background loop.",
parameters: {
label: z.string().describe("The loop's label."),
},
implementation: async ({ label }, ctx) => {
ctx.status(`Stopping loop "${label}"`);
return stopLoop(label, "stopped by request")
? `Loop "${label}" stopped.`
: `Error: no running loop named "${label}".`;
},
}),
);
}
const taskStore = new TaskStore(root, allowWrite);
tools.push(
tool({
name: "set_tasks",
description:
"Create or replace the working checklist for this workspace. Use it at the start of a " +
"multi-step job to plan the steps, then work them one at a time with update_task. " +
"Replaces any existing checklist.",
parameters: {
// Plain strings are what we want, but models often wrap each step in an
// object. Accepting both avoids a rejected call over pure formatting.
tasks: z
.array(
z.union([
z.string(),
z.object({
text: z.string().optional(),
task: z.string().optional(),
description: z.string().optional(),
name: z.string().optional(),
}),
]),
)
.min(1)
.describe("The steps, in the order you intend to do them."),
},
implementation: async ({ tasks: items }, ctx) => {
const texts = items
.map((item) =>
typeof item === "string"
? item
: firstText(item.text, item.task, item.description, item.name),
)
.map((text) => text.trim())
.filter((text) => text !== "");
if (texts.length === 0) {
return "Error: no readable steps. Pass tasks as a list of short strings, e.g. [\"Read the parser\", \"Add the guard\"].";
}
ctx.status(`Writing ${texts.length} task(s)`);
const tasks: Task[] = texts.map((text) => ({ text, status: "pending", note: "" }));
await taskStore.save(tasks);
return `Checklist saved to ${taskStore.storageDescription}:\n\n${renderTasks(tasks)}`;
},
}),
);
tools.push(
tool({
name: "get_tasks",
description:
"Read the current checklist for this workspace, including which steps are done. Call " +
"this when resuming work to see where you left off.",
parameters: {},
implementation: async (_params, ctx) => {
ctx.status("Reading checklist");
return renderTasks(await taskStore.load());
},
}),
);
tools.push(
tool({
name: "update_task",
description:
"Change the status of one checklist item. Mark a step in_progress before starting it and " +
"done when it is finished, so the checklist reflects reality across messages.",
parameters: {
// Every field is optional. A model that emits only a status still gets a
// useful result, which matters because a rejected call costs a whole turn
// and small models mangle multi-parameter calls.
// No .min() here: the sentinel default (0) would fail its own rule and
// reject every call that omitted the number. Range is checked below.
task_number: z
.number()
.int()
.default(0)
.describe("1-based position in the checklist. Omit to update the step you are on."),
task_id: z.number().int().optional().describe("Alias for task_number."),
index: z.number().int().optional().describe("Alias for task_number."),
number: z.number().int().optional().describe("Alias for task_number."),
status: z
.enum(TASK_STATUSES)
.describe("New status: pending, in_progress, done, or blocked."),
note: z
.string()
.default("")
.describe("Optional short note, e.g. why it is blocked or what changed."),
},
implementation: async ({ task_number, task_id, index, number, status, note }, ctx) => {
const tasks = await taskStore.load();
if (tasks.length === 0) {
return "Error: there is no checklist yet. Use set_tasks first.";
}
const given = [task_number, task_id, index, number].find(
(value): value is number => typeof value === "number" && value >= 1,
);
// With no usable number, act on the step in progress, else the first one
// not yet done -- which is what "mark it done" almost always means.
const position =
given ??
(tasks.findIndex((task) => task.status === "in_progress") + 1 ||
tasks.findIndex((task) => task.status !== "done") + 1);
if (position < 1) {
return "Every step is already done. Use set_tasks to start a new checklist.";
}
if (position > tasks.length) {
return `Error: task ${position} does not exist. The checklist has ${tasks.length} item(s).`;
}
const target = tasks[position - 1];
target.status = status as TaskStatus;
if (note.trim() !== "") target.note = note.trim();
ctx.status(`Task ${position} -> ${status}`);
await taskStore.save(tasks);
const inferred = given === undefined ? ` (assumed step ${position}: "${target.text}")` : "";
return `${renderTasks(tasks)}${inferred}`;
},
}),
);
// Capability-gated modules. Each factory returns an empty array when its
// toggle is off, so a disabled capability is genuinely absent from the
// model's tool list rather than merely discouraged.
tools.push(
...projectTools(ws),
...searchTools(ws),
...editTools(ws),
...agentTools(ws),
...gitTools(ws),
...processTools(ws),
...webTools(ws),
);
tools.push(...symbolTools(ws), ...reasonTools(ws), ...bulkTools(ws), ...previewTools(ws));
if (config.get("allowSubagents")) {
tools.push(
...subagentTools(ws, {
client: ctl.client,
abortSignal: ctl.abortSignal,
maxSteps: config.get("subagentMaxSteps"),
modelKey: config.get("subagentModel"),
}),
);
}
if (config.get("focusedToolset")) {
return tools.filter((entry) => FOCUSED_TOOLS.has(entry.name));
}
return tools;
}