Route to the coworker somebody named, and stop building a Bot four ways - #296
jerelvelarde wants to merge 1 commit into
Conversation
Four callers now build a Bot for a person — a chat request, a routine's headless turn, a hop delivered to another Bot, and the boundary's own lookup — and each passed the same eleven collaborators positionally. One of them getting an argument wrong is a Bot that runs and quietly holds different tools or a different role from the one the person is talking to. ActorAgentResolver binds them once. Choosing a coworker moves out of the HTTP route for the same reason: it was the routing model call, the visibility rule, and the channel.routed row all written inside a Hono handler, so nothing that is not an HTTP request could route. CoworkerRoutingService owns the decision, and the route turns its outcome into status codes. That move makes an explicit name cheap enough to honour: a message that names exactly one coworker on the asking person's roster no longer pays a model call to be told what the person already said. Two matches are refused with both names rather than guessed at.
|
Verified and want this (named-coworker routing + the 4→1 resolver consolidation). Holding for a rebase: both #290 (index.ts/copilot.ts) and #309 (copilot.ts) landed after this branched and touch the same regions your |
|
Read this closely because it rewrites The service extraction is the right shape. Four call sites building a Bot positionally from eleven One thing I would not merge as it stands. Every suffix of a coworker's name becomes a standalone mention
addAlias(aliases, normalized, profile);
for (const suffix of suffixes(normalized)) addAlias(aliases, suffix, profile);
Those are ordinary English words, and
matches Nobody named anybody. The person used a common noun, and the deployment records that they addressed a Two things follow, and the second is the one I care about: The model is skipped, so nothing catches it. The whole value of the short-circuit is that a named The trail says something that is not true. Ambiguity does not save it: two coworkers ending in What I would doAny of these, and I have no strong preference between the first two:
The full-name match needs none of this — Smaller notes, none blocking
Happy to be wrong about the suffix case if the intent is that a bare |
Hotragn
left a comment
There was a problem hiding this comment.
This has sat two and a half weeks with no review. Here is one, starting with the mechanical state because it decides whether the rest is worth your time, and then two things I would want changed — one of which is a hardening that has landed since you opened this and would be silently undone by a rebase.
It conflicts, and one of the conflicts is load-bearing
git merge-tree against main gives five:
| File | |
|---|---|
CHANGELOG.md |
trivial, the usual ## Unreleased collision |
server/tests/copilot.test.ts |
follows copilot.ts |
server/tests/routing-routes.test.ts |
follows routes.ts |
server/src/copilot.ts |
eight commits since, mostly #505 and #451 |
server/src/index.ts |
eight commits since |
server/src/routing/routes.ts |
one commit, and it is the one to read |
#548 capped the routing text. This PR deletes the file it capped
471d1a4 ("Cap routing text and harden routine dispatch and page-frame params", #548) landed in routing/routes.ts on 15 September:
// Unbounded text becomes the model prompt. Cap it so a multi-megabyte body cannot be used
// to force a timeout or OOM in the router.
if (text.length > 10000) {
return context.json(
{ error: "A message of at most 10000 characters is required." },
400,
);
}This PR takes 171 lines out of that file. I checked both new files rather than assuming:
routing/routes.tson this branch still hasif (!text) return 400and no length check.routing/service.tshas no length check either —grepfor10000and for.length >finds only the duplicate-alias counting.
So a straight rebase resolves the conflict by taking your side of a file whose hardening arrived after you wrote it, and POST /api/route goes back to passing unbounded text to the model prompt. Nothing fails; the check is just gone, which is the worst way for a guard to disappear.
It needs a decision about where it lives now, not just a re-paste. The cap is about what reaches the model, and after this PR the thing that reaches the model is the service. My instinct is the service, so a routine or a hop routing through the same seam is capped too — the route is no longer the only caller, which is the whole point of the PR. But the route returning the 400 keeps status codes where you have deliberately put them. Either is defensible; dropping it is not.
viaMention now means two different things, and the trail cannot tell them apart
This is the part I would most want changed, and I should disclose that I wrote the channel.routed row's "say why, not only where" semantics in #248, so I am reviewing my own field.
On main that field has one meaning, and the docblock is explicit about it:
A person who named a coworker with
@has already decided, so nothing is inferred and no model is called. It is still recorded, withviaMentiontrue and the person as the reason.
On this branch, two different paths produce the identical row. service.ts:311-323:
const namedId = input.agentId?.trim() || null;
if (namedId) { ... reason: "named by the person asking", viaMention: true }and service.ts:249-257, reached from explicitNameRoute(input.text, roster):
if (profiles.size === 1) {
return { ..., reason: "named by the person asking", viaMention: true };
}The first is the person having decided. The second is this deployment's alias matcher finding a coworker's name somewhere in free text — NFKC-normalised, token-bounded, with suffix aliases and a containment-suppression rule. That is an inference. A good one, and I like the feature; the model call it saves is real. But it is not the person deciding, and the row says it was.
It matters because the matcher can be wrong in a way an @ cannot. It scans the whole message, so "don't let Risk Analyst see this, send it to Legal" contains Risk Analyst. Depending on what else is on the roster that is either a 409 or a route to the coworker the person explicitly excluded — and the trail records viaMention: true, reason: "named by the person asking". Somebody reading that row later has no way to find out the deployment guessed, because the row it produces is byte-identical to one where the person pointed.
The fix is cheap and I think it makes the feature more defensible rather than less — a third value. Keep viaMention for @ only, and give the name match its own reason, something like "matched a coworker's name in the message", with its own flag if auditReason needs to branch. Then the field still answers "did a model decide this", the new one answers "did the person decide or did we", and the first operator who gets a surprising route can count them.
What I would keep
- Re-applying
canAccessAgentat the service boundary (service.ts:307-309) rather than trusting the store's SQL is right, and the comment gives the correct reason. Two enforcements of one rule is not duplication when one of them is a query somebody might later widen. - The diagnosis in the first section is the strongest part of this PR. "Eleven collaborators passed positionally, with a capitalised comment doing the work a type should do" is exactly the failure this repo keeps finding, and
ActorAgentResolveris the right answer to it.copilot.tslosing 107 lines androutes.tslosing 171 is the shape a correct extraction has.
I have not run this: the conflicts mean I would be testing a merge I invented rather than the one you will write. Happy to look again after the rebase — and if you would rather I sent the text cap as a separate commit onto your branch once it is rebased, say so.
The problem
Four things in this deployment now build a Bot for a person: a chat request through
mountCopilotRuntime, a routine's headless turn throughbuildAgentFor, a hop delivered to anotherBot through
agentFor, and whatever comes next. They have to build the same Bot, and today eachone is handed the same eleven collaborators positionally, in the same order, by hand.
index.tsalready says so in a comment block in capitals — "they have to build the SAME Bot" — which is a
comment doing the work a type should do. One caller passing
undefinedwhereagentFetchgoes is aBot that runs, answers, and quietly holds different tools or a different role from the one the person
is talking to. Nothing fails; the answer is just worse, and there is nothing to point at.
Choosing which coworker has the same shape of problem for a different reason. The model call, the
visibility rule, the preferred-coworker fallback and the
channel.routedrow are all written insidea Hono handler, so nothing that is not an HTTP request can route. A routine cannot. A hop cannot.
And routing is paying for something it already knows. "ask Risk Analyst to review the Q3 filing"
names the coworker in the first four words, and the deployment sends the sentence to a model to be
told what the person already said — a model call and its latency on every such message, and
occasionally a different answer than the one asked for.
The approach
ActorAgentResolverbinds the collaborators once. It is constructed at boot from the namedconstants that already exist for this reason, and every surface asks it for a coworker instead of
re-assembling the wiring.
resolveAgentsForActorgives the whole roster for a request;resolveAgentForActorgives one, and passes the id down toresolveRuntimeAgentsso the other Botsare neither built nor asked what they hold. The roster is still read in full, so a coworker somebody
cannot see is still absent — that check does not move.
CoworkerRoutingServiceowns the decision, and the route owns the status codes. The servicereturns
selected,ambiguousornone;routing/routes.tsturns those into 200, 409 and 404. Italso re-applies
canAccessAgentat its own boundary rather than trusting the store's SQL alone,because a broader store implementation must not be able to leak a coworker into routing.
A name that matches exactly one coworker is honoured without a model call. Matching is against
the asking person's own roster, on NFKC-normalised names with Unicode token boundaries, so
Riskierdoes not match
Risk. Suffix aliases are indexed too, and a longer alias that fully contains ashorter one suppresses it — otherwise "Risk Analyst" would always read as ambiguous with a coworker
called "Analyst". Two coworkers that genuinely answer to one name is a 409 carrying both display
labels, not a guess, because silently redirecting a message somebody addressed by hand is the worst
answer available.
The cost is real. Name matching is string work on every untagged message, and it can be wrong in
the direction of refusing: a deployment with two coworkers whose names overlap will see 409s where it
used to see a model's pick. That is deliberate — the 409 names both, so the person resolves it in one
reply — but it is a behaviour change, not a pure addition, and it is in
CHANGELOG.mdas one.A failed reachability read now stops the turn. Which systems a coworker can reach is weighed by
the router. Treating a failed read as "reaches nothing" is a false statement about the deployment
rather than an absence of one, and it quietly routed work away from the coworker that could do it.
CoworkerReachabilityUnavailableErrorrefuses instead.What is not covered
the roster on every untagged message.
pretty. A friendlier disambiguator needs something the roster does not carry yet.
resolveAgentForActorthrows for a coworker the person cannot reach and the two existing callersboth turn that into
null. The distinction between "not registered" and "not yours" is notexposed, because neither caller should tell the difference to a person.
the same as before apart from the new
named by the person askingreason.Verification
Full suite on this branch, against a live PostgreSQL: 2094 pass, 0 fail, 23 skip, 2117 tests
across 172 files.
mainin the same environment runs 2084 across 170; the two new files are thedifference.
bun run format:check,bun run lint,bun run typecheckandbun run buildall clean. No schema change,so
drizzle-kit checkand the unwritten-migration probe are untouched.The recording drives
CoworkerRoutingServiceagainst a real database, a realAgentProfileStoreandthe real audit store. Only the intent model is a stand-in, and only so its calls can be counted — the
whole claim is that a named coworker never reaches it. It ends on the
channel.routedrows, whichcarry the reason and the candidates and never the message text.
server/tests/routing-service.test.ts— the matching rules, one case per way a name can beambiguous or contained; the visibility filter; the reachability refusal; and that the audit row is
written exactly once and never carries the message.
server/tests/agent-resolver.test.ts— that both entry points build through the same collaborators,and that asking for one coworker builds only that one.