summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/tools
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 21:10:09 +0900
committerAdam Malczewski <[email protected]>2026-06-02 21:10:09 +0900
commitd9f53727845dface3e6d8a84ba2270b1de55482b (patch)
tree6d42f0a0fbda15057296992e78c4b4e12046f9ed /packages/core/src/tools
parent80212bfb009eaf71a4743310dee6ed08b8f7e1da (diff)
parent9d8cf7005ba4c0bb8ade0775f54c2557aa1c5683 (diff)
downloaddispatch-d9f53727845dface3e6d8a84ba2270b1de55482b.tar.gz
dispatch-d9f53727845dface3e6d8a84ba2270b1de55482b.zip
Merge branch 'dev' into feat/cs-code-search-tool
# Conflicts: # packages/api/src/agent-manager.ts # packages/api/tests/agent-manager.test.ts # packages/frontend/src/lib/components/ToolPermissions.svelte # packages/frontend/src/lib/settings.svelte.ts
Diffstat (limited to 'packages/core/src/tools')
-rw-r--r--packages/core/src/tools/lsp.ts135
-rw-r--r--packages/core/src/tools/send-to-tab.ts61
-rw-r--r--packages/core/src/tools/summon.ts163
-rw-r--r--packages/core/src/tools/write-file.ts30
4 files changed, 334 insertions, 55 deletions
diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts
new file mode 100644
index 0000000..3842cd4
--- /dev/null
+++ b/packages/core/src/tools/lsp.ts
@@ -0,0 +1,135 @@
+import { isAbsolute, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { z } from "zod";
+import { report as reportDiagnostics } from "../lsp/diagnostic.js";
+import type { LspManager } from "../lsp/manager.js";
+import type { ResolvedLspServer } from "../lsp/server.js";
+import type { ToolDefinition } from "../types/index.js";
+
+const OPERATIONS = ["diagnostics", "hover", "definition", "references", "documentSymbol"] as const;
+type Operation = (typeof OPERATIONS)[number];
+
+/**
+ * Context the LSP tool needs from the host: the live manager, the tab's
+ * effective working directory (used as the LSP `root`), and the servers
+ * resolved from that directory's `dispatch.toml`.
+ */
+export interface LspToolContext {
+ manager: LspManager;
+ workingDirectory: string;
+ servers: ResolvedLspServer[];
+}
+
+/**
+ * On-demand LSP query tool. Exposes diagnostics plus the navigation
+ * capabilities (hover/definition/references/documentSymbol) for a file at a
+ * position. Gated behind `perm_lsp` by the host.
+ *
+ * Coordinates are **1-based** in this tool's API (editor-style, matching what
+ * `read_file` shows); they are converted to the LSP wire's 0-based positions
+ * before the request.
+ */
+export function createLspTool(getContext: () => LspToolContext): ToolDefinition {
+ return {
+ name: "lsp",
+ description:
+ "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file. " +
+ "Operations: 'diagnostics' (type/lint errors for a file), 'hover' (type/docs at a position), " +
+ "'definition' (where a symbol is defined), 'references' (all uses of a symbol), " +
+ "'documentSymbol' (outline of a file). Line and character are 1-based (as shown in editors). " +
+ "Returns JSON. Requires an [lsp] server configured in dispatch.toml that matches the file's extension.",
+ parameters: z.object({
+ operation: z.enum(OPERATIONS).describe("The LSP operation to perform"),
+ path: z.string().describe("Path to the file, relative to the working directory"),
+ line: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe(
+ "Line number, 1-based (as shown in editors). Required for hover/definition/references.",
+ ),
+ character: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe(
+ "Character/column, 1-based (as shown in editors). Required for hover/definition/references.",
+ ),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const { manager, workingDirectory, servers } = getContext();
+ const operation = args.operation as Operation;
+ const pathArg = typeof args.path === "string" ? args.path : "";
+ if (!pathArg) return "Error: 'path' is required.";
+
+ const file = isAbsolute(pathArg) ? pathArg : resolve(workingDirectory, pathArg);
+
+ if (servers.length === 0) {
+ return "Error: no LSP servers are configured. Add an [lsp] entry to dispatch.toml.";
+ }
+ if (!manager.hasServerForFile(file, servers)) {
+ return `Error: no configured LSP server matches "${pathArg}" (check the server's extensions in dispatch.toml).`;
+ }
+
+ // Sync the file so the server has current content, then act.
+ await manager.touchFile({ file, root: workingDirectory, servers, mode: "document" });
+
+ if (operation === "diagnostics") {
+ const all = manager.getDiagnostics({ root: workingDirectory, servers, file });
+ const block = reportDiagnostics(file, all[file] ?? []);
+ return block || `No errors reported for ${pathArg}.`;
+ }
+
+ if (operation === "documentSymbol") {
+ const uri = pathToFileURL(file).href;
+ const results = await manager.request({
+ file,
+ root: workingDirectory,
+ servers,
+ method: "textDocument/documentSymbol",
+ params: { textDocument: { uri } },
+ });
+ const flat = results.flat().filter(Boolean);
+ return flat.length === 0
+ ? `No symbols found in ${pathArg}.`
+ : JSON.stringify(flat, null, 2);
+ }
+
+ // Positional operations need line + character.
+ const line = typeof args.line === "number" ? Math.floor(args.line) : undefined;
+ const character = typeof args.character === "number" ? Math.floor(args.character) : undefined;
+ if (line === undefined || character === undefined) {
+ return `Error: '${operation}' requires both 'line' and 'character' (1-based).`;
+ }
+
+ const uri = pathToFileURL(file).href;
+ // Convert editor 1-based → LSP wire 0-based.
+ const position = { line: line - 1, character: character - 1 };
+ const method =
+ operation === "hover"
+ ? "textDocument/hover"
+ : operation === "definition"
+ ? "textDocument/definition"
+ : "textDocument/references";
+ const params: Record<string, unknown> = {
+ textDocument: { uri },
+ position,
+ ...(operation === "references" ? { context: { includeDeclaration: true } } : {}),
+ };
+
+ const results = await manager.request({
+ file,
+ root: workingDirectory,
+ servers,
+ method,
+ params,
+ });
+ const flat = results.flat().filter(Boolean);
+ return flat.length === 0
+ ? `No results found for ${operation} at ${pathArg}:${line}:${character}.`
+ : JSON.stringify(flat, null, 2);
+ },
+ };
+}
diff --git a/packages/core/src/tools/send-to-tab.ts b/packages/core/src/tools/send-to-tab.ts
index eb86b7e..eae6bfa 100644
--- a/packages/core/src/tools/send-to-tab.ts
+++ b/packages/core/src/tools/send-to-tab.ts
@@ -44,6 +44,13 @@ export interface SendToTabCallbacks {
/** The calling tab's own id + handle — used to block self-sends and to
* stamp provenance onto the delivered message. */
self: { id: string; handle: string };
+ /**
+ * Whether THIS calling tab also has the `read_tab` tool granted. The
+ * tab-messaging permissions are split, so a tab can hold `send_to_tab`
+ * without `read_tab`. When false, the tool must NOT tell the agent to use
+ * `read_tab` (it doesn't have it) — replies only arrive on their own.
+ */
+ canReadTab: boolean;
}
/** Render the "available tabs" hint shared by the none/ambiguous branches. */
@@ -54,6 +61,19 @@ function renderOpenHandles(handles: Array<{ handle: string; title: string }>): s
}
export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefinition {
+ // The `read_tab` follow-up hint is only truthful when this tab actually
+ // holds the `read_tab` tool (the permissions are split). When it doesn't,
+ // the only honest guidance is that a reply will wake it as a new message — never tell
+ // the agent to call a tool it wasn't granted.
+ const waitLine = callbacks.canReadTab
+ ? "money. If the target replies it will WAKE you with a new message in a later turn; you"
+ : "money. If the target replies it will WAKE you with a new message in a later turn.";
+ const readTabLine = callbacks.canReadTab
+ ? ["can also call 'read_tab' with the same ID in a FUTURE turn to check. If you have other"]
+ : [];
+ const keepGoingLine = callbacks.canReadTab
+ ? "work to do, keep going; if you are ONLY waiting for the reply, end your turn now."
+ : "If you have other work to do, keep going; if you are ONLY waiting for the reply, end your turn now.";
return {
name: "send_to_tab",
description: [
@@ -64,9 +84,14 @@ export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefiniti
" - If the target tab is idle, your message WAKES it and starts a new turn.",
"",
"This is fire-and-forget: it returns immediately and does NOT wait for a reply.",
- "Use the 'read_tab' tool with the same ID later to read the target's latest response.",
+ "Do NOT sleep, poll, or run shell commands to wait for a reply — that wastes turns and",
+ waitLine,
+ ...readTabLine,
+ keepGoingLine,
"",
- "Your tab ID is auto-added to the top of the message so the recipient can reply to you.",
+ "Your tab ID is auto-added to the top of the message so the recipient knows who to reply",
+ "to. The recipient must use this same 'send_to_tab' tool (addressed to your ID) to answer;",
+ "a plain text response reaches only their own user, not you.",
"IDs are git-style prefixes: pass any length that uniquely identifies the target (min 4 chars).",
"If the ID is ambiguous you'll be asked to add a character.",
].join("\n"),
@@ -117,8 +142,18 @@ export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefiniti
}
// Stamp provenance so the recipient (and the watching user) can see
- // which tab the message came from and reply back via its handle.
- const delivered = `[message from tab ${callbacks.self.handle}]\n\n${message}`;
+ // which tab the message came from and how to reply. The header makes
+ // clear this is a PEER AGENT, not the recipient's own user, and the
+ // footer states the reply contract: a reply (only if warranted) must
+ // go back through `send_to_tab`, since a plain text answer reaches
+ // only the recipient's own user — not this sender.
+ const delivered = [
+ `[message from tab ${callbacks.self.handle} — this is another agent, NOT your user]`,
+ "",
+ message,
+ "",
+ `[To reply to tab ${callbacks.self.handle}, use the send_to_tab tool with tab_id "${callbacks.self.handle}". ONLY reply if this message asks you to, or your user tells you to — it may just be context or instructions. A plain text response goes to your own user, not to this agent.]`,
+ ].join("\n");
try {
const result = await callbacks.deliver(target.id, delivered);
@@ -138,7 +173,23 @@ export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefiniti
result.status === "queued"
? "queued (target is busy; it will be picked up next turn)"
: "delivered (target was idle; a new turn has started)";
- return `Message ${verb}. Target tab: ${target.handle} (${target.title}). Use read_tab with "${target.handle}" to read its reply later.`;
+ const tail = callbacks.canReadTab
+ ? [
+ "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it",
+ `will WAKE you with a new message later; you can also call read_tab with "${target.handle}"`,
+ "in a FUTURE turn to check. Keep working if you have other tasks; if you are ONLY",
+ "waiting for this reply, end your turn now.",
+ ]
+ : [
+ "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it",
+ "will WAKE you with a new message later. Keep working if you have other tasks; if",
+ "you are ONLY waiting for this reply, end your turn now.",
+ ];
+ return [
+ `Message ${verb}. Target tab: ${target.handle} (${target.title}).`,
+ "",
+ ...tail,
+ ].join("\n");
} catch (err) {
return `Error delivering message: ${err instanceof Error ? err.message : String(err)}`;
}
diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts
index 250af25..b941152 100644
--- a/packages/core/src/tools/summon.ts
+++ b/packages/core/src/tools/summon.ts
@@ -60,10 +60,13 @@ function renderAgentGroup(label: string, agents: AvailableAgent[]): string[] {
* the disk locations where they live, injected into the summon tool's
* description.
*
- * When `userAgentEnabled` is false only subagents are shown (under the
- * generic "Available agents" heading). When it is true, subagents and
- * user agents are listed as two labelled groups so the LLM understands
- * which slugs require `top_level=true`.
+ * `subagentEnabled` and `userAgentEnabled` independently control which
+ * groups are shown — they mirror the `perm_summon` and `perm_user_agent`
+ * permissions respectively:
+ * - subagents only → generic "Available agents" heading;
+ * - user agents only → a single user-agent group (top_level is implied);
+ * - both → two labelled groups so the LLM understands which slugs
+ * require `top_level=true`.
*
* Returns a compact "no agents defined" notice when nothing is visible.
*/
@@ -72,6 +75,7 @@ function buildAgentsCatalog(
userAgents: AvailableAgent[],
agentDirs: string[],
userAgentEnabled: boolean,
+ subagentEnabled: boolean,
): string {
const lines: string[] = [];
lines.push("");
@@ -80,8 +84,9 @@ function buildAgentsCatalog(
lines.push(` - ${d}`);
}
+ const visibleSubagents = subagentEnabled ? subagents : [];
const visibleUserAgents = userAgentEnabled ? userAgents : [];
- if (subagents.length === 0 && visibleUserAgents.length === 0) {
+ if (visibleSubagents.length === 0 && visibleUserAgents.length === 0) {
lines.push("");
lines.push("No agent definitions are currently defined.");
return lines.join("\n");
@@ -93,12 +98,26 @@ function buildAgentsCatalog(
lines.push("and working directory; the 'tools' parameter is ignored.");
lines.push("");
+ // User-agent-only mode: list just the user agents. top_level is implied
+ // (it is the only thing this grant can spawn), so the heading omits it.
+ if (!subagentEnabled && userAgentEnabled) {
+ lines.push(
+ ...renderAgentGroup(
+ "User agents (spawned as independent top-level tabs):",
+ visibleUserAgents,
+ ),
+ );
+ return lines.join("\n");
+ }
+
+ // Subagent-only mode: single generic heading.
if (!userAgentEnabled) {
- lines.push(...renderAgentGroup("Available agents:", subagents));
+ lines.push(...renderAgentGroup("Available agents:", visibleSubagents));
return lines.join("\n");
}
- const subagentLines = renderAgentGroup("Subagents (spawned as child tabs):", subagents);
+ // Both enabled: two labelled groups.
+ const subagentLines = renderAgentGroup("Subagents (spawned as child tabs):", visibleSubagents);
const userAgentLines = renderAgentGroup(
"User agents (spawned as independent top-level tabs, requires top_level=true):",
visibleUserAgents,
@@ -122,9 +141,14 @@ function buildAgentsCatalog(
* its description; this is information-only — the runtime resolves
* slugs through `loadAgent` independently.
*
- * `userAgentEnabled` controls whether the `top_level` parameter and the
- * user-agent catalog are surfaced to the LLM. It mirrors the
- * `perm_user_agent` permission.
+ * `userAgentEnabled` mirrors the `perm_user_agent` permission and
+ * `subagentEnabled` mirrors the `perm_summon` permission. They are
+ * independent: the tool is registered whenever at least one is granted.
+ * - subagentEnabled only → spawn ordinary subagents (no `top_level`);
+ * - userAgentEnabled only → spawn ONLY top-level user agents
+ * (`top_level` is forced on, the `background` knob is dropped, and
+ * the catalog lists user agents only);
+ * - both → full behavior (subagents plus `top_level` user agents).
*/
export function createSummonTool(
_defaultWorkingDirectory: string,
@@ -133,39 +157,29 @@ export function createSummonTool(
availableUserAgents: AvailableAgent[] = [],
agentDirs: string[] = [],
userAgentEnabled = false,
+ subagentEnabled = true,
): ToolDefinition {
+ // When only the user-agent permission is granted the tool spawns user
+ // agents exclusively: `top_level` is implied (and forced), subagent
+ // mechanics (background, retrieve, parallel work) are irrelevant.
+ const userAgentOnly = userAgentEnabled && !subagentEnabled;
+
const catalog = buildAgentsCatalog(
availableSubagents,
availableUserAgents,
agentDirs,
userAgentEnabled,
+ subagentEnabled,
);
const subagentSlugs = availableSubagents.map((a) => a.slug);
const userAgentSlugs = availableUserAgents.map((a) => a.slug);
- const allSlugs = userAgentEnabled ? [...subagentSlugs, ...userAgentSlugs] : subagentSlugs;
+ const allSlugs = userAgentOnly
+ ? userAgentSlugs
+ : userAgentEnabled
+ ? [...subagentSlugs, ...userAgentSlugs]
+ : subagentSlugs;
- const description = [
- "Spawn a new child agent to work on a task independently.",
- "",
- "By default, blocks until the child agent finishes and returns the result directly.",
- "Set background=true to return immediately with an agent_id instead — use retrieve to collect the result later.",
- "",
- "The child agent runs in its own tab visible to the user. Use the 'retrieve' tool with the returned agent_id to get the result when needed.",
- "",
- "Pattern for parallel work:",
- " 1. Call summon multiple times with background=true to start several agents",
- " 2. Do your own work or wait",
- " 3. Call retrieve for each agent_id to collect results",
- ...(userAgentEnabled
- ? [
- "",
- "Set top_level=true to spawn an independent user agent — a first-class",
- "top-level tab with no parent. User agents are fire-and-forget: you get",
- "an agent_id back but cannot retrieve their result. top_level requires an",
- "'agent' definition listed under 'User agents' below.",
- ]
- : []),
- "",
+ const toolNamesList = [
"The 'tools' parameter controls what the child can do. Available tool names:",
" - read_file: Read file contents",
" - read_file_slice: Read a character-range slice of a single line",
@@ -180,11 +194,50 @@ export function createSummonTool(
" - youtube_transcribe: Fetch YouTube video transcripts",
" - send_to_tab: Send a message to another tab/agent by its ID",
" - read_tab: Read another tab/agent's latest response by its ID",
- "",
- "The 'agent' parameter is required — every spawned agent must use a definition.",
- "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).",
- catalog,
- ].join("\n");
+ ];
+
+ const description = userAgentOnly
+ ? [
+ "Spawn an independent top-level user agent to work on a task.",
+ "",
+ "User agents are first-class top-level tabs with no parent. They are",
+ "fire-and-forget: you get an agent_id back but cannot retrieve their result.",
+ "The user agent runs in its own tab visible to the user.",
+ "",
+ ...toolNamesList,
+ "",
+ "The 'agent' parameter is required — every spawned agent must use a definition.",
+ "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).",
+ catalog,
+ ].join("\n")
+ : [
+ "Spawn a new child agent to work on a task independently.",
+ "",
+ "By default, blocks until the child agent finishes and returns the result directly.",
+ "Set background=true to return immediately with an agent_id instead — use retrieve to collect the result later.",
+ "",
+ "The child agent runs in its own tab visible to the user. Use the 'retrieve' tool with the returned agent_id to get the result when needed.",
+ "",
+ "Pattern for parallel work:",
+ " 1. Call summon multiple times with background=true to start several agents",
+ " 2. Do your own work or wait",
+ " 3. Call retrieve for each agent_id to collect results",
+ ...(userAgentEnabled
+ ? [
+ "",
+ "Set top_level=true to spawn an independent user agent — a first-class",
+ "top-level tab with no parent. User agents are fire-and-forget: you get",
+ "an agent_id back but cannot retrieve their result. top_level requires an",
+ "'agent' definition listed under 'User agents' below.",
+ ]
+ : []),
+ "",
+ ...toolNamesList,
+ "",
+ "The 'agent' parameter is required — every spawned agent must use a definition.",
+ "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).",
+ catalog,
+ ].join("\n");
const parametersShape = {
task: z
@@ -206,7 +259,10 @@ export function createSummonTool(
.filter(Boolean)
.join(" "),
),
- ...(userAgentEnabled
+ // `top_level` is only an explicit choice when BOTH subagents and user
+ // agents are available. In user-agent-only mode it is implied (forced
+ // on), so the knob is omitted entirely.
+ ...(userAgentEnabled && !userAgentOnly
? {
top_level: z
.boolean()
@@ -250,12 +306,18 @@ export function createSummonTool(
.describe(
"Absolute path for the child to work in. Defaults to the agent definition's cwd (or the spawning agent's directory).",
),
- background: z
- .boolean()
- .optional()
- .describe(
- "If true, returns immediately with an agent_id for later retrieval. If false (default), blocks until the child agent finishes and returns the result directly. Ignored when top_level is true.",
- ),
+ // `background` is meaningless for fire-and-forget user agents, so the
+ // knob is omitted in user-agent-only mode.
+ ...(userAgentOnly
+ ? {}
+ : {
+ background: z
+ .boolean()
+ .optional()
+ .describe(
+ "If true, returns immediately with an agent_id for later retrieval. If false (default), blocks until the child agent finishes and returns the result directly. Ignored when top_level is true.",
+ ),
+ }),
};
return {
@@ -268,9 +330,14 @@ export function createSummonTool(
const tools = args.tools as string[] | undefined;
const workingDirectory = args.working_directory as string | undefined;
const background = (args.background as boolean | undefined) ?? false;
- const topLevel = userAgentEnabled
- ? ((args.top_level as boolean | undefined) ?? false)
- : false;
+ // User-agent-only mode always spawns top-level user agents. When both
+ // capabilities are present the caller chooses via `top_level`. When
+ // only subagents are available, top-level spawning is unavailable.
+ const topLevel = userAgentOnly
+ ? true
+ : userAgentEnabled
+ ? ((args.top_level as boolean | undefined) ?? false)
+ : false;
try {
const agentId = await callbacks.spawn({
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts
index aa69c86..8a73352 100644
--- a/packages/core/src/tools/write-file.ts
+++ b/packages/core/src/tools/write-file.ts
@@ -4,7 +4,21 @@ import { z } from "zod";
import type { ToolDefinition } from "../types/index.js";
import { canonicalize } from "./path-utils.js";
-export function createWriteFileTool(workingDirectory: string): ToolDefinition {
+/**
+ * Optional hook invoked AFTER a successful write, with the canonicalized
+ * absolute path of the file just written. Its returned string (when non-empty)
+ * is appended to the tool result. This is how LSP diagnostics are surfaced
+ * back to the model on write without coupling `@dispatch/core`'s tools to the
+ * API layer or the LSP manager — the host wires an implementation that touches
+ * the file through the LSP and formats any diagnostics. Errors thrown here are
+ * swallowed so a flaky LSP never fails the write itself.
+ */
+export type AfterWriteHook = (absolutePath: string) => Promise<string>;
+
+export function createWriteFileTool(
+ workingDirectory: string,
+ onAfterWrite?: AfterWriteHook,
+): ToolDefinition {
return {
name: "write_file",
description: "Write content to a file relative to the working directory.",
@@ -31,10 +45,22 @@ export function createWriteFileTool(workingDirectory: string): ToolDefinition {
try {
await mkdir(dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content, "utf8");
- return `Successfully wrote to "${filePath}".`;
} catch (err) {
return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
}
+
+ let result = `Successfully wrote to "${filePath}".`;
+ // Post-write hook (e.g. LSP diagnostics). Best-effort: never let a
+ // hook failure turn a successful write into an error.
+ if (onAfterWrite) {
+ try {
+ const extra = await onAfterWrite(absolutePath);
+ if (extra) result += `\n\n${extra}`;
+ } catch {
+ /* ignore — diagnostics are advisory */
+ }
+ }
+ return result;
},
};
}