Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions server/src/agents/handoff-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,9 @@ export function createCoordinationTools(options: {
};
return {
async toolsForRun(from: RunAssertion): Promise<GrantedTool[]> {
// 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 &&
Expand Down
44 changes: 42 additions & 2 deletions server/tests/remote-coordination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
depth?: number;
} = {},
) {
const sent: RunAssertion[] = [];
const questions: {
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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");
}
},
);
});