Route to the coworker somebody named, and stop building a Bot four ways - #296
Route to the coworker somebody named, and stop building a Bot four ways#296jerelvelarde 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 |
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.