Skip to content

Commit c936c2b

Browse files
kevin9327claudedavidmckayv
authored
Match a coworker's connector by name, not as a substring of another word (#293)
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: kevin9327 <kevin9327@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
1 parent 91d8ae8 commit c936c2b

3 files changed

Lines changed: 84 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### A message no longer routes to a specialist because a longer word contained a connector's name
12+
13+
When the intent router falls back — it is unreachable, or it declines — and exactly one coworker can
14+
reach a system the message names, the message goes to that coworker. The name was matched as a bare
15+
substring, so "how do I deal with a slacker" matched the **slack** connector and "una jirafa" (a
16+
giraffe) matched **jira**: a message naming neither system was pinned, for the life of the thread,
17+
to a specialist that could not answer it. A connector's name now has to appear on a word boundary,
18+
so a system named on its own still routes and one buried inside another word does not.
1119
### The audit page no longer says "Allowed" about six kinds of refusal
1220

1321
A hop one Bot was not allowed to make, an endpoint this deployment would not dial, a rotation the

server/src/routing/classify.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,40 @@ export function routingPrompt(
123123
].join("\n");
124124
}
125125

126+
/**
127+
* Whether a message names a system, by its id or by that id with separators loosened.
128+
*
129+
* Matched at word boundaries rather than as a bare substring. `haystack.includes("slack")` is true
130+
* of "how do I handle a slacker", and `includes("jira")` is true of the Spanish for giraffe,
131+
* "jirafa" — so a message that names neither system was read as naming one, and in a fallback the
132+
* whole conversation was pinned to that specialist. A system id has to sit on its own here: bounded
133+
* by a non-alphanumeric character or an edge of the message, not buried inside a longer word.
134+
*
135+
* The id is still matched with separators loosened, so `google-drive` answers to "google drive" as
136+
* somebody would type it, and its raw form is matched too. Deliberately not fuzzy beyond that: a
137+
* router that guesses at near-misses is a router nobody can predict.
138+
*/
139+
function messageNames(haystack: string, system: string): boolean {
140+
const spelled = system.toLowerCase().replace(/[-_]+/g, " ");
141+
return bounded(haystack, spelled) || bounded(haystack, system.toLowerCase());
142+
}
143+
144+
/** `needle` present in `haystack`, on word boundaries, with `needle`'s own characters taken literally. */
145+
function bounded(haystack: string, needle: string): boolean {
146+
if (!needle) return false;
147+
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
148+
return new RegExp(`(?:^|[^a-z0-9])${escaped}(?:[^a-z0-9]|$)`).test(haystack);
149+
}
150+
126151
/**
127152
* The one coworker that can reach a system this message names, when there is exactly one.
128153
*
129154
* A hint for the router became a decision for the fallback, and only there. A confident match on
130155
* purpose still wins: a specialist with no connectors is the right answer to a question about its
131156
* specialism, and this must not turn reach into a filter that overrides that.
132157
*
133-
* Matched on the system's own id with separators loosened, so `google-drive` answers to "Google
134-
* Drive" as somebody would type it. Deliberately not fuzzy beyond that: a router that guesses at
135-
* near-misses is a router nobody can predict.
158+
* A message names a system by {@link messageNames}: its id, or that id with separators loosened, at
159+
* a word boundary.
136160
*/
137161
function onlyCoworkerReaching(
138162
text: string,
@@ -142,11 +166,7 @@ function onlyCoworkerReaching(
142166
const named = new Set<string>();
143167
for (const candidate of candidates) {
144168
for (const system of candidate.reaches ?? []) {
145-
const spelled = system.toLowerCase().replace(/[-_]+/g, " ");
146-
if (
147-
haystack.includes(spelled) ||
148-
haystack.includes(system.toLowerCase())
149-
) {
169+
if (messageNames(haystack, system)) {
150170
named.add(system);
151171
}
152172
}

server/tests/routing-classify.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,54 @@ describe("falling back to somebody who can actually answer", () => {
228228
expect(decision.agentId).toBe("general-assistant");
229229
});
230230

231+
/*
232+
* A system id buried inside a longer word does not name the system.
233+
*
234+
* `includes("slack")` is true of "slacker" and `includes("jira")` is true of "jirafa" — the
235+
* Spanish for giraffe — so a message that names neither system was read as naming one and, in a
236+
* fallback, pinned the whole conversation to that specialist. The id has to sit on its own.
237+
*/
238+
const NAMED: RoutingCandidate[] = [
239+
{ id: "general", name: "General", roleDescription: "everyday work" },
240+
{
241+
id: "slackbot",
242+
name: "Slack",
243+
roleDescription: "chat",
244+
reaches: ["slack"],
245+
},
246+
{
247+
id: "jirabot",
248+
name: "Jira",
249+
roleDescription: "tickets",
250+
reaches: ["jira"],
251+
},
252+
];
253+
254+
test("a system id inside an unrelated word is not a match", async () => {
255+
const slacker = await BROKEN.route(
256+
"how do I deal with a slacker on my team",
257+
NAMED,
258+
"general",
259+
);
260+
expect(slacker.agentId).toBe("general");
261+
262+
const giraffe = await BROKEN.route(
263+
"escribe un cuento sobre una jirafa",
264+
NAMED,
265+
"general",
266+
);
267+
expect(giraffe.agentId).toBe("general");
268+
});
269+
270+
test("the same system named on its own still routes to its holder", async () => {
271+
const decision = await BROKEN.route(
272+
"post this update to slack for me",
273+
NAMED,
274+
"general",
275+
);
276+
expect(decision.agentId).toBe("slackbot");
277+
});
278+
231279
test("uses the default when two coworkers reach the same system", async () => {
232280
// Not a decision this can make. Two holders is exactly the case the router is for.
233281
const shared: RoutingCandidate[] = [

0 commit comments

Comments
 (0)