From e53f120dbbfd57c4021e1d6934b74ef07609fc63 Mon Sep 17 00:00:00 2001 From: kevin9327 Date: Sun, 30 Aug 2026 15:15:09 +0900 Subject: [PATCH] Match a coworker's connector by name, not as a substring of another word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the intent router falls back and exactly one coworker can reach a system the message names, the message is routed to that coworker. onlyCoworkerReaching matched the system id with `haystack.includes(...)`, a bare substring test. So "how do I deal with a slacker" matched the `slack` connector, and "escribe un cuento sobre una jirafa" — a giraffe — matched `jira`. A message that named neither system was read as naming one, and because a fallback pins the channel to one coworker for the life of the thread, it misrouted the whole conversation to a specialist that could not answer it. Every untagged message takes this path when the router endpoint is down, which is the case that surfaced it. The id is now matched on word boundaries: bounded by a non-alphanumeric character or an edge of the message, with the id's own characters taken literally. A system named on its own still routes to its holder, and one buried inside a longer word does not. Separator loosening is unchanged, so google-drive still answers to "google drive". Word boundaries do not settle a name that genuinely appears as its own word for another reason (a `linear` connector and "linear regression"); that is a limit of a lexical reach hint, not this substring defect, and is left as is. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 +++++ server/src/routing/classify.ts | 36 +++++++++++++++----- server/tests/routing-classify.test.ts | 48 +++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c851fcd3..b8fe2c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A message no longer routes to a specialist because a longer word contained a connector's name + +When the intent router falls back — it is unreachable, or it declines — and exactly one coworker can +reach a system the message names, the message goes to that coworker. The name was matched as a bare +substring, so "how do I deal with a slacker" matched the **slack** connector and "una jirafa" (a +giraffe) matched **jira**: a message naming neither system was pinned, for the life of the thread, +to a specialist that could not answer it. A connector's name now has to appear on a word boundary, +so a system named on its own still routes and one buried inside another word does not. + ### A Bot's shell can no longer reach the embedded database without a password In the all-in-one image the cluster was `trust`-auth on loopback, and the Bot's shell runs in the diff --git a/server/src/routing/classify.ts b/server/src/routing/classify.ts index 0286fa7c..58a4777d 100644 --- a/server/src/routing/classify.ts +++ b/server/src/routing/classify.ts @@ -123,6 +123,31 @@ export function routingPrompt( ].join("\n"); } +/** + * Whether a message names a system, by its id or by that id with separators loosened. + * + * Matched at word boundaries rather than as a bare substring. `haystack.includes("slack")` is true + * of "how do I handle a slacker", and `includes("jira")` is true of the Spanish for giraffe, + * "jirafa" — so a message that names neither system was read as naming one, and in a fallback the + * whole conversation was pinned to that specialist. A system id has to sit on its own here: bounded + * by a non-alphanumeric character or an edge of the message, not buried inside a longer word. + * + * The id is still matched with separators loosened, so `google-drive` answers to "google drive" as + * somebody would type it, and its raw form is matched too. Deliberately not fuzzy beyond that: a + * router that guesses at near-misses is a router nobody can predict. + */ +function messageNames(haystack: string, system: string): boolean { + const spelled = system.toLowerCase().replace(/[-_]+/g, " "); + return bounded(haystack, spelled) || bounded(haystack, system.toLowerCase()); +} + +/** `needle` present in `haystack`, on word boundaries, with `needle`'s own characters taken literally. */ +function bounded(haystack: string, needle: string): boolean { + if (!needle) return false; + const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(?:^|[^a-z0-9])${escaped}(?:[^a-z0-9]|$)`).test(haystack); +} + /** * The one coworker that can reach a system this message names, when there is exactly one. * @@ -130,9 +155,8 @@ export function routingPrompt( * purpose still wins: a specialist with no connectors is the right answer to a question about its * specialism, and this must not turn reach into a filter that overrides that. * - * Matched on the system's own id with separators loosened, so `google-drive` answers to "Google - * Drive" as somebody would type it. Deliberately not fuzzy beyond that: a router that guesses at - * near-misses is a router nobody can predict. + * A message names a system by {@link messageNames}: its id, or that id with separators loosened, at + * a word boundary. */ function onlyCoworkerReaching( text: string, @@ -142,11 +166,7 @@ function onlyCoworkerReaching( const named = new Set(); for (const candidate of candidates) { for (const system of candidate.reaches ?? []) { - const spelled = system.toLowerCase().replace(/[-_]+/g, " "); - if ( - haystack.includes(spelled) || - haystack.includes(system.toLowerCase()) - ) { + if (messageNames(haystack, system)) { named.add(system); } } diff --git a/server/tests/routing-classify.test.ts b/server/tests/routing-classify.test.ts index f801f5f6..54db5c3e 100644 --- a/server/tests/routing-classify.test.ts +++ b/server/tests/routing-classify.test.ts @@ -228,6 +228,54 @@ describe("falling back to somebody who can actually answer", () => { expect(decision.agentId).toBe("general-assistant"); }); + /* + * A system id buried inside a longer word does not name the system. + * + * `includes("slack")` is true of "slacker" and `includes("jira")` is true of "jirafa" — the + * Spanish for giraffe — so a message that names neither system was read as naming one and, in a + * fallback, pinned the whole conversation to that specialist. The id has to sit on its own. + */ + const NAMED: RoutingCandidate[] = [ + { id: "general", name: "General", roleDescription: "everyday work" }, + { + id: "slackbot", + name: "Slack", + roleDescription: "chat", + reaches: ["slack"], + }, + { + id: "jirabot", + name: "Jira", + roleDescription: "tickets", + reaches: ["jira"], + }, + ]; + + test("a system id inside an unrelated word is not a match", async () => { + const slacker = await BROKEN.route( + "how do I deal with a slacker on my team", + NAMED, + "general", + ); + expect(slacker.agentId).toBe("general"); + + const giraffe = await BROKEN.route( + "escribe un cuento sobre una jirafa", + NAMED, + "general", + ); + expect(giraffe.agentId).toBe("general"); + }); + + test("the same system named on its own still routes to its holder", async () => { + const decision = await BROKEN.route( + "post this update to slack for me", + NAMED, + "general", + ); + expect(decision.agentId).toBe("slackbot"); + }); + test("uses the default when two coworkers reach the same system", async () => { // Not a decision this can make. Two holders is exactly the case the router is for. const shared: RoutingCandidate[] = [