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
24 changes: 16 additions & 8 deletions app/src/lib/channels/route.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
import { client } from "@/lib/client";

/**
* Which coworker an untagged message should go to.
* Which coworker a 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.
* 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.
*
* Pass `agentId` when the draft named somebody with `@`. Nothing is inferred in that case and no
* model is called; the call exists so the choice reaches the audit trail, which otherwise had a row
* for every routed conversation and none at all for chosen ones.
*/
export type RoutingDecision = {
agentId: string;
name: string;
reason: string;
fallback: boolean;
viaMention: boolean;
};

export async function routeMessage(text: string): Promise<RoutingDecision> {
export async function routeMessage(
text: string,
agentId?: string,
): Promise<RoutingDecision> {
const response = await client("/api/route", {
method: "POST",
body: { text },
body: agentId ? { text, agentId } : { text },
fallback: "Could not choose a coworker.",
});
return (await response.json()) as RoutingDecision;
Expand Down
9 changes: 8 additions & 1 deletion app/src/routes/_authed/_app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,14 @@ function RouteComponent() {
setError(null);
try {
let agentId: string | undefined = draft.agentId ?? undefined;
if (!agentId) {
if (agentId) {
/*
* Told to the server so the choice is recorded, and its answer thrown away: the
* person already decided and nothing here may change that. Failing to write the
* audit row must not stop the conversation, so a rejection is swallowed whole.
*/
await routeMessage(draft.text, agentId).catch(() => undefined);
} else {
try {
agentId = (await routeMessage(draft.text)).agentId;
} catch {
Expand Down
50 changes: 47 additions & 3 deletions app/src/routes/_authed/admin/audit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,22 @@ function Row({
*/
event.eventType === "mcp.callback_refused";
const stalled = event.eventType === "agent.stream_stalled";
/*
* Three different things, and the difference is what somebody comes to this row to find out.
*
* A person naming a coworker, the router matching one, and the router giving up and using the
* default are not the same event, and one label covering all three would make the row worth less
* than the reason line under it. Nothing here is a refusal, so none of them take the refusal
* colour.
*/
const routed =
event.eventType === "channel.routed"
? payload.viaMention === true
? "The person chose this coworker"
: payload.fallback === true
? "Sent to the default coworker"
: "Sent to the coworker it is for"
: null;
// Allowed by policy but not carried out. A stalled turn belongs in the same family: the Bot was
// asked and the answer never arrived. Colour is how this table is read, and a row left in the
// muted foreground reads as "Allowed", which a turn nobody ever got an answer to was not.
Expand All @@ -173,8 +189,17 @@ function Row({
: event.eventType}
</td>
<td className="px-4 py-2">
{/* Named targets and file paths are the audit subject before page elements. */}
{NAMED_TARGETS.has(event.targetType) && event.targetId ? (
{/*
* A routing row's subject is the coworker it went to, and it is the only thing on the row
* worth reading. Its target type is `agent`, which is not a named target because everywhere
* else an agent id appears it belongs in the Bot column; here nothing acted, so there is no
* Bot and the target is all there is. Rendered through `nameFor` so it reads as the name on
* the roster rather than the immutable id.
*/}
{event.eventType === "channel.routed" && event.targetId ? (
<span title={event.targetId}>{nameFor(event.targetId)}</span>
) : /* Named targets and file paths are the audit subject before page elements. */
NAMED_TARGETS.has(event.targetType) && event.targetId ? (
<span className="font-mono text-xs">
{event.targetId}
{typeof payload.function === "string" ? (
Expand Down Expand Up @@ -229,7 +254,8 @@ function Row({
: "text-muted-foreground"
}
>
{DECISIONS[event.eventType] ??
{routed ??
DECISIONS[event.eventType] ??
(refused ? "Blocked" : failed ? "Did not happen" : "Allowed")}
</span>
{/* Refusal reasons mirror the conversation-facing reason. */}
Expand All @@ -247,6 +273,24 @@ function Row({
{payload.refusal}
</div>
) : null}
{/*
* Why the conversation went where it went, which is the whole reason the row is written.
* Without it a routing row says "Allowed" and names nobody, which is indistinguishable from
* a row that failed to write.
*/}
{event.eventType === "channel.routed" &&
typeof payload.reason === "string" ? (
/*
* A width rather than a max-width, because the table lays itself out from its content and
* a max-width on a block inside a cell does not constrain that. A router's reason is a
* sentence a model wrote, not a rule name, and left unbounded in the last column it
* pushes the table wider than the page and the end of the sentence goes off the edge,
* where nobody scrolls to find it.
*/
<div className="mt-0.5 w-[22rem] break-words text-xs text-muted-foreground">
{payload.reason}
</div>
) : null}
{event.eventType === "bot.declined" &&
typeof payload.reason === "string" ? (
<div className="mt-0.5 text-xs text-muted-foreground">
Expand Down
109 changes: 90 additions & 19 deletions server/src/routing/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,35 @@ 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.
* Who to record the routing against, or nobody.
*
* The roster is read for the person asking, so the router can only ever pick a coworker they are
* The single-user development actor is not a real person and has no row to point at, so it is left
* off rather than written as a user id that resolves to nothing.
*/
function actorId(
actor:
| {
id?: string;
email?: string;
}
| null
| undefined,
): string | undefined {
return actor?.id && actor.email !== DEV_ACTOR_EMAIL ? actor.id : undefined;
}

/**
* Decide which coworker a message is for, before a channel is pinned to one.
*
* The roster is read for the person asking, so this can only ever land on 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.
*
* A person who named a coworker with `@` has already decided, so nothing is inferred and no model
* is called. It is still recorded, with `viaMention` true and the person as the reason. Without
* that the trail answered "why did this go to Risk Analyst" for routed conversations and said
* nothing at all for chosen ones, which reads exactly like a row that failed to write.
*/
export function createRoutingRoutes(
store: AgentProfileStore,
Expand All @@ -32,12 +55,41 @@ export function createRoutingRoutes(
) {
const routes = new Hono<{ Variables: AppVariables }>();

/*
* The one place a `channel.routed` row is written, for both ways a message finds a coworker.
*
* Two call sites writing the same event is two payloads that drift, and a trail whose rows mean
* slightly different things depending on which branch produced them cannot be read at all.
*/
async function record(
actorUserId: string | undefined,
chosen: string,
reason: string,
fallback: boolean,
viaMention: boolean,
candidates: readonly string[],
): Promise<void> {
if (!auditStore) return;
await recordAuditEvent(auditStore, {
eventType: "channel.routed",
targetType: "agent",
targetId: chosen,
...(actorUserId ? { actorUserId } : {}),
payload: { chosen, reason, fallback, viaMention, candidates },
});
}

routes.post("/", requireUser, async (context) => {
const body = (await context.req.json().catch(() => null)) as {
text?: unknown;
agentId?: unknown;
} | null;
const text = typeof body?.text === "string" ? body.text.trim() : "";
if (!text) return context.json({ error: "A message is required." }, 400);
const named =
typeof body?.agentId === "string" && body.agentId.trim()
? body.agentId.trim()
: null;

const actor = context.var.actor;
const roster = await store.list(actor, false);
Expand All @@ -47,6 +99,33 @@ export function createRoutingRoutes(
if (!preferred) {
return context.json({ error: "No coworker is available." }, 409);
}

/*
* A named coworker is an instruction, not a question, so it is honoured as given.
*
* Checked against the same roster the router picks from, so `@` cannot reach further than
* routing can: a name that is not on it is refused rather than quietly turned into somebody
* else, because silently redirecting a message the person addressed by hand is the worst
* available answer.
*/
if (named) {
const chosen = roster.find((a) => a.id === named);
if (!chosen) {
return context.json(
{ error: "That coworker is not on your roster." },
404,
);
}
const reason = "you chose them yourself";
await record(actorId(actor), chosen.id, reason, false, true, [chosen.id]);
return context.json({
agentId: chosen.id,
name: chosen.name,
reason,
fallback: false,
viaMention: true,
});
}
const candidates: RoutingCandidate[] = await Promise.all(
roster.map(async (a) => ({
id: a.id,
Expand All @@ -69,29 +148,21 @@ export function createRoutingRoutes(

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),
},
});
}
await record(
actorId(actor),
decision.agentId,
decision.reason,
decision.fallback,
false,
candidates.map((c) => c.id),
);

return context.json({
agentId: decision.agentId,
name: decision.name,
reason: decision.reason,
fallback: decision.fallback,
viaMention: false,
});
});

Expand Down
Loading