Route to the coworker somebody named, and stop building a Bot four ways - #296
Open
jerelvelarde wants to merge 1 commit into
Open
Route to the coworker somebody named, and stop building a Bot four ways#296jerelvelarde wants to merge 1 commit into
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.
jerelvelarde
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 30, 2026 15:24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.