Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
c006f8a
Publish the images a laptop would otherwise have to build
davidmckayv Sep 7, 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
7 changes: 7 additions & 0 deletions .github/published-images.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
"agent-computer",
"supervisor",
"agent-bot",
"agent-langgraph",
"server"
]
62 changes: 61 additions & 1 deletion .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 Expand Up @@ -341,11 +353,59 @@ jobs:

# One check for branch protection to require. A new job above is covered by this without anybody
# remembering to add it to a list, and a job that was skipped for the wrong reason is not a pass.
# The five images the release publishes alongside the one above. Nothing else here builds them:
# they are built by `docker compose up --build`, which only the smoke journey runs and which
# cannot run in CI. So a broken `supervisor/Dockerfile` used to surface during a release, after
# the `openbot` image had already been pushed, which is the one failure this repository calls out
# as leaving a published image with nothing pointing at it.
#
# Built, not pushed, and one architecture: this answers whether the Dockerfile resolves. Whether
# it resolves on arm64 too is answered at release time, on an arm64 runner.
component-images:
name: component images
runs-on: ubuntu-latest
# The same cost gate as `image` above, and for the same reason: `agent-computer` pulls
# Playwright's base and `server` builds the app, so this is minutes rather than seconds. On main
# and through the release's workflow_call it always runs.
if: >-
github.event_name != 'pull_request' ||
contains(github.event.pull_request.labels.*.name, 'full-ci')
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
# A Dockerfile the release does not publish is one a machine would build from source, so the
# list and the tree have to agree. Checked here rather than trusted, because the failure is
# silent: the release succeeds and a laptop somewhere compiles the app.
- name: Every buildable service is published
run: |
set -euo pipefail
found="$(find . -mindepth 2 -maxdepth 2 -name Dockerfile -not -path './node_modules/*' \
| sed -e 's|^\./||' -e 's|/Dockerfile$||' | sort)"
published="$(jq -r '.[]' .github/published-images.json | sort)"
if [ "$found" != "$published" ]; then
echo "::error::.github/published-images.json does not match the Dockerfiles in the tree."
diff <(echo "$published") <(echo "$found") || true
exit 1
fi
- name: They build
run: |
set -euo pipefail
while read -r image; do
echo "::group::$image"
docker buildx build --file "$image/Dockerfile" \
--cache-from "type=gha,scope=ci-$image" \
--cache-to "type=gha,mode=max,scope=ci-$image" \
.
echo "::endgroup::"
done < <(jq -r '.[]' .github/published-images.json)

verify:
name: verify
runs-on: ubuntu-latest
if: always()
needs: [static, deployables, chart, test, build, migrations, image]
needs: [static, deployables, chart, test, build, migrations, image, component-images]
steps:
- name: Require every check
env:
Expand Down
191 changes: 188 additions & 3 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ jobs:
needs: metadata
if: needs.metadata.outputs.is_release == 'true'
runs-on: ubuntu-latest
outputs:
components: ${{ steps.components.outputs.components }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -94,6 +96,11 @@ jobs:
echo "::error::CHANGELOG.md has no section for ${VERSION#v}."
exit 1
}
# The services published alongside the image above. Read from the tree rather than written
# here, because CI checks the same file when it proves those Dockerfiles still build, so the
# set that is tested and the set that is published cannot drift apart.
- id: components
run: echo "components=$(jq -c . .github/published-images.json)" >> "$GITHUB_OUTPUT"

# The same checks CI runs, against the commit being published. This is the gate: nothing is built
# or tagged unless they pass here, on this exact tree.
Expand Down Expand Up @@ -156,12 +163,167 @@ jobs:
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

# The images the installer pulls instead of building. `docker-compose.yml` builds these from
# source on every machine, which needs a toolchain and several minutes a desktop install does not
# have. Published here, from the same commit as the image above, so a deployment and a laptop run
# the same code.
#
# Two architectures, because the machines are laptops: arm64 Macs and amd64 everything else. Built
# on native runners rather than under QEMU. Emulated `bun install` and `vite build` are a known
# source of release-day flakiness, and arm64 runners are free to a public repository, so emulation
# would be the slower and less reliable option at no saving.
component-images:
name: ${{ matrix.image }} ${{ matrix.platform.arch }}
needs: [metadata, verify, checks]
if: needs.metadata.outputs.is_release == 'true'
runs-on: ${{ matrix.platform.runner }}
permissions:
contents: read
packages: write
strategy:
# One image failing should not hide whether the others build, and a half-finished run leaves
# nothing deployable: these builds are pushed untagged, and the tags are written by the job
# below only once both architectures of an image exist.
fail-fast: false
matrix:
image: ${{ fromJSON(needs.verify.outputs.components) }}
platform:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Pushed by digest and deliberately untagged. A tag written here would name one architecture,
# and the two jobs for one image would race to own it, so the last to finish would decide what
# the tag meant.
- id: push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ${{ matrix.image }}/Dockerfile
platforms: linux/${{ matrix.platform.arch }}
outputs: type=image,name=ghcr.io/copilotkit/openbot-${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true
# Scoped per image and per architecture. One shared scope would have ten builds
# overwriting each other's cache and none of them reading their own.
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.platform.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.platform.arch }}
provenance: true
sbom: true
# A matrix job's outputs are not addressable by the jobs that consume them, so the digest
# travels as a file. One artifact per image and architecture, because same-named artifacts
# from different matrix legs collide.
- name: Record the digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
ARCH: ${{ matrix.platform.arch }}
run: |
set -euo pipefail
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
mkdir -p digests
echo "$DIGEST" > "digests/$ARCH"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digest-${{ matrix.image }}-${{ matrix.platform.arch }}
path: digests/
retention-days: 1

# Two per-architecture images become one reference. Whatever pulls it, a laptop or a cluster, names
# the manifest list and gets its own architecture without being told which one it is.
component-manifests:
name: ${{ matrix.image }} manifest
needs: [metadata, verify, checks, component-images]
if: needs.metadata.outputs.is_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# Same identity and the same reason as the image above: the attestation says which workflow,
# repository and commit produced this, and there is no key to hold.
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
image: ${{ fromJSON(needs.verify.outputs.components) }}
steps:
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: digest-${{ matrix.image }}-*
path: digests
merge-multiple: true
- id: merge
name: Write the tags over both architectures
env:
REPOSITORY: ghcr.io/copilotkit/openbot-${{ matrix.image }}
VERSION: ${{ needs.metadata.outputs.version }}
COMMIT: ${{ github.sha }}
run: |
set -euo pipefail
# Both architectures or neither. A manifest list holding one of them installs on half the
# machines and looks exactly like one holding both until somebody's laptop says
# "no matching manifest".
refs=()
for arch in amd64 arm64; do
digest="$(cat "digests/$arch")"
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]
refs+=("$REPOSITORY@$digest")
done
docker buildx imagetools create \
--tag "$REPOSITORY:$VERSION" \
--tag "$REPOSITORY:$COMMIT" \
--tag "$REPOSITORY:latest" \
"${refs[@]}"
# The list's own digest, which is what anything downstream pins. Read back from the
# registry rather than derived here, and checked, so a template that stops returning a
# digest fails now instead of writing something unusable into the release.
digest="$(docker buildx imagetools inspect "$REPOSITORY:$VERSION" --format '{{.Manifest.Digest}}')"
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/copilotkit/openbot-${{ matrix.image }}
subject-digest: ${{ steps.merge.outputs.digest }}
push-to-registry: true
# For the same reason the digests above travel as files: a matrix cannot hand a value to a
# later job.
- name: Record the manifest
env:
NAME: ${{ matrix.image }}
REPOSITORY: ghcr.io/copilotkit/openbot-${{ matrix.image }}
DIGEST: ${{ steps.merge.outputs.digest }}
run: |
set -euo pipefail
mkdir -p manifests
jq -n --arg name "$NAME" --arg repository "$REPOSITORY" --arg digest "$DIGEST" \
'{name: $name, repository: $repository, digest: $digest}' > "manifests/$NAME.json"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: manifest-${{ matrix.image }}
path: manifests/
retention-days: 1

# The tag and the release, last, so nothing is announced that was not built. The manifest is the
# useful artefact: it pins the digest, so a deploy or a rollback names an exact image rather than a
# tag somebody could move.
github-release:
name: tag and release
needs: [metadata, verify, checks, image]
needs: [metadata, verify, checks, image, component-manifests]
if: needs.metadata.outputs.is_release == 'true'
runs-on: ubuntu-latest
permissions:
Expand All @@ -173,14 +335,36 @@ jobs:
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: manifest-*
path: manifests
merge-multiple: true
- name: Write the image manifest
env:
VERSION: ${{ needs.metadata.outputs.version }}
DIGEST: ${{ needs.image.outputs.digest }}
COMMIT: ${{ github.sha }}
COMPONENTS: ${{ needs.verify.outputs.components }}
run: |
set -euo pipefail
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
# Every image this release published, or the file is not written. A missing entry is a
# service whatever reads this would quietly build from source instead, which is the thing
# publishing them was for, and it would only be noticed on somebody's laptop.
expected="$(jq -r 'length' <<< "$COMPONENTS")"
found="$(find manifests -name '*.json' -type f | wc -l | tr -d ' ')"
if [ "$found" != "$expected" ]; then
echo "::error::Expected $expected component manifests, found $found."
exit 1
fi
components="$(jq -s 'map({
(.name): {
repository: .repository,
digest: .digest,
reference: (.repository + "@" + .digest),
}
}) | add' manifests/*.json)"
# `jq`, not `bun`: this job deliberately checks out without credentials and installs no
# toolchain, so reaching for the repository's runtime here is a step that was never taken.
# It was, and the tag was never cut: the manifest step died on `bun: command not found`
Expand All @@ -191,16 +375,17 @@ jobs:
--arg digest "$DIGEST" \
--arg commit "$COMMIT" \
--arg repository "ghcr.io/copilotkit/openbot" \
--argjson components "$components" \
'{
version: $version,
commit: $commit,
images: {
images: ({
openbot: {
repository: $repository,
digest: $digest,
reference: ($repository + "@" + $digest),
},
},
} + $components),
}' > container-images.json
cat container-images.json
- name: Tag and publish
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
Loading