diff options
| author | Adam Malczewski <[email protected]> | 2026-05-31 18:34:15 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-31 18:34:15 +0900 |
| commit | cb640f25b577a68ceea76b7c9a95a198e5e91441 (patch) | |
| tree | 35e5ae5741cb0ab3c573446ef13528aa6f4b242a | |
| parent | c59f7bad32bb8682acecfcf2209c6ad587544eda (diff) | |
| download | dispatch-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.ts | 136 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 3 | ||||
| -rw-r--r-- | packages/core/src/tools/summon.ts | 363 | ||||
| -rw-r--r-- | packages/core/tests/tools/summon.test.ts | 138 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ToolPermissions.svelte | 5 | ||||
| -rw-r--r-- | packages/frontend/src/lib/settings.svelte.ts | 2 |
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, |
