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/read-tab.ts | |
| 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/read-tab.ts')
| -rw-r--r-- | packages/core/src/tools/read-tab.ts | 95 |
1 files changed, 95 insertions, 0 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"); + }, + }; +} |
