diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 01:46:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 01:46:13 +0900 |
| commit | 8b9533c22a47bbf6f916667e2c25d8e8e419da37 (patch) | |
| tree | 715a6a3d6f43781395e7dc7c8cdb519cef46a870 /packages/core/src/tools | |
| parent | 1853dd1d40308deb829bc621beb79c5d39b9c57f (diff) | |
| download | dispatch-8b9533c22a47bbf6f916667e2c25d8e8e419da37.tar.gz dispatch-8b9533c22a47bbf6f916667e2c25d8e8e419da37.zip | |
feat(tabs): tab-to-tab agent communication via short handles
Add send_to_tab / read_tab tools so an agent can message or read another
tab by a git-style short handle (shortest unique prefix of the tab UUID,
min 4 chars), shown in the tab bar.
- core/db/tabs: resolveTabPrefix + shortestUniquePrefix (open tabs only,
LIKE-sanitized prefix matching)
- new tools read-tab.ts / send-to-tab.ts (+ tests) decoupled from the DB
TabRow via a minimal ResolvedTabRef projection
- agent-manager: unified deliverMessage routing (busy -> queue, idle ->
new turn) shared by POST /chat and send_to_tab; agent->agent auto-wake
budget (MAX_AGENT_AUTO_WAKES) to bound ping-pong loops
- summon/loader: send_to_tab + read_tab as grantable tools
- frontend: shortHandleFor + handle badge in TabBar; perm toggles
- notes: tab-comm / user-agents / todo-redesign plans
- chore: biome format fixes (debug-logger, summon.test)
Refs notes/plan-tab-comm.md
Diffstat (limited to 'packages/core/src/tools')
| -rw-r--r-- | packages/core/src/tools/read-tab.ts | 95 | ||||
| -rw-r--r-- | packages/core/src/tools/send-to-tab.ts | 147 | ||||
| -rw-r--r-- | packages/core/src/tools/summon.ts | 8 |
3 files changed, 249 insertions, 1 deletions
diff --git a/packages/core/src/tools/read-tab.ts b/packages/core/src/tools/read-tab.ts new file mode 100644 index 0000000..e80dbd0 --- /dev/null +++ b/packages/core/src/tools/read-tab.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import type { AgentStatus, ToolDefinition } from "../types/index.js"; +import type { TabResolution } from "./send-to-tab.js"; + +export interface ReadTabCallbacks { + /** Resolve a (possibly short) handle to one open tab. */ + resolveShortId(prefix: string): TabResolution; + /** + * Return the target tab's most recent COMPLETED assistant turn as plain + * text, plus its current status. `text` is null when the tab has no + * completed assistant turn yet. + */ + getLastResponse(tabId: string): { text: string | null; status: AgentStatus }; + /** Snapshot of currently-open tabs, for "available tabs" error hints. */ + listOpenHandles(): Array<{ handle: string; title: string }>; +} + +/** Render the "available tabs" hint shared by the none/ambiguous branches. */ +function renderOpenHandles(handles: Array<{ handle: string; title: string }>): string { + if (handles.length === 0) return "No other tabs are currently open."; + const lines = handles.map((h) => ` - ${h.handle}: ${h.title}`); + return ["Currently open tabs:", ...lines].join("\n"); +} + +export function createReadTabTool(callbacks: ReadTabCallbacks): ToolDefinition { + return { + name: "read_tab", + description: [ + "Read the most recent completed response from another tab (agent) by its short ID.", + "", + "Returns a SNAPSHOT — it does NOT block or wait for the target to finish.", + " - If the target is idle, you get its just-finished turn.", + " - If the target is still running, you get its PREVIOUS completed turn (if any);", + " call read_tab again later to get the newest one.", + "", + "Use this after send_to_tab to collect another agent's reply. IDs are git-style", + "prefixes: pass any length that uniquely identifies the target (min 4 chars).", + ].join("\n"), + parameters: z.object({ + tab_id: z + .string() + .describe( + "The short ID (handle) of the tab to read, as shown in the tab bar. Any unique-length prefix works (min 4 chars).", + ), + }), + execute: async (args: Record<string, unknown>): Promise<string> => { + const rawId = (args.tab_id as string | undefined)?.trim() ?? ""; + + if (!rawId) { + return `Error: tab_id is required.\n\n${renderOpenHandles(callbacks.listOpenHandles())}`; + } + + const resolution = callbacks.resolveShortId(rawId); + + if (resolution.status === "none") { + return [ + `Error: no open tab matches the ID "${rawId}".`, + "", + renderOpenHandles(callbacks.listOpenHandles()), + ].join("\n"); + } + if (resolution.status === "ambiguous") { + const matches = resolution.matches.map((m) => ` - ${m.handle}: ${m.title}`).join("\n"); + return [ + `Error: the ID "${rawId}" is ambiguous — it matches multiple open tabs:`, + matches, + "", + "Add one or more characters to disambiguate.", + ].join("\n"); + } + + const target = resolution.tab; + const { text, status } = callbacks.getLastResponse(target.id); + + const runningNote = + status === "running" + ? " (this tab is still running; the response below is its previous completed turn — read again later for the newest)" + : ""; + + if (text === null) { + const reason = + status === "running" + ? "it is still working on its first turn" + : "it has no assistant responses yet"; + return `Tab ${target.handle} (${target.title}) has no completed response — ${reason}.`; + } + + return [ + `<tab_response tab="${target.handle}" status="${status}"${runningNote}>`, + text, + "</tab_response>", + ].join("\n"); + }, + }; +} diff --git a/packages/core/src/tools/send-to-tab.ts b/packages/core/src/tools/send-to-tab.ts new file mode 100644 index 0000000..eb86b7e --- /dev/null +++ b/packages/core/src/tools/send-to-tab.ts @@ -0,0 +1,147 @@ +import { z } from "zod"; +import type { ToolDefinition } from "../types/index.js"; + +/** + * A tab reference surfaced to the `send_to_tab` / `read_tab` tools. The tools + * are intentionally decoupled from the DB `TabRow` shape — the AgentManager + * maps `resolveTabPrefix(...)` results down to this minimal projection so the + * tools (and their unit tests) never depend on the persistence layer. + */ +export interface ResolvedTabRef { + /** The tab's canonical full UUID. */ + id: string; + /** The tab's display title (for disambiguation hints). */ + title: string; + /** The tab's current short handle (shortest unique prefix). */ + handle: string; +} + +/** + * Outcome of resolving a short tab handle. Mirrors core's + * `ResolveTabPrefixResult` but over the minimal `ResolvedTabRef` projection. + */ +export type TabResolution = + | { status: "ok"; tab: ResolvedTabRef } + | { status: "none" } + | { status: "ambiguous"; matches: ResolvedTabRef[] }; + +export interface SendToTabCallbacks { + /** Resolve a (possibly short) handle to one open tab. */ + resolveShortId(prefix: string): TabResolution; + /** + * Deliver `message` to `tabId`. If the target is mid-turn the message is + * queued (same path as a user message); if idle/errored it wakes the tab + * and starts a new turn. Returns quickly — does NOT block on the turn. + */ + deliver( + tabId: string, + message: string, + ): + | Promise<{ status: "queued" | "started" | "suppressed" }> + | { status: "queued" | "started" | "suppressed" }; + /** Snapshot of currently-open tabs, for "available tabs" error hints. */ + listOpenHandles(): Array<{ handle: string; title: string }>; + /** 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 }; +} + +/** Render the "available tabs" hint shared by the none/ambiguous branches. */ +function renderOpenHandles(handles: Array<{ handle: string; title: string }>): string { + if (handles.length === 0) return "No other tabs are currently open."; + const lines = handles.map((h) => ` - ${h.handle}: ${h.title}`); + return ["Currently open tabs:", ...lines].join("\n"); +} + +export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefinition { + return { + name: "send_to_tab", + description: [ + "Send a message to another tab (agent) by its short ID — the handle shown in the tab bar.", + "", + "Behaviour mirrors a user sending a message:", + " - If the target tab is mid-turn (busy), your message is QUEUED and picked up next.", + " - 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.", + "", + "Your tab ID is auto-added to the top of the message so the recipient can reply to 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"), + parameters: z.object({ + tab_id: z + .string() + .describe( + "The short ID (handle) of the target tab, as shown in the tab bar. Any unique-length prefix of the tab's id works (min 4 chars).", + ), + message: z + .string() + .describe("The message to deliver to the target tab, exactly as a user would type it."), + }), + execute: async (args: Record<string, unknown>): Promise<string> => { + const rawId = (args.tab_id as string | undefined)?.trim() ?? ""; + const message = (args.message as string | undefined) ?? ""; + + if (!rawId) { + return `Error: tab_id is required.\n\n${renderOpenHandles(callbacks.listOpenHandles())}`; + } + if (!message.trim()) { + return "Error: message must not be empty."; + } + + const resolution = callbacks.resolveShortId(rawId); + + if (resolution.status === "none") { + return [ + `Error: no open tab matches the ID "${rawId}".`, + "", + renderOpenHandles(callbacks.listOpenHandles()), + ].join("\n"); + } + if (resolution.status === "ambiguous") { + const matches = resolution.matches.map((m) => ` - ${m.handle}: ${m.title}`).join("\n"); + return [ + `Error: the ID "${rawId}" is ambiguous — it matches multiple open tabs:`, + matches, + "", + "Add one or more characters to disambiguate.", + ].join("\n"); + } + + const target = resolution.tab; + + if (target.id === callbacks.self.id) { + return "Error: cannot send a message to your own tab."; + } + + // 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}`; + + try { + const result = await callbacks.deliver(target.id, delivered); + if (result.status === "suppressed") { + // The target hit its automatic agent-to-agent wake limit. The + // message was preserved (queued) but did NOT start a turn — a + // human must step in. Tell the sender plainly so it stops + // hammering the target and creating a runaway loop. + return [ + `Message HELD for tab ${target.handle} (${target.title}) — it was NOT delivered as a wake.`, + `That tab has reached its automatic agent-to-agent message limit, so it will not`, + `auto-respond again until a human sends it a message. Do not keep resending:`, + `your message is already queued and will be seen when a human resumes that tab.`, + ].join("\n"); + } + const verb = + 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.`; + } 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 d40f0ca..4820e89 100644 --- a/packages/core/src/tools/summon.ts +++ b/packages/core/src/tools/summon.ts @@ -177,6 +177,8 @@ export function createSummonTool( " - retrieve: Collect results from its children (required if summon is given)", " - web_search: Search the web", " - 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).", @@ -232,6 +234,8 @@ export function createSummonTool( "retrieve", "web_search", "youtube_transcribe", + "send_to_tab", + "read_tab", ]), ) .optional() @@ -262,7 +266,9 @@ 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; + const topLevel = userAgentEnabled + ? ((args.top_level as boolean | undefined) ?? false) + : false; try { const agentId = await callbacks.spawn({ |
