summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/tools
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 01:46:13 +0900
committerAdam Malczewski <[email protected]>2026-06-01 01:46:13 +0900
commit8b9533c22a47bbf6f916667e2c25d8e8e419da37 (patch)
tree715a6a3d6f43781395e7dc7c8cdb519cef46a870 /packages/core/src/tools
parent1853dd1d40308deb829bc621beb79c5d39b9c57f (diff)
downloaddispatch-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.ts95
-rw-r--r--packages/core/src/tools/send-to-tab.ts147
-rw-r--r--packages/core/src/tools/summon.ts8
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({