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
25 changes: 13 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,19 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

- A cancellation the native host never collected is now dropped instead of being reported as
pending forever. Every timed-out, stopped or revoked host operation queues a cancel for the desktop
worker, and only that worker ever removed it: a worker that stopped polling left the entry in
memory for the life of the process, so the Host access panel showed an operation that could never
finish. A desktop that reconnects within the operation timeout is still told to stop.
- A Bot's shell now honours a Stop that landed before the command was spawned, not only one that
arrives afterwards. A person who pressed Stop in the window between the request reaching the
computer and the command starting got no answer until that command finished on its own.
- A file download refused because the computer's response carried no usable byte length now releases
the connection before reporting the refusal. The unread body could be as large as the whole
download budget, so a computer reached through a proxy that re-chunks left a transfer running and a
connection checked out of the pool on every attempt.
### A coworker the message speaks to is routed to without asking a model

Addressing a coworker by name went to the intent router like any other message, so the deployment
paid a model call to be told what the person had already said, and sometimes was told something
else. A message that speaks to a coworker by its full name now routes straight to them: it opens
with the name ("Risk Analyst, please check this"), names it with `@`, or asks it ("ask Risk Analyst
to review this"). It is recorded as `matched a coworker’s name in the message` with
`viaNameMatch: true` on the same `channel.routed` row. A name in passing ("don't send this to Risk
Analyst") and a word that only ends a name ("review this contract", "my meeting notes") still go
to the router. When the message addresses more than one coworker, or two share the addressed name,
it is refused with distinct labels, and the composer keeps the draft and asks the person to choose
instead of starting the default coworker. Explicit picker choices still use `viaMention: true`.

### A Bot's turn in a group is no longer offered coordination tools it cannot call

A Bot answering another Bot in a group conversation was offered `ask_person`, and `message_bot`
Expand Down
32 changes: 27 additions & 5 deletions app/src/lib/channels/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { client } from "@/lib/client";
import { tryClient } from "@/lib/client";

/**
* Which coworker a message should go to.
*
* 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.
* permits the caller to fall back to the default coworker. An ambiguous name instead asks the
* person to choose and must stop the send.
*
* 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
Expand All @@ -21,6 +21,9 @@ export type RoutingDecision = {
viaMention: boolean;
};

/** A routing decision that must be resolved before a conversation can start. */
export class CoworkerRoutingRefusedError extends Error {}

/**
* The most of a message `POST /api/route` reads, and a message's opening cut to it.
*
Expand Down Expand Up @@ -49,10 +52,29 @@ export async function routeMessage(
agentId?: string,
): Promise<RoutingDecision> {
const asked = routingText(text);
const response = await client("/api/route", {
const response = await tryClient("/api/route", {
method: "POST",
body: agentId ? { text: asked, agentId } : { text: asked },
fallback: "Could not choose a coworker.",
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
error?: unknown;
names?: unknown;
} | null;
const message =
typeof body?.error === "string"
? body.error
: "Could not choose a coworker.";
if (
response.status === 409 &&
Array.isArray(body?.names) &&
body.names.every((name) => typeof name === "string")
) {
throw new CoworkerRoutingRefusedError(
`${message} ${body.names.join(", ")}. Choose one with @.`,
);
}
throw new Error(message);
}
return (await response.json()) as RoutingDecision;
}
9 changes: 7 additions & 2 deletions app/src/routes/_authed/_app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import { Empty, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { defaultAgentProfile } from "@/lib/agents/default-agent";
import { agentListQueryOptions, isSharedWithYou } from "@/lib/agents/queries";
import { routeMessage } from "@/lib/channels/route";
import {
CoworkerRoutingRefusedError,
routeMessage,
} from "@/lib/channels/route";
import { useStartChannel } from "@/lib/channels/start";
import { appConfig } from "@/lib/generated/application-config";

Expand Down Expand Up @@ -72,7 +75,9 @@ function RouteComponent() {
let agentId: string | undefined;
try {
agentId = (await routeMessage(draft.text)).agentId;
} catch {
} catch (caught) {
if (caught instanceof CoworkerRoutingRefusedError)
throw caught;
agentId = fallback?.id;
}
if (!agentId) return;
Expand Down
32 changes: 32 additions & 0 deletions app/tests/home-fallback-routing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,35 @@ test("/ keeps a successful route decision ahead of the fallback", async () => {

await waitFor(() => expect(starts).toEqual([["shared-public"]]));
});

for (const refusal of [
{
status: 409,
body: {
error: "More than one coworker matches that name.",
names: ["Data Analyst", "Risk Analyst"],
},
message: "Data Analyst, Risk Analyst",
},
]) {
test(`routing refusal ${refusal.status} preserves the draft and shows the explanation without starting a fallback`, async () => {
const starts: string[][] = [];
installHomeRoutingFetch({
routeResponse: Response.json(refusal.body, { status: refusal.status }),
starts,
});
const view = renderHome(
queryClientWithAgents([
agent({ id: "default", name: "Default", visibility: "public" }),
]),
);
await submitHomeMessage(view, "ask analyst");
await waitFor(() =>
expect(view.getByRole("alert").textContent).toContain(refusal.message),
);
expect(starts).toEqual([]);
expect(view.getByRole("textbox", { name: "Message" }).textContent).toBe(
"ask analyst",
);
});
}
27 changes: 24 additions & 3 deletions app/tests/route-long-message.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { afterEach, expect, test } from "bun:test";
import type { AgentProfileStore } from "../../server/src/agents/profile-store";
import type { AgentProfile } from "../../server/src/agents/profile-types";
import type { AuditStore } from "../../server/src/audit";
import type { IntentRouter } from "../../server/src/routing/classify";
import { createRoutingRoutes } from "../../server/src/routing/routes";
import { createCoworkerRoutingService } from "../../server/src/routing/service";
import { routeMessage } from "../src/lib/channels/route";

/**
Expand Down Expand Up @@ -42,7 +44,7 @@ function serve() {
const asked: string[] = [];
const written: { eventType: string; payload: Record<string, unknown> }[] = [];

const asActor: Parameters<typeof createRoutingRoutes>[2] = async (
const asActor: Parameters<typeof createRoutingRoutes>[1] = async (
context,
next,
) => {
Expand All @@ -53,7 +55,23 @@ function serve() {
});
await next();
};
const store = { list: async () => ROSTER } as unknown as AgentProfileStore;
const store = {
list: async (): Promise<AgentProfile[]> =>
ROSTER.map((item) => ({
...item,
visibility: "public",
title: item.name,
avatarSeed: item.id,
ownerUserId: null,
deletedAt: null,
hidden: false,
pinned: false,
systemOwned: false,
endpoint: null,
hasAuth: false,
hasCallbackToken: false,
})),
} as unknown as AgentProfileStore;
const router = {
route: async (text: string) => {
asked.push(text);
Expand All @@ -75,7 +93,10 @@ function serve() {
},
} as unknown as AuditStore;

const routes = createRoutingRoutes(store, router, asActor, auditStore);
const routes = createRoutingRoutes(
createCoworkerRoutingService({ store, router, auditStore }),
asActor,
);
globalThis.fetch = Object.assign(
async (
path: Parameters<typeof fetch>[0],
Expand Down
147 changes: 147 additions & 0 deletions server/src/agents/agent-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { AbstractAgent } from "@ag-ui/client";
import type { AuditInitiator } from "../audit";
import type { AgentFetch, StallGuard } from "../channels/stall-guard";
import {
type HandoffForRun,
type LoadAgentsForActor,
type LoadAttachment,
type LoadInstructions,
type LoadToolsForBot,
type MarkAttachmentsSent,
type RuntimeModel,
resolveRuntimeAgents,
type SignRun,
type ToolSelection,
} from "../copilot";
import type { AcquireLearnedSkills } from "../learning/runtime";
import type { LoadPersonalMemory } from "../memory/tools";
import type { AgentActor } from "./profile-types";

export type AgentResolutionContext = {
initiator?: AuditInitiator;
depth?: number;
};

export class CoworkerUnavailableError extends Error {}

export type ActorAgentResolver = {
resolveAgentsForActor(
actor: AgentActor,
): Promise<Record<string, AbstractAgent>>;
resolveAgentForActor(
actor: AgentActor,
agentId: string,
context?: AgentResolutionContext,
): Promise<AbstractAgent>;
};

export type ActorAgentResolverDependencies = {
loadAgents: LoadAgentsForActor;
model: RuntimeModel;
resolveModelApiKey: () => Promise<string | null>;
stallGuard?: StallGuard;
loadToolsForActor?: (
actorId: string,
initiator?: AuditInitiator,
) => LoadToolsForBot;
signRunForActor?: (
actorId: string,
initiator?: AuditInitiator,
depth?: number,
) => SignRun;
computerGuidance?: string;
loadVendors?: () => Promise<readonly string[]>;
selectionForActor?: (actorId: string) => ToolSelection;
agentFetch?: AgentFetch;
/**
* What a Bot may reach past itself for, resolved for whoever is asking.
*
* Per actor for the same reason the tools are: which Bots may be reached is decided against the
* roster that person can see, so a Bot must never be able to address one they cannot.
*/
handoffForActor?: (
actorId: string,
initiator?: AuditInitiator,
depth?: number,
) => HandoffForRun;
loadInstructionsForActor?: (actorId: string) => LoadInstructions;
loadAttachmentForActor?: (actorId: string) => LoadAttachment;
markAttachmentsSentForActor?: (actorId: string) => MarkAttachmentsSent;
acquireLearnedSkills?: AcquireLearnedSkills;
loadPersonalMemoryForActor?: (actorId: string) => LoadPersonalMemory;
};

/**
* Resolves the coworkers available to one OpenBot actor.
*
* Every surface enters through this boundary so it shares the same visibility, grants, assertions,
* skill selection, and endpoint dial policy for a person.
*/
export function createActorAgentResolver(
deps: ActorAgentResolverDependencies,
): ActorAgentResolver {
const resolveRegisteredAgents = (
actor: AgentActor,
registered: Awaited<ReturnType<LoadAgentsForActor>>,
/**
* Build only this Bot, when the caller already knows which one it wants.
*
* The roster is still read in full, so a Bot this person cannot see is still absent. The others
* are simply neither built nor asked what they hold, which is a query per Bot a headless turn
* or a Slack thread has no use for.
*/
onlyAgentId?: string,
context?: AgentResolutionContext,
) =>
resolveRuntimeAgents(
() => Promise.resolve(registered),
deps.model,
deps.resolveModelApiKey,
deps.stallGuard,
deps.loadToolsForActor?.(actor.id, context?.initiator),
deps.signRunForActor?.(actor.id, context?.initiator, context?.depth),
deps.computerGuidance,
deps.loadVendors,
deps.selectionForActor?.(actor.id),
deps.agentFetch,
deps.handoffForActor?.(actor.id, context?.initiator, context?.depth),
onlyAgentId,
deps.loadInstructionsForActor?.(actor.id),
context?.initiator,
deps.loadAttachmentForActor?.(actor.id),
deps.markAttachmentsSentForActor?.(actor.id),
deps.acquireLearnedSkills,
deps.loadPersonalMemoryForActor?.(actor.id),
);

const resolveAgentsForActor = async (actor: AgentActor) =>
resolveRegisteredAgents(actor, await deps.loadAgents(actor));

return {
resolveAgentsForActor,
async resolveAgentForActor(actor, agentId, context) {
const registered = await deps.loadAgents(actor);
if (!registered.some((agent) => agent.id === agentId)) {
throw new CoworkerUnavailableError(
`Coworker ${agentId} is unavailable to this user.`,
);
}

const agents = await resolveRegisteredAgents(
actor,
registered,
agentId,
context,
);
const agent = Object.hasOwn(agents, agentId)
? agents[agentId]
: undefined;
if (!agent) {
throw new CoworkerUnavailableError(
`Coworker ${agentId} is unavailable to this user.`,
);
}
return agent;
},
};
}
Loading
Loading