Skip to content
Open
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### A coworker named in the message is routed to without asking a model

Naming a coworker in the text — "ask Risk Analyst to review this" — 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 name that matches exactly one coworker on that person's
roster now routes straight to them, recorded as `named by the person asking` on the same
`channel.routed` row. A name that matches more than one is refused with both names rather than
guessed at, and a name nobody on the roster answers to falls through to the router as before.

### Routing refuses rather than routes on a connector read it could not make

Which systems a coworker can reach is weighed by the router alongside what the coworker is for. A
failed read of that used to be treated as "reaches nothing", which is a statement about the
deployment rather than an absence of one: a database that blinked quietly re-routed messages away
from the coworker that could actually do the work. It now fails the request instead.

### A Bot's shell can no longer reach the embedded database without a password

In the all-in-one image the cluster was `trust`-auth on loopback, and the Bot's shell runs in the
Expand Down
101 changes: 101 additions & 0 deletions server/src/agents/agent-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { AbstractAgent } from "@ag-ui/client";
import type { AgentFetch, StallGuard } from "../channels/stall-guard";
import {
type HandoffForRun,
type LoadAgentsForActor,
type LoadToolsForBot,
type RuntimeModel,
resolveRuntimeAgents,
type SignRun,
type ToolSelection,
} from "../copilot";
import type { AgentActor } from "./profile-types";

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

export type ActorAgentResolverDependencies = {
loadAgents: LoadAgentsForActor;
model: RuntimeModel;
resolveModelApiKey: () => Promise<string | null>;
stallGuard?: StallGuard;
loadToolsForActor?: (actorId: string) => LoadToolsForBot;
signRunForActor?: (actorId: string) => 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) => HandoffForRun;
};

/**
* 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,
) =>
resolveRuntimeAgents(
() => Promise.resolve(registered),
deps.model,
deps.resolveModelApiKey,
deps.stallGuard,
deps.loadToolsForActor?.(actor.id),
deps.signRunForActor?.(actor.id),
deps.computerGuidance,
deps.loadVendors,
deps.selectionForActor?.(actor.id),
deps.agentFetch,
deps.handoffForActor?.(actor.id),
onlyAgentId,
);

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

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

const agents = await resolveRegisteredAgents(actor, registered, agentId);
const agent = Object.hasOwn(agents, agentId)
? agents[agentId]
: undefined;
if (!agent) {
throw new Error(`Coworker ${agentId} is unavailable to this user.`);
}
return agent;
},
};
}
47 changes: 25 additions & 22 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { createRoutineRoutes, type RoutineStore } from "./routines/routes";
import type { RoutineRunner } from "./routines/runner";
import type { IntentRouter } from "./routing/classify";
import { createRoutingRoutes } from "./routing/routes";
import { createCoworkerRoutingService } from "./routing/service";
import type { PackageStatusReader } from "./tenant-package";

/**
Expand Down Expand Up @@ -788,29 +789,31 @@ export function createApp(
app.route(
"/api/route",
createRoutingRoutes(
agentProfileStore,
intentRouter,
requireUser,
auditStore,
/*
* Which vendors each coworker holds tools for, so the router weighs what a coworker can
* reach and not only what somebody wrote it was for. Only when there is a plugin store to
* ask: a deployment with no connectors routes exactly as it did.
*/
pluginStore
? async (agentId) => {
const granted = await pluginStore.listForAgent(agentId);
return [
...new Set(
granted.tools.map(
(tool) =>
tool.toolName.replace(/^mcp__/, "").split("__")[0] ??
tool.toolName,
createCoworkerRoutingService({
store: agentProfileStore,
router: intentRouter,
auditStore,
/*
* Which vendors each coworker holds tools for, so the router weighs what a coworker can
* reach and not only what somebody wrote it was for. Only when there is a plugin store to
* ask: a deployment with no connectors routes exactly as it did.
*/
reachableSystems: pluginStore
? async (agentId) => {
const granted = await pluginStore.listForAgent(agentId);
return [
...new Set(
granted.tools.map(
(tool) =>
tool.toolName.replace(/^mcp__/, "").split("__")[0] ??
tool.toolName,
),
),
),
];
}
: undefined,
];
}
: undefined,
}),
requireUser,
),
);
}
Expand Down
119 changes: 12 additions & 107 deletions server/src/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@ import { createCopilotHonoHandler } from "@copilotkit/runtime/v2/hono";
import type { Observable } from "rxjs";
import { defer, from, switchMap } from "rxjs";
import { z } from "zod";
import {
COMPUTER_GUIDANCE,
PROVENANCE_GUIDANCE,
} from "../../shared/bot-prompt";
import { PROVENANCE_GUIDANCE } from "../../shared/bot-prompt";
import type { ActorAgentResolver } from "./agents/agent-resolver";
import type { AgentActor } from "./agents/profile-types";
import type { AgentFetch, StallGuard } from "./channels/stall-guard";
import type { DeploymentConfig } from "./config";
Expand Down Expand Up @@ -856,55 +854,10 @@ export type LoadAgentsForActor = (
*/
export function createRequestAgents(
identifyActor: IdentifyActor,
loadAgents: LoadAgentsForActor,
model: RuntimeModel,
resolveModelApiKey: () => Promise<string | null>,
/**
* Shared across every request rather than built per run, because it is the thing that has to
* outlive one: the sweep that notices a silent stream has to still be running after the request
* that opened it has been answered.
*/
stallGuard?: StallGuard,
/** What each Bot may call, resolved for whoever is asking. Absent means no tools. */
loadToolsForActor?: (actorId: string) => LoadToolsForBot,
/** Resolved per request, because what it signs is who this request turned out to be. */
signRunForActor?: (actorId: string) => SignRun,
/** What every built-in Bot is told about the computer. Absent means this deployment has none. */
computerGuidance?: string,
/** Which vendors this deployment connects to, held by a Bot or not. Absent means none. */
loadVendors?: () => Promise<readonly string[]>,
/**
* How a run's tools are narrowed, resolved for whoever is asking.
*
* Per actor like the tools themselves, because the skills a Bot holds are read through the same
* grants, and because the discovery row has to name the person the run belongs to.
*/
selectionForActor?: (actorId: string) => ToolSelection,
/** The fetch remote agents are dialled with. See {@link buildAgents}. */
agentFetch?: AgentFetch,
/**
* How a run gets its tool for handing work to another Bot, 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) => HandoffForRun,
resolver: ActorAgentResolver,
) {
return async ({ request }: { request: Request }) => {
const actor = await identifyActor(request);
return resolveRuntimeAgents(
() => loadAgents(actor),
model,
resolveModelApiKey,
stallGuard,
loadToolsForActor?.(actor.id),
signRunForActor?.(actor.id),
computerGuidance,
loadVendors,
selectionForActor?.(actor.id),
agentFetch,
handoffForActor?.(actor.id),
);
return resolver.resolveAgentsForActor(await identifyActor(request));
};
}

Expand Down Expand Up @@ -994,26 +947,10 @@ const THREAD_LOCK_TTL_SECONDS = 120;

export function mountCopilotRuntime(
config: DeploymentConfig,
model: RuntimeModel,
loadAgents: LoadAgentsForActor,
resolveModelApiKey: () => Promise<string | null>,
resolver: ActorAgentResolver,
identifyUser: IdentifyUser,
identifyActor: IdentifyActor,
/**
* The watch on Bot streams. Not optional, unlike the parameter it forwards to: a guard built from
* a timeout of zero already watches nothing, so an unconfigured deployment has one to hand and
* there is no reason for a caller to have to say `undefined` here to reach `basePath`.
*/
stallGuard: StallGuard,
loadToolsForActor?: (actorId: string) => LoadToolsForBot,
signRunForActor?: (actorId: string) => SignRun,
basePath = "/api/copilotkit",
loadVendors?: () => Promise<readonly string[]>,
selectionForActor?: (actorId: string) => ToolSelection,
/** The fetch remote agents are dialled with. See {@link buildAgents}. */
agentFetch?: AgentFetch,
/** How a run gets its tool for handing work on. Absent means no Bot is offered one. */
handoffForActor?: (actorId: string) => HandoffForRun,
) {
const { intelligence } = config.runtime;

Expand All @@ -1039,25 +976,12 @@ export function mountCopilotRuntime(
actor: AgentActor;
botId: string;
}): Promise<AbstractAgent | null> => {
const { actor } = input;
const agents = await resolveRuntimeAgents(
() => loadAgents(actor),
model,
resolveModelApiKey,
stallGuard,
loadToolsForActor?.(actor.id),
signRunForActor?.(actor.id),
config.computer ? COMPUTER_GUIDANCE : undefined,
loadVendors,
selectionForActor?.(actor.id),
agentFetch,
handoffForActor?.(actor.id),
// Only the Bot this hop is for. The roster is still read in full, so a Bot this person cannot
// see is still absent; what this skips is constructing the other Bots and asking the database
// what each of them was granted, on every delivery and again on every retry.
input.botId,
);
return agents[input.botId] ?? null;
// Only the Bot this hop is for. The roster is still read in full, so a Bot this person cannot
// see is still absent; what this skips is constructing the other Bots and asking the database
// what each of them was granted, on every delivery and again on every retry.
return await resolver
.resolveAgentForActor(input.actor, input.botId)
.catch(() => null);
};

/*
Expand Down Expand Up @@ -1088,26 +1012,7 @@ export function mountCopilotRuntime(
: {}),
// `identifyUser` is the Intelligence projection of the same person `identifyActor` returns:
// one resolver decides both whose threads these are and whose coworkers exist.
agents: createRequestAgents(
identifyActor,
loadAgents,
model,
resolveModelApiKey,
stallGuard,
loadToolsForActor,
signRunForActor,
/*
* Only when a computer exists. The tools themselves are registered by the surface, so a Bot is
* offered them without this and the guidance is what tells it how they go together: snapshot
* before acting, and ask a person to take the wheel at a sign-in rather than reporting the task
* as impossible. Absent computer, absent guidance: a Bot is not told about hands it has not got.
*/
config.computer ? COMPUTER_GUIDANCE : undefined,
loadVendors,
selectionForActor,
agentFetch,
handoffForActor,
) as never,
agents: createRequestAgents(identifyActor, resolver) as never,
});

return {
Expand Down
Loading