From 8c6edc101327bfa49bfc8b384a064d3c22905679 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 21 Aug 2026 16:51:58 -0700 Subject: [PATCH 1/2] Send an untagged message to the coworker it is for Typing without naming anyone reached the default coworker; a specialist needed an @. That makes the person the router, which is the friction that reads as dated next to assistants that just take what you say and act. Now an untagged message is routed to the coworker whose purpose matches it. A channel is pinned to one coworker before its first turn, so the choice happens at the one seam where an untagged message picks a coworker, before the channel is created. A new POST /api/route reads the roster for the person asking (so it can only ever pick a coworker they may already reach), asks the deployment's own model to choose against each coworker's own description, validates the answer is a coworker on that roster, records a channel.routed row, and returns it. The model call is the deployment's existing model and key, not a second thing to configure. Named, not silent, which the composer already believed: the channel header is the coworker it went to, and the audit row carries the reason and the candidates but never the message, which the payload redaction would drop anyway. @ is unchanged and wins: an addressed message skips routing entirely, no model call, no inference recorded. And every uncertain path lands on the same default the composer always used and says so rather than misroute or drop: no roster, one coworker, model unreachable, unparseable answer, an id not on the roster, or low confidence. The classifier is a pure function with the model call injected, so those failure paths are a plain test rather than a mock of a network. --- CHANGELOG.md | 8 ++ app/src/lib/channels/route.ts | 26 +++++ app/src/routes/_authed/_app/index.tsx | 21 ++++- server/src/app.ts | 24 +++++ server/src/audit.ts | 10 ++ server/src/index.ts | 18 ++++ server/src/routing/classify.ts | 131 ++++++++++++++++++++++++++ server/src/routing/model.ts | 44 +++++++++ server/src/routing/routes.ts | 77 +++++++++++++++ server/tests/routing-classify.test.ts | 102 ++++++++++++++++++++ 10 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 app/src/lib/channels/route.ts create mode 100644 server/src/routing/classify.ts create mode 100644 server/src/routing/model.ts create mode 100644 server/src/routing/routes.ts create mode 100644 server/tests/routing-classify.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c2456167a..e8bc3681c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,14 @@ Sessions survive and nobody signs in again. a copy of a customer's corpus is not a thing OpenBot does. ### Added +- **A message with no `@` goes to the coworker it is for.** Typing without naming anyone used to + reach the default coworker; to get a specialist you had to `@` them. Now an untagged message is + routed to the coworker whose purpose matches it, chosen against each coworker's own description by + the deployment's own model, before the channel is pinned. It is named, not silent: the channel + header is the coworker it went to, and a `channel.routed` row records the choice, the reason, and + the candidates it chose between (never the message itself). `@` still wins as an explicit override + and skips routing entirely. If the router is uncertain or unreachable, it falls back to the same + default the composer always used, and says so, rather than misroute or drop. - **Releases are cut by a workflow, not by hand.** `Create release PR` bumps the version and promotes `## Unreleased` to a numbered section; merging the pull request it opens is what publishes. Merging diff --git a/app/src/lib/channels/route.ts b/app/src/lib/channels/route.ts new file mode 100644 index 000000000..5c3fc183e --- /dev/null +++ b/app/src/lib/channels/route.ts @@ -0,0 +1,26 @@ +import { client } from "@/lib/client"; + +/** + * Which coworker an untagged message should go to. + * + * Called only when the composer draft names no one with `@`. The server reads the roster for the + * person asking and picks by what each coworker is for, so this can only ever return a coworker they + * are already allowed to reach. `fallback` is true when it is the default rather than an inferred + * match, which the caller can say out loud. A thrown error here is not fatal: the caller falls back + * to the default coworker, which is exactly what the server does too. + */ +export type RoutingDecision = { + agentId: string; + name: string; + reason: string; + fallback: boolean; +}; + +export async function routeMessage(text: string): Promise { + const response = await client("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/api/route", { + method: "POST", + body: { text }, + fallback: "Could not choose a coworker.", + }); + return (await response.json()) as RoutingDecision; +} diff --git a/app/src/routes/_authed/_app/index.tsx b/app/src/routes/_authed/_app/index.tsx index e54b08a31..2bec34ca4 100644 --- a/app/src/routes/_authed/_app/index.tsx +++ b/app/src/routes/_authed/_app/index.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { AgentCard } from "@/components/agents/agent-card"; import { Composer, toAgentOptions } from "@/components/channels/composer"; import { agentListQueryOptions } from "@/lib/agents/queries"; +import { routeMessage } from "@/lib/channels/route"; import { useStartChannel } from "@/lib/channels/start"; import { appConfig } from "@/lib/generated/application-config"; @@ -36,12 +37,21 @@ function RouteComponent() { className="w-full max-w-2xl" disabled={!fallback} onSubmit={async (draft) => { - // A channel is pinned to one coworker for the life of its thread. - const agentId = draft.agentId ?? fallback?.id; - if (!agentId) return; - + // A channel is pinned to one coworker for the life of its thread, so the coworker is + // chosen now, before it is created. An `@` is an explicit choice and is honoured as-is. + // With no `@`, the message is routed to the coworker it is for; if that routing cannot + // run, it falls back to the same default the composer used to always use. setError(null); try { + let agentId: string | undefined = draft.agentId ?? undefined; + if (!agentId) { + try { + agentId = (await routeMessage(draft.text)).agentId; + } catch { + agentId = fallback?.id; + } + } + if (!agentId) return; await start(agentId, draft.text); } catch (caught) { setError( @@ -58,7 +68,8 @@ function RouteComponent() { // Said out loud: a message that silently reaches somebody you did not choose is the // kind of surprise that costs trust the first time it happens.

- Goes to {fallback.name}. Type @ to reach somebody else. + Sent to the coworker it is for. Type @ to choose one + yourself.

) : null} {error ? ( diff --git a/server/src/app.ts b/server/src/app.ts index 0ccb1d4f4..63025b6ab 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -5,6 +5,8 @@ import { authoriseAgentCall } from "./agents/callback-token"; import type { BotAccessCheck } from "./agents/profile-policy"; import type { AgentProfileStore } from "./agents/profile-store"; import { createAgentRoutes } from "./agents/routes"; +import { createRoutingRoutes } from "./routing/routes"; +import type { IntentRouter } from "./routing/classify"; import { type AuditReader, type AuditStore, @@ -147,6 +149,14 @@ export function createApp( * metadata in. See identity-provider-store.ts. */ identityProviders?: IdentityProviderStore, + /** + * Chooses which coworker an untagged message is for, before a channel is pinned to one. + * + * Passed in already built, like the copilot handler, so this module never imports the model + * client. Absent leaves the composer's existing behaviour untouched: an untagged message goes to + * the default coworker, which is exactly the failsafe the router itself falls back to. + */ + intentRouter?: IntentRouter, ) { const app = new Hono<{ Variables: AppVariables }>(); @@ -675,6 +685,20 @@ export function createApp( auditStore, ), ); + // Choosing a coworker for an untagged message needs the same permission-filtered roster the + // agents routes read, so it is mounted here where that store is in scope. Only when a router was + // configured; without one the composer keeps sending untagged messages to the default. + if (intentRouter) { + app.route( + "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/api/route", + createRoutingRoutes( + agentProfileStore, + intentRouter, + requireUser, + auditStore, + ), + ); + } } if (channelStore) { diff --git a/server/src/audit.ts b/server/src/audit.ts index 5d3462085..8391f1720 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -40,6 +40,16 @@ export const auditEventTypes = [ "connector.sync_succeeded", "connector.sync_failed", "knowledge.searched", + /** + * Which coworker an untagged message was routed to, and why. + * + * A channel is pinned to one coworker before its first turn, so when the person did not name one + * with `@`, something chooses. This is that choice, made visible: the row names the coworker it + * went to, whether it was an inferred match or the default it fell back to, and the coworkers it + * chose between. The message itself is not here (the payload redaction drops it either way) — a + * routing decision is a fact about where a conversation went, not a copy of what was said. + */ + "channel.routed", "agent.invoked", /** * A Bot's stream stopped producing anything and the turn was ended for it. diff --git a/server/src/index.ts b/server/src/index.ts index f14d72785..a0f2bfdcc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,3 +1,5 @@ +import { createIntentRouter } from "./routing/classify"; +import { createModelCompleter } from "./routing/model"; import { serve } from "bun"; import { mintRunAssertion } from "./agents/callback-token"; import { createAgentProfileStore } from "./agents/profile-store"; @@ -350,6 +352,20 @@ const stallGuard = createStallGuard({ auditStore: bootAuditStore, }); +const intentRouter = createIntentRouter({ + complete: createModelCompleter({ + model: tenantPackage.model, + resolveApiKey: () => + resolveModelApiKey({ + encryptionKey: config.keyEncryptionKey, + reader: credentialStore, + provider: tenantPackage.model.provider, + keyId: tenantPackage.model.credentialSecretRef, + environment: process.env, + }), + }), +}); + const app = createApp( config, auth, @@ -425,6 +441,8 @@ const app = createApp( // The enterprise identity providers registered here. Read as facts about the deployment rather // than through Better Auth's own listing, which answers per person. See identity-provider-store.ts. identityProviderStore, + // Chooses the coworker for an untagged message, on the deployment's own model and key. + intentRouter, ); /** diff --git a/server/src/routing/classify.ts b/server/src/routing/classify.ts new file mode 100644 index 000000000..843773379 --- /dev/null +++ b/server/src/routing/classify.ts @@ -0,0 +1,131 @@ +/** + * Choosing which coworker an untagged message is for. + * + * A channel is pinned to one coworker for the life of its thread, and the pin is set before the + * first turn runs, so this decision happens at channel creation, not during a run. When the person + * named a coworker with `@`, there is nothing to decide and this is never called. When they did not, + * this reads the message against what each coworker is for and picks one. + * + * The model call is injected rather than made here, so the part that matters — what happens when the + * answer is missing, malformed, or names a coworker that is not on the roster — is a plain function + * with no network in the way. Every one of those failure paths lands on the deployment's default + * coworker and says so; nothing here ever throws, because a router that throws would turn "we were + * not sure who to ask" into "your message went nowhere". + */ + +export type RoutingCandidate = { + id: string; + name: string; + /** What this coworker is for. The one line an operator wrote to say when to reach them. */ + roleDescription: string; +}; + +export type RoutingDecision = { + agentId: string; + name: string; + /** A sentence a person reads, naming why it went where it went. */ + reason: string; + /** True when this is the default rather than an inferred match: an honest "we were not sure". */ + fallback: boolean; +}; + +/** Below this the match is a guess, and a guess should defer to the default rather than surprise. */ +const MIN_CONFIDENCE = 0.6; + +export function routingPrompt( + text: string, + candidates: readonly RoutingCandidate[], +): string { + const roster = candidates + .map((c) => `- id: ${c.id}\n name: ${c.name}\n for: ${c.roleDescription}`) + .join("\n"); + return [ + "You route a person's message to the one coworker best suited to it.", + "Here are the coworkers and what each is for:", + roster, + "", + 'Reply with only JSON: {"agentId": "", "reason": "", "confidence": <0..1>}.', + "Pick the specialist whose purpose matches the message. If none clearly fits, use the most general coworker and give it a low confidence.", + "", + `Message: ${text}`, + ].join("\n"); +} + +export function createIntentRouter(deps: { + /** Runs the prompt and returns the model's raw text. May reject; the router absorbs it. */ + complete: (prompt: string) => Promise; +}) { + return { + async route( + text: string, + candidates: readonly RoutingCandidate[], + defaultId: string, + ): Promise { + const byId = new Map(candidates.map((c) => [c.id, c])); + const fallback = (reason: string): RoutingDecision => { + const chosen = byId.get(defaultId) ?? candidates[0]; + return chosen + ? { agentId: chosen.id, name: chosen.name, reason, fallback: true } + : // No roster at all is a misconfiguration, not a routing outcome; surface the default id. + { agentId: defaultId, name: defaultId, reason, fallback: true }; + }; + + // Nothing to decide between: one coworker, or none but the default. + if (candidates.length <= 1) { + return fallback("the only coworker available"); + } + + let raw: string; + try { + raw = await this._complete(text, candidates); + } catch { + return fallback( + "sent to your default while the router was unreachable", + ); + } + + let parsed: { agentId?: unknown; reason?: unknown; confidence?: unknown }; + try { + // The model is asked for bare JSON, but tolerate a fenced or padded answer. + const match = raw.match(/\{[\s\S]*\}/); + parsed = match ? JSON.parse(match[0]) : {}; + } catch { + return fallback( + "sent to your default; the router's answer did not parse", + ); + } + + const id = typeof parsed.agentId === "string" ? parsed.agentId : ""; + const match = byId.get(id); + if (!match) { + // A returned id that is not on the roster is the dangerous case: never act on it. + return fallback( + "sent to your default; the router named no coworker on your roster", + ); + } + const confidence = + typeof parsed.confidence === "number" ? parsed.confidence : 0; + if (confidence < MIN_CONFIDENCE) { + return fallback( + "sent to your default; no specialist was a confident match", + ); + } + + const reason = + typeof parsed.reason === "string" && parsed.reason.trim() + ? parsed.reason.trim() + : `matches ${match.name}`; + return { agentId: match.id, name: match.name, reason, fallback: false }; + }, + + // Split out so the prompt-build + call is one seam the tests can leave alone. + async _complete( + text: string, + candidates: readonly RoutingCandidate[], + ): Promise { + return deps.complete(routingPrompt(text, candidates)); + }, + }; +} + +export type IntentRouter = ReturnType; diff --git a/server/src/routing/model.ts b/server/src/routing/model.ts new file mode 100644 index 000000000..fc6fa4870 --- /dev/null +++ b/server/src/routing/model.ts @@ -0,0 +1,44 @@ +import type { RuntimeModel } from "../copilot"; + +/** + * The one model call the router makes, kept apart from the routing logic so that logic stays a pure + * function the tests drive without a network. This reuses the deployment's own model and key — the + * same ones the built-in coworkers answer on — so a router is never a second thing to configure. + * + * It throws on a missing key or a bad response on purpose: the router treats a throw as "not sure" + * and lands on the default, so failure here is a soft landing, not an error a person sees. + */ +export function createModelCompleter(deps: { + model: RuntimeModel; + resolveApiKey: () => Promise; +}): (prompt: string) => Promise { + return async (prompt: string) => { + const key = await deps.resolveApiKey(); + if (!key) throw new Error("no model key"); + const base = + process.env.OPENAI_BASE_URL?.trim() || "https://api.openai.com"; + const response = await fetch(`${base}/v1/chat/completions`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${key}`, + }, + body: JSON.stringify({ + model: deps.model.defaultModel, + temperature: 0, + response_format: { type: "json_object" }, + messages: [{ role: "user", content: prompt }], + }), + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) + throw new Error(`router model answered ${response.status}`); + const body = (await response.json()) as { + choices?: { message?: { content?: unknown } }[]; + }; + const content = body.choices?.[0]?.message?.content; + if (typeof content !== "string") + throw new Error("router model returned no text"); + return content; + }; +} diff --git a/server/src/routing/routes.ts b/server/src/routing/routes.ts new file mode 100644 index 000000000..6e90a6191 --- /dev/null +++ b/server/src/routing/routes.ts @@ -0,0 +1,77 @@ +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import type { AuditStore } from "../audit"; +import { recordAuditEvent } from "../audit"; +import type { AppVariables } from "../auth/guards"; +import type { AgentProfileStore } from "../agents/profile-store"; +import type { IntentRouter, RoutingCandidate } from "./classify"; + +const DEV_ACTOR_EMAIL = "dev@openbot.local"; + +/** + * Decide which coworker an untagged message is for, before a channel is pinned to one. + * + * The roster is read for the person asking, so the router can only ever pick a coworker they are + * already allowed to reach. The decision is recorded like every other one in the product: a + * `channel.routed` row names where it went and why, and carries the candidate ids but never the + * message itself, which the audit payload redaction would drop anyway. + */ +export function createRoutingRoutes( + store: AgentProfileStore, + router: IntentRouter, + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, + auditStore?: AuditStore, +) { + const routes = new Hono<{ Variables: AppVariables }>(); + + routes.post("/", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + text?: unknown; + } | null; + const text = typeof body?.text === "string" ? body.text.trim() : ""; + if (!text) return context.json({ error: "A message is required." }, 400); + + const actor = context.var.actor; + const roster = await store.list(actor, false); + if (roster.length === 0) { + return context.json({ error: "No coworker is available." }, 409); + } + // The same default the composer shows: the first public coworker, else the first at all. + const preferred = + roster.find((a) => a.visibility === "public") ?? roster[0]!; + const candidates: RoutingCandidate[] = roster.map((a) => ({ + id: a.id, + name: a.name, + roleDescription: a.roleDescription, + })); + + const decision = await router.route(text, candidates, preferred.id); + + if (auditStore) { + await recordAuditEvent(auditStore, { + eventType: "channel.routed", + targetType: "agent", + targetId: decision.agentId, + ...(actor?.id && actor.email !== DEV_ACTOR_EMAIL + ? { actorUserId: actor.id } + : {}), + payload: { + chosen: decision.agentId, + reason: decision.reason, + fallback: decision.fallback, + viaMention: false, + candidates: candidates.map((c) => c.id), + }, + }); + } + + return context.json({ + agentId: decision.agentId, + name: decision.name, + reason: decision.reason, + fallback: decision.fallback, + }); + }); + + return routes; +} diff --git a/server/tests/routing-classify.test.ts b/server/tests/routing-classify.test.ts new file mode 100644 index 000000000..ef93da24e --- /dev/null +++ b/server/tests/routing-classify.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import { + createIntentRouter, + type RoutingCandidate, +} from "../src/routing/classify"; + +const ROSTER: RoutingCandidate[] = [ + { + id: "general-assistant", + name: "General Assistant", + roleDescription: "everyday work", + }, + { + id: "knowledge", + name: "Knowledge", + roleDescription: "company knowledge questions", + }, + { + id: "risk-analyst", + name: "Risk Analyst", + roleDescription: "transaction monitoring and fraud risk", + }, +]; +const withAnswer = (answer: string) => + createIntentRouter({ complete: async () => answer }); +const throwing = () => + createIntentRouter({ + complete: async () => { + throw new Error("model down"); + }, + }); + +describe("routing a message with no @mention", () => { + test("routes to the specialist the model picks, named, not a fallback", async () => { + const r = await withAnswer( + '{"agentId":"risk-analyst","reason":"fraud review","confidence":0.9}', + ).route( + "review this transaction for fraud risk", + ROSTER, + "general-assistant", + ); + expect(r.agentId).toBe("risk-analyst"); + expect(r.name).toBe("Risk Analyst"); + expect(r.fallback).toBe(false); + expect(r.reason).toContain("fraud"); + }); + + test("falls back to the default, named, when the model is unreachable", async () => { + const r = await throwing().route("anything", ROSTER, "general-assistant"); + expect(r.agentId).toBe("general-assistant"); + expect(r.fallback).toBe(true); + expect(r.reason).toContain("unreachable"); + }); + + test("falls back when the answer does not parse", async () => { + const r = await withAnswer("I think the risk analyst?").route( + "x", + ROSTER, + "general-assistant", + ); + expect(r.agentId).toBe("general-assistant"); + expect(r.fallback).toBe(true); + }); + + test("NEVER acts on an id that is not on the roster", async () => { + const r = await withAnswer( + '{"agentId":"payroll-bot","reason":"payroll","confidence":0.99}', + ).route("x", ROSTER, "general-assistant"); + expect(r.agentId).toBe("general-assistant"); + expect(r.fallback).toBe(true); + expect(r.reason).toContain("no coworker on your roster"); + }); + + test("defers to the default when confidence is low", async () => { + const r = await withAnswer( + '{"agentId":"risk-analyst","reason":"maybe","confidence":0.3}', + ).route("hi", ROSTER, "general-assistant"); + expect(r.agentId).toBe("general-assistant"); + expect(r.fallback).toBe(true); + }); + + test("a fenced/padded JSON answer is still parsed", async () => { + const r = await withAnswer( + '```json\n{"agentId":"knowledge","reason":"policy lookup","confidence":0.8}\n```', + ).route("what is our refund policy", ROSTER, "general-assistant"); + expect(r.agentId).toBe("knowledge"); + expect(r.fallback).toBe(false); + }); + + test("a single-coworker roster is a fallback, not a model call", async () => { + let called = false; + const r = await createIntentRouter({ + complete: async () => { + called = true; + return "{}"; + }, + }).route("x", [ROSTER[0]!], "general-assistant"); + expect(called).toBe(false); + expect(r.agentId).toBe("general-assistant"); + expect(r.fallback).toBe(true); + }); +}); From 669ff735389cb2b9113f68734e99fc4a0ac3c0cf Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 21 Aug 2026 16:55:40 -0700 Subject: [PATCH 2/2] Pick the default coworker without a non-null assertion The lint gate now fails on warnings (#120), and roster[0]! tripped noNonNullAssertion. Guard on the resolved value instead: find the first public coworker or fall back to the first, and 409 when there is none, which also reads straighter than asserting a length check the linter cannot see. --- server/src/routing/routes.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/routing/routes.ts b/server/src/routing/routes.ts index 6e90a6191..c884d4d12 100644 --- a/server/src/routing/routes.ts +++ b/server/src/routing/routes.ts @@ -33,12 +33,12 @@ export function createRoutingRoutes( const actor = context.var.actor; const roster = await store.list(actor, false); - if (roster.length === 0) { - return context.json({ error: "No coworker is available." }, 409); - } // The same default the composer shows: the first public coworker, else the first at all. const preferred = - roster.find((a) => a.visibility === "public") ?? roster[0]!; + roster.find((a) => a.visibility === "public") ?? roster[0]; + if (!preferred) { + return context.json({ error: "No coworker is available." }, 409); + } const candidates: RoutingCandidate[] = roster.map((a) => ({ id: a.id, name: a.name,