Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions app/src/lib/channels/route.ts
Original file line number Diff line number Diff line change
@@ -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<RoutingDecision> {
const response = await client("/api/route", {
method: "POST",
body: { text },
fallback: "Could not choose a coworker.",
});
return (await response.json()) as RoutingDecision;
}
21 changes: 16 additions & 5 deletions app/src/routes/_authed/_app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
Expand All @@ -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.
<p className="mt-2 w-full max-w-2xl text-xs text-muted-foreground text-center">
Goes to {fallback.name}. Type <code>@</code> to reach somebody else.
Sent to the coworker it is for. Type <code>@</code> to choose one
yourself.
</p>
) : null}
{error ? (
Expand Down
24 changes: 24 additions & 0 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }>();

Expand Down Expand Up @@ -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(
"/api/route",
createRoutingRoutes(
agentProfileStore,
intentRouter,
requireUser,
auditStore,
),
);
}
}

if (channelStore) {
Expand Down
10 changes: 10 additions & 0 deletions server/src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
);

/**
Expand Down
131 changes: 131 additions & 0 deletions server/src/routing/classify.ts
Original file line number Diff line number Diff line change
@@ -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": "<one id from the list>", "reason": "<short, names the fit>", "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<string>;
}) {
return {
async route(
text: string,
candidates: readonly RoutingCandidate[],
defaultId: string,
): Promise<RoutingDecision> {
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<string> {
return deps.complete(routingPrompt(text, candidates));
},
};
}

export type IntentRouter = ReturnType<typeof createIntentRouter>;
44 changes: 44 additions & 0 deletions server/src/routing/model.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>;
}): (prompt: string) => Promise<string> {
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;
};
}
Loading