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 | |
| 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')
| -rw-r--r-- | packages/core/src/agents/loader.ts | 4 | ||||
| -rw-r--r-- | packages/core/src/db/tabs.ts | 75 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 11 | ||||
| -rw-r--r-- | packages/core/src/llm/debug-logger.ts | 21 | ||||
| -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 |
7 files changed, 345 insertions, 16 deletions
diff --git a/packages/core/src/agents/loader.ts b/packages/core/src/agents/loader.ts index 333716e..f4a6c5a 100644 --- a/packages/core/src/agents/loader.ts +++ b/packages/core/src/agents/loader.ts @@ -108,8 +108,8 @@ export function expandAgentToolNames(tools: string[]): string[] { default: // Pass through tool names that aren't permission-group // aliases (summon, retrieve, web_search, youtube_transcribe, - // todo, and the granular file tools themselves if a user - // hand-wrote them in a TOML). + // send_to_tab, read_tab, todo, and the granular file tools + // themselves if a user hand-wrote them in a TOML). expanded.add(t); } } diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts index 928f434..8b290d2 100644 --- a/packages/core/src/db/tabs.ts +++ b/packages/core/src/db/tabs.ts @@ -159,3 +159,78 @@ export function getDescendantIds(rootId: string): string[] { } return order.reverse(); } + +/** + * Minimum length of a tab-handle prefix accepted by `resolveTabPrefix`. + * Mirrors the frontend's minimum DISPLAY length (4 hex chars). Anything + * shorter is rejected as too broad — an agent must echo at least the 4-char + * handle shown in the UI. + */ +export const MIN_TAB_PREFIX_LENGTH = 4; + +/** + * Outcome of resolving a short tab handle (a git-style prefix of a tab's + * UUID) back to a concrete open tab. + * + * - `ok` — exactly one open tab matched; `tab` is it. + * - `none` — no open tab matched (bad/stale handle, or too-short prefix). + * - `ambiguous` — more than one open tab shares the prefix; `matches` lists + * them so the caller can ask for one more character (the same + * UX as `git checkout <ambiguous-sha>`). + */ +export type ResolveTabPrefixResult = + | { status: "ok"; tab: TabRow } + | { status: "none" } + | { status: "ambiguous"; matches: TabRow[] }; + +/** + * Resolve a short tab handle to a single OPEN tab by prefix match — the + * git-short-hash model. The handle is NEVER stored: it is always derived from + * (and matched against) the canonical lowercase UUID in `tabs.id`. + * + * Sanitization is mandatory because the SQLite `LIKE` operator treats `%` and + * `_` as wildcards: an unsanitized prefix like `a%` would match broadly. We + * lowercase the input (UUIDs are canonical lowercase; SQLite `LIKE` is also + * ASCII-case-insensitive by default) and strip everything outside the UUID + * alphabet `[0-9a-f-]` so no wildcard can survive into the query. + * + * A prefix shorter than `MIN_TAB_PREFIX_LENGTH` after sanitization returns + * `none` rather than matching a large swath of tabs. + * + * Only OPEN tabs (`is_open = 1`) are addressable — a closed tab's UUID prefix + * must not cause phantom ambiguity or resolve to a dead conversation. + */ +export function resolveTabPrefix(prefix: string): ResolveTabPrefixResult { + const sanitized = (prefix ?? "").toLowerCase().replace(/[^0-9a-f-]/g, ""); + if (sanitized.length < MIN_TAB_PREFIX_LENGTH) { + return { status: "none" }; + } + const db = getDatabase(); + const rows = db + .query("SELECT * FROM tabs WHERE is_open = 1 AND id LIKE $prefix ORDER BY position ASC") + .all({ $prefix: `${sanitized}%` }) as Array<Record<string, unknown>>; + if (rows.length === 0) return { status: "none" }; + if (rows.length === 1) return { status: "ok", tab: rowToTab(rows[0] as Record<string, unknown>) }; + return { status: "ambiguous", matches: rows.map(rowToTab) }; +} + +/** + * Compute the shortest unique prefix (minimum `MIN_TAB_PREFIX_LENGTH` chars) + * that identifies `tabId` among the currently OPEN tabs — the backend twin of + * the frontend's display helper. Used when a tool needs to echo a tab's own + * handle (e.g. provenance prefixes, "available tabs" hints) without trusting a + * value from the wire. + * + * Returns the full id if no shorter unique prefix exists (degenerate — only if + * two open tabs share an entire id, which UUID uniqueness precludes). + */ +export function shortestUniquePrefix(tabId: string): string { + const db = getDatabase(); + const rows = db.query("SELECT id FROM tabs WHERE is_open = 1").all() as Array<{ id: string }>; + const others = rows.map((r) => r.id).filter((id) => id !== tabId); + for (let len = MIN_TAB_PREFIX_LENGTH; len < tabId.length; len++) { + const candidate = tabId.slice(0, len); + if (!others.some((id) => id.startsWith(candidate))) return candidate; + } + return tabId; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a3816ea..b1b17cc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,6 +49,10 @@ export { createTab, getTab, listOpenTabs, + MIN_TAB_PREFIX_LENGTH, + type ResolveTabPrefixResult, + resolveTabPrefix, + shortestUniquePrefix, type TabRow, updateTabModel, updateTabStatus, @@ -78,9 +82,16 @@ export { prefix as bashArityPrefix } from "./tools/bash-arity.js"; export { createListFilesTool } from "./tools/list-files.js"; export { createReadFileTool } from "./tools/read-file.js"; export { createReadFileSliceTool } from "./tools/read-file-slice.js"; +export { createReadTabTool, type ReadTabCallbacks } from "./tools/read-tab.js"; export { createToolRegistry } from "./tools/registry.js"; export { createRetrieveTool, type RetrieveCallbacks } from "./tools/retrieve.js"; export { BackgroundShellStore, createRunShellTool } from "./tools/run-shell.js"; +export { + createSendToTabTool, + type ResolvedTabRef, + type SendToTabCallbacks, + type TabResolution, +} from "./tools/send-to-tab.js"; export { analyzeCommand } from "./tools/shell-analyze.js"; export { type AvailableAgent, diff --git a/packages/core/src/llm/debug-logger.ts b/packages/core/src/llm/debug-logger.ts index 2b7420c..072a7a1 100644 --- a/packages/core/src/llm/debug-logger.ts +++ b/packages/core/src/llm/debug-logger.ts @@ -281,11 +281,7 @@ export function logStepLifecycle(data: { * Log agent loop-level events (loop start, break conditions, etc.). * Only logs at verbosity >= 3. */ -export function logAgentLoop(data: { - tabId?: string; - event: string; - detail?: unknown; -}): void { +export function logAgentLoop(data: { tabId?: string; event: string; detail?: unknown }): void { if (!ENABLED || VERBOSITY < 3) return; const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : ""; console.error(`[dispatch-debug] AGENT tab=${data.tabId ?? "?"} ${data.event}${detail}`); @@ -308,16 +304,14 @@ export function logAgentLoop(data: { * we just clone and read once via `.text()` — simpler and safe because * non-streaming bodies are bounded. */ -export function wrapFetchWithLogging< - F extends (...args: never[]) => Promise<Response> | Response, ->(baseFetch: F, opts: { tabId?: string; modelHint?: string }): F { +export function wrapFetchWithLogging<F extends (...args: never[]) => Promise<Response> | Response>( + baseFetch: F, + opts: { tabId?: string; modelHint?: string }, +): F { if (!ENABLED) return baseFetch; const wrapped = async (...args: Parameters<F>) => { const requestId = ++seq; - const [input, init] = args as unknown as [ - RequestInfo | URL, - RequestInit | undefined, - ]; + const [input, init] = args as unknown as [RequestInfo | URL, RequestInit | undefined]; const url = typeof input === "string" ? input @@ -325,7 +319,8 @@ export function wrapFetchWithLogging< ? input.toString() : (input as Request).url; const method = - init?.method ?? (typeof input === "object" && "method" in input ? (input as Request).method : "POST"); + init?.method ?? + (typeof input === "object" && "method" in input ? (input as Request).method : "POST"); // Snapshot headers as a plain object for logging. const headerObj: Record<string, string> = {}; 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({ |
