Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ec92e32
Let one Bot hand work to another: the decision half
davidmckayv Aug 26, 2026
8d4ec3e
Offer the handoff tool to the run that is allowed to have it
davidmckayv Aug 26, 2026
48bc510
Deliver a hop: run the Bot that was addressed, and record what became…
davidmckayv Aug 26, 2026
12313a3
Wire the hop up: every replica sweeps, and a hop becomes a turn
davidmckayv Aug 26, 2026
c590295
Make a hop visible, configurable and written down
davidmckayv Aug 26, 2026
44db1a4
Cap a culler sweep, and stop drawing every accepted hop as Blocked
davidmckayv Aug 26, 2026
3e0a676
Make a hop actually reach the Bot it was handed to
davidmckayv Aug 26, 2026
355954f
Give the addressed Bot the conversation it was handed
davidmckayv Aug 26, 2026
86e25d0
Hold the fan-out cap when a Bot asks for several things at once
davidmckayv Aug 26, 2026
e945353
Draw a Bot asking a person, and share the decoder that tells the two …
davidmckayv Aug 26, 2026
25d1a45
Write down where a hop's answer lands, and what asking a person is for
davidmckayv Aug 26, 2026
d4c2eb2
Keep the instruction that produced a notice out of the person's trans…
davidmckayv Aug 26, 2026
79e6d54
Merge remote-tracking branch 'origin/main' into feat/bot-handoff
davidmckayv Aug 26, 2026
7f948ea
Merge remote-tracking branch 'origin/main' into feat/bot-handoff
davidmckayv Aug 26, 2026
1395278
Stop a batch of hops outliving its lease, and let an administrator re…
davidmckayv Aug 26, 2026
b912ed0
Merge remote-tracking branch 'origin/main' into feat/bot-handoff
davidmckayv Aug 27, 2026
9735e2b
Survive an upgrade that reuses values, and stop sweeps piling up
davidmckayv Aug 27, 2026
de0a3d1
Answer the rest of the review: honest markers, real names, and less w…
davidmckayv Aug 27, 2026
2662347
Guard the routines values key too, and find the next one by machine
davidmckayv Aug 27, 2026
65b62db
Stop a nil-guard from defeating the off switch
davidmckayv Aug 27, 2026
d18b6f3
Stop a grant refusal answering questions about other people's Bots
davidmckayv Aug 27, 2026
e4b8d97
Put the Helm assertions in the job that has Helm
davidmckayv Aug 27, 2026
2cc833c
Thread the person's role all the way to the delivery, and stop the se…
davidmckayv Aug 27, 2026
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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The new-values-key check compares this chart against the last released one, or against
# main where the chart has not shipped yet. A shallow clone has neither to compare with.
fetch-depth: 0
- uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
with:
version: v3.19.0
Expand Down Expand Up @@ -156,6 +159,15 @@ jobs:
--set networkPolicy.enabled=true \
--set computers.extraEnv[0].name=EGRESS_PROXY_DEFAULT \
--set-string computers.extraEnv[0].value=http://proxy.internal:3128
# And that a values key this chart did not used to have still renders when it is absent.
#
# `helm upgrade --reuse-values` takes the previous release's computed values rather than
# merging the new chart's defaults, so a key added by the release being installed is missing on
# every deployment that already exists. Unguarded that is a nil dereference that fails the
# whole render, or an empty scalar Kubernetes reads as unset. Both shipped: one was found in
# review, the other by a live upgrade after the first had been fixed one key over.
- name: A new values key can be absent
run: bun scripts/check-new-values-keys.ts charts/openbot/ci/${{ matrix.target }}-values.yaml

test:
name: tests
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/agents/orb/agent-orb.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { cn } from "@/lib/utils";
import {
type MotionStyle,
motion,
Expand All @@ -7,6 +6,7 @@ import {
useReducedMotion,
useTransform,
} from "motion/react";
import { cn } from "@/lib/utils";
import {
type AIAmplitude,
type AIState,
Expand Down
14 changes: 7 additions & 7 deletions app/src/components/channels/chat-transcript.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import type { Message } from "@ag-ui/core";
import { IconBox } from "@tabler/icons-react";
import { useRenderToolCall } from "@copilotkit/react-core/v2";
import { IconBox } from "@tabler/icons-react";
import { motion, useReducedMotion } from "motion/react";
import { memo, useEffect, useMemo, useRef } from "react";
import { Streamdown } from "streamdown";
import { markdownComponents } from "@/lib/markdown";
import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
MessageContent,
MessageFooter,
Message as MessageRow,
} from "@/components/ui/message";
import { Skeleton } from "@/components/ui/skeleton";
import {
MessageScroller,
MessageScrollerButton,
Expand All @@ -22,12 +19,15 @@ import {
MessageScrollerViewport,
useMessageScroller,
} from "@/components/ui/message-scroller";
import { toVisibleChatItems } from "./chat-messages";
import { asText, forDisplay, REFUSAL_MARKER } from "@/lib/plugins/tool-result";
import { Skeleton } from "@/components/ui/skeleton";
import { markdownComponents } from "@/lib/markdown";
import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion";
import { readToolName } from "@/lib/plugins/tool-name";
import { asText, forDisplay, REFUSAL_MARKER } from "@/lib/plugins/tool-result";
import { toVisibleChatItems } from "./chat-messages";
import type { QueuedMessage } from "./composer";
import { ToolLine } from "./tool-line";
import { ToolRenderBoundary } from "./tool-boundary";
import { ToolLine } from "./tool-line";

type ChatTranscriptProps = {
busy?: boolean;
Expand Down
5 changes: 2 additions & 3 deletions app/src/components/layout/page-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { IconChevronLeft } from "@tabler/icons-react";
import { Link, type LinkProps } from "@tanstack/react-router";
import type * as React from "react";

import { cn } from "@/lib/utils";
import { Link, type LinkProps } from "@tanstack/react-router";
import { Button } from "../ui/button";
import { IconChevronLeft } from "@tabler/icons-react";

/**
* The frame every configuration screen sits in.
Expand Down
2 changes: 1 addition & 1 deletion app/src/lib/channels/mutations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
mutationOptions,
type InfiniteData,
mutationOptions,
type QueryClient,
} from "@tanstack/react-query";
import { client, tryClient } from "@/lib/client";
Expand Down
61 changes: 61 additions & 0 deletions app/src/lib/copilot/escalation-tool.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useRenderTool } from "@copilotkit/react-core/v2";
import { z } from "zod";
import { ToolLine } from "@/components/channels/tool-line";
import { PUT_TO } from "@/lib/copilot/markers";
import { saidItWentAhead } from "@/lib/plugins/tool-result";

/**
* How a Bot stopping to ask a person reads in the transcript.
*
* RENDER ONLY, for the same reason as the handoff beside it: `ask_person` runs on the server, where
* the route and the audit row are. What this adds is that the choice is legible. A Bot which decided
* it could not settle something on its own, and said so rather than guessing, has done the right
* thing; drawn as a raw `ask_person` call with its arguments as JSON it reads as a malfunction.
*/
const parameters = z.object({
question: z.string().optional(),
why: z.string().optional(),
});

/**
* Whether the question reached anybody.
*
* Decoded first, because a server-side tool's result arrives as a JSON-encoded string and a prefix
* matched against the raw value never matches: that mistake drew every successful handoff as
* Blocked. A route that could not reach a person is the case worth drawing differently, because the
* Bot has stopped and nobody has been asked.
*/
function reached(result: unknown): boolean {
return saidItWentAhead(result, PUT_TO);
}

export function EscalationTool() {
useRenderTool({
name: "ask_person",
parameters,
render: ({ parameters: given, result, status }) => {
const running = status !== "complete" && result === undefined;
return (
<ToolLine
label="Asked you"
detail={given?.question}
running={running}
refused={!running && !reached(result)}
>
<div className="space-y-1 text-sm">
{given?.question ? <p>{given.question}</p> : null}
{/*
* Why it stopped, which is the half a person is owed. "I need a decision only you can
* make" and "I could not find the answer" look the same from the outside and are not.
*/}
{given?.why ? (
<p className="text-muted-foreground">{given.why}</p>
) : null}
</div>
</ToolLine>
);
},
});

return null;
}
79 changes: 79 additions & 0 deletions app/src/lib/copilot/handoff-tool.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useRenderTool } from "@copilotkit/react-core/v2";
import { z } from "zod";
import { ToolLine } from "@/components/channels/tool-line";
import { HANDED_OVER } from "@/lib/copilot/markers";
import { saidItWentAhead } from "@/lib/plugins/tool-result";

/**
* How a Bot handing work to another Bot reads in the transcript.
*
* RENDER ONLY. `message_bot` runs on the server, where the grant, the caps and the audit row are, so
* nothing here registers a tool or decides anything. What it registers is a line, because a hop that
* happens off-screen is the thing the issue asks to avoid: a conversation that quietly fans out to
* four Bots and bills for all of them should say so while it is doing it.
*
* Without this the call still appears, as a generic tool call named `message_bot` with its arguments
* as JSON. That is technically visible and practically not: the point is that a person can see their
* Bot bringing in another one and read what it asked for.
*/
const parameters = z.object({
bot: z.string().optional(),
task: z.string().optional(),
constraints: z.string().optional(),
expecting: z.string().optional(),
});

/**
* Whether the deployment refused the hop.
*
* The result is a sentence the Bot can say either way, because a refusal mid-run is an answer rather
* than an exception. The transcript still has to tell the two apart: one is a Bot bringing in help,
* the other is a boundary holding, and drawing them the same way would make a working cap look like
* a working handoff.
*/
function refused(result: unknown): boolean {
return !saidItWentAhead(result, HANDED_OVER);
}

export function HandoffTool() {
useRenderTool({
name: "message_bot",
parameters,
render: ({ parameters: given, result, status }) => {
const asked = given?.bot?.trim();
const running = status !== "complete" && result === undefined;
return (
<ToolLine
label={asked ? `Asked ${asked}` : "Asked another Bot"}
detail={given?.task}
running={running}
refused={!running && refused(result)}
>
{/*
* The parts, kept as parts. The asking model was made to name them so the receiving one
* need not infer them, and a person reading the conversation gets the same benefit: what
* was asked, what bounded it, and what was wanted back.
*/}
<div className="space-y-1 text-sm">
{given?.task ? <p>{given.task}</p> : null}
{given?.constraints ? (
<p className="text-muted-foreground">
Constraints: {given.constraints}
</p>
) : null}
{given?.expecting ? (
<p className="text-muted-foreground">
Wanted back: {given.expecting}
</p>
) : null}
{typeof result === "string" ? (
<p className="text-muted-foreground">{result}</p>
) : null}
</div>
</ToolLine>
);
},
});

return null;
}
8 changes: 8 additions & 0 deletions app/src/lib/copilot/markers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* The two marker phrases, re-exported from the one place they are declared.
*
* `shared/` is where the server reads them from too, so a rewording changes both sides at once. This
* file exists so the browser code keeps importing through `@/`, and so the path to `shared/` is
* written down once rather than in every renderer.
*/
export { HANDED_OVER, PUT_TO } from "../../../../shared/handoff-markers";
8 changes: 8 additions & 0 deletions app/src/lib/copilot/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { CopilotKitProvider } from "@copilotkit/react-core/v2";
import type { ReactNode } from "react";
import { ActiveBotProvider } from "./active-bot";
import { ComputerTools } from "./computer-tools";
import { EscalationTool } from "./escalation-tool";
import { GalleryTools } from "./gallery-tools";
import { HandoffTool } from "./handoff-tool";
import { SandboxedTools } from "./sandboxed-tools";

/**
Expand All @@ -25,6 +27,12 @@ export function CopilotProvider({ children }: { children: ReactNode }) {
{/* Computer tools target the Bot declared by the mounted surface. */}
<ActiveBotProvider>
<ComputerTools />
{/*
Draws a Bot bringing in another Bot. Registers no tool: `message_bot` runs on the server,
where the grant and the caps are. A hop that happens off-screen is the thing to avoid.
*/}
<HandoffTool />
<EscalationTool />
{/* Gallery tools are registered once; their handlers re-read the active Bot to avoid shadowing renderers. */}
<GalleryTools />
{/* Browser-authored components use the same component grants as the compiled gallery. */}
Expand Down
19 changes: 19 additions & 0 deletions app/src/lib/plugins/tool-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,22 @@ export function forDisplay(text: string): string {

return `\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``;
}

/**
* Whether a server-side tool's result begins with the phrase that means it went ahead.
*
* TWO CALLERS AND ONE RULE, because they had two. A tool that runs on the server reaches the
* transcript as text meant for a model, so the only thing the renderer can read an outcome out of is
* the wording — and the wording arrives JSON-encoded, which is why this decodes before it matches.
*
* The awkward case is a result that is neither a string nor absent. `message_bot` treated that as
* success and `ask_person` treated it as a refusal, for the same situation, and the handoff's own
* comments say which way round is worse: a boundary that held drawn as a Bot getting on with it.
* So anything unrecognisable is not success. Absent is left alone, because a call still running has
* no result yet and the caller decides that from its status.
*/
export function saidItWentAhead(result: unknown, marker: string): boolean {
if (result === undefined) return true;
if (typeof result !== "string") return false;
return asText(result).startsWith(marker);
}
2 changes: 1 addition & 1 deletion app/src/routes/_authed/_app/agents/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { useQuery } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { z } from "zod";
import { AgentCard } from "@/components/agents/agent-card";
import { StaggerItem } from "@/components/layout/stagger";
import { AgentProfile as AgentProfileDetail } from "@/components/agents/agent-profile";
import { NewAgent } from "@/components/agents/new-agent";
import { DetailPanel } from "@/components/layout/detail-panel";
import { StaggerItem } from "@/components/layout/stagger";
import { Button } from "@/components/ui/button";
import { Empty, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { agentListQueryOptions } from "@/lib/agents/queries";
Expand Down
23 changes: 11 additions & 12 deletions app/src/routes/_authed/_app/skills.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { IconDots, IconPlus } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { z } from "zod";
import { IconPlus } from "@tabler/icons-react";
import { DetailPanel } from "@/components/layout/detail-panel";
import {
PageRows,
Expand All @@ -13,26 +13,25 @@ import { StaggerItem } from "@/components/layout/stagger";
import { EditSkill } from "@/components/skills/edit-skill";
import { NewSkill } from "@/components/skills/new-skill";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Empty, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import { removeSkillMutationOptions } from "@/lib/plugins/mutations";
import { pluginsPageQueryOptions } from "@/lib/plugins/queries";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from "@/components/ui/item";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { IconDots } from "@tabler/icons-react";
import { Separator } from "@/components/ui/separator";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import { removeSkillMutationOptions } from "@/lib/plugins/mutations";
import { pluginsPageQueryOptions } from "@/lib/plugins/queries";

/**
* Personal `/` skills. They are instructions, not capabilities, and can only be granted to Bots the
Expand Down
4 changes: 2 additions & 2 deletions app/src/routes/_authed/admin/boundaries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { PageSection, PageShell } from "@/components/layout/page-shell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { saveActionPolicyMutationOptions } from "@/lib/computers/mutations";
import {
type ActionPolicy,
Expand All @@ -11,8 +13,6 @@ import {
type PolicyMode,
} from "@/lib/computers/queries";
import { queryClient } from "@/query-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

/**
* CEL computer-action boundary editor. Rules are shown as the gateway evaluates them, and denied
Expand Down
14 changes: 7 additions & 7 deletions app/src/routes/_authed/settings/connected-accounts/$key.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@ import {
PageSection,
PageShell,
} from "@/components/layout/page-shell";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from "@/components/ui/item";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from "@/components/ui/item";
import { Separator } from "@/components/ui/separator";
import { connectAccountMutationOptions } from "@/lib/plugins/mutations";
import {
Expand Down
Loading