diff options
| author | Adam Malczewski <[email protected]> | 2026-05-22 00:19:14 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-22 00:19:14 +0900 |
| commit | fb97d4cb72d0a90dde102b7001603716ee6e4c3b (patch) | |
| tree | 55c6e8b56b3395008523ab94c16c4f526083d846 /packages/core/src/tools | |
| parent | 7884709e3b2adb1b65c1c086257e0300eed51cee (diff) | |
| download | dispatch-fb97d4cb72d0a90dde102b7001603716ee6e4c3b.tar.gz dispatch-fb97d4cb72d0a90dde102b7001603716ee6e4c3b.zip | |
feat: agent summoning system, todo improvements, security fixes, double-execution bug fix
- Add summon/retrieve tools for spawning child agents in new tabs
- summon: non-blocking, returns agent_id immediately
- retrieve: blocking, waits for child to finish, returns result
- Child tools are intersected with parent permissions (no privilege escalation)
- Working directory validated to stay within workspace
- Abort controller stops orphaned agents on tab close
- Rename task_list tool to todo with comprehensive usage guidance in system prompt
- Rename PermissionLog.svelte to ToolPermissions.svelte
- Add 'Summon agents' toggle to tool permissions UI
- Redesign TaskListPanel with DaisyUI checkboxes (indeterminate for in-progress)
- Remove 'blocked' status from task system
- Add tab-created WebSocket event for child agent tab visibility
- Add HMR cleanup for WebSocket connections (close stale connections on hot reload)
- Fix ensureAssistantMessage to not throw on closed tabs
- Fix double tool execution: remove execute from AI SDK tool() in registry.ts
(agent.ts already executes tools manually via executeToolWithStreaming)
- Fix all pre-existing test failures (missing mocks, stale API signatures)
- Add debug info to copy button (tab ID, injected skills, all tab IDs)
- Add tab ID and tools to conversation copy output
Diffstat (limited to 'packages/core/src/tools')
| -rw-r--r-- | packages/core/src/tools/registry.ts | 9 | ||||
| -rw-r--r-- | packages/core/src/tools/retrieve.ts | 41 | ||||
| -rw-r--r-- | packages/core/src/tools/summon.ts | 88 | ||||
| -rw-r--r-- | packages/core/src/tools/task-list.ts | 15 |
4 files changed, 138 insertions, 15 deletions
diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 4699c93..0c7b110 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -18,15 +18,14 @@ export function createToolRegistry(tools: ToolDefinition[]) { const result: Record<string, ReturnType<typeof tool>> = {}; for (const [name, def] of toolMap) { const schema = def.parameters; + // Do NOT pass execute here — agent.ts handles tool execution + // manually via executeToolWithStreaming. Passing execute would + // cause the AI SDK to auto-execute tools AND agent.ts to execute + // them again, resulting in double execution. const t = tool({ description: def.description, parameters: schema instanceof z.ZodObject ? schema : z.object({}), - execute: async (args) => { - return def.execute(args as Record<string, unknown>); - }, }); - // The AI SDK tool() overloads cause type narrowing issues when - // execute is provided. The runtime value is correct. result[name] = t as unknown as ReturnType<typeof tool>; } return result; diff --git a/packages/core/src/tools/retrieve.ts b/packages/core/src/tools/retrieve.ts new file mode 100644 index 0000000..93f4c89 --- /dev/null +++ b/packages/core/src/tools/retrieve.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import type { ToolDefinition } from "../types/index.js"; + +export interface RetrieveCallbacks { + getResult( + agentId: string, + ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }>; +} + +export function createRetrieveTool(callbacks: RetrieveCallbacks): ToolDefinition { + return { + name: "retrieve", + description: [ + "Wait for a child agent to finish and retrieve its result. This tool BLOCKS until the child completes.", + "", + "Pass the agent_id returned by the summon tool. Once the child finishes, its final output is returned.", + "If the child encountered an error, the error message is returned instead.", + "", + "Typical usage:", + ' 1. summon({ task: "...", tools: [...] }) -> get agent_id', + " 2. ... do other work or summon more agents ...", + ' 3. retrieve({ agent_id: "..." }) -> blocks until done, returns result', + ].join("\n"), + parameters: z.object({ + agent_id: z.string().describe("The agent_id returned by a previous summon call."), + }), + execute: async (args: Record<string, unknown>): Promise<string> => { + const agentId = args.agent_id as string; + + try { + const outcome = await callbacks.getResult(agentId); + if (outcome.status === "done") { + return ["<agent_result>", outcome.result, "</agent_result>"].join("\n"); + } + return `Agent error: ${outcome.error}`; + } catch (err) { + return `Error retrieving result: ${err instanceof Error ? err.message : String(err)}`; + } + }, + }; +} diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts new file mode 100644 index 0000000..582b871 --- /dev/null +++ b/packages/core/src/tools/summon.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; +import type { ToolDefinition } from "../types/index.js"; + +export interface SummonCallbacks { + spawn(options: { task: string; tools: string[]; workingDirectory?: string }): Promise<string>; +} + +export function createSummonTool( + defaultWorkingDirectory: string, + callbacks: SummonCallbacks, +): ToolDefinition { + return { + name: "summon", + description: [ + "Spawn a new child agent to work on a task independently. Returns immediately with an agent_id — does NOT wait for the child to finish.", + "", + "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 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)", + "", + "If tools is omitted, the child gets read_file, list_files, and todo only (read-only by default).", + ].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.", + ), + tools: z + .array( + z.enum([ + "read_file", + "list_files", + "write_file", + "run_shell", + "todo", + "summon", + "retrieve", + ]), + ) + .optional() + .describe( + 'Tool names to give the child. Defaults to ["read_file", "list_files", "todo"]. Include "summon" and "retrieve" to allow nesting.', + ), + working_directory: z + .string() + .optional() + .describe( + "Absolute path for the child to work in. Defaults to the current working directory.", + ), + }), + execute: async (args: Record<string, unknown>): Promise<string> => { + const task = args.task as string; + const tools = (args.tools as string[] | undefined) ?? ["read_file", "list_files", "todo"]; + const workingDirectory = + (args.working_directory as string | undefined) ?? defaultWorkingDirectory; + + try { + const agentId = await callbacks.spawn({ + task, + tools, + workingDirectory, + }); + return [ + `Agent spawned successfully.`, + `agent_id: ${agentId}`, + ``, + `The child agent is now working on the task in its own tab.`, + `Use the retrieve tool with this agent_id to get the result when ready.`, + ].join("\n"); + } catch (err) { + return `Error spawning agent: ${err instanceof Error ? err.message : String(err)}`; + } + }, + }; +} diff --git a/packages/core/src/tools/task-list.ts b/packages/core/src/tools/task-list.ts index 0bacdb4..29f1543 100644 --- a/packages/core/src/tools/task-list.ts +++ b/packages/core/src/tools/task-list.ts @@ -60,24 +60,19 @@ export class TaskList { export function createTaskListTool(taskList: TaskList): ToolDefinition { return { - name: "task_list", + name: "todo", description: - "Manages a task list for tracking work items. The agent can add tasks, update their status, list all tasks, or get details on a specific task.", + "Manage a todo list for planning and tracking work. Add items, update their status, list all items, or get details on a specific item.", parameters: z.object({ - action: z - .enum(["add", "update", "list", "get", "remove"]) - .describe("The action to perform"), + action: z.enum(["add", "update", "list", "get", "remove"]).describe("The action to perform"), title: z.string().optional().describe("Task title (required for 'add')"), description: z .string() .optional() .describe("Task description (for 'add', defaults to empty)"), - task_id: z - .string() - .optional() - .describe("Task ID (required for 'update', 'get', 'remove')"), + task_id: z.string().optional().describe("Task ID (required for 'update', 'get', 'remove')"), status: z - .enum(["pending", "in_progress", "done", "blocked"]) + .enum(["pending", "in_progress", "done"]) .optional() .describe("New status (required for 'update')"), }), |
