summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 15:53:15 +0900
committerAdam Malczewski <[email protected]>2026-06-02 15:53:15 +0900
commitaa295e82197ebc77d9466eee28380bc5bcc0863d (patch)
tree2a900e1f10564f4f4b3f698d1058ebc37d5c8d45
parente475e527cd768dc05368a0881a07a84ea140e13e (diff)
downloaddispatch-aa295e82197ebc77d9466eee28380bc5bcc0863d.tar.gz
dispatch-aa295e82197ebc77d9466eee28380bc5bcc0863d.zip
fix(tabs): only mention read_tab when the sender actually has it; CAPS on ONLY
The send_to_tab guidance previously told the agent it could call read_tab to check for a reply, but the tab-messaging permissions are split — a tab can hold send_to_tab WITHOUT read_tab (the exact case in testing). Advertising a tool the agent wasn't granted is wrong. Thread a canReadTab flag from AgentManager.buildTabCommToolEntries into createSendToTabTool (true iff this tab is also granted read_tab). The tool description and the delivery-result text now only reference read_tab when canReadTab is true; otherwise they say a reply arrives on its own and to end the turn. Drop the read_tab phrasing from the static TOOL_DESCRIPTIONS one-liner (can't be conditional per-tab there). Also uppercase ONLY in the recipient reply-contract footer for emphasis. Tests: cover both canReadTab branches for description + result text; assert ONLY is uppercased.
-rw-r--r--packages/api/src/agent-manager.ts13
-rw-r--r--packages/core/src/tools/send-to-tab.ts45
-rw-r--r--packages/core/tests/tools/send-to-tab.test.ts33
3 files changed, 79 insertions, 12 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 4264884..3d233fc 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -84,7 +84,7 @@ const TOOL_DESCRIPTIONS: Record<string, string> = {
youtube_transcribe:
"Fetch the transcript/subtitles for a YouTube video. Set background=true to start in the background and get a job_id for later retrieval.",
send_to_tab:
- "Send a message to another tab (agent) by its short ID, as shown in the tab bar. Fire-and-forget: it queues/wakes the target and returns immediately without waiting for a reply. Do NOT sleep, poll, or run commands to wait — a reply arrives on its own in a later turn (or use read_tab in a future turn); if you are only waiting, end your turn.",
+ "Send a message to another tab (agent) by its short ID, as shown in the tab bar. Fire-and-forget: it queues/wakes the target and returns immediately without waiting for a reply. Do NOT sleep, poll, or run commands to wait — a reply arrives on its own in a later turn; if you are only waiting, end your turn.",
read_tab:
"Read another tab (agent)'s most recent completed response by its short ID. Returns a non-blocking snapshot; if the target is still running you get its previous completed turn. Use after send_to_tab to collect a reply.",
};
@@ -546,7 +546,7 @@ export class AgentManager {
}
// Tab-to-tab communication — gated on the child whitelist.
if (allowed.has("send_to_tab") || allowed.has("read_tab")) {
- for (const entry of this.buildTabCommToolEntries(tabId)) {
+ for (const entry of this.buildTabCommToolEntries(tabId, allowed.has("read_tab"))) {
if (allowed.has(entry.name)) toolEntries.push(entry);
}
}
@@ -631,7 +631,7 @@ export class AgentManager {
const tabCommAllowed = new Set<string>();
if (permSendToTab) tabCommAllowed.add("send_to_tab");
if (permReadTab) tabCommAllowed.add("read_tab");
- for (const entry of this.buildTabCommToolEntries(tabId)) {
+ for (const entry of this.buildTabCommToolEntries(tabId, permReadTab)) {
if (tabCommAllowed.has(entry.name)) toolEntries.push(entry);
}
}
@@ -1241,9 +1241,15 @@ export class AgentManager {
* both tool-construction paths (child whitelist + permission-gated parent).
* `selfHandle` is computed once so the calling tab can stamp provenance and
* reject self-sends.
+ *
+ * `canReadTab` reflects whether THIS tab will also be granted `read_tab`
+ * (the permissions are split). It is forwarded into `send_to_tab` so the
+ * tool only points the agent at `read_tab` when it actually has it — never
+ * advertising a tool the agent wasn't granted.
*/
private buildTabCommToolEntries(
tabId: string,
+ canReadTab: boolean,
): Array<{ name: string; tool: ReturnType<typeof createSendToTabTool> }> {
const selfHandle = shortestUniquePrefix(tabId);
return [
@@ -1257,6 +1263,7 @@ export class AgentManager {
this.deliverMessage(targetId, message, { origin: "agent" }),
listOpenHandles: () => this.listOpenHandles(tabId),
self: { id: tabId, handle: selfHandle },
+ canReadTab,
}),
},
{
diff --git a/packages/core/src/tools/send-to-tab.ts b/packages/core/src/tools/send-to-tab.ts
index 84e5f25..50023a7 100644
--- a/packages/core/src/tools/send-to-tab.ts
+++ b/packages/core/src/tools/send-to-tab.ts
@@ -44,6 +44,13 @@ export interface SendToTabCallbacks {
/** 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 };
+ /**
+ * Whether THIS calling tab also has the `read_tab` tool granted. The
+ * tab-messaging permissions are split, so a tab can hold `send_to_tab`
+ * without `read_tab`. When false, the tool must NOT tell the agent to use
+ * `read_tab` (it doesn't have it) — replies only arrive on their own.
+ */
+ canReadTab: boolean;
}
/** Render the "available tabs" hint shared by the none/ambiguous branches. */
@@ -54,6 +61,19 @@ function renderOpenHandles(handles: Array<{ handle: string; title: string }>): s
}
export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefinition {
+ // The `read_tab` follow-up hint is only truthful when this tab actually
+ // holds the `read_tab` tool (the permissions are split). When it doesn't,
+ // the only honest guidance is that a reply arrives on its own — never tell
+ // the agent to call a tool it wasn't granted.
+ const waitLine = callbacks.canReadTab
+ ? "money. If the target replies it arrives on its own as a new message in a later turn; you"
+ : "money. If the target replies it arrives on its own as a new message in a later turn.";
+ const readTabLine = callbacks.canReadTab
+ ? ["can also call 'read_tab' with the same ID in a FUTURE turn to check. If you have other"]
+ : [];
+ const keepGoingLine = callbacks.canReadTab
+ ? "work to do, keep going; if you are ONLY waiting for the reply, end your turn now."
+ : "If you have other work to do, keep going; if you are ONLY waiting for the reply, end your turn now.";
return {
name: "send_to_tab",
description: [
@@ -65,9 +85,9 @@ export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefiniti
"",
"This is fire-and-forget: it returns immediately and does NOT wait for a reply.",
"Do NOT sleep, poll, or run shell commands to wait for a reply — that wastes turns and",
- "money. If the target replies it arrives on its own as a new message in a later turn; you",
- "can also call 'read_tab' with the same ID in a FUTURE turn to check. If you have other",
- "work to do, keep going; if you are ONLY waiting for the reply, end your turn now.",
+ waitLine,
+ ...readTabLine,
+ keepGoingLine,
"",
"Your tab ID is auto-added to the top of the message so the recipient knows who to reply",
"to. The recipient must use this same 'send_to_tab' tool (addressed to your ID) to answer;",
@@ -132,7 +152,7 @@ export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefiniti
"",
message,
"",
- `[To reply to tab ${callbacks.self.handle}, use the send_to_tab tool with tab_id "${callbacks.self.handle}". Only reply if this message asks you to, or your user tells you to — it may just be context or instructions. A plain text response goes to your own user, not to this agent.]`,
+ `[To reply to tab ${callbacks.self.handle}, use the send_to_tab tool with tab_id "${callbacks.self.handle}". ONLY reply if this message asks you to, or your user tells you to — it may just be context or instructions. A plain text response goes to your own user, not to this agent.]`,
].join("\n");
try {
@@ -153,13 +173,22 @@ export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefiniti
result.status === "queued"
? "queued (target is busy; it will be picked up next turn)"
: "delivered (target was idle; a new turn has started)";
+ const tail = callbacks.canReadTab
+ ? [
+ "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it",
+ `arrives on its own as a new message later; you can also call read_tab with "${target.handle}"`,
+ "in a FUTURE turn to check. Keep working if you have other tasks; if you are ONLY",
+ "waiting for this reply, end your turn now.",
+ ]
+ : [
+ "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it",
+ "arrives on its own as a new message later. Keep working if you have other tasks; if",
+ "you are ONLY waiting for this reply, end your turn now.",
+ ];
return [
`Message ${verb}. Target tab: ${target.handle} (${target.title}).`,
"",
- "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it",
- `arrives on its own as a new message later; you can also call read_tab with "${target.handle}"`,
- "in a FUTURE turn to check. Keep working if you have other tasks; if you are ONLY",
- "waiting for this reply, end your turn now.",
+ ...tail,
].join("\n");
} catch (err) {
return `Error delivering message: ${err instanceof Error ? err.message : String(err)}`;
diff --git a/packages/core/tests/tools/send-to-tab.test.ts b/packages/core/tests/tools/send-to-tab.test.ts
index 68f8fa0..48ff460 100644
--- a/packages/core/tests/tools/send-to-tab.test.ts
+++ b/packages/core/tests/tools/send-to-tab.test.ts
@@ -14,6 +14,7 @@ function makeCallbacks(overrides: Partial<SendToTabCallbacks> = {}): SendToTabCa
deliver: () => ({ status: "started" }),
listOpenHandles: () => [{ handle: "targ", title: "Target" }],
self: { id: "self-id", handle: "self" },
+ canReadTab: true,
...overrides,
};
}
@@ -28,6 +29,19 @@ describe("createSendToTabTool — schema & description", () => {
expect(tool.description.toLowerCase()).toContain("do not sleep");
expect(tool.description.toLowerCase()).toContain("end your turn");
});
+
+ it("mentions read_tab in the description only when canReadTab is true", () => {
+ const tool = createSendToTabTool(makeCallbacks({ canReadTab: true }));
+ expect(tool.description).toContain("read_tab");
+ });
+
+ it("never mentions read_tab in the description when canReadTab is false", () => {
+ const tool = createSendToTabTool(makeCallbacks({ canReadTab: false }));
+ expect(tool.description).not.toContain("read_tab");
+ // Still tells the agent a reply arrives on its own + to end its turn.
+ expect(tool.description.toLowerCase()).toContain("arrives on its own");
+ expect(tool.description.toLowerCase()).toContain("end your turn");
+ });
});
describe("createSendToTabTool — execute()", () => {
@@ -46,7 +60,7 @@ describe("createSendToTabTool — execute()", () => {
// Reply contract: the recipient must answer via send_to_tab back to the
// sender's handle, not as a plain text reply to its own user.
expect(delivered).toContain('send_to_tab tool with tab_id "self"');
- expect(delivered.toLowerCase()).toContain("only reply if");
+ expect(delivered).toContain("ONLY reply if");
expect(out).toContain("idle");
expect(out).toContain("targ");
// Sender is steered away from busy-waiting and told to end its turn.
@@ -54,6 +68,23 @@ describe("createSendToTabTool — execute()", () => {
expect(out.toLowerCase()).toContain("end your turn");
});
+ it("points the sender at read_tab in the result only when canReadTab is true", async () => {
+ const deliver = vi.fn(() => ({ status: "started" as const }));
+ const tool = createSendToTabTool(makeCallbacks({ deliver, canReadTab: true }));
+ const out = await tool.execute({ tab_id: "targ", message: "hi" });
+ expect(out).toContain("read_tab");
+ });
+
+ it("omits read_tab from the result when canReadTab is false", async () => {
+ const deliver = vi.fn(() => ({ status: "started" as const }));
+ const tool = createSendToTabTool(makeCallbacks({ deliver, canReadTab: false }));
+ const out = await tool.execute({ tab_id: "targ", message: "hi" });
+ expect(out).not.toContain("read_tab");
+ // Still steers away from busy-waiting and toward ending the turn.
+ expect(out.toLowerCase()).toContain("do not sleep");
+ expect(out.toLowerCase()).toContain("end your turn");
+ });
+
it("reports the queued status when the target is busy", async () => {
const deliver = vi.fn(() => ({ status: "queued" as const }));
const tool = createSendToTabTool(makeCallbacks({ deliver }));