From 6315eb329a1567eb1e450dd45704ef2b2cdef25c Mon Sep 17 00:00:00 2001 From: Omkar Chebale Date: Sat, 3 Oct 2026 23:18:22 +0530 Subject: [PATCH] Offer coordination tools only to a run that may call them A group peer turn runs at depth 1 with no handoff claim, so authoriseCoordinationRun refuses every ask_person and message_bot call from it, yet toolsForRun still offered both. toolsForRun now asks the same authoriseRun first and offers nothing when a call would be refused. call() keeps its own check for stale schemas. Fixes #716 --- CHANGELOG.md | 8 +++++ server/src/agents/handoff-tool.ts | 3 ++ server/tests/remote-coordination.test.ts | 44 ++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb13829c..2a0e59a49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A Bot's turn in a group is no longer offered coordination tools it cannot call + +A Bot answering another Bot in a group conversation was offered `ask_person`, and `message_bot` +when hops allowed it, but every call was refused with "This run no longer has permission to +coordinate work in this conversation" and an `mcp.callback_refused` row nobody had caused. A run is +now offered these tools only when a call from it would be allowed, so that turn is offered neither. +A call that is refused anyway, from a schema offered earlier, is still refused and audited. + ## 0.1.0 **Before upgrading.** Six things change for an existing deployment: diff --git a/server/src/agents/handoff-tool.ts b/server/src/agents/handoff-tool.ts index 96717aef2..d45f5efb3 100644 --- a/server/src/agents/handoff-tool.ts +++ b/server/src/agents/handoff-tool.ts @@ -389,6 +389,9 @@ export function createCoordinationTools(options: { }; return { async toolsForRun(from: RunAssertion): Promise { + // Offered only when `call` would accept it: a group peer turn at depth 1 holds no handoff + // claim, so every call from it was refused and audited. `call` still checks, for stale schemas. + if (!(await options.authoriseRun(from))) return []; const canHandOn = options.caps.maxDepth > 0 && options.caps.maxPerRun > 0 && diff --git a/server/tests/remote-coordination.test.ts b/server/tests/remote-coordination.test.ts index f0428b7c2..b23c70ffb 100644 --- a/server/tests/remote-coordination.test.ts +++ b/server/tests/remote-coordination.test.ts @@ -174,7 +174,12 @@ describe("coordination run authority", () => { describe("the common coordination tools", () => { function tools( - options: { granted?: boolean; authorised?: boolean; depth?: number } = {}, + options: { + granted?: boolean; + authorised?: boolean; + authoriseRun?: (run: RunAssertion) => Promise; + depth?: number; + } = {}, ) { const sent: RunAssertion[] = []; const questions: { @@ -208,7 +213,8 @@ describe("the common coordination tools", () => { audit.push(event); }, }, - authoriseRun: async () => options.authorised ?? true, + authoriseRun: + options.authoriseRun ?? (async () => options.authorised ?? true), }); return { coordinator, sent, questions, audit }; } @@ -279,4 +285,38 @@ describe("the common coordination tools", () => { expect(audit).toHaveLength(2); expect(audit[0]?.initiator).toEqual(RUN.initiator); }); + + test("offers no coordination tool to a run that may not coordinate", async () => { + const { coordinator } = tools({ authorised: false }); + expect(await coordinator.toolsForRun(DELEGATED)).toEqual([]); + }); + + // A group peer turn is relayed at depth 1 with no handoff claim, the shape of RUN. + test.each([ + ["a leased delivery", DELEGATED, ["message_bot", "ask_person"]], + [ + "a direct run", + { ...RUN, depth: 0, botId: "source-bot" }, + ["message_bot", "ask_person"], + ], + ["a group peer turn", RUN, []], + ])( + "every tool offered to %s is callable by it", + async (_label, run, names) => { + const { coordinator } = tools({ + authoriseRun: (asserted) => + authoriseCoordinationRun(asserted, authority()), + }); + const offered = await coordinator.toolsForRun(run); + expect(offered.map((tool) => tool.name)).toEqual(names); + for (const tool of offered) { + const result = await coordinator.call({ + name: tool.name, + args: {}, + run, + }); + expect(result?.text).not.toContain("no longer has permission"); + } + }, + ); });