Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
31 changes: 29 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### One Bot can hand work to another, and reach a person when no Bot will do

A Bot asked something it is not the right Bot for can now put the question to one that is. The
addressed Bot answers **as itself, in its own conversation**, with its own tools and its own
knowledge. The asking Bot does not relay text on its behalf, so what you read is the answer that
Bot actually gave rather than another Bot's summary of it. The asking conversation records that the
question was put and to whom. A Bot that judges no other Bot will do can instead reach the person
who asked it.

**No Bot may address any other until an administrator says so.** Which Bot may reach which is an
ordinary grant, made per Bot, and a Bot with no grant is told it cannot rather than quietly trying.
A Bot addressed by a name two Bots answer to is refused and both are named, because picking one
would be a guess about which colleague a person meant.

Two ceilings, because a Bot deciding to ask another Bot is a Bot deciding to spend a run:
`BOT_HANDOFF_MAX_DEPTH` is how many Bots deep a chain may go and defaults to `1`, and **`0` switches
the capability off entirely**: the tool is not offered rather than offered and refused.
`BOT_HANDOFF_MAX_PER_RUN` is how many Bots one run may address and defaults to `3`. The Helm chart
takes the same two as `config.handoff.maxDepth` and `config.handoff.maxPerRun`.

A hop that fails is reported back by the Bot that asked, after its attempts are spent, rather than
leaving the person watching a conversation that never finishes. One rough edge to know about: a hop
that is retried leaves one "asked" line per attempt in the addressed Bot's own transcript, so a hop
that took three attempts reads there as having been asked three times.

No new tables: this uses the work queue that already fires the culler.

### The framework Bot answers on 5.6-tier models, and can be told how hard to think

Pointing `BOT_MODEL` at a `gpt-5.6-*` model gave a Bot that started, reported healthy, and then said
Expand Down Expand Up @@ -194,8 +221,8 @@ A Helm chart under `charts/openbot`, Bots and all, and the fixes that installing
up. Proven on a real EKS cluster: five workloads, replicas across two nodes, EBS volumes bound, and a
Bot opening a real page from inside AWS with the decision in the audit trail.

One chart, four targets: EKS, GKE, AKS and somebody's own cluster, with nothing but values between
them. There is no cloud branching in any template. Every place the clouds genuinely differ is a
One chart, five targets: EKS with a shared browser, EKS with a computer for each Bot, GKE, AKS and
somebody's own cluster, with nothing but values between them. There is no cloud branching in any template. Every place the clouds genuinely differ is a
value whose default is what a plain self-hosted cluster does: the cluster's own default StorageClass,
no RuntimeClass, a plain Kubernetes Secret, an Ingress. Identity is one `serviceAccount.annotations`
map, which is all IRSA, Workload Identity and AKS workload identity are. Secrets are a plain Secret
Expand Down
8 changes: 8 additions & 0 deletions app/src/components/agents/agent-profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type ReactNode, useState } from "react";
import { AbstractAvatar } from "@/components/agents/abstract-avatar";
import { AgentFields } from "@/components/agents/agent-fields";
import { CallbackTokenPanel } from "@/components/agents/callback-token-panel";
import { HandoffPanel } from "@/components/agents/handoff-panel";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
Expand Down Expand Up @@ -154,6 +155,13 @@ export function AgentProfile({ agentId }: { agentId: string }) {
/>
) : null}

{/*
* Not while editing, for the same reason the panel above is not: the form owns the screen, and
* these switches write immediately rather than on save, which would make one half of an open
* form apply and the other half not.
*/}
{isEditing ? null : <HandoffPanel agentId={agentId} />}

{actionError ? (
<p className="text-sm text-destructive" role="alert">
{actionError.message}
Expand Down
111 changes: 111 additions & 0 deletions app/src/components/agents/handoff-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AbstractAvatar } from "@/components/agents/abstract-avatar";
import { Switch } from "@/components/ui/switch";
import { setHandoffGrantMutationOptions } from "@/lib/agents/mutations";
import {
agentHandoffQueryOptions,
agentListQueryOptions,
} from "@/lib/agents/queries";

/**
* Which Bots this one may hand work to.
*
* On the Bot's own screen rather than in the connector catalogue: a catalogue entry has a fixed list
* of tools somebody else maintains, and the Bots a deployment has are whatever was made here. It is
* also the question a person asks while looking at a Bot, not while looking at a vendor.
*
* DIRECTIONAL, and said so on the screen, because the pair is the one thing about this that is easy
* to get backwards: this is who this Bot may ask, not who may ask it.
*/
export function HandoffPanel({ agentId }: { agentId: string }) {
const queryClient = useQueryClient();
const handoff = useQuery(agentHandoffQueryOptions(agentId));
const agents = useQuery(agentListQueryOptions());
const setGrant = useMutation(setHandoffGrantMutationOptions(queryClient));

if (handoff.isPending || !handoff.data) return null;
const { enabled, canGrant, reachable } = handoff.data;

/*
* A Bot may not be granted itself, and the server refuses it, so it is not offered here either.
* Hidden Bots are already absent from this list.
*/
const others = (agents.data ?? []).filter(
(candidate) => candidate.id !== agentId,
);

// Nothing to say to somebody who cannot change it and has nothing to read.
if (!canGrant && reachable.length === 0) return null;

return (
<section className="mt-6 grid gap-2">
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Bots it may ask
</h2>

<p className="text-muted-foreground text-sm">
{enabled
? "Work this Bot cannot do itself, it may hand to one of these. The Bot it asks answers in its own conversation, as itself."
: "Handing work between Bots is switched off for this deployment, so none of these takes effect until it is switched back on."}
</p>

{setGrant.error ? (
<p className="text-destructive text-sm" role="alert">
{setGrant.error.message}
</p>
) : null}

{others.length === 0 ? (
<p className="text-muted-foreground text-sm">
There is no other Bot here to hand work to.
</p>
) : (
<ul className="grid gap-1 rounded-lg border border-border bg-card p-1">
{others.map((candidate) => {
const held = reachable.includes(candidate.id);
return (
<li
className="flex items-center justify-between gap-3 rounded-md px-2 py-1.5"
key={candidate.id}
>
<span className="flex min-w-0 items-center gap-2">
<AbstractAvatar
name={candidate.name}
seed={candidate.avatarSeed}
size={24}
/>
<span className="min-w-0">
<span className="block truncate text-sm">
{candidate.name}
</span>
<span className="block truncate text-muted-foreground text-xs">
{candidate.title}
</span>
</span>
</span>
<Switch
aria-label={`Let this Bot ask ${candidate.name}`}
checked={held}
disabled={!canGrant || setGrant.isPending}
onCheckedChange={(next: boolean) =>
setGrant.mutate({
agentId,
ref: candidate.id,
granted: next,
})
}
/>
</li>
);
})}
</ul>
)}

{canGrant ? null : (
<p className="text-muted-foreground text-xs">
An administrator decides which Bots may be asked.
</p>
)}
</section>
);
}
36 changes: 36 additions & 0 deletions app/src/lib/agents/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,39 @@ export function revokeCallbackTokenMutationOptions(queryClient: QueryClient) {
onSuccess: () => invalidateAgents(queryClient),
});
}

/**
* Whether one Bot may hand work to another.
*
* The same `plugin_grants` write every other grant makes, with `kind: "bot"`, so the audit row and
* the refusals are the ones already in place: an administrator only, never a Bot on itself, and
* never onto a Bot that does not exist.
*
* DIRECTIONAL, and the two ids are easy to swap: `agentId` is the Bot doing the asking and `ref` is
* the Bot it may reach. Granted the other way round it reads as working and hands over nothing.
*/
export function setHandoffGrantMutationOptions(queryClient: QueryClient) {
return mutationOptions({
mutationFn: async (variables: {
/** The Bot doing the asking. */
agentId: string;
/** The Bot it may reach. */
ref: string;
granted: boolean;
}) => {
if (variables.granted) {
await client("/api/plugins/grants", {
method: "POST",
body: { kind: "bot", ref: variables.ref, agentId: variables.agentId },
fallback: FALLBACK,
});
return;
}
await client(
`/api/plugins/grants?kind=bot&ref=${encodeURIComponent(variables.ref)}&agentId=${encodeURIComponent(variables.agentId)}`,
{ method: "DELETE", fallback: FALLBACK },
);
},
onSuccess: () => invalidateAgents(queryClient),
});
}
26 changes: 26 additions & 0 deletions app/src/lib/agents/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ export const agentKeys = {
all: ["agents"] as const,
list: (hidden = false) => ["agents", "list", { hidden }] as const,
detail: (agentId: string) => ["agents", "detail", agentId] as const,
handoff: (agentId: string) => ["agents", "handoff", agentId] as const,
};

/** Which Bots one Bot may hand work to, and whether this deployment lets it. */
export type HandoffGrants = {
/**
* Whether the capability is switched on at all.
*
* Separate from the grants because the two fail differently: with this false, a grant is a row
* nothing will ever read, so the screen says so rather than offering a switch wired to nothing.
*/
enabled: boolean;
/** Whether the signed-in person may change any of it. Granting is an administrator's. */
canGrant: boolean;
/** Bot ids this Bot may address today. */
reachable: string[];
};

export function agentListQueryOptions(hidden = false) {
Expand All @@ -65,6 +81,16 @@ export function agentQueryOptions(agentId: string) {
});
}

export function agentHandoffQueryOptions(agentId: string) {
return queryOptions({
queryKey: agentKeys.handoff(agentId),
queryFn: (): Promise<HandoffGrants> =>
client(`/api/agents/${agentId}/handoff`, "handoff", {
fallback: "Could not load which Bots this one may ask",
}),
});
}

/** What the server said when it tried the endpoint. */
export type ConnectionVerdict =
| { ok: true; events: string[] }
Expand Down
67 changes: 56 additions & 11 deletions app/src/lib/copilot/repair-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,29 @@ function isToolResult(message: Message): message is Message & ToolResult {
}

/**
* The same messages, with a result inserted for any tool call that has none.
* The same messages, with every tool call answered by a result that FOLLOWS it.
*
* Returns the original array when no repair is needed.
*
* POSITION IS THE WHOLE POINT, and it was the half this function did not check. It decided which
* calls were answered by collecting every tool result in the array regardless of where it sat, so a
* result stored BEFORE its own call marked that call answered and the array was returned untouched.
* A provider matches a result to the call above it, so what it saw was a call with nothing after it:
* `AI_MissingToolResultsError`, thrown while converting the prompt, which fails the whole
* conversation rather than one turn. Every later message in that channel then failed the same way,
* including "what is 2 plus 2" — the conversation was dead for good and said only that a tool result
* was missing.
*
* Threads really are stored that way. Read back from the platform after a Bot handed work to another
* Bot, the result sat three messages ahead of the call that produced it, and the same inversion held
* for `ask_person` and for an MCP tool call; only the computer's own tools came back in order. So
* this is not a hypothetical ordering: it is what a transcript looks like after using the features
* this release is named for.
*
* An early result is MOVED rather than replaced, because it is the real one — "Handed to Knowledge"
* says more than this function's apology ever could. Only a call with no result anywhere gets the
* sentence below. A result whose call never appears at all is dropped: it answers nothing, and a
* provider rejects it for the same reason it rejects the mirror image.
*/
export function repairUnansweredToolCalls(
messages: ReadonlyArray<Message>,
Expand All @@ -29,36 +49,61 @@ export function repairUnansweredToolCalls(
*/
mintId: () => string = newId,
): ReadonlyArray<Message> {
const called = new Set<string>();
/** Answered where a provider can see it: by a result later in the array than the call. */
const answered = new Set<string>();
/** A real result that arrived before its own call, kept so it can be put back in the right place. */
const early = new Map<string, Message>();
/** Results that answer nothing where they sit, and so must not be sent where they sit. */
const misplaced = new Set<Message>();

for (const message of messages) {
if (isToolResult(message)) answered.add(message.toolCallId);
if (message.role === "assistant") {
for (const call of message.toolCalls ?? []) called.add(call.id);
continue;
}
if (!isToolResult(message)) continue;
const id = message.toolCallId;
if (called.has(id) && !answered.has(id)) {
answered.add(id);
continue;
}
// Before its call, or a second result for a call already answered, or answering no call at all.
if (!early.has(id)) early.set(id, message);
misplaced.add(message);
}

const missing = messages.some(
(message) =>
message.role === "assistant" &&
(message.toolCalls ?? []).some((call) => !answered.has(call.id)),
);
if (!missing) return messages;
if (!missing && misplaced.size === 0) return messages;

const repaired: Message[] = [];
const filled = new Set(answered);
for (const message of messages) {
if (misplaced.has(message)) continue;
repaired.push(message);
if (message.role !== "assistant") continue;

for (const call of message.toolCalls ?? []) {
if (answered.has(call.id)) continue;
if (filled.has(call.id)) continue;
// Immediately after the assistant message that made the call, and before any later message:
// OpenAI requires the results to follow their calls, and some providers require the order to
// match the `tool_calls` array as well.
repaired.push({
id: mintId(),
role: "tool",
toolCallId: call.id,
content: UNANSWERED,
} as Message);
const moved = early.get(call.id);
repaired.push(
moved ??
({
id: mintId(),
role: "tool",
toolCallId: call.id,
content: UNANSWERED,
} as Message),
);
// A duplicated call id may only receive one repair result.
answered.add(call.id);
filled.add(call.id);
}
}

Expand Down
13 changes: 11 additions & 2 deletions app/src/routes/_authed/_app/agents/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ export const Route = createFileRoute("/_authed/_app/agents/")({
component: AgentsScreen,
});

/*
* The roster wraps on the width it actually has, not on the window's.
*
* A card is a fixed 144px, so four fixed columns overlap the moment the column they sit in is
* narrower than the card. That is not a narrow-window case: opening the detail pane takes the width
* out of this column at any window size, so the cards behind an open Bot overlapped each other on a
* perfectly ordinary screen. `auto-fill` tracks the container instead, which is the thing that
* actually changed.
*/
function AgentsScreen() {
const { new: isCreating, agent: selectedAgentId } = Route.useSearch();
const navigate = Route.useNavigate();
Expand Down Expand Up @@ -66,7 +75,7 @@ function AgentsScreen() {
</div>
<div className="flex flex-row mt-4">
{!!mine?.length && (
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-[repeat(auto-fill,minmax(144px,1fr))] gap-4">
{mine.map((agent, index) => {
return (
<StaggerItem index={index} key={agent.id}>
Expand All @@ -91,7 +100,7 @@ function AgentsScreen() {
</div>
<div className="mt-8 w-full max-w-2xl">
<h2 className="font-bold text-lg">Explore agents</h2>
<div className="grid grid-cols-4 gap-4 mt-4">
<div className="mt-4 grid grid-cols-[repeat(auto-fill,minmax(144px,1fr))] gap-4">
{!!explore?.length &&
explore.map((agent, index) => {
return (
Expand Down
Loading