summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/tools
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-31 18:34:15 +0900
committerAdam Malczewski <[email protected]>2026-05-31 18:34:15 +0900
commitcb640f25b577a68ceea76b7c9a95a198e5e91441 (patch)
tree35e5ae5741cb0ab3c573446ef13528aa6f4b242a /packages/core/src/tools
parentc59f7bad32bb8682acecfcf2209c6ad587544eda (diff)
downloaddispatch-cb640f25b577a68ceea76b7c9a95a198e5e91441.tar.gz
dispatch-cb640f25b577a68ceea76b7c9a95a198e5e91441.zip
feat: implement user agents (top-level tabs via summon)
- agent parameter is now required on summon tool - new top_level param spawns independent fire-and-forget user agent tabs - gated by perm_user_agent permission (UI checkbox added) - agent definition type validation (subagent vs user-agent slug mismatch) - context-aware error messages when agent slug not found - read_file_slice added to summon tool's allowed tools enum - updated and expanded summon tests
Diffstat (limited to 'packages/core/src/tools')
-rw-r--r--packages/core/src/tools/summon.ts363
1 files changed, 247 insertions, 116 deletions
diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts
index cf845ea..d40f0ca 100644
--- a/packages/core/src/tools/summon.ts
+++ b/packages/core/src/tools/summon.ts
@@ -14,6 +14,13 @@ export interface SummonCallbacks {
* `tools` and `workingDirectory` parameters passed alongside.
*/
agentSlug?: string;
+ /**
+ * When true, spawn the agent as an independent top-level "user
+ * agent" tab (no parent, persistent, fire-and-forget) instead of
+ * a subagent child tab. Only honoured when the spawning agent has
+ * the `perm_user_agent` permission.
+ */
+ topLevel?: boolean;
}): Promise<string>;
getResult(
agentId: string,
@@ -35,34 +42,73 @@ export interface AvailableAgent {
}
/**
+ * Render a labelled list of agents. Returns an empty array when there
+ * are no agents so callers can omit the group entirely.
+ */
+function renderAgentGroup(label: string, agents: AvailableAgent[]): string[] {
+ if (agents.length === 0) return [];
+ const lines: string[] = [label];
+ for (const a of agents) {
+ const desc = a.description ? ` — ${a.description}` : "";
+ lines.push(` - ${a.slug}: ${a.name}${desc}`);
+ }
+ return lines;
+}
+
+/**
* Build the prose paragraph that lists available agent definitions plus
* the disk locations where they live, injected into the summon tool's
* description.
*
- * Returns the empty string when no agents are visible — keeps the
- * description compact for environments where no definitions exist yet.
+ * 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`.
+ *
+ * Returns a compact "no agents defined" notice when nothing is visible.
*/
-function buildAgentsCatalog(agents: AvailableAgent[], agentDirs: string[]): string {
+function buildAgentsCatalog(
+ subagents: AvailableAgent[],
+ userAgents: AvailableAgent[],
+ agentDirs: string[],
+ userAgentEnabled: boolean,
+): string {
const lines: string[] = [];
lines.push("");
lines.push("Agent definitions live on disk and can be inspected with read_file/list_files:");
for (const d of agentDirs) {
lines.push(` - ${d}`);
}
- if (agents.length === 0) {
+
+ const visibleUserAgents = userAgentEnabled ? userAgents : [];
+ if (subagents.length === 0 && visibleUserAgents.length === 0) {
lines.push("");
lines.push("No agent definitions are currently defined.");
return lines.join("\n");
}
+
lines.push("");
lines.push("To summon a specific agent, pass its slug as the 'agent' parameter.");
lines.push("When 'agent' is set, the child inherits that definition's tools, models,");
lines.push("and working directory; the 'tools' parameter is ignored.");
lines.push("");
- lines.push("Available agents:");
- for (const a of agents) {
- const desc = a.description ? ` — ${a.description}` : "";
- lines.push(` - ${a.slug}: ${a.name}${desc}`);
+
+ if (!userAgentEnabled) {
+ lines.push(...renderAgentGroup("Available agents:", subagents));
+ return lines.join("\n");
+ }
+
+ const subagentLines = renderAgentGroup("Subagents (spawned as child tabs):", subagents);
+ const userAgentLines = renderAgentGroup(
+ "User agents (spawned as independent top-level tabs, requires top_level=true):",
+ visibleUserAgents,
+ );
+ if (subagentLines.length > 0) {
+ lines.push(...subagentLines);
+ }
+ if (userAgentLines.length > 0) {
+ if (subagentLines.length > 0) lines.push("");
+ lines.push(...userAgentLines);
}
return lines.join("\n");
}
@@ -75,113 +121,170 @@ function buildAgentsCatalog(agents: AvailableAgent[], agentDirs: string[]): stri
* `agentDirs` is the list of filesystem paths the catalog references in
* 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.
*/
export function createSummonTool(
- defaultWorkingDirectory: string,
+ _defaultWorkingDirectory: string,
callbacks: SummonCallbacks,
- availableAgents: AvailableAgent[] = [],
+ availableSubagents: AvailableAgent[] = [],
+ availableUserAgents: AvailableAgent[] = [],
agentDirs: string[] = [],
+ userAgentEnabled = false,
): ToolDefinition {
- const catalog = buildAgentsCatalog(availableAgents, agentDirs);
- const agentSlugs = availableAgents.map((a) => a.slug);
+ const catalog = buildAgentsCatalog(
+ availableSubagents,
+ availableUserAgents,
+ agentDirs,
+ userAgentEnabled,
+ );
+ const subagentSlugs = availableSubagents.map((a) => a.slug);
+ const userAgentSlugs = availableUserAgents.map((a) => a.slug);
+ const allSlugs = 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.",
+ ]
+ : []),
+ "",
+ "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",
+ " - list_files: List files and directories",
+ " - write_file: Write/edit files",
+ " - run_shell: Execute shell commands",
+ " - todo: Track work items",
+ " - summon: Spawn its own child agents (enables nesting)",
+ " - retrieve: Collect results from its children (required if summon is given)",
+ " - web_search: Search the web",
+ " - youtube_transcribe: Fetch YouTube video transcripts",
+ "",
+ "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
+ .string()
+ .describe(
+ "Detailed instructions for the child agent. Be specific about what it should do and what it should return.",
+ ),
+ agent: z
+ .string()
+ .describe(
+ [
+ "Slug of an agent definition to use as the basis for the child agent.",
+ "Required. The child inherits the definition's tools, models, and",
+ "working directory; the 'tools' parameter only narrows them further.",
+ "Inspect the agent directories listed above to discover which slugs",
+ "are available and what each one does.",
+ allSlugs.length > 0 ? `Available slugs: ${allSlugs.join(", ")}.` : "",
+ ]
+ .filter(Boolean)
+ .join(" "),
+ ),
+ ...(userAgentEnabled
+ ? {
+ top_level: z
+ .boolean()
+ .optional()
+ .describe(
+ [
+ "If true, spawn the agent as an independent top-level user agent tab",
+ "instead of a child subagent. User agents have no parent, persist on",
+ "their own, and are fire-and-forget (cannot be retrieved). Requires an",
+ "'agent' definition listed under 'User agents'. The 'background' option",
+ "is ignored when top_level is true.",
+ ].join(" "),
+ ),
+ }
+ : {}),
+ tools: z
+ .array(
+ z.enum([
+ "read_file",
+ "read_file_slice",
+ "list_files",
+ "write_file",
+ "run_shell",
+ "todo",
+ "summon",
+ "retrieve",
+ "web_search",
+ "youtube_transcribe",
+ ]),
+ )
+ .optional()
+ .describe(
+ "Tool names to give the child. Defaults to the agent definition's tools. Intersected with the spawning agent's tools (you can't grant capabilities you don't have).",
+ ),
+ working_directory: z
+ .string()
+ .optional()
+ .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.",
+ ),
+ };
return {
name: "summon",
- 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",
- "",
- "The 'tools' parameter controls what the child can do. Available tool names:",
- " - read_file: Read file contents",
- " - list_files: List files and directories",
- " - write_file: Write/edit files",
- " - run_shell: Execute shell commands",
- " - todo: Track work items",
- " - summon: Spawn its own child agents (enables nesting)",
- " - retrieve: Collect results from its children (required if summon is given)",
- " - web_search: Search the web",
- " - youtube_transcribe: Fetch YouTube video transcripts",
- "",
- "If tools is omitted (and no 'agent' is specified), the child gets read_file, list_files, and todo only (read-only by default).",
- catalog,
- ].join("\n"),
- parameters: z.object({
- task: z
- .string()
- .describe(
- "Detailed instructions for the child agent. Be specific about what it should do and what it should return.",
- ),
- agent: z
- .string()
- .optional()
- .describe(
- [
- "Slug of an agent definition to use as the basis for the child agent.",
- "When provided, the child inherits the definition's tools, models, and",
- "working directory; the 'tools' parameter is ignored. Inspect the agent",
- "directories listed above to discover which slugs are available and what",
- "each one does.",
- agentSlugs.length > 0 ? `Available slugs: ${agentSlugs.join(", ")}.` : "",
- ]
- .filter(Boolean)
- .join(" "),
- ),
- tools: z
- .array(
- z.enum([
- "read_file",
- "list_files",
- "write_file",
- "run_shell",
- "todo",
- "summon",
- "retrieve",
- "web_search",
- "youtube_transcribe",
- ]),
- )
- .optional()
- .describe(
- 'Tool names to give the child. Defaults to ["read_file", "list_files", "todo"]. Include "summon" and "retrieve" to allow nesting. Ignored when "agent" is set.',
- ),
- working_directory: z
- .string()
- .optional()
- .describe(
- "Absolute path for the child to work in. Defaults to the current working directory. When 'agent' is set and its definition has a cwd, that takes precedence.",
- ),
- 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.",
- ),
- }),
+ description,
+ parameters: z.object(parametersShape),
execute: async (args: Record<string, unknown>): Promise<string> => {
const task = args.task as string;
const agentSlug = args.agent as string | undefined;
- const tools = (args.tools as string[] | undefined) ?? ["read_file", "list_files", "todo"];
- const workingDirectory =
- (args.working_directory as string | undefined) ?? defaultWorkingDirectory;
+ 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;
try {
const agentId = await callbacks.spawn({
task,
- tools,
- workingDirectory,
+ tools: tools ?? [],
+ ...(workingDirectory ? { workingDirectory } : {}),
...(agentSlug ? { agentSlug } : {}),
+ ...(topLevel ? { topLevel: true } : {}),
});
+ if (topLevel) {
+ // User agents are always fire-and-forget — never block on a
+ // result and make it explicit that retrieve won't work.
+ return [
+ `User agent spawned successfully.`,
+ `agent_id: ${agentId}`,
+ ``,
+ `The user agent is now working independently in its own top-level tab.`,
+ `It is fire-and-forget — you cannot retrieve its result.`,
+ ].join("\n");
+ }
+
if (!background) {
// Block until the child agent completes. Always prefix the
// result with `agent_id: <uuid>` so the frontend's
@@ -212,29 +315,57 @@ export function createSummonTool(
}
/**
- * Build the `AvailableAgent[]` projection from a list of full
- * `AgentDefinition` records. Each entry's `path` is derived from the
- * scope+slug so the agent can `read_file(path)` directly.
+ * Build the `AvailableAgent[]` projection of an `AgentDefinition` list,
+ * deriving each entry's readable `path` from its scope+slug.
+ */
+function toAvailableAgents(
+ defs: AgentDefinition[],
+ globalDir: string,
+ projectDir: string | null,
+): AvailableAgent[] {
+ return defs.map((d) => {
+ const baseDir =
+ d.scope === "global"
+ ? globalDir
+ : projectDir
+ ? `${projectDir.replace(/\/$/, "")}/.dispatch/agents`
+ : globalDir;
+ return {
+ slug: d.slug,
+ name: d.name,
+ description: d.description,
+ path: `${baseDir}/${d.slug}.toml`,
+ };
+ });
+}
+
+/**
+ * Subagent definitions (`is_subagent === true`) — spawned as child tabs.
+ */
+export function toAvailableSubagents(
+ defs: AgentDefinition[],
+ globalDir: string,
+ projectDir: string | null,
+): AvailableAgent[] {
+ return toAvailableAgents(
+ defs.filter((d) => d.is_subagent),
+ globalDir,
+ projectDir,
+ );
+}
+
+/**
+ * User-agent definitions (`is_subagent !== true`) — spawnable as
+ * independent top-level tabs when `perm_user_agent` is granted.
*/
-export function toAvailableAgents(
+export function toAvailableUserAgents(
defs: AgentDefinition[],
globalDir: string,
projectDir: string | null,
): AvailableAgent[] {
- return defs
- .filter((d) => d.is_subagent)
- .map((d) => {
- const baseDir =
- d.scope === "global"
- ? globalDir
- : projectDir
- ? `${projectDir.replace(/\/$/, "")}/.dispatch/agents`
- : globalDir;
- return {
- slug: d.slug,
- name: d.name,
- description: d.description,
- path: `${baseDir}/${d.slug}.toml`,
- };
- });
+ return toAvailableAgents(
+ defs.filter((d) => d.is_subagent !== true),
+ globalDir,
+ projectDir,
+ );
}