diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 16:04:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 16:04:20 +0900 |
| commit | a24397636de35f4b92c7cd85154ddc03b98d47cd (patch) | |
| tree | 99c5de728457d816d9baf0bfffe3c4fc2eb34af5 /packages/api | |
| parent | 3ff2db698c2633023934d8477a9e995f78fa011e (diff) | |
| parent | e0b63c0c03880bf77a07d47b28bbabf84649fcc3 (diff) | |
| download | dispatch-a24397636de35f4b92c7cd85154ddc03b98d47cd.tar.gz dispatch-a24397636de35f4b92c7cd85154ddc03b98d47cd.zip | |
Merge branch 'dev' into perm/fix-user-agent-summon-permission
# Conflicts:
# packages/api/tests/agent-manager.test.ts
Diffstat (limited to 'packages/api')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 15 | ||||
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 71 |
2 files changed, 82 insertions, 4 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 9499ce5..2795a6c 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -83,6 +83,10 @@ const TOOL_DESCRIPTIONS: Record<string, string> = { web_search: "Search the web and optionally scrape full page content from results.", 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 — if the target replies it will wake you with a new message 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.", }; /** @@ -542,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); } } @@ -639,7 +643,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); } } @@ -1249,9 +1253,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 [ @@ -1265,6 +1275,7 @@ export class AgentManager { this.deliverMessage(targetId, message, { origin: "agent" }), listOpenHandles: () => this.listOpenHandles(tabId), self: { id: tabId, handle: selfHandle }, + canReadTab, }), }, { diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index f3ea207..3353aff 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -75,7 +75,11 @@ function makeRow( // because the production code reassigns `agent.messages = // rows.slice(...)` AFTER `new Agent()` returns — capturing a // reference at construction would yield a stale empty array. -const constructedAgents: Array<{ initialMessages: unknown[]; toolNames: string[] }> = []; +const constructedAgents: Array<{ + initialMessages: unknown[]; + toolNames: string[]; + systemPrompt: string; +}> = []; function resetConstructedAgents(): void { constructedAgents.length = 0; } @@ -159,8 +163,10 @@ vi.mock("@dispatch/core", () => ({ status = "idle"; messages: unknown[] = []; toolNames: string[] = []; - constructor(config: { tools?: Array<{ name: string }> }) { + systemPrompt = ""; + constructor(config: { tools?: Array<{ name: string }>; systemPrompt?: string }) { this.toolNames = (config?.tools ?? []).map((t) => t.name); + this.systemPrompt = config?.systemPrompt ?? ""; } async *run(message: string, options?: { reasoningEffort?: string }): AsyncGenerator<unknown> { // Snapshot the post-construction pre-populated message list @@ -170,6 +176,7 @@ vi.mock("@dispatch/core", () => ({ constructedAgents.push({ initialMessages: [...this.messages], toolNames: [...this.toolNames], + systemPrompt: this.systemPrompt, }); capturedRunOptions.push(options); if (runImpl) { @@ -1502,6 +1509,66 @@ describe("AgentManager", () => { }); }); + // Regression: granted tab-messaging tools must also be ADVERTISED in the + // agent's system prompt. The tools were registered in the API tool payload + // but `buildSystemPrompt` filtered its "You have access to the following + // tools" list through TOOL_DESCRIPTIONS, which lacked send_to_tab/read_tab + // — so the model was told it didn't have them and refused to use them. This + // locks the prompt's capability list to the granted toolset. + describe("send_to_tab / read_tab system-prompt advertisement", () => { + async function promptForPerms(tabId: string, perms: Record<string, string>): Promise<string> { + for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v); + const manager = new AgentManager(); + await manager.processMessage(tabId, "go"); + return constructedAgents.at(-1)?.systemPrompt ?? ""; + } + + it("lists send_to_tab in the system prompt when granted", async () => { + const prompt = await promptForPerms("tab-prompt-send", { perm_send_to_tab: "allow" }); + expect(prompt).toContain("- send_to_tab:"); + expect(prompt).not.toContain("- read_tab:"); + }); + + it("lists read_tab in the system prompt when granted", async () => { + const prompt = await promptForPerms("tab-prompt-read", { perm_read_tab: "allow" }); + expect(prompt).toContain("- read_tab:"); + expect(prompt).not.toContain("- send_to_tab:"); + }); + + it("lists both tab-messaging tools when both are granted", async () => { + const prompt = await promptForPerms("tab-prompt-both", { + perm_send_to_tab: "allow", + perm_read_tab: "allow", + }); + expect(prompt).toContain("- send_to_tab:"); + expect(prompt).toContain("- read_tab:"); + }); + + it("omits both from the system prompt when neither is granted", async () => { + const prompt = await promptForPerms("tab-prompt-neither", {}); + expect(prompt).not.toContain("- send_to_tab:"); + expect(prompt).not.toContain("- read_tab:"); + }); + + it("advertises exactly the granted tab tools (prompt list matches schema)", async () => { + for (const [k, v] of Object.entries({ + perm_send_to_tab: "allow", + perm_read_tab: "allow", + })) { + setFakeSetting(k, v); + } + const manager = new AgentManager(); + await manager.processMessage("tab-prompt-match", "go"); + const inst = constructedAgents.at(-1); + // Every granted tab-messaging tool surfaced in the schema must also be + // advertised in the prompt, so the model never believes it lacks one. + for (const name of ["send_to_tab", "read_tab"]) { + expect(inst?.toolNames).toContain(name); + expect(inst?.systemPrompt).toContain(`- ${name}:`); + } + }); + }); + // ─── Usage side-channel persistence ────────────────────────────── // // `usage` AgentEvents (one per LLM round-trip) are persisted as invisible |
