From d428f669f10e21700b6d5eb1fb14e90c9cc59cfe Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 30 Oct 2026 23:09:52 -0400 Subject: [PATCH] Keep ambiguous cross-app routing on the default Bot When separate named apps have different unique Bot holders, do not let roster order choose which Bot owns the conversation. Preserve a single unique holder and confident model decisions. AI assistance: implemented and locally validated with OpenAI Codex; maintainer review remains required. --- CHANGELOG.md | 6 ++++ docs/architecture.md | 5 +++ server/src/routing/classify.ts | 8 +++-- server/tests/routing-classify.test.ts | 50 +++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f9d3f16f4..76cdb56df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,12 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `COMPUTER_MEMORY_HIGH_PERCENT=0` and `COMPUTER_TAB_IDLE_MS=0` (Helm: `computers.memoryHighPercent` and `computers.tabIdleMs`) keep the old behaviour. +### A message naming different Bots' apps stays on the default when routing is unsure + +When the router cannot decide and a message names apps held by different Bots, OpenBot keeps the +default Bot instead of picking whichever app appears first in the roster. A single Bot uniquely +holding the named apps still receives the message, and a confident routing decision still wins. + ### A computer closes browsers nobody is using when its memory runs high A computer runs one browser for each Bot that is browsing, and the only limits were a count and half diff --git a/docs/architecture.md b/docs/architecture.md index 7650efc6fe..9079a57a3c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -168,6 +168,11 @@ A coworker is a durable Bot profile: A channel is a conversation with one coworker and a CopilotKit Intelligence thread mapping. Starting a new channel creates a new thread. +For a message with no named Bot, the intent router chooses from the person's roster. If it cannot +choose confidently, a unique app holder may receive the message instead of the default Bot. When +different named apps point to different unique holders, that fallback is ambiguous and the default +stays selected; roster order does not decide which Bot owns the conversation. + A group conversation holds several Bots. An Intelligence thread admits exactly one agent, so each Bot gets a thread of its own in the group (`group_bot_threads`), and what the people in it read is a shared transcript (`group_messages`) with every reply attributed to its speaker. A Bot that names a peer as `@Name` hands the conversation to it under the same grant, caps and audit rows as any other hop. Who may reach one is decided by membership: every channel route resolves the caller in diff --git a/server/src/routing/classify.ts b/server/src/routing/classify.ts index 58a4777d88..5cc4e5979c 100644 --- a/server/src/routing/classify.ts +++ b/server/src/routing/classify.ts @@ -172,16 +172,20 @@ function onlyCoworkerReaching( } } + let reachable: { id: string; name: string; system: string } | null = null; for (const system of named) { const holders = candidates.filter((candidate) => candidate.reaches?.includes(system), ); const only = holders[0]; if (holders.length === 1 && only) { - return { id: only.id, name: only.name, system }; + // Different systems can point to different Bots. The roster's order is not evidence that + // one of them should receive the whole conversation when the model could not decide. + if (reachable && reachable.id !== only.id) return null; + reachable ??= { id: only.id, name: only.name, system }; } } - return null; + return reachable; } export function createIntentRouter(deps: { diff --git a/server/tests/routing-classify.test.ts b/server/tests/routing-classify.test.ts index 54db5c3e24..564555b8ec 100644 --- a/server/tests/routing-classify.test.ts +++ b/server/tests/routing-classify.test.ts @@ -292,6 +292,56 @@ describe("falling back to somebody who can actually answer", () => { expect(decision.agentId).toBe("general-assistant"); }); + test("different unique holders do not make an arbitrary multi-system fallback", async () => { + const general: RoutingCandidate = { + id: "general", + name: "General", + roleDescription: "everyday work", + }; + const slack: RoutingCandidate = { + id: "slack-bot", + name: "Slack Bot", + roleDescription: "chat", + reaches: ["slack"], + }; + const drive: RoutingCandidate = { + id: "drive-bot", + name: "Drive Bot", + roleDescription: "documents", + reaches: ["google-drive"], + }; + for (const roster of [ + [general, slack, drive], + [general, drive, slack], + ]) { + const decision = await BROKEN.route( + "Compare the Slack discussion with the document in Google Drive", + roster, + "general", + ); + expect(decision.agentId).toBe("general"); + expect(decision.undecided).toBe("unreachable"); + } + }); + + test("one Bot uniquely reaching both named systems is still an unambiguous fallback", async () => { + const decision = await BROKEN.route( + "Compare the Slack discussion with the document in Google Drive", + [ + { id: "general", name: "General", roleDescription: "everyday work" }, + { + id: "knowledge", + name: "Knowledge", + roleDescription: "company knowledge", + reaches: ["slack", "google-drive"], + }, + ], + "general", + ); + expect(decision.agentId).toBe("knowledge"); + expect(decision.undecided).toBe("unreachable"); + }); + test("a confident match still wins over reach", async () => { /* * The line this must not cross. A specialist with no connectors is the right answer to a