summaryrefslogtreecommitdiffhomepage
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
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
-rw-r--r--packages/api/src/agent-manager.ts136
-rw-r--r--packages/core/src/index.ts3
-rw-r--r--packages/core/src/tools/summon.ts363
-rw-r--r--packages/core/tests/tools/summon.test.ts138
-rw-r--r--packages/frontend/src/lib/components/ToolPermissions.svelte5
-rw-r--r--packages/frontend/src/lib/settings.svelte.ts2
6 files changed, 474 insertions, 173 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 5f2027d..111237c 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -45,7 +45,8 @@ import {
type SystemChunkKind,
type TabStatusSnapshot,
TaskList,
- toAvailableAgents,
+ toAvailableSubagents,
+ toAvailableUserAgents,
validateConfig,
} from "@dispatch/core";
import type { PermissionManager } from "./permission-manager.js";
@@ -364,10 +365,11 @@ export class AgentManager {
const permEdit = getSetting("perm_edit") === "allow";
const permBash = getSetting("perm_bash") === "allow";
const permSummon = getSetting("perm_summon") === "allow";
+ const permUserAgent = getSetting("perm_user_agent") === "allow";
const permWebSearch = getSetting("perm_web_search") === "allow";
const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow";
const sysPrompt = getSetting("system_prompt") ?? "";
- const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permWebSearch}:${permYoutubeTranscribe}:${sysPrompt}`;
+ const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permWebSearch}:${permYoutubeTranscribe}:${sysPrompt}`;
// If the override differs or permissions changed, invalidate the cached agent
if (
@@ -455,8 +457,14 @@ export class AgentManager {
}
if (allowed.has("summon")) {
const childParentAllowedTools = new Set(toolEntries.map((e) => e.name));
- const availableAgents = toAvailableAgents(
- loadAgents(workingDirectory),
+ const allAgentDefs = loadAgents(workingDirectory);
+ const availableSubagents = toAvailableSubagents(
+ allAgentDefs,
+ GLOBAL_AGENTS_DIR,
+ workingDirectory,
+ );
+ const availableUserAgents = toAvailableUserAgents(
+ allAgentDefs,
GLOBAL_AGENTS_DIR,
workingDirectory,
);
@@ -476,8 +484,10 @@ export class AgentManager {
}),
getResult: (id) => this.getChildResult(id),
},
- availableAgents,
+ availableSubagents,
+ availableUserAgents,
agentDirPaths,
+ permUserAgent,
),
});
}
@@ -526,8 +536,14 @@ export class AgentManager {
if (permSummon) {
// Capture parent's allowed tool names for child permission enforcement
const parentAllowedTools = new Set(toolEntries.map((e) => e.name));
- const availableAgents = toAvailableAgents(
- loadAgents(workingDirectory),
+ const allAgentDefs = loadAgents(workingDirectory);
+ const availableSubagents = toAvailableSubagents(
+ allAgentDefs,
+ GLOBAL_AGENTS_DIR,
+ workingDirectory,
+ );
+ const availableUserAgents = toAvailableUserAgents(
+ allAgentDefs,
GLOBAL_AGENTS_DIR,
workingDirectory,
);
@@ -547,8 +563,10 @@ export class AgentManager {
}),
getResult: (id) => this.getChildResult(id),
},
- availableAgents,
+ availableSubagents,
+ availableUserAgents,
agentDirPaths,
+ permUserAgent,
),
});
toolEntries.push({
@@ -917,15 +935,23 @@ export class AgentManager {
parentModelId?: string | null;
parentAllowedTools?: Set<string>;
parentTabId?: string;
+ /**
+ * When true, spawn as an independent top-level "user agent" tab
+ * instead of a subagent child tab. User agents have no parent,
+ * are persistent, and cannot be retrieved (fire-and-forget).
+ */
+ topLevel?: boolean;
}): Promise<string> {
const tabId = crypto.randomUUID();
const title = options.task.length > 50 ? `${options.task.slice(0, 47)}...` : options.task;
// Validate working directory is within the parent agent's effective CWD
const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
- let parentEffectiveDir = options.parentTabId
- ? (this.tabAgents.get(options.parentTabId)?.workingDirectoryOverride ?? defaultWorkDir)
- : defaultWorkDir;
+ let parentEffectiveDir = options.topLevel
+ ? defaultWorkDir
+ : options.parentTabId
+ ? (this.tabAgents.get(options.parentTabId)?.workingDirectoryOverride ?? defaultWorkDir)
+ : defaultWorkDir;
// Expand ~ in parent dir
if (parentEffectiveDir === "~" || parentEffectiveDir.startsWith("~/")) {
@@ -944,16 +970,41 @@ export class AgentManager {
agentDef = loadAgent(options.agentSlug, parentEffectiveDir);
if (!agentDef) {
const allDefs = loadAgents(parentEffectiveDir);
- const subagents = allDefs.filter((d) => d.is_subagent).map((d) => `${d.slug} (${d.name})`);
- const hint =
- subagents.length > 0
- ? ` Available subagents: ${subagents.join(", ")}.`
- : " No subagent definitions exist yet.";
- throw new Error(`Agent definition not found: "${options.agentSlug}".${hint}`);
+ if (options.topLevel) {
+ const userAgents = allDefs.filter((d) => !d.is_subagent).map((d) => `${d.slug} (${d.name})`);
+ const hint =
+ userAgents.length > 0
+ ? ` Available user agents: ${userAgents.join(", ")}.`
+ : " No user agent definitions exist yet.";
+ throw new Error(`Agent definition not found: "${options.agentSlug}".${hint}`);
+ } else {
+ const subagents = allDefs.filter((d) => d.is_subagent).map((d) => `${d.slug} (${d.name})`);
+ const hint =
+ subagents.length > 0
+ ? ` Available subagents: ${subagents.join(", ")}.`
+ : " No subagent definitions exist yet.";
+ throw new Error(`Agent definition not found: "${options.agentSlug}".${hint}`);
+ }
+ }
+
+ // Validate that the definition type matches the spawn mode:
+ // subagent slugs can't be used with top_level=true, and
+ // user-agent slugs can't be used without top_level=true.
+ if (options.topLevel && agentDef.is_subagent) {
+ throw new Error(
+ `Cannot spawn user agent: "${options.agentSlug}" is a subagent definition. Use a non-subagent definition for top_level=true.`,
+ );
+ }
+ if (!options.topLevel && !agentDef.is_subagent) {
+ throw new Error(
+ `Cannot spawn subagent: "${options.agentSlug}" is a user agent definition. Set top_level=true to spawn it as an independent tab, or use a subagent definition.`,
+ );
}
}
- // Resolve and validate child working directory against parent's effective dir
+ // Resolve child working directory.
+ // Subagents are validated to stay within the parent's effective dir.
+ // User agents (topLevel) are free to use any directory.
const requestedDir = agentDef?.cwd ?? options.workingDirectory;
let resolvedWorkingDirectory = requestedDir;
if (requestedDir) {
@@ -964,18 +1015,24 @@ export class AgentManager {
const { homedir } = await import("node:os");
childDir = join(homedir(), childDir.slice(1));
}
- const parentDir = resolve(parentEffectiveDir);
- const resolved = resolve(parentDir, childDir);
- const rel = relative(parentDir, resolved);
- const isOutside = rel.startsWith("..") || isAbsolute(rel);
- if (isOutside) {
- throw new Error(
- `Working directory "${requestedDir}" is outside the parent's working directory "${parentDir}".`,
- );
+ if (options.topLevel) {
+ // User agents: resolve freely, no containment check
+ resolvedWorkingDirectory = resolve(defaultWorkDir, childDir);
+ } else {
+ // Subagents: validate within parent's directory
+ const parentDir = resolve(parentEffectiveDir);
+ const resolved = resolve(parentDir, childDir);
+ const rel = relative(parentDir, resolved);
+ const isOutside = rel.startsWith("..") || isAbsolute(rel);
+ if (isOutside) {
+ throw new Error(
+ `Working directory "${requestedDir}" is outside the parent's working directory "${parentDir}".`,
+ );
+ }
+ // Store the resolved absolute path so downstream code doesn't
+ // re-resolve against the wrong base directory
+ resolvedWorkingDirectory = resolved;
}
- // Store the resolved absolute path so downstream code doesn't
- // re-resolve against the wrong base directory
- resolvedWorkingDirectory = resolved;
}
// Determine the child's tool whitelist. When an agent definition
@@ -1020,10 +1077,13 @@ export class AgentManager {
}
}
- // Set up completion tracking
- tabAgent.completionPromise = new Promise((resolve) => {
- tabAgent.completionResolve = resolve;
- });
+ // Set up completion tracking — user agents are fire-and-forget,
+ // so only subagents get completion promises.
+ if (!options.topLevel) {
+ tabAgent.completionPromise = new Promise((resolve) => {
+ tabAgent.completionResolve = resolve;
+ });
+ }
// Create tab in DB
try {
@@ -1031,7 +1091,7 @@ export class AgentManager {
createTab(tabId, title, {
keyId: tabAgent.keyId,
modelId: tabAgent.modelId,
- parentTabId: options.parentTabId,
+ parentTabId: options.topLevel ? undefined : options.parentTabId,
});
} catch {
// Continue even if DB fails
@@ -1045,7 +1105,7 @@ export class AgentManager {
title,
keyId: tabAgent.keyId,
modelId: tabAgent.modelId,
- parentTabId: options.parentTabId ?? null,
+ parentTabId: options.topLevel ? null : (options.parentTabId ?? null),
agentSlug: options.agentSlug ?? null,
workingDirectory: resolvedWorkingDirectory ?? null,
agentModels: tabAgent.agentModels ?? null,
@@ -1084,6 +1144,12 @@ export class AgentManager {
if (tabAgent.status === "idle") {
return { status: "done", result: tabAgent.finalOutput ?? "(no output)" };
}
+ if (tabAgent.status === "running") {
+ return {
+ status: "error",
+ error: "This is a user agent (top-level tab) and cannot be retrieved. User agents are fire-and-forget.",
+ };
+ }
return {
status: "error",
error: "Agent has no completion tracking. It may not have been spawned via summon.",
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 9fe7550..3445301 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -79,7 +79,8 @@ export {
type AvailableAgent,
createSummonTool,
type SummonCallbacks,
- toAvailableAgents,
+ toAvailableSubagents,
+ toAvailableUserAgents,
} from "./tools/summon.js";
export { createTaskListTool, TaskList } from "./tools/task-list.js";
export { clearSpillForTab } from "./tools/truncate.js";
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,
+ );
}
diff --git a/packages/core/tests/tools/summon.test.ts b/packages/core/tests/tools/summon.test.ts
index 722748c..6597c21 100644
--- a/packages/core/tests/tools/summon.test.ts
+++ b/packages/core/tests/tools/summon.test.ts
@@ -16,6 +16,7 @@ describe("createSummonTool — description content", () => {
"/tmp/work",
noopCallbacks,
[],
+ [],
["/home/u/.config/dispatch/agents", "/tmp/work/.dispatch/agents"],
);
expect(tool.description).toContain("/home/u/.config/dispatch/agents");
@@ -38,7 +39,7 @@ describe("createSummonTool — description content", () => {
path: "/home/u/.config/dispatch/agents/researcher.toml",
},
];
- const tool = createSummonTool("/tmp/work", noopCallbacks, agents, [
+ const tool = createSummonTool("/tmp/work", noopCallbacks, agents, [], [
"/home/u/.config/dispatch/agents",
]);
expect(tool.description).toContain("programmer");
@@ -53,10 +54,75 @@ describe("createSummonTool — description content", () => {
"/tmp/work",
noopCallbacks,
[],
+ [],
["/home/u/.config/dispatch/agents"],
);
expect(tool.description).toContain("No agent definitions are currently defined");
});
+
+ it("shows two groups when userAgentEnabled is true", () => {
+ const subagents: AvailableAgent[] = [
+ {
+ slug: "programmer",
+ name: "Programmer",
+ description: "Codes things",
+ path: "/agents/programmer.toml",
+ },
+ ];
+ const userAgents: AvailableAgent[] = [
+ {
+ slug: "default",
+ name: "Default",
+ description: "Default agent",
+ path: "/agents/default.toml",
+ },
+ ];
+ const tool = createSummonTool(
+ "/tmp/work",
+ noopCallbacks,
+ subagents,
+ userAgents,
+ ["/agents"],
+ true,
+ );
+ expect(tool.description).toContain("Subagents (spawned as child tabs):");
+ expect(tool.description).toContain(
+ "User agents (spawned as independent top-level tabs, requires top_level=true):",
+ );
+ expect(tool.description).toContain("programmer");
+ expect(tool.description).toContain("default");
+ });
+
+ it("hides user agents group when userAgentEnabled is false", () => {
+ const subagents: AvailableAgent[] = [
+ {
+ slug: "programmer",
+ name: "Programmer",
+ description: "Codes things",
+ path: "/agents/programmer.toml",
+ },
+ ];
+ const userAgents: AvailableAgent[] = [
+ {
+ slug: "default",
+ name: "Default",
+ description: "Default agent",
+ path: "/agents/default.toml",
+ },
+ ];
+ const tool = createSummonTool(
+ "/tmp/work",
+ noopCallbacks,
+ subagents,
+ userAgents,
+ ["/agents"],
+ false,
+ );
+ expect(tool.description).toContain("Available agents:");
+ expect(tool.description).not.toContain("User agents");
+ // "default" appears in generic description text, so check for the slug listing format
+ expect(tool.description).not.toContain("- default: Default");
+ });
});
describe("createSummonTool — execute() argument forwarding", () => {
@@ -81,27 +147,10 @@ describe("createSummonTool — execute() argument forwarding", () => {
});
});
- it("omits agentSlug from the spawn payload when no agent param is given", async () => {
- const spawn = vi.fn(async () => "tab-xyz");
- const tool = createSummonTool(
- "/tmp/work",
- { spawn, getResult: async () => ({ status: "done", result: "ok" }) },
- [],
- [],
- );
- await tool.execute({
- task: "do thing",
- background: true,
- });
- expect(spawn).toHaveBeenCalledTimes(1);
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).not.toHaveProperty("agentSlug");
- });
-
it("returns spawned agent_id when background=true (no blocking on result)", async () => {
const getResult = vi.fn(async () => ({ status: "done" as const, result: "should-not-see" }));
const tool = createSummonTool("/tmp/work", { spawn: async () => "id-42", getResult }, [], []);
- const out = await tool.execute({ task: "x", background: true });
+ const out = await tool.execute({ task: "x", agent: "test-agent", background: true });
expect(out).toContain("id-42");
// Background mode must not block on getResult
expect(getResult).not.toHaveBeenCalled();
@@ -117,7 +166,7 @@ describe("createSummonTool — execute() argument forwarding", () => {
[],
[],
);
- const out = await tool.execute({ task: "x" });
+ const out = await tool.execute({ task: "x", agent: "test-agent" });
// Foreground summons prefix the blocked result with `agent_id: <id>` so
// the frontend's ToolCallDisplay regex can surface the "Open Tab" button
// (see summon.ts). Assert both the prefix and the child output survive.
@@ -135,7 +184,54 @@ describe("createSummonTool — execute() argument forwarding", () => {
[],
[],
);
- const out = await tool.execute({ task: "x" });
+ const out = await tool.execute({ task: "x", agent: "test-agent" });
expect(out).toContain("boom");
});
+
+ it("returns fire-and-forget message when top_level=true", async () => {
+ const spawn = vi.fn(async () => "ua-tab-1");
+ const getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" }));
+ const tool = createSummonTool(
+ "/tmp/work",
+ { spawn, getResult },
+ [],
+ [],
+ [],
+ true, // userAgentEnabled
+ );
+ const out = await tool.execute({
+ task: "do stuff",
+ agent: "default",
+ top_level: true,
+ });
+ expect(out).toContain("User agent spawned successfully");
+ expect(out).toContain("ua-tab-1");
+ expect(out).toContain("fire-and-forget");
+ expect(getResult).not.toHaveBeenCalled();
+
+ // Verify topLevel was forwarded to spawn
+ const callArg = spawn.mock.calls[0]?.[0];
+ expect(callArg).toMatchObject({ topLevel: true });
+ });
+
+ it("ignores top_level when userAgentEnabled is false", async () => {
+ const spawn = vi.fn(async () => "tab-1");
+ const getResult = vi.fn(async () => ({ status: "done" as const, result: "result" }));
+ const tool = createSummonTool(
+ "/tmp/work",
+ { spawn, getResult },
+ [],
+ [],
+ [],
+ false, // userAgentEnabled
+ );
+ const out = await tool.execute({
+ task: "do stuff",
+ agent: "default",
+ top_level: true, // should be ignored
+ });
+ // Should behave as a normal foreground summon, not fire-and-forget
+ expect(out).not.toContain("fire-and-forget");
+ expect(getResult).toHaveBeenCalled();
+ });
});
diff --git a/packages/frontend/src/lib/components/ToolPermissions.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte
index 7fdaad3..d64eaf9 100644
--- a/packages/frontend/src/lib/components/ToolPermissions.svelte
+++ b/packages/frontend/src/lib/components/ToolPermissions.svelte
@@ -23,6 +23,11 @@ const toolPermissions: ToolPermission[] = [
description: "Allow the AI to spawn child agents to work on tasks",
},
{
+ id: "user_agent",
+ label: "Spawn user agents",
+ description: "Allow the AI to open new independent top-level tabs",
+ },
+ {
id: "web_search",
label: "Web search",
description: "Allow the AI to search the web via Firecrawl",
diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts
index 29aca5e..11eb79c 100644
--- a/packages/frontend/src/lib/settings.svelte.ts
+++ b/packages/frontend/src/lib/settings.svelte.ts
@@ -8,6 +8,7 @@ let toolPerms = $state<Record<string, boolean>>({
edit: false,
bash: false,
summon: false,
+ user_agent: false,
external_directory: false,
web_search: false,
youtube_transcribe: false,
@@ -17,6 +18,7 @@ let savedToolPerms = $state<Record<string, boolean>>({
edit: false,
bash: false,
summon: false,
+ user_agent: false,
external_directory: false,
web_search: false,
youtube_transcribe: false,