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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions server/src/routing/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
50 changes: 50 additions & 0 deletions server/tests/routing-classify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down