chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
**/node_modules
.next
__pycache__
*.pyc
.git
**/vitest.config.ts
**/test-setup.ts
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
+9
View File
@@ -0,0 +1,9 @@
# API Keys (shared across integrations)
OPENAI_API_KEY=replace-with-your-key
ANTHROPIC_API_KEY=replace-with-your-key
# Agent backend URL (for the CopilotKit runtime proxy)
AGENT_URL=http://localhost:8000
# Showcase
NEXT_PUBLIC_BASE_URL=http://localhost:3000
+13
View File
@@ -0,0 +1,13 @@
node_modules/
.next/
.env.local
.env
*.pyc
__pycache__/
.venv/
dist/
playwright-report/
test-results/
# Shared modules (copied by CI)
shared_frontend/
+82
View File
@@ -0,0 +1,82 @@
# Stage 1: Build Next.js frontend
# Cache bust: 2026-04-07 smoke route
FROM node:22-slim AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps
COPY . .
RUN npm run build
# Stage 2: Build Python venv with agent deps
#
# True multi-stage split — the builder carries full `python:3.12` (compilers,
# build-essential, dev headers needed to compile wheels that don't ship
# pre-built for ``-slim``) and produces a self-contained ``/opt/venv`` that
# the runner COPYs in as a single opaque tree. The runner never runs
# ``pip install`` itself, so no compiler toolchain bloats the final image.
FROM python:3.12.13 AS agent-builder
WORKDIR /agent
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
COPY requirements.txt ./requirements.txt
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# Stage 3: Production image with Node.js + Python (runtime only — no pip,
# no build tools). Node.js is installed via NodeSource because the package
# runs BOTH Next.js (frontend) and the Python agent inside this single image;
# entrypoint.sh orchestrates both processes.
FROM python:3.12.13-slim AS runner
RUN apt-get update && apt-get install -y --no-install-recommends \
curl && \
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs && \
apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Create unprivileged runtime user BEFORE any COPY so --chown resolves
# by name and so recursive chown over /app is never needed (fast builds).
# Mirrors the starter Dockerfile pattern for parity — Railway / any
# platform that enforces non-root by policy needs this from the package
# image too, not just the generated starter.
RUN (groupadd --system --gid 1001 app 2>/dev/null || true) \
&& (useradd --system --uid 1001 --gid 1001 --no-create-home app 2>/dev/null || true) \
&& mkdir -p /home/app && chown app:app /home/app
# Python venv (prebuilt in agent-builder stage — no pip in the runner).
COPY --chown=app:app --from=agent-builder /opt/venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
# Next.js build artifacts
COPY --chown=app:app --from=frontend /app/.next ./.next
COPY --chown=app:app --from=frontend /app/node_modules ./node_modules
COPY --chown=app:app --from=frontend /app/package.json ./
COPY --chown=app:app --from=frontend /app/public ./public
# Agent code
COPY --chown=app:app src/agent_server.py ./
COPY --chown=app:app src/agents/ ./agents/
# Shared Python tools (symlinked locally, copied into build context by CI)
COPY --chown=app:app tools/ /app/tools/
# Shared CVDIAG bootstrap module (single source: showcase/integrations/_shared;
# symlinked in source, dereferenced into this build context by the harness
# stage_shared step). Lands at /app/_shared; /app is already on PYTHONPATH.
COPY --chown=app:app _shared/ ./_shared/
ENV PYTHONPATH=/app
# Entrypoint
COPY --chown=app:app entrypoint.sh ./
RUN chmod +x entrypoint.sh
USER app
EXPOSE 10000
# Intentionally NOT setting `ENV NODE_ENV=production` at the image level.
# NODE_ENV=production at the image level would leak into every child process
# (Python agent, shell scripts, healthchecks) — most of which don't use it
# the way Next.js does. entrypoint.sh scopes NODE_ENV=production to the
# Next.js invocation only so non-Next children see the host's environment.
CMD ["./entrypoint.sh"]
+248
View File
@@ -0,0 +1,248 @@
# AG2 Parity Notes
Status of AG2 showcase demos relative to the langgraph-python canonical set.
## Ported
### Batch 1 — Frontend variants over the shared ConversableAgent
These demos reuse the existing `src/agents/agent.py` (one `ConversableAgent`
wrapped with `AGUIStream`). The runtime route registers each agent name,
all pointing to the same HTTP backend.
- `prebuilt-sidebar``<CopilotSidebar />` docked layout
- `prebuilt-popup``<CopilotPopup />` floating launcher
- `chat-slots` — slot-overridden `<CopilotChat />` (welcomeScreen, disclaimer, assistantMessage)
- `chat-customization-css` — scoped CSS theming of built-in classes
- `headless-simple` — bespoke chat built on `useAgent` / `useComponent`
- `readonly-state-agent-context``useAgentContext` read-only context
- `reasoning-default` — built-in `CopilotChatReasoningMessage` (no custom slot)
- `tool-rendering-default-catchall``useDefaultRenderTool()` (built-in card)
- `tool-rendering-custom-catchall` — single branded wildcard renderer
- `frontend-tools``useFrontendTool` with sync handler (change_background)
- `frontend-tools-async``useFrontendTool` with async handler (notes-card)
- `hitl-in-app` — async `useFrontendTool` + app-level modal (approval-dialog)
### Previously ported (kept)
- `agentic-chat`, `hitl-in-chat`, `tool-rendering`, `gen-ui-tool-based`,
`gen-ui-agent`, `shared-state-streaming`
### Batch 3 — Headless complete + manifest-only entries
- `cli-start` — informational manifest entry (copy-paste starter command).
- `gen-ui-tool-based` — already shipped; manifest entry added.
- `headless-complete` — TRULY headless chat re-composed from low-level
hooks (`useRenderToolCall`, `useRenderActivityMessage`,
`useRenderCustomMessages`). Backend: dedicated AG2
`ConversableAgent` (`agents/headless_complete.py`) mounted at
`/headless-complete/` with `get_weather` + `get_stock_price` tools;
`highlight_note` is registered on the frontend via `useComponent`.
### Batch 4 — A2UI / OGUI / MCP + reasoning ports (this batch)
Each demo gets its own AG2 sub-app mounted at a named path, plus
(where required) its own dedicated `/api/copilotkit-*` runtime route so
the runtime middleware config doesn't leak into other cells.
- `declarative-gen-ui` — A2UI Dynamic Schema. Backend
(`src/agents/a2ui_dynamic.py`) owns the `generate_a2ui` tool, which
invokes a secondary OpenAI client bound to `render_a2ui` and returns
an `a2ui_operations` container. Runtime route at
`api/copilotkit-declarative-gen-ui/route.ts` with
`a2ui.injectA2UITool: false`.
- `a2ui-fixed-schema` — A2UI Fixed Schema. Backend
(`src/agents/a2ui_fixed.py`) ships `flight_schema.json` and exposes a
`display_flight(origin, destination, airline, price)` tool that emits
`a2ui_operations` directly. Runtime route at
`api/copilotkit-a2ui-fixed-schema/route.ts` with
`a2ui.injectA2UITool: false`.
- `mcp-apps` — Backend (`src/agents/mcp_apps_agent.py`) is a no-tools
ConversableAgent; the runtime route at
`api/copilotkit-mcp-apps/route.ts` configures
`mcpApps.servers` pointing at the public Excalidraw MCP server, and
the runtime middleware injects MCP tools at request time.
- `open-gen-ui`, `open-gen-ui-advanced` — Backends are no-tools
ConversableAgents (`src/agents/open_gen_ui_agent.py` and
`src/agents/open_gen_ui_advanced_agent.py`). Shared runtime route at
`api/copilotkit-ogui/route.ts` enables
`openGenerativeUI: { agents: [...] }` so the runtime middleware
converts streamed `generateSandboxedUi` tool calls into
`open-generative-ui` activity events.
- `reasoning-custom`, `tool-rendering-reasoning-chain` — Frontend
ports of the LangGraph reasoning cells. The custom `reasoningMessage`
slot is wired exactly as in the canonical reference. The tool chain
(`tool-rendering-reasoning-chain` backend at
`src/agents/tool_rendering_reasoning_chain.py`, mounted at
`/tool-rendering-reasoning-chain/`) still exercises end-to-end.
**Reasoning channel does NOT light up — confirmed framework-bridge
limitation, not a fixture bug.** See the dedicated section below.
### Batch 2 — Dedicated AG2 sub-apps
These demos own their own `ConversableAgent(s)` plus FastAPI sub-app
mounted at a named path (`agent_server.py` mounts each one before the
catch-all `/`). The Next.js runtime points an `HttpAgent` at the
matching path so each demo gets its own ContextVariables-backed state
slot, isolated from the shared default agent.
- `shared-state-read-write` — bidirectional shared state via AG2
`ContextVariables` + `ReplyResult`. Agent calls `get_current_preferences`
to read UI-written prefs and `set_notes` to write back.
- `subagents` — supervisor `ConversableAgent` that delegates to three
sub-`ConversableAgent`s (research/writing/critique) exposed as tools;
each delegation appends to `delegations` in shared state for the live
log UI.
## Deferred (require per-demo agent specialization)
AG2's AG-UI integration mounts a single `AGUIStream` over one
`ConversableAgent` at the FastAPI root. Achieving per-demo specialized
behavior (tailored system prompts, dedicated tool sets, backend-owned
A2UI tools, MCP integration, vision input, structured-output BYOC, etc.)
requires adding additional Python agent modules AND either (a) mounting
each as its own ASGI app at a distinct path and pointing a dedicated
`HttpAgent({ url })` at it from a per-demo Next.js runtime route, or
(b) adopting AG2's `GroupChat` to host multiple specialized agents
behind a single stream with router logic. Both approaches are feasible
but represent a distinct engineering investment and are not a pure port
of the langgraph-python cell.
The following demos fall into that bucket and are **deferred**, not
strictly "missing primitive" skips:
- `agent-config` — needs the agent to re-materialize system prompt from
forwardedProps on every turn (AG2 ConversableAgent supports this but a
dedicated runtime wiring is required).
- `auth` — pure runtime `onRequest` hook demo; dedicated `/api/copilotkit-auth`
route; agent stays unchanged. Straightforward but requires a new route.
- `byoc-hashbrown`, `byoc-json-render` — streaming structured-output BYOC
with Zod-validated catalogs; each has its own runtime route, catalog,
renderer, and supporting components.
- `multimodal` — vision-capable AG2 agent + dedicated `/api/copilotkit-multimodal`.
- `voice` — frontend voice STT; needs dedicated `/api/copilotkit-voice` and
the lazy-init agent shape from langgraph-python.
## Shipped — wave 2 follow-up
- `beautiful-chat` — simplified port: combines A2UI Dynamic + Open
Generative UI on a dedicated runtime (`/api/copilotkit-beautiful-chat`).
MCP Apps is intentionally out-of-scope (covered separately by
`/demos/mcp-apps`); the canonical reference's app-mode toggle / todos
canvas is also not ported. Frontend reuses the catalog from
`/demos/declarative-gen-ui` to avoid duplication.
- `hitl-in-chat-booking` — manifest alias to the existing `hitl-in-chat`
cell. The langgraph reference itself aliases the booking variant to
the same `/demos/hitl-in-chat` route; AG2's `useHumanInTheLoop`
surface (TimePickerCard) is functionally equivalent for the booking
flow. NOT a missing-primitive case — the earlier "skipped" entry was
incorrect (it conflated `hitl-in-chat-booking` with the
`useInterrupt`-driven flow, which it isn't).
## Skipped (missing primitive)
- `gen-ui-interrupt` — requires a LangGraph-style `interrupt()` that
round-trips a resumable graph pause through the event stream. AG2's
`human_input_mode` is a synchronous request/reply; it does not resume
the same run from a persisted checkpoint. Marked as
`not_supported_features` in `manifest.yaml`; the route renders a stub
page pointing at `hitl-in-chat` / `hitl-in-app`.
- `interrupt-headless` — same underlying primitive as `gen-ui-interrupt`.
Marked `not_supported_features`; stub page points at `hitl-in-app` /
`frontend-tools-async`.
## Reasoning channel — framework-bridge limitation (verified)
Applies to `reasoning-custom`, `tool-rendering-reasoning-chain`,
and `reasoning-default`. The custom/built-in `reasoningMessage`
slot is wired correctly, but the AG-UI reasoning channel never lights up
because **AG2's `AGUIStream` bridge cannot emit `REASONING_MESSAGE_*`
events** — it has no reasoning data to emit. This is the same class of
gap as pydantic-ai, not a fixture or wiring bug. Do NOT attempt to fix
it by hacking the aimock fixtures.
Verified against `ag2==0.13.3` / `autogen 0.13.3` (the version pinned by
`requirements.txt`, `ag2[openai,ag-ui]>=0.9.0`).
### What AGUIStream actually emits
`autogen.ag_ui.adapter` (the `AGUIStream` / `run_stream` implementation)
imports and emits only this fixed set of AG-UI event types:
- `RUN_STARTED`, `RUN_FINISHED`, `RUN_ERROR`
- `STATE_SNAPSHOT`
- `TEXT_MESSAGE_START` / `_CONTENT` / `_END` / `_CHUNK`
- `TOOL_CALL_START` / `_ARGS` / `_CHUNK` / `_END` / `_RESULT`
There is **no** `REASONING_MESSAGE_*` import and **no** `THINKING_*`
import anywhere in the adapter. So the question "does it emit
`REASONING_MESSAGE_*`, `THINKING_*`, or nothing?" resolves to **nothing**
— the reasoning channel is entirely absent from the bridge. (Note: even
if it emitted `THINKING_*`, that would be a dead end — `@ag-ui/client`
0.0.52 drops `THINKING_*`; only `REASONING_MESSAGE_*` with
`role:"reasoning"` reaches the UI.)
### Why a custom-synth interceptor is NOT feasible
The agno / claude-sdk-python pattern (synthesize `REASONING_MESSAGE_*`
from the model's native reasoning channel — agno reads
`RunContentEvent.reasoning_content`; claude-sdk-python reads Anthropic's
Messages-API `thinking_delta`, never chat-completions
`delta.reasoning_content`) cannot be applied here, because the reasoning
data never survives into any layer the bridge can see:
1. `AGUIStream` exposes an `event_interceptors` hook, but interceptors
receive `ServiceResponse` objects (`autogen.agentchat.remote.protocol`).
`ServiceResponse` has exactly four fields — `message`, `context`,
`input_required`, `streaming_text` — and **no reasoning field**.
2. Upstream of that, `AgentService` (`agent_service.py`) builds its
streaming text from an `AsyncIOQueueStream` whose `send()` only
captures `StreamEvent.content.content` (visible text). The final
reply comes from `a_generate_oai_reply`, which returns a plain OAI
message (content + tool_calls).
3. Upstream of _that_, autogen's OpenAI chat-completions client
(`autogen/oai/client.py`) reads only `choice.delta.content` and
`choice.delta.tool_calls` from each streaming chunk.
`choice.delta.reasoning_content` is **never read** in the
chat-completions path — it is silently dropped at ingestion. (Only the
separate `responses_v2` / Responses-API client surfaces reasoning via
`response.reasoning`, and that path does not flow through `AGUIStream`
either.)
Empirical confirmation: an OpenAI-compatible endpoint that streams
`delta.reasoning_content` (exactly the channel aimock's `reasoning`
fixture field drives) + `delta.content`, driven through a real
`ConversableAgent` + `AGUIStream`, produces:
```
RUN_STARTED: 1
TEXT_MESSAGE_START: 1
TEXT_MESSAGE_CONTENT: 3
TEXT_MESSAGE_END: 1
RUN_FINISHED: 1
REASONING_MESSAGE_START: 0 ← reasoning channel never fires
```
and the assembled reply is just the visible string — the
`reasoning_content` is gone. There is therefore no reasoning data for a
custom interceptor to synthesize from; manufacturing reasoning text would
be a demo fabrication, which we explicitly do not do.
### What a real fix requires (upstream, in AG2)
A genuine fix must add reasoning support inside autogen itself, end to
end:
1. `autogen/oai/client.py` streaming consumer must read
`choice.delta.reasoning_content` and accumulate it alongside content.
2. A reasoning carrier must be threaded through `StreamEvent`
`AsyncIOQueueStream``AgentService`, and `ServiceResponse` must gain
a reasoning field (or a dedicated streaming reasoning chunk type).
3. `autogen/ag_ui/adapter.py::run_stream` must import and emit
`REASONING_MESSAGE_START` / `_CONTENT` / `_END` (role `"reasoning"`)
when reasoning deltas arrive — analogous to its existing
`TEXT_MESSAGE_*` handling.
Until AG2 ships that, the showcase reasoning slot for AG2 demos will
render empty/skeletal. The cells remain valuable for exercising the slot
plumbing and (for `tool-rendering-reasoning-chain`) the multi-tool chain.
+1
View File
@@ -0,0 +1 @@
../_shared
+47
View File
@@ -0,0 +1,47 @@
{
"framework": "ag2",
"features": {
"agentic-chat": {
"og_docs_url": "https://docs.copilotkit.ai/ag2/prebuilt-components",
"shell_docs_path": "/prebuilt-components"
},
"hitl-in-chat": {
"og_docs_url": "https://docs.copilotkit.ai/ag2/human-in-the-loop",
"shell_docs_path": "/generative-ui/your-components/interactive"
},
"tool-rendering": {
"og_docs_url": "https://docs.copilotkit.ai/ag2/generative-ui/tool-rendering",
"shell_docs_path": "/generative-ui/tool-rendering"
},
"gen-ui-tool-based": {
"og_docs_url": "https://docs.copilotkit.ai/ag2/generative-ui/your-components/display-only",
"shell_docs_path": "/generative-ui/your-components/display-only"
},
"gen-ui-agent": {
"og_docs_url": "https://docs.copilotkit.ai/ag2/generative-ui/state-rendering",
"shell_docs_path": "/generative-ui/state-rendering"
},
"shared-state-read-write": {
"og_docs_url": "https://docs.copilotkit.ai/ag2/shared-state",
"shell_docs_path": "/shared-state"
},
"shared-state-streaming": {
"og_docs_url": "https://docs.copilotkit.ai/ag2/shared-state/write",
"shell_docs_path": "/shared-state"
},
"subagents": {
"og_docs_url": "https://docs.copilotkit.ai/ag2",
"shell_docs_path": "/multi-agent/subagents"
},
"auth": {
"og_docs_url": "https://docs.copilotkit.ai/ag2/auth",
"shell_docs_path": "/auth"
}
},
"missing": [
{
"feature": "subagents",
"reason": "No /ag2/multi-agent/subagents page on docs.copilotkit.ai (SPA fallback). OG falls back to the ag2 framework root; shell points at the unified /multi-agent/subagents content."
}
]
}
+108
View File
@@ -0,0 +1,108 @@
#!/bin/bash
set -e
cleanup() {
kill $AGENT_PID $NEXTJS_PID $WATCHDOG_PID 2>/dev/null || true
}
trap cleanup EXIT
# Disable Python stdout buffering so the FastAPI/uvicorn agent flushes
# tracebacks and log lines immediately. Without this a silent crash during
# module import can sit in Python's userspace buffer until the process
# exits, by which point the container is already gone.
export PYTHONUNBUFFERED=1
echo "========================================="
echo "[entrypoint] Starting showcase package: ag2"
echo "[entrypoint] Time: $(date -u)"
echo "[entrypoint] PORT=${PORT:-not set}"
echo "[entrypoint] NODE_ENV=${NODE_ENV:-not set}"
echo "========================================="
if [ -z "$OPENAI_API_KEY" ]; then
echo "[entrypoint] WARNING: OPENAI_API_KEY is not set! Agent will fail."
else
echo "[entrypoint] OPENAI_API_KEY: set (${#OPENAI_API_KEY} chars)"
fi
# Start agent backend on :8000 with log prefixing so its output is
# distinguishable from Next.js in the Railway log stream.
#
# Belt-and-suspenders log flushing: `PYTHONUNBUFFERED=1` above exports the env
# var, but a child process could in principle un-export or override it. The
# `-u` flag to the Python interpreter forces unbuffered stdout/stderr at the
# interpreter level and is not overridable by user code. Combined with the
# `fflush()` inside the awk pipe below, this guarantees uvicorn request lines
# and tracebacks reach Railway's log stream line-at-a-time rather than
# block-buffered in pipe buffers.
echo "[entrypoint] Starting Python agent on port 8000..."
python -u -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 &> >(awk '{print "[agent] " $0; fflush()}') &
AGENT_PID=$!
sleep 2
if kill -0 $AGENT_PID 2>/dev/null; then
echo "[entrypoint] Agent started (PID: $AGENT_PID)"
else
echo "[entrypoint] ERROR: Agent failed to start — exiting"
exit 1
fi
echo "========================================="
echo "[entrypoint] Starting Next.js frontend on port ${PORT:-10000}..."
echo "========================================="
PORT=${PORT:-10000}
# Scope NODE_ENV=production to the Next.js invocation ONLY, not the whole
# container environment. `ENV NODE_ENV=production` at the image level would
# leak into every child process (Python agent, shell, healthchecks). `env`
# prefix binds the value to this single exec.
env NODE_ENV=production npx next start --port $PORT &> >(awk '{print "[nextjs] " $0; fflush()}') &
NEXTJS_PID=$!
echo "[entrypoint] Next.js started (PID: $NEXTJS_PID)"
# Watchdog: Railway deploys of showcase packages have been observed to hit a
# silent agent hang — the Python process stays alive (so `wait -n` never
# fires and the container never restarts) but stops responding on :8000.
# Poll the agent's /health endpoint every 30s; after 3 consecutive failures
# (90s of unreachable agent), kill the agent process so `wait -n` returns
# and Railway restarts the container. We kill the agent (not the whole
# script) first so `set -e` + `wait -n; exit $?` handles the restart
# through the normal path rather than a forced `exit` that would bypass
# logging. Generalized from showcase/integrations/crewai-crews/entrypoint.sh
# (PRs #4114 + #4115).
(
FAILS=0
while sleep 30; do
if ! kill -0 $AGENT_PID 2>/dev/null; then
# Agent already dead — wait -n in the main shell will handle it.
break
fi
if curl -fsS --max-time 5 http://127.0.0.1:8000/health > /dev/null 2>&1; then
FAILS=0
else
FAILS=$((FAILS + 1))
echo "[watchdog] Agent health probe failed (count=$FAILS)"
if [ $FAILS -ge 3 ]; then
echo "[watchdog] Agent unresponsive for ~90s — killing PID $AGENT_PID to trigger container restart"
kill -9 $AGENT_PID 2>/dev/null || true
break
fi
fi
done
) &
WATCHDOG_PID=$!
echo "[entrypoint] Watchdog started (PID: $WATCHDOG_PID)"
echo "[entrypoint] All processes running. Waiting..."
wait -n $AGENT_PID $NEXTJS_PID
EXIT_CODE=$?
if ! kill -0 $AGENT_PID 2>/dev/null; then
echo "[entrypoint] Agent (PID: $AGENT_PID) exited with code $EXIT_CODE"
elif ! kill -0 $NEXTJS_PID 2>/dev/null; then
echo "[entrypoint] Next.js (PID: $NEXTJS_PID) exited with code $EXIT_CODE"
else
echo "[entrypoint] A process exited with code $EXIT_CODE"
fi
exit $EXIT_CODE
+557
View File
@@ -0,0 +1,557 @@
name: AG2
slug: ag2
category: emerging
language: python
logo: /logos/ag2.svg
description: >-
CopilotKit integration with AG2. Supports the full range of CopilotKit
features including generative UI, shared state, human-in-the-loop, sub-agents,
and streaming.
partner_docs: null
repo: https://github.com/CopilotKit/CopilotKit/tree/main/showcase/integrations/ag2
copilotkit_version: 2.0.0
deployed: true
docs_mode: authored
sort_order: 100
generative_ui:
- constrained-explicit
- a2ui-fixed-schema
- a2ui-dynamic-schema
not_supported_features:
- shared-state-streaming
- gen-ui-interrupt
- interrupt-headless
# AG2's ConversableAgent/AGUIStream does not emit AG-UI REASONING_MESSAGE_*
# events (documented in src/agents/tool_rendering_reasoning_chain.py) — the
# reasoning-block can never mount, so any reasoning-bearing pill is
# upstream-incapable until autogen maps reasoning deltas to AG-UI events.
# The reasoning-default-render / agentic-chat-reasoning demos remain wired
# on disk but cannot stream reasoning text; tool-rendering-reasoning-chain's
# tool loop works, but its reasoning slot is degraded for the same reason.
- reasoning-default-render
- agentic-chat-reasoning
- tool-rendering-reasoning-chain
interaction_modalities:
- sidebar
- embedded
- chat
features:
- cli-start
- agentic-chat
- hitl-in-chat
- hitl-in-chat-booking
- hitl-in-app
- hitl
- tool-rendering
- tool-rendering-default-catchall
- tool-rendering-custom-catchall
- gen-ui-agent
- gen-ui-tool-based
- declarative-gen-ui
- a2ui-fixed-schema
- mcp-apps
- open-gen-ui
- open-gen-ui-advanced
- prebuilt-sidebar
- prebuilt-popup
- chat-slots
- chat-customization-css
- headless-simple
- headless-complete
- readonly-state-agent-context
- frontend-tools
- frontend-tools-async
- shared-state-read-write
- subagents
- auth
- agent-config
- voice
- multimodal
- beautiful-chat
agent_config_pattern: shared-state
a2ui_pattern: schema-loading
auth_pattern: ag2-context-variables
demos:
- id: cli-start
name: CLI Start Command
description: Copy-paste command to clone the canonical starter
tags:
- chat-ui
command: "npx copilotkit@latest init --framework ag2"
- id: agentic-chat
name: Agentic Chat
description: Natural conversation with frontend tool execution
tags:
- chat-ui
route: /demos/agentic-chat
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/agentic-chat/page.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-chat
name: In-Chat HITL (useHumanInTheLoop)
description:
Interactive approval/decision surface rendered inline in the chat
via the high-level `useHumanInTheLoop` hook
tags:
- interactivity
route: /demos/hitl-in-chat
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/hitl-in-chat/page.tsx
- src/app/demos/hitl-in-chat/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-chat-booking
name: In-Chat HITL (Booking)
description: Time-picker card rendered inline via useHumanInTheLoop for a
booking flow — same surface as hitl-in-chat with a booking-focused framing
tags:
- interactivity
route: /demos/hitl-in-chat
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/hitl-in-chat/page.tsx
- src/app/demos/hitl-in-chat/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-app
name: In-App Human in the Loop (Frontend Tools + async HITL)
description:
Agent requests approval via useFrontendTool with an async handler;
the approval UI pops up as an app-level modal OUTSIDE the chat
tags:
- interactivity
route: /demos/hitl-in-app
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/hitl-in-app/page.tsx
- src/app/demos/hitl-in-app/approval-dialog.tsx
- src/app/api/copilotkit/route.ts
- id: hitl
name: In-Chat Human in the Loop (Original)
description: User approves agent actions before execution
tags:
- interactivity
route: /demos/hitl
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/hitl/page.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering
name: Tool Rendering
description: Backend agent tools rendered as UI components
tags:
- agent-capabilities
route: /demos/tool-rendering
animated_preview_url:
highlight:
- src/agents/agent.py
- tools/get_weather.py
- tools/query_data.py
- tools/schedule_meeting.py
- tools/search_flights.py
- src/app/demos/tool-rendering/page.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering-default-catchall
name: Tool Rendering (Default Catch-all)
description: Out-of-the-box tool rendering — backend defines the tools; the
frontend adds zero custom renderers and relies on CopilotKit's built-in
default UI
tags:
- generative-ui
route: /demos/tool-rendering-default-catchall
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/tool-rendering-default-catchall/page.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering-custom-catchall
name: Tool Rendering (Custom Catch-all)
description: Single branded wildcard renderer via useDefaultRenderTool — the
same app-designed card paints every tool call
tags:
- generative-ui
route: /demos/tool-rendering-custom-catchall
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/tool-rendering-custom-catchall/page.tsx
- src/app/demos/tool-rendering-custom-catchall/custom-catchall-renderer.tsx
- src/app/api/copilotkit/route.ts
- id: gen-ui-agent
name: Agentic Generative UI
description: Long-running agent tasks with generated UI
tags:
- generative-ui
route: /demos/gen-ui-agent
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/gen-ui-agent/page.tsx
- src/app/api/copilotkit/route.ts
- id: gen-ui-tool-based
name: Tool-Based Generative UI
description: Agent uses tools to trigger UI generation
tags:
- generative-ui
route: /demos/gen-ui-tool-based
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/gen-ui-tool-based/page.tsx
- src/app/api/copilotkit/route.ts
- id: shared-state-streaming
name: State Streaming
description: Per-token state delta streaming from agent to UI
tags:
- agent-state
route: /demos/shared-state-streaming
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/shared-state-streaming/page.tsx
- src/app/api/copilotkit/route.ts
- id: prebuilt-sidebar
name: "Pre-Built: Sidebar"
description: Docked sidebar chat via <CopilotSidebar />
tags:
- chat-ui
route: /demos/prebuilt-sidebar
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/prebuilt-sidebar/page.tsx
- src/app/api/copilotkit/route.ts
- id: prebuilt-popup
name: "Pre-Built: Popup"
description: Floating popup chat via <CopilotPopup />
tags:
- chat-ui
route: /demos/prebuilt-popup
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/prebuilt-popup/page.tsx
- src/app/api/copilotkit/route.ts
- id: chat-slots
name: Chat Customization (Slots)
description: Customize CopilotChat via its slot system
tags:
- chat-ui
route: /demos/chat-slots
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/chat-slots/page.tsx
- src/app/api/copilotkit/route.ts
- id: chat-customization-css
name: Chat Customization (CSS)
description: Default CopilotChat re-themed via CopilotKitCSSProperties
tags:
- chat-ui
route: /demos/chat-customization-css
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/chat-customization-css/page.tsx
- src/app/demos/chat-customization-css/theme.css
- src/app/api/copilotkit/route.ts
- id: headless-simple
name: Headless Chat (Simple)
description: Minimal custom chat surface built on useAgent
tags:
- chat-ui
route: /demos/headless-simple
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/headless-simple/page.tsx
- src/app/api/copilotkit/route.ts
- id: headless-complete
name: Headless Chat (Complete)
description: Full chat implementation built from scratch on useAgent
tags:
- chat-ui
route: /demos/headless-complete
animated_preview_url:
highlight:
- src/agents/headless_complete.py
- src/app/demos/headless-complete/page.tsx
- src/app/api/copilotkit-mcp-apps/route.ts
- src/app/demos/headless-complete/chat/chat.tsx
- src/app/demos/headless-complete/hooks/use-tool-renderers.tsx
- src/app/demos/headless-complete/hooks/use-frontend-components.ts
- src/app/demos/headless-complete/hooks/use-headless-suggestions.ts
- src/app/demos/headless-complete/attachments/use-attachments-config.ts
- src/app/demos/headless-complete/tools/weather-card.tsx
- src/app/demos/headless-complete/tools/stock-card.tsx
- src/app/demos/headless-complete/tools/chart-card.tsx
- src/app/demos/headless-complete/tools/highlight-note.tsx
- id: readonly-state-agent-context
name: Readonly State (Agent Context)
description: Frontend provides read-only context to the agent via useAgentContext
tags:
- agent-state
route: /demos/readonly-state-agent-context
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/readonly-state-agent-context/page.tsx
- src/app/api/copilotkit/route.ts
- id: frontend-tools
name: Frontend Tools (In-App Actions)
description: Agent invokes client-side handlers registered with useFrontendTool
tags:
- interactivity
route: /demos/frontend-tools
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/frontend-tools/page.tsx
- src/app/api/copilotkit/route.ts
- id: frontend-tools-async
name: Frontend Tools (Async)
description:
useFrontendTool with an async handler — agent awaits a client-side
async operation and uses the returned result
tags:
- interactivity
route: /demos/frontend-tools-async
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/frontend-tools-async/page.tsx
- src/app/demos/frontend-tools-async/notes-card.tsx
- src/app/api/copilotkit/route.ts
- id: shared-state-read-write
name: Shared State (Read + Write)
description: Bidirectional agent state — UI writes preferences, agent writes
notes back via AG2 ContextVariables
tags:
- agent-state
route: /demos/shared-state-read-write
animated_preview_url:
highlight:
- src/agents/shared_state_read_write.py
- src/app/demos/shared-state-read-write/page.tsx
- src/app/demos/shared-state-read-write/preferences-card.tsx
- src/app/demos/shared-state-read-write/notes-card.tsx
- src/app/api/copilotkit/route.ts
- id: subagents
name: Sub-Agents
description: Supervisor delegates tasks to research, writing, and critique
sub-agents with a live delegation log
tags:
- multi-agent
route: /demos/subagents
animated_preview_url:
highlight:
- src/agents/subagents.py
- src/app/demos/subagents/page.tsx
- src/app/demos/subagents/delegation-log.tsx
- src/app/api/copilotkit/route.ts
- id: gen-ui-interrupt
name: Gen UI Interrupt (Frontend Tool + async Promise)
description:
In-chat time-picker card via useFrontendTool with an async handler
that blocks until the user picks a slot — AG2 adaptation of the LangGraph
interrupt() primitive
tags:
- interactivity
route: /demos/gen-ui-interrupt
animated_preview_url:
highlight:
- src/agents/interrupt_agent.py
- src/app/demos/gen-ui-interrupt/page.tsx
- src/app/demos/gen-ui-interrupt/_components/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: interrupt-headless
name: Headless Interrupt (Frontend Tool + async Promise)
description:
Time-picker popup rendered outside the chat in the app surface via
useFrontendTool with an async handler — AG2 adaptation of the LangGraph
headless interrupt pattern
tags:
- interactivity
route: /demos/interrupt-headless
animated_preview_url:
highlight:
- src/agents/interrupt_agent.py
- src/app/demos/interrupt-headless/page.tsx
- src/app/api/copilotkit/route.ts
- id: declarative-gen-ui
name: Declarative Generative UI (A2UI Dynamic Schema)
description:
Agent dynamically composes UI from a registered catalog of branded
components via the A2UI middleware
tags:
- generative-ui
route: /demos/declarative-gen-ui
animated_preview_url:
highlight:
- src/agents/a2ui_dynamic.py
- src/app/demos/declarative-gen-ui/page.tsx
- src/app/demos/declarative-gen-ui/a2ui/catalog.ts
- src/app/demos/declarative-gen-ui/a2ui/definitions.ts
- src/app/demos/declarative-gen-ui/a2ui/renderers.tsx
- src/app/api/copilotkit-declarative-gen-ui/route.ts
- id: a2ui-fixed-schema
name: A2UI (Fixed Schema)
description:
Agent streams data into a frontend-authored fixed component schema;
backend ships JSON, agent fills in values
tags:
- generative-ui
route: /demos/a2ui-fixed-schema
animated_preview_url:
highlight:
- src/agents/a2ui_fixed.py
- src/agents/a2ui_schemas/flight_schema.json
- src/app/demos/a2ui-fixed-schema/page.tsx
- src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts
- src/app/demos/a2ui-fixed-schema/a2ui/definitions.ts
- src/app/demos/a2ui-fixed-schema/a2ui/renderers.tsx
- src/app/api/copilotkit-a2ui-fixed-schema/route.ts
- id: mcp-apps
name: MCP Apps
description: Runtime mcpApps middleware injects an MCP server's tools and
renders associated UI resources inline
tags:
- generative-ui
route: /demos/mcp-apps
animated_preview_url:
highlight:
- src/agents/mcp_apps_agent.py
- src/app/demos/mcp-apps/page.tsx
- src/app/api/copilotkit-mcp-apps/route.ts
- id: open-gen-ui
name: Open Generative UI (Minimal)
description:
Agent streams a single generateSandboxedUi tool call; the runtime
renders agent-authored HTML/CSS in a sandboxed iframe
tags:
- generative-ui
route: /demos/open-gen-ui
animated_preview_url:
highlight:
- src/agents/open_gen_ui_agent.py
- src/app/demos/open-gen-ui/page.tsx
- src/app/api/copilotkit-ogui/route.ts
- id: open-gen-ui-advanced
name: Open Generative UI (Advanced)
description: Sandboxed iframe UIs invoke host-page sandbox functions via
Websandbox.connection.remote
tags:
- generative-ui
route: /demos/open-gen-ui-advanced
animated_preview_url:
highlight:
- src/agents/open_gen_ui_advanced_agent.py
- src/app/demos/open-gen-ui-advanced/page.tsx
- src/app/demos/open-gen-ui-advanced/sandbox-functions.ts
- src/app/demos/open-gen-ui-advanced/suggestions.ts
- src/app/api/copilotkit-ogui/route.ts
- id: tool-rendering-reasoning-chain
name: Tool Rendering (Reasoning Chain)
description:
Sequential tool calls combined with a custom reasoningMessage slot
— weather, flights, custom catch-all
tags:
- generative-ui
route: /demos/tool-rendering-reasoning-chain
animated_preview_url:
highlight:
- src/agents/tool_rendering_reasoning_chain.py
- src/app/demos/tool-rendering-reasoning-chain/page.tsx
- src/app/demos/tool-rendering-reasoning-chain/weather-card.tsx
- src/app/demos/tool-rendering-reasoning-chain/flight-list-card.tsx
- src/app/demos/tool-rendering-reasoning-chain/custom-catchall-renderer.tsx
- src/app/demos/tool-rendering-reasoning-chain/reasoning-block.tsx
- src/app/api/copilotkit/route.ts
- id: auth
name: Authentication
description:
Bearer-token gate via runtime onRequest hook with unauthenticated /
authenticated states
tags:
- chat-ui
route: /demos/auth
animated_preview_url:
highlight:
- src/app/demos/auth/page.tsx
- src/app/demos/auth/auth-banner.tsx
- src/app/demos/auth/use-demo-auth.ts
- src/app/demos/auth/demo-token.ts
- src/app/api/copilotkit-auth/[[...slug]]/route.ts
- id: agent-config
name: Agent Config Object
description:
Forward a typed config object (tone / expertise / response length)
from the provider to the agent's dynamic system prompt
tags:
- platform
route: /demos/agent-config
animated_preview_url:
highlight:
- src/agents/agent_config_agent.py
- src/app/demos/agent-config/page.tsx
- src/app/demos/agent-config/config-card.tsx
- src/app/demos/agent-config/use-agent-config.ts
- src/app/demos/agent-config/config-types.ts
- src/app/api/copilotkit/route.ts
- id: voice
name: Voice Input
description: Speech-to-text via @copilotkit/voice with a bundled sample audio button
tags:
- chat-ui
route: /demos/voice
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/voice/page.tsx
- src/app/demos/voice/sample-audio-button.tsx
- src/app/api/copilotkit-voice/[[...slug]]/route.ts
- id: multimodal
name: Multimodal Attachments
description:
Image and PDF uploads via CopilotChat attachments, processed by a
vision-capable agent
tags:
- chat-ui
route: /demos/multimodal
animated_preview_url:
highlight:
- src/agents/multimodal_agent.py
- src/app/demos/multimodal/page.tsx
- src/app/demos/multimodal/sample-attachment-buttons.tsx
- src/app/api/copilotkit-multimodal/route.ts
- id: beautiful-chat
name: Beautiful Chat
description: Simplified flagship cell that combines A2UI Dynamic + Open
Generative UI on a single dedicated runtime — agent picks branded catalog
components for structured visuals and free-form sandboxed UI for
everything else
tags:
- chat-ui
- generative-ui
route: /demos/beautiful-chat
animated_preview_url:
highlight:
- src/agents/beautiful_chat.py
- src/app/demos/beautiful-chat/page.tsx
- src/app/demos/declarative-gen-ui/a2ui/catalog.ts
- src/app/api/copilotkit-beautiful-chat/route.ts
- src/app/demos/beautiful-chat/components/example-layout/index.tsx
- src/app/demos/beautiful-chat/components/example-canvas/index.tsx
- src/app/demos/beautiful-chat/components/generative-ui/charts/bar-chart.tsx
- src/app/demos/beautiful-chat/components/generative-ui/charts/pie-chart.tsx
- src/app/demos/beautiful-chat/hooks/use-example-suggestions.tsx
- src/app/demos/beautiful-chat/hooks/use-generative-ui-examples.tsx
managed_platform:
name: Agent OS
url: https://ag2.ai/product
+25
View File
@@ -0,0 +1,25 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow iframe embedding from the showcase shell
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Frame-Options",
value: "ALLOWALL",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors *;",
},
],
},
];
},
typescript: { ignoreBuildErrors: true },
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
{
"name": "@copilotkit/showcase-ag2",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"next dev --turbopack\" \"PYTHONPATH=. python -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 --reload\"",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test:e2e": "playwright test"
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@copilotkit/a2ui-renderer": "1.61.2",
"@copilotkit/react-core": "1.61.2",
"@copilotkit/runtime": "1.61.2",
"@copilotkit/shared": "1.61.2",
"@copilotkit/voice": "1.61.2",
"@hashbrownai/core": "0.5.0-beta.4",
"@hashbrownai/react": "0.5.0-beta.4",
"@json-render/core": "0.18.0",
"@json-render/react": "0.18.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-separator": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^1.14.0",
"next": "^15.5.15",
"openai": "^5.9.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"recharts": "^2.15.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"yaml": "^2.8.4",
"zod": "^3.24.0"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"concurrently": "^9.1.0",
"postcss": "^8.5.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
},
"overrides": {
"@copilotkit/web-inspector": {
"@copilotkit/core": "1.61.2"
}
},
"pnpm": {
"overrides": {
"@copilotkit/web-inspector>@copilotkit/core": "1.61.2"
}
}
}
@@ -0,0 +1,35 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
trace: "on-first-retry",
extraHTTPHeaders: {
"X-AIMock-Context": "ag2",
},
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: process.env.CI
? undefined
: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
env: {
...process.env,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL || "",
OPENAI_API_KEY: process.env.OPENAI_API_KEY || "",
},
},
});
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,18 @@
# Voice demo audio
Drop a small (<100KB) WAV file named `sample.wav` in this directory. The voice
demo (`/demos/voice`) fetches it client-side and POSTs to the transcription
endpoint so the flow works without mic permissions.
Expected content: an audio clip speaking the phrase
**"What is the weather in Tokyo?"** — the demo caption advertises that phrase
to the user, and the bundled QA checklist + E2E spec assert the transcribed
text contains "weather" and/or "Tokyo".
Generate locally, for example:
- macOS: `say -o sample.aiff "What is the weather in Tokyo?" && ffmpeg -i sample.aiff -ar 16000 -ac 1 sample.wav`
- Linux: `espeak-ng -w sample.wav "What is the weather in Tokyo?"`
- Windows: PowerShell `System.Speech.Synthesis.SpeechSynthesizer``SetOutputToWaveFile`
Target: 16kHz mono, 3-5s duration, <100KB.
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bd4aa7b049f1c3e324dfd15af4068d7f8fbf2eae1dd044df270dddc5f38a5c57
size 87078
@@ -0,0 +1,22 @@
# Demo Files — Multimodal Demo
This directory bundles sample files referenced by the `/demos/multimodal` page.
Required files (must be committed as binaries; see `.gitattributes` at repo root):
- `sample.png` — a small (< 50 KB) PNG that the "Try with sample image" button
injects into the chat. A CopilotKit logo or other recognizable brand mark
works well so the vision-capable agent has something to describe.
- `sample.pdf` — a small (< 50 KB) one-page PDF that the "Try with sample PDF"
button injects. The content must mention "CopilotKit" so E2E soft
assertions hold (e.g. a one-page export of the CopilotKit quickstart).
The page at `src/app/demos/multimodal/page.tsx` fetches these via the public
path (`/demo-files/sample.png`, `/demo-files/sample.pdf`), wraps the fetched
blob in a `File`, and routes it through the same `AttachmentsConfig.onUpload`
callback the paperclip button uses — so the sample path exercises the exact
same queueing code as a real user upload.
If these files are missing, the demo page still renders but the sample
buttons will surface a fetch error. The paperclip / drag-and-drop paths
continue to work without them.
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3da2afae36a1a81fd2c02f15e54bfc38b6c22e41655c31a5b54ff1e0e3daab41
size 2486
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:01aa5681de99461247543e9215c1e4da3242e26b2bee11593fcdbe209672d973
size 10083
@@ -0,0 +1,62 @@
# QA: Agentic Chat — AG2
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the agentic-chat demo page
- [ ] Verify the chat interface loads with a text input placeholder "Type a message"
- [ ] Verify the background container (`data-testid="background-container"`) is visible
- [ ] Verify the default background color is the theme default (rgb(250, 250, 249))
- [ ] Send a basic message (e.g. "Hello")
- [ ] Verify the agent responds with a text message
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Change background" suggestion button is visible
- [ ] Verify "Generate sonnet" suggestion button is visible
- [ ] Click the "Change background" suggestion
- [ ] Verify the suggestion either populates the input or sends the message
#### Background Change (useFrontendTool)
- [ ] Ask "Change the background to a sunset gradient"
- [ ] Verify the background container style changes from the default
- [ ] Verify the change_background tool returns a success status
#### Weather Render Tool (useRenderTool)
- [ ] Type "What's the weather in Tokyo?"
- [ ] Verify loading state shows "Loading weather..." (`data-testid="weather-info-loading"`)
- [ ] Verify WeatherCard renders (`data-testid="weather-info"`) with:
- [ ] City name displayed
- [ ] Temperature in degrees C
- [ ] Humidity percentage
- [ ] Wind speed in mph
- [ ] Conditions text
#### Agent Context
- [ ] Verify the agent knows the user's name is "Bob" (provided via useAgentContext)
- [ ] Ask "What is my name?" and verify the agent responds with "Bob"
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
- [ ] Send a very long message and verify no UI breakage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Background changes are instant after tool execution
- Weather card renders with all data fields populated
- No UI errors or broken layouts
@@ -0,0 +1,11 @@
# QA: Chat Customization (CSS) — AG2
- [ ] Navigate to /demos/chat-customization-css
- [ ] Verify scoped theme applied — `.chat-css-demo-scope` wrapper has hot-pink accents
- [ ] Send a message — verify user bubble is hot-pink gradient, assistant bubble is amber monospace
- [ ] Input border is dashed pink
- [ ] No theme leak to other pages
## Expected Results
- All CSS overrides visible, scoped to this demo
@@ -0,0 +1,12 @@
# QA: Chat Slots — AG2
- [ ] Navigate to /demos/chat-slots
- [ ] Verify custom welcome screen visible (`data-testid="custom-welcome-screen"`)
- [ ] Verify "Custom Slot" badge
- [ ] Send a message (or click a suggestion)
- [ ] Verify custom assistant message wrapper (`data-testid="custom-assistant-message"`) renders
- [ ] Verify custom disclaimer (`data-testid="custom-disclaimer"`) renders
## Expected Results
- All three slot overrides render and are visible
@@ -0,0 +1,11 @@
# QA: Frontend Tools (Async) — AG2
- [ ] Navigate to /demos/frontend-tools-async
- [ ] Click "Find project-planning notes"
- [ ] Verify NotesCard loading state ("Querying local notes DB...")
- [ ] After ~500ms, verify matches render (notes-list)
- [ ] Verify agent summarizes the notes in the next assistant bubble
## Expected Results
- Async frontend-tool handler waits and returns data to the agent
@@ -0,0 +1,10 @@
# QA: Frontend Tools — AG2
- [ ] Navigate to /demos/frontend-tools
- [ ] Click "Change background" suggestion
- [ ] Verify background updates via client-side handler
- [ ] Verify success message in chat
## Expected Results
- useFrontendTool handler runs on the client; agent uses the return value
@@ -0,0 +1,62 @@
# QA: Agentic Generative UI — AG2
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the gen-ui-agent demo page
- [ ] Verify the chat interface loads in a centered full-height layout
- [ ] Verify the chat input placeholder "Type a message" is visible
- [ ] Send a basic message
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Simple plan" suggestion button is visible (plan to go to Mars in 5 steps)
- [ ] Verify "Complex plan" suggestion button is visible (plan to make pizza in 10 steps)
#### Task Progress Tracker (useAgent with state streaming)
- [ ] Click "Simple plan" suggestion or type "Build a plan to go to Mars in 5 steps"
- [ ] Verify the TaskProgress component renders (`data-testid="task-progress"`)
- [ ] Verify the progress bar appears with a gradient fill
- [ ] Verify step items appear with descriptions (`data-testid="task-step-text"`)
- [ ] Verify the "N/N Complete" counter updates as steps complete
- [ ] Verify completed steps show:
- Green background gradient
- Check icon
- Green text color
- [ ] Verify the current pending step shows:
- Blue/purple background gradient
- Spinner icon with "Processing..." text
- Pulsing animation
- [ ] Verify future pending steps show:
- Gray background
- Clock icon
- Muted text color
#### Complex Plan
- [ ] Type "Plan to make pizza in 10 steps"
- [ ] Verify 10 steps appear in the progress tracker
- [ ] Verify progress bar width increases as steps complete
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Task progress tracker shows live step completion
- Progress bar animates smoothly
- No UI errors or broken layouts
@@ -0,0 +1,59 @@
# QA: Tool-Based Generative UI — AG2
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the gen-ui-tool-based demo page
- [ ] Verify the CopilotSidebar opens by default with title "Haiku Generator"
- [ ] Verify a placeholder haiku card is displayed on the main area
- [ ] Send a basic message via the sidebar
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Nature Haiku" suggestion button is visible
- [ ] Verify "Ocean Haiku" suggestion button is visible
- [ ] Verify "Spring Haiku" suggestion button is visible
#### Haiku Generation (useFrontendTool)
- [ ] Click the "Nature Haiku" suggestion or type "Write me a haiku about nature"
- [ ] Verify a HaikuCard renders (`data-testid="haiku-card"`) with:
- [ ] Three Japanese text lines (`data-testid="haiku-japanese-line"`)
- [ ] Three English translation lines (`data-testid="haiku-english-line"`)
- [ ] A background gradient style applied to the card
- [ ] Verify the Japanese text contains actual Japanese characters (not Latin)
- [ ] Verify the English lines are a readable English translation
#### Image Display
- [ ] After haiku generation, verify an image renders (`data-testid="haiku-image"`) if the agent provides an image_name
- [ ] Verify the image src points to /images/ with a valid filename from the predefined list
#### Multiple Haikus
- [ ] Generate a second haiku (e.g. "Ocean Haiku")
- [ ] Verify the new haiku card appears at the top
- [ ] Verify the previous haiku card is still visible below
- [ ] Verify the initial placeholder haiku is removed
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Sidebar loads within 3 seconds
- Agent responds and generates haiku within 10 seconds
- Haiku cards display with Japanese and English text
- Generated haikus stack with newest on top
- No UI errors or broken layouts
@@ -0,0 +1,13 @@
# QA: Headless Chat (Simple) — AG2
- [ ] Navigate to /demos/headless-simple
- [ ] Verify "Headless Chat (Simple)" heading
- [ ] Type "show a card about cats"
- [ ] Click Send
- [ ] Verify user bubble appears
- [ ] Verify assistant replies, `ShowCard` renders (title + body)
## Expected Results
- Custom chat UI works (not pre-built CopilotChat)
- useComponent registration wired via copilotkit.runAgent
@@ -0,0 +1,12 @@
# QA: In-App HITL — AG2
- [ ] Navigate to /demos/hitl-in-app
- [ ] Verify tickets panel on left, chat on right
- [ ] Click suggestion "Approve refund for #12345"
- [ ] Verify approval dialog modal appears (`data-testid="approval-dialog"`) OUTSIDE the chat
- [ ] Click Approve
- [ ] Verify dialog closes and agent acknowledges approval in chat
## Expected Results
- App-level modal routes through async useFrontendTool Promise
@@ -0,0 +1,57 @@
# QA: Human in the Loop — AG2
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the HITL demo page (`/demos/hitl`) > _Note: the URL path `/demos/hitl` is intentionally shorter than the feature id `hitl-in-chat`._
- [ ] Verify the chat interface loads in a centered max-w-4xl container
- [ ] Verify the chat input placeholder "Type a message" is visible
- [ ] Send a basic message
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Simple plan" suggestion button is visible
- [ ] Verify "Complex plan" suggestion button is visible
- [ ] Click the "Simple plan" suggestion
- [ ] Verify it triggers a message about planning a trip to Mars in 5 steps
#### Step Selection and Approval
The HITL demo renders a single StepSelector card regardless of whether
the underlying flow uses an interrupt hook or a frontend HITL tool. The
framework-specific hook name is an implementation detail covered in each
package's demo source; QA below validates the user-visible card behavior.
- [ ] Send "Plan a trip to Mars in 5 steps"
- [ ] Verify the StepSelector card appears (`data-testid="select-steps"`)
- [ ] Verify step items are displayed with checkboxes (`data-testid="step-item"`)
- [ ] Verify step text is visible (`data-testid="step-text"`)
- [ ] Verify the selected count display shows "N/N selected"
- [ ] Toggle a step checkbox off and verify the count decreases
- [ ] Toggle it back on and verify the count increases
- [ ] Click "Perform Steps (N)" / "Confirm (N)" button
- [ ] Verify the agent continues processing after confirmation
- [ ] In a new conversation, trigger the same flow and click "Reject" (where present)
- [ ] Verify the card reflects the decision (Accepted / Rejected) and buttons disable
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Step selector renders with toggleable checkboxes
- Accept/Reject flow completes without errors
- No UI errors or broken layouts
@@ -0,0 +1,12 @@
# QA: Pre-Built Popup — AG2
- [ ] Navigate to /demos/prebuilt-popup
- [ ] Verify floating launcher visible
- [ ] Popup opens by default
- [ ] Send "Say hi from the popup"
- [ ] Verify agent responds
## Expected Results
- Popup opens as overlay
- Agent replies inside the popup window
@@ -0,0 +1,20 @@
# QA: Pre-Built Sidebar — AG2
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
- [ ] Navigate to /demos/prebuilt-sidebar
- [ ] Verify main content renders ("Sidebar demo — click the launcher")
- [ ] Verify sidebar launcher button visible
- [ ] Verify sidebar is open by default
- [ ] Send "Say hi"
- [ ] Verify agent responds
## Expected Results
- Sidebar toggles via launcher
- Agent replies inside the sidebar
@@ -0,0 +1,12 @@
# QA: Readonly State (Agent Context) — AG2
- [ ] Navigate to /demos/readonly-state-agent-context
- [ ] Verify context card (`data-testid="context-card"`)
- [ ] Change name in `data-testid="ctx-name"` input
- [ ] Toggle an activity checkbox
- [ ] Send "Who am I?"
- [ ] Verify agent reflects the context (name, timezone, activities)
## Expected Results
- useAgentContext values visibly propagate into agent's replies
@@ -0,0 +1,10 @@
# QA: Reasoning (Default Render) — AG2
- [ ] Navigate to /demos/reasoning-default-render
- [ ] Send "Think step-by-step: what is 17 \* 23?"
- [ ] Verify a reasoning card appears above the final answer (default CopilotChatReasoningMessage)
- [ ] Card is collapsible
## Expected Results
- Built-in reasoning UI renders without a custom slot
@@ -0,0 +1,69 @@
# QA: Shared State (Read + Write) — AG2
## Prerequisites
- Demo is deployed and accessible at `/demos/shared-state-read-write`
- Agent backend is healthy (check `/api/copilotkit` GET → `agent_status: reachable`)
- Backend has `OPENAI_API_KEY` set
- The `shared-state-read-write` agent is mounted at `/shared-state-read-write` on
the FastAPI server (see `src/agent_server.py`)
## Test Steps
### 1. Page renders with the two cards and chat
- [ ] Navigate to `/demos/shared-state-read-write`
- [ ] Sidebar shows the **Preferences** card (`data-testid="preferences-card"`)
- [ ] Sidebar shows the **Agent notes** card (`data-testid="notes-card"`) with the
empty state copy "No notes yet. Ask the agent to remember something."
- [ ] Right-hand pane shows `<CopilotChat />` with the placeholder
"Chat with the agent..."
- [ ] The "Shared state" panel inside the preferences card shows JSON with
`name: ""`, `tone: "casual"`, `language: "English"`, `interests: []`
### 2. UI -> agent (write)
- [ ] Type a name (e.g. `Atai`) into the **Name** input. The "Shared state"
JSON inside the card updates immediately to reflect the new name.
- [ ] Change **Tone** to `playful`. JSON updates.
- [ ] Change **Language** to `Spanish`. JSON updates.
- [ ] Toggle 2-3 interests (e.g. `Cooking`, `Tech`). JSON updates and the
buttons show the selected style.
- [ ] In the chat, send: **"Greet me in one sentence."**
- [ ] The agent's reply addresses the user by name in a playful tone, in
Spanish (i.e. it actually used the preferences). It must NOT just
echo the JSON.
### 3. agent -> UI (read)
- [ ] In the chat, send: **"Remember that I prefer morning meetings and that
I don't eat dairy."**
- [ ] The **Agent notes** card transitions from the empty state to a
bulleted list with at least 2 entries (`data-testid="note-item"`),
reflecting the two facts above. The list updates in real time
while/after the agent finishes its turn.
- [ ] In the chat, send: **"Also remember I work in Pacific time."**
- [ ] The notes list now has at least 3 entries (the agent passed the FULL
list to `set_notes`, not just the new one).
### 4. Round-trip + Clear
- [ ] Click the **Clear** button on the notes card.
- [ ] The notes card returns to the empty state immediately.
- [ ] In the chat, send: **"What do you remember about me?"**
- [ ] The agent reports it has no remembered notes (because the UI cleared
them via `agent.setState`), confirming the UI's write-back was
visible to the agent on its next turn.
### 5. Error handling
- [ ] Send an empty message → handled gracefully (no crash, no broken UI).
- [ ] No console errors during normal usage.
## Expected Results
- Page loads in < 3 seconds.
- Preferences edits propagate to agent state instantly.
- Agent replies adapt visibly to preferences (name, tone, language).
- Notes card reflects every `set_notes` call from the agent.
- Clearing notes from the UI is reflected on the agent's next turn.
@@ -0,0 +1,75 @@
# QA: Shared State (Reading) — AG2
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the shared-state-read demo page
- [ ] Verify the recipe card form loads (`data-testid="recipe-card"`)
- [ ] Verify the CopilotSidebar opens by default with title "AI Recipe Assistant"
- [ ] Send a message via the sidebar
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Initial Recipe State
- [ ] Verify the recipe title input shows "Make Your Recipe"
- [ ] Verify the cooking time dropdown defaults to "45 min"
- [ ] Verify the skill level dropdown defaults to "Intermediate"
- [ ] Verify the default ingredients are displayed:
- [ ] Carrots (3 large, grated) with carrot emoji
- [ ] All-Purpose Flour (2 cups) with wheat emoji
- [ ] Verify the default instruction is displayed: "Preheat oven to 350 F"
#### Suggestions
- [ ] Verify "Create Italian recipe" suggestion is visible
- [ ] Verify "Make it healthier" suggestion is visible
- [ ] Verify "Suggest variations" suggestion is visible
#### Recipe Editing (Local State)
- [ ] Edit the recipe title and verify it updates
- [ ] Change the skill level dropdown and verify it updates
- [ ] Change the cooking time dropdown and verify it updates
- [ ] Toggle a dietary preference checkbox (e.g. "Vegetarian") and verify it's checked
- [ ] Click "+ Add Ingredient" (`data-testid="add-ingredient-button"`) and verify a new empty row appears
- [ ] Edit an ingredient name and amount
- [ ] Remove an ingredient by clicking the "x" button
- [ ] Click "+ Add Step" and verify a new instruction row appears
- [ ] Edit an instruction and verify it saves
- [ ] Remove an instruction by clicking the "x" button
#### AI-Powered Recipe Updates (useAgent with shared state)
- [ ] Click "Create Italian recipe" suggestion
- [ ] Verify the agent updates the recipe title, ingredients, and instructions
- [ ] Verify the ping indicator appears on changed sections
- [ ] Verify the "Improve with AI" button (`data-testid="improve-button"`) changes to "Please Wait..." while loading
- [ ] Click "Improve with AI" and verify the recipe is enhanced
#### Agent Reads Frontend State
- [ ] Edit the recipe (change title, add ingredients)
- [ ] Ask the agent "What recipe am I making?"
- [ ] Verify the agent's response references the current recipe state
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
- [ ] Verify the "Improve with AI" button is disabled while loading
## Expected Results
- Recipe card and sidebar load within 3 seconds
- Agent responds within 10 seconds
- Recipe state syncs bidirectionally between UI and agent
- Ping indicators highlight changed sections
- No UI errors or broken layouts
@@ -0,0 +1,41 @@
# QA: State Streaming — AG2
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the shared-state-streaming demo page
- [ ] Verify the chat interface loads with title "State Streaming"
- [ ] Verify the chat input placeholder "Type a message..." is visible
- [ ] Send a basic message (e.g. "Hello! What can you do?")
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Get started" suggestion button is visible
#### Note: Stub Demo
> **Status: Stub** — This demo is currently a stub (TODO: implement)
- [ ] Verify the basic CopilotChat loads and accepts messages
- [ ] Verify the agent responds to messages
- [ ] No custom UI components are expected beyond the chat interface
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- No UI errors or broken layouts
@@ -0,0 +1,41 @@
# QA: Shared State (Writing) — AG2
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the shared-state-write demo page
- [ ] Verify the chat interface loads with title "Shared State (Writing)"
- [ ] Verify the chat input placeholder "Type a message..." is visible
- [ ] Send a basic message (e.g. "Hello! What can you do?")
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Get started" suggestion button is visible
#### Note: Stub Demo
> **Status: Stub** — This demo is currently a stub (TODO: implement)
- [ ] Verify the basic CopilotChat loads and accepts messages
- [ ] Verify the agent responds to messages
- [ ] No custom UI components are expected beyond the chat interface
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- No UI errors or broken layouts
+74
View File
@@ -0,0 +1,74 @@
# QA: Sub-Agents — AG2
## Prerequisites
- Demo is deployed and accessible at `/demos/subagents`
- Agent backend is healthy (check `/api/copilotkit` GET → `agent_status: reachable`)
- Backend has `OPENAI_API_KEY` set
- The `subagents` supervisor is mounted at `/subagents` on the FastAPI
server (see `src/agent_server.py`)
## Test Steps
### 1. Page renders with delegation log + chat
- [ ] Navigate to `/demos/subagents`
- [ ] Left pane shows the **Delegation log** panel
(`data-testid="delegation-log"`)
- [ ] Header reads "Sub-agent delegations" with a counter
`data-testid="delegation-count"` showing `0 calls`
- [ ] Empty-state copy: "Ask the supervisor to complete a task. Every
sub-agent it calls will appear here."
- [ ] Right pane shows the chat with placeholder
"Give the supervisor a task..."
### 2. Single delegation chain
- [ ] Click suggestion **"Write a blog post"** (or send the equivalent
message).
- [ ] While the supervisor runs, the badge
`data-testid="supervisor-running"` ("Supervisor running") appears in
the header.
- [ ] As the supervisor delegates, entries appear in the log
(`data-testid="delegation-entry"`). Expect at least 3 entries — one
`Research`, one `Writing`, one `Critique` — in that order.
- [ ] Each entry shows:
- A `#N` index, a colored badge with the sub-agent name + emoji.
- A `Task: ...` line summarizing what was delegated.
- A `result` block containing the sub-agent's output (real LLM text,
not placeholders).
- [ ] Counter updates to `3 calls` (or more if the supervisor iterated).
### 3. Independent delegations
- [ ] Reload the page (state resets).
- [ ] Send: **"Research what causes the northern lights."**
- [ ] At least 1 `Research` delegation appears with a bulleted list of
facts in the result.
- [ ] Send: **"Now write a paragraph aimed at a 10-year-old, using those
facts."**
- [ ] A `Writing` delegation appears with a polished paragraph in the
result.
- [ ] Send: **"Critique that paragraph."**
- [ ] A `Critique` delegation appears with 2-3 actionable critiques.
### 4. Supervisor reply hygiene
- [ ] After each chain, the supervisor's chat reply is short — it
summarizes rather than re-pasting the full sub-agent output (which
already lives in the delegation log).
- [ ] The "Supervisor running" badge disappears once the run is complete.
### 5. Error handling
- [ ] Send a very short message (e.g. "Hi"). The supervisor responds
gracefully (it may not delegate for a trivial greeting).
- [ ] No console errors during normal usage.
## Expected Results
- Page loads in < 3 seconds.
- Each user request that's non-trivial produces at least one delegation
entry.
- The delegation log grows live during the run, not just at the end.
- Sub-agent results are real LLM outputs (not stubbed strings).
@@ -0,0 +1,10 @@
# QA: Tool Rendering (Custom Catch-all) — AG2
- [ ] Navigate to /demos/tool-rendering-custom-catchall
- [ ] Click "Weather in SF"
- [ ] Verify custom catch-all card renders (`data-testid="custom-catchall-card"`)
- [ ] Status progresses through streaming / running / done
## Expected Results
- The single custom wildcard renderer paints every tool call
@@ -0,0 +1,10 @@
# QA: Tool Rendering (Default Catch-all) — AG2
- [ ] Navigate to /demos/tool-rendering-default-catchall
- [ ] Click "Weather in SF" suggestion
- [ ] Verify DefaultToolCallRenderer fires — tool name visible, Running → Done
- [ ] Expand arguments / result
## Expected Results
- Out-of-box default tool-call card renders without custom config
@@ -0,0 +1,61 @@
# QA: Tool Rendering — AG2
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the tool-rendering demo page
- [ ] Verify the chat interface loads in a centered full-height layout
- [ ] Verify the chat input placeholder "Type a message" is visible
- [ ] Send a basic message
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Weather in San Francisco" suggestion button is visible
- [ ] Verify "Weather in New York" suggestion button is visible
- [ ] Verify "Weather in Tokyo" suggestion button is visible
- [ ] Click a weather suggestion and verify it populates the input or sends the message
#### Weather Card Rendering (useRenderTool)
- [ ] Type "What's the weather in San Francisco?"
- [ ] Verify loading state shows "Retrieving weather..." with a spinner
- [ ] Verify the WeatherCard renders (`data-testid="weather-card"`) with:
- [ ] City name displayed (`data-testid="weather-city"`)
- [ ] Temperature in both Celsius and Fahrenheit
- [ ] Humidity percentage (`data-testid="weather-humidity"`)
- [ ] Wind speed in mph (`data-testid="weather-wind"`)
- [ ] Feels-like temperature (`data-testid="weather-feels-like"`)
- [ ] Conditions text with appropriate weather icon (sun/rain/cloud)
- [ ] Verify the card background color matches the weather condition theme:
- Clear/Sunny: #667eea (blue-purple)
- Rain/Storm: #4A5568 (dark gray)
- Cloudy: #718096 (medium gray)
- Snow: #63B3ED (light blue)
#### Multiple Weather Queries
- [ ] Ask about weather in a second city
- [ ] Verify a second WeatherCard renders without breaking the first
- [ ] Verify each card shows the correct city name
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Weather cards render with all data fields populated
- Weather icon and theme color match the conditions
- No UI errors or broken layouts
@@ -0,0 +1,8 @@
ag2[openai,ag-ui]>=0.9.0
ag-ui-protocol>=0.1.10
fastapi>=0.115.0
uvicorn>=0.34.0
httpx>=0.27.0
python-dotenv==1.0.1
pypdf>=4.0.0
openai>=1.50.0
@@ -0,0 +1,219 @@
"""
Agent Server for AG2
FastAPI server that hosts the AG2 agent backends.
The Next.js CopilotKit runtime proxies requests here via AG-UI protocol.
Most demos share a single ConversableAgent at the root path. Demos that
require dedicated state mechanics or multi-agent topologies are mounted
as their own sub-apps at distinct paths so each demo gets its own
ContextVariables-backed state slot.
"""
# ORDER-CRITICAL: load .env BEFORE any agent module imports. The agent
# modules (agents/agent.py et al.) construct module-level
# ``openai.AsyncOpenAI()`` / autogen ``LLMConfig`` clients that read
# ``OPENAI_API_KEY`` (and friends) at construction time. If we import the
# agent modules before calling ``load_dotenv()``, those module-level
# clients latch onto whatever the OS environment had at import time
# (usually nothing in a dev shell), and subsequent .env values never
# reach them. ``load_dotenv()`` is idempotent so the redundant call
# inside each agent module is harmless — but the FIRST call must happen
# here, before the agent imports below.
# CVDIAG bootstrap — MUST be the first non-stdlib import (folded in from the
# dropped L1-H slot). Importing this module configures the root logger via
# ``logging.basicConfig`` so the ``agents._header_forwarding`` (and sibling
# ``agents.*``) CVDIAG loggers actually EMIT (fixes the silent-drop bug), and
# resolves the verbosity tier + PB writer. It imports pydantic/starlette only
# and has no dependency on ``.env``, so it is safe to run before ``load_dotenv``.
import _shared.cvdiag_bootstrap # noqa: F401,E402 (first non-stdlib import — bootstrap side effects)
from dotenv import load_dotenv
load_dotenv()
import os
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
# ORDER-CRITICAL: install the global httpx hook BEFORE any agent module
# imports. The autogen / openai SDK construct their httpx client lazily
# per-call, but other integrations construct at module-import time;
# keeping the patch at the top of agent_server.py is the consistent
# placement across all Python showcase integrations and is harmless here.
from agents._cvdiag_backend import CvdiagBackendMiddleware
from agents._header_forwarding import (
HeaderForwardingHTTPMiddleware,
install_executor_contextvar_propagation,
install_global_httpx_hook,
)
from agents._request_context import RequestUserMessageMiddleware
install_global_httpx_hook()
# AG2-specific: autogen's ConversableAgent.a_generate_oai_reply dispatches
# the underlying sync LLM call onto the default ThreadPoolExecutor via
# loop.run_in_executor(...), which does NOT propagate ContextVars to the
# worker thread. Without this, the forwarded-header ContextVar set on the
# inbound request task is empty by the time the outbound httpx hook fires,
# and aimock can't match the right fixture for the request.
install_executor_contextvar_propagation()
from agents.agent import stream as default_stream
from agents.a2ui_dynamic import a2ui_dynamic_app
from agents.a2ui_fixed import a2ui_fixed_app
from agents.agent_config_agent import agent_config_app
from agents.beautiful_chat import beautiful_chat_app
from agents.byoc_hashbrown_agent import byoc_hashbrown_app
from agents.byoc_json_render_agent import byoc_json_render_app
from agents.gen_ui_agent import gen_ui_agent_app
from agents.headless_complete import headless_complete_app
from agents.mcp_apps_agent import mcp_apps_app
from agents.multimodal_agent import multimodal_app
from agents.open_gen_ui_advanced_agent import open_gen_ui_advanced_app
from agents.open_gen_ui_agent import open_gen_ui_app
from agents.shared_state_read_write import (
shared_state_read_write_app,
)
from agents.subagents import subagents_app
from agents.interrupt_agent import interrupt_app
from agents.reasoning_agent import reasoning_app
from agents.tool_rendering_reasoning_chain import (
tool_rendering_reasoning_chain_app,
)
app = FastAPI(title="AG2 Agent Server")
# Serve /health via middleware so it short-circuits BEFORE route resolution.
# A plain `@app.get("/health")` decorator is shadowed by the subsequent
# `app.mount("/", ...)` call: Starlette's Mount at "/" matches every path
# (including /health) and the decorated route never fires. Middleware runs
# above the routing layer, so the health endpoint stays reachable regardless
# of what the framework-specific AG-UI adapter mounts at root.
class HealthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
if request.url.path == "/health" and request.method == "GET":
return JSONResponse({"status": "ok"})
return await call_next(request)
# ORDER-CRITICAL: Starlette's ``add_middleware`` is LIFO — the LAST call
# becomes the OUTERMOST layer in the request pipeline. This ordering
# matters because ``BaseHTTPMiddleware`` (HealthMiddleware,
# HeaderForwardingHTTPMiddleware) internally uses anyio TaskGroups that
# can sever ``contextvars.ContextVar`` propagation from outer layers to
# the inner ASGI app. The raw-ASGI ``RequestUserMessageMiddleware`` sets
# a ContextVar that downstream tool handlers must observe, so it MUST
# sit OUTSIDE the BaseHTTPMiddleware layers — i.e. be added LAST so it
# wraps them. CORSMiddleware (also raw ASGI) is added last of all so it
# remains the absolute outermost layer (handles preflight + headers
# before anything else runs).
#
# Resulting outer→inner execution order:
# CORS → RequestUserMessage → HeaderForwarding → Health → routes/mounts
# Innermost: serve /health via middleware so it short-circuits BEFORE
# route resolution. (Already declared above as HealthMiddleware.)
app.add_middleware(HealthMiddleware)
# Capture inbound CopilotKit `x-*` headers (e.g. `x-aimock-context`) into a
# per-request ContextVar so any outbound LLM/provider httpx call made inside
# the request scope copies them onto its outbound request. The matching
# ``install_httpx_hook(...)`` call lives next to each LLM client
# construction site (see ``agents/agent.py``).
app.add_middleware(HeaderForwardingHTTPMiddleware)
# CVDIAG backend emitter (spec §3 Layer 2) — emits the HTTP-observable backend
# boundaries (request.ingress, sse.first_byte, sse.event, sse.aborted,
# response.complete, error.caught) as structured CVDIAG envelopes. Added here so
# it wraps the Health + HeaderForwarding BaseHTTPMiddleware layers but stays
# INSIDE the outer raw-ASGI RequestUserMessage + CORS layers (CORS remains the
# absolute outermost so preflight is handled first). Gated behind
# ``CVDIAG_BACKEND_EMITTER`` (default OFF, canary-safe) — the middleware
# fast-paths to a bare pass-through when the flag is unset.
app.add_middleware(CvdiagBackendMiddleware)
# R2-A3: Capture the latest user message from each inbound RunAgentInput POST
# into a per-request ContextVar so tool handlers (e.g. generate_a2ui) can read
# the per-request prompt without consulting autogen's shared, race-prone
# ``ConversableAgent.chat_messages`` state. See agents/_request_context.py.
# Added AFTER the BaseHTTPMiddlewares above so it wraps them (raw ASGI on
# the outside preserves ContextVar propagation across the anyio
# TaskGroups they spawn internally).
app.add_middleware(RequestUserMessageMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Mount per-demo sub-apps FIRST. Starlette's router resolves mounts in
# registration order; the catch-all `/` mount below shadows everything
# under it, so the named mounts must come first.
app.mount("/shared-state-read-write", shared_state_read_write_app)
app.mount("/subagents", subagents_app)
app.mount("/headless-complete", headless_complete_app)
app.mount("/gen-ui-agent", gen_ui_agent_app)
app.mount("/declarative-gen-ui", a2ui_dynamic_app)
app.mount("/a2ui-fixed-schema", a2ui_fixed_app)
app.mount("/beautiful-chat", beautiful_chat_app)
app.mount("/mcp-apps", mcp_apps_app)
# IMPORTANT: mount /open-gen-ui-advanced BEFORE /open-gen-ui — Starlette
# resolves mounts via prefix matching in registration order, so the shorter
# prefix "/open-gen-ui" would shadow "/open-gen-ui-advanced" if it came first.
app.mount("/open-gen-ui-advanced", open_gen_ui_advanced_app)
app.mount("/open-gen-ui", open_gen_ui_app)
app.mount(
"/tool-rendering-reasoning-chain",
tool_rendering_reasoning_chain_app,
)
# Reasoning-aware route. AG2's stock AGUIStream emits no REASONING_MESSAGE_*
# events (and autogen drops the model's reasoning_content channel), so the
# reasoning-custom / reasoning-default cells use this custom sub-app instead.
# Mirrors agno's /reasoning/agui mount.
app.mount("/reasoning", reasoning_app)
app.mount("/agent-config", agent_config_app)
app.mount("/multimodal", multimodal_app)
app.mount("/byoc-hashbrown", byoc_hashbrown_app)
app.mount("/byoc-json-render", byoc_json_render_app)
# Interrupt-adapted scheduling agent. Shared by gen-ui-interrupt and
# interrupt-headless demos — backend has tools=[], the frontend provides
# `schedule_meeting` via `useFrontendTool` with an async Promise handler.
app.mount("/interrupt-adapted", interrupt_app)
# Mount the default AG2 AG-UI endpoint at the root.
# `app.mount("/", ...)` is a catch-all Mount that shadows any later route
# decorators, which is why /health is served by HealthMiddleware above
# rather than a `@app.get("/health")` handler registered here.
app.mount("/", default_stream.build_asgi())
def main():
"""Run the uvicorn server.
``reload=True`` is gated behind ``DEV_RELOAD=1`` so production
containers (which set neither var) get a single non-reloading
process. The reloader spawns a watcher process and re-imports the
app on every file change, which is appropriate for local dev but
burns memory + risks half-imported state in prod.
"""
port = int(os.getenv("PORT", "8000"))
dev_reload = os.getenv("DEV_RELOAD", "0") == "1"
uvicorn.run(
"agent_server:app",
host="0.0.0.0",
port=port,
reload=dev_reload,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,812 @@
"""_cvdiag_backend.py — backend-layer CVDIAG boundary instrumentation.
This module wires the spec §3 / §5 **11 backend boundaries** into a Python
showcase integration, emitting schema-v1 CVDIAG envelopes through the shared
``_shared.cvdiag_bootstrap.emit_cvdiag`` sink. It is the per-integration
companion to the header-forwarding shim (``_header_forwarding.py``): that file
forwards correlation headers onto outbound LLM calls and logs lightweight
``CVDIAG component=backend-<fw> boundary=...`` breadcrumbs; THIS file emits the
full structured ``CVDIAG {<json>}`` envelopes the harness/classifier consume.
The 11 backend boundaries (spec §5 / §6 tier matrix):
1. ``backend.request.ingress`` — HTTP request received (default)
2. ``backend.agent.enter`` — agent loop entered (default)
3. ``backend.llm.call.start`` — outbound LLM call dispatched (verbose)
4. ``backend.llm.call.heartbeat`` — fires ~10s while an LLM call is
outstanding (verbose)
5. ``backend.llm.call.response`` — LLM response received (verbose)
6. ``backend.sse.first_byte`` — first SSE byte written (verbose)
7. ``backend.sse.event`` — every SSE event written (debug)
8. ``backend.sse.aborted`` — stream terminated abnormally (default)
9. ``backend.agent.exit`` — agent loop exited (default)
10. ``backend.response.complete`` — HTTP response stream closed (default)
11. ``backend.error.caught`` — exception caught in the agent loop
(default)
Guarding
--------
ALL emission is gated behind the ``CVDIAG_BACKEND_EMITTER`` env flag, default
OFF. With the flag off this module is byte-for-byte inert — no envelope is
built, no stdout line is written, the middleware passes the request straight
through. This is the canary-safe default: the flag is flipped ON only after a
deploy is confirmed healthy.
Tier gating
-----------
Each boundary carries a tier per the §6 matrix. ``_shared.cvdiag_bootstrap``
resolves the active tier (default | verbose | debug) once at import; this
module suppresses a boundary whose tier exceeds the active tier so the
default-tier production emit stays within the §7 event-count budget.
Pure instrumentation
--------------------
Nothing here may throw into the request path. ``emit_cvdiag`` already swallows
its own errors; the helpers below additionally guard envelope construction so a
malformed metadata bag degrades to a dropped emit, never a 500.
Plan unit: L1-C.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import secrets
import time
import uuid
from typing import Any, Dict, Optional
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from _shared.cvdiag_bootstrap import _resolve_tier, current_tier, emit_cvdiag
logger = logging.getLogger(__name__)
# Framework tag — mirrors ``_header_forwarding._CVDIAG_FRAMEWORK`` so the
# structured envelopes and the breadcrumb log lines agree on the integration
# identity. (L1-D: change this single constant when copying to a sibling.)
_CVDIAG_FRAMEWORK = "ag2"
# ── Env gate ─────────────────────────────────────────────────────────────────
_BACKEND_EMITTER_ENV = "CVDIAG_BACKEND_EMITTER"
def cvdiag_backend_enabled() -> bool:
"""True iff the backend emitter is explicitly enabled (default OFF).
Read live (not cached) so a test can toggle the env var per-case via
``monkeypatch.setenv``; the cost is one ``os.environ`` lookup per emit,
which is negligible against the JSON serialization that follows.
"""
return os.environ.get(_BACKEND_EMITTER_ENV) == "1"
# ── Tier ordering (spec §6) ────────────────────────────────────────────────
_TIER_RANK = {"default": 0, "verbose": 1, "debug": 2}
# Per-boundary minimum tier required to emit (spec §6 matrix, backend rows).
_BOUNDARY_TIER: Dict[str, str] = {
"backend.request.ingress": "verbose",
"backend.agent.enter": "default",
"backend.llm.call.start": "verbose",
"backend.llm.call.heartbeat": "verbose",
"backend.llm.call.response": "verbose",
"backend.sse.first_byte": "verbose",
"backend.sse.event": "debug",
"backend.sse.aborted": "default",
"backend.agent.exit": "default",
"backend.response.complete": "default",
"backend.error.caught": "default",
}
def _active_tier() -> str:
"""Resolve the verbosity tier from a LIVE env read.
``cvdiag_backend_enabled()`` reads ``CVDIAG_BACKEND_EMITTER`` live, so the
tier MUST be read from the same live source — otherwise flipping
``CVDIAG_VERBOSE`` / ``CVDIAG_DEBUG`` AFTER import arms the emitter but the
tier stays frozen at the import-time ``setup()`` value, silently no-op'ing
every verbose/debug-gated boundary. We reuse the bootstrap's
``_resolve_tier`` so the §6 fail-closed DEBUG guard still applies (a
production / unresolved DEBUG request raises → degrade to the frozen tier).
"""
try:
return _resolve_tier(dict(os.environ))
except RuntimeError:
# Fail-closed DEBUG refusal: fall back to the import-time resolved tier
# (never silently escalate to debug in production).
return current_tier()
def _tier_permits(boundary: str) -> bool:
"""True iff the active tier is at-or-above the boundary's minimum tier."""
need = _TIER_RANK.get(_BOUNDARY_TIER.get(boundary, "default"), 0)
have = _TIER_RANK.get(_active_tier(), 0)
return have >= need
# ── Edge headers (spec §5 — 9-key allow-list + 12-name deny-list) ───────────
# The closed 9-key edge-header allow-list. Always-present in the envelope;
# absent header → ``None``.
_EDGE_ALLOW = (
"cf-ray",
"cf-mitigated",
"cf-cache-status",
"x-railway-edge",
"x-railway-request-id",
"x-hikari-trace",
"retry-after",
"via",
"server",
)
# Exact-match deny-list (spec §5). REJECTED even if accidentally present in the
# allow-list — these carry client IP / geo PII and must never round-trip.
_EDGE_DENY = frozenset(
{
"cf-ipcountry",
"cf-connecting-ip",
"cf-ipcity",
"cf-iplatitude",
"cf-iplongitude",
"cf-iptimezone",
"cf-visitor",
"cf-worker",
"true-client-ip",
"x-forwarded-for",
"x-real-ip",
"forwarded",
}
)
def extract_edge_headers(headers: Any) -> Dict[str, Optional[str]]:
"""Build the closed 9-key ``edge_headers`` bag from a headers mapping.
All nine keys are ALWAYS present; an absent (or deny-listed) header maps to
``None``. ``headers`` is any case-insensitive mapping exposing ``.get`` /
iteration of ``(name, value)`` pairs (Starlette ``Headers``, httpx, dict).
"""
bag: Dict[str, Optional[str]] = {k: None for k in _EDGE_ALLOW}
if headers is None:
return bag
try:
getter = headers.get
except AttributeError:
return bag
for key in _EDGE_ALLOW:
if key in _EDGE_DENY: # belt-and-braces: never emit a deny-listed key
continue
val = getter(key)
if val is not None:
bag[key] = str(val)
return bag
# ── PII scrub (spec §6) ──────────────────────────────────────────────────────
# Bearer tokens, OpenAI/Stripe-style secret keys, publishable keys, and URL
# userinfo. Applied to any captured free-text metadata value
# (``message_scrubbed``, stack frames) before it is emitted. The ``sk-``/``pk-``
# key bodies allow hyphens/underscores so test-style keys such as the spec
# regression fixture ``sk-test-12345`` are redacted alongside real production
# keys (``sk-<48+ base62>``).
#
# Parity with the canonical TS scrubber (``harness/src/cvdiag/scrub.ts``):
# * Bearer — grabs the WHOLE token (``\S+``) to match TS ``Bearer\s+\S+``;
# the legacy ``[A-Za-z0-9._\-]+`` stopped at ``/``/``+``/``=`` and left an
# un-redacted JWT tail (e.g. ``Bearer a.b.c/sig+more=`` → ``…/sig+more=``).
# * URL userinfo — redacts BOTH ``scheme://user:pw@host`` AND colon-less
# ``scheme://token@host`` (TS ``([scheme]://)[^/\s?#]*@``); the legacy
# ``[^/\s:@]+:[^/\s@]+@`` required a mandatory ``:`` so a bare-token
# authority such as ``https://ghp_xxx@host`` LEAKED. The userinfo class
# excludes ``?``/``#`` so the match never crosses into the query/fragment.
_SCRUB_PATTERNS = (
re.compile(r"Bearer\s+\S+", re.IGNORECASE),
re.compile(r"\bsk-[A-Za-z0-9][A-Za-z0-9_-]{3,}"),
re.compile(r"\bpk-[A-Za-z0-9][A-Za-z0-9_-]{3,}"),
re.compile(r"(?P<scheme>[a-z][a-z0-9+.\-]*://)[^/\s?#]*@", re.IGNORECASE),
)
# Per-event field byte caps (spec §5). message_scrubbed ≤512B.
_MESSAGE_CAP = 512
# Hard input-size guard (mirrors TS ``SCRUB_MAX_SCAN_LEN``): no regex ever runs
# on a string longer than this. A longer value has only its bounded prefix
# scanned and a self-describing ``…[unscanned:<N>]`` marker records the dropped
# tail length, so an adversarial multi-KB string can never make the regex
# engine scan unbounded input. 2 KB covers any legitimate metadata value with
# headroom. Set below the byte cap so the marker survives the §5 byte clamp.
_SCRUB_MAX_SCAN_LEN = 400
def _run_scrub_regexes(s: str) -> str:
"""Apply the secret regexes in sequence (TS ``runScrubRegexes`` parity)."""
for pat in _SCRUB_PATTERNS:
if pat.groupindex.get("scheme"):
s = pat.sub(r"\g<scheme>[REDACTED]@", s)
else:
s = pat.sub("[REDACTED]", s)
return s
def scrub(text: Any) -> str:
"""Redact secrets from a free-text value and cap it at 512 bytes.
Returns ``"[REDACTED]"`` substitutions for any matched secret pattern so a
synthetic ``sk-test-12345`` in an exception message can never reach the
emitted envelope. A value longer than ``_SCRUB_MAX_SCAN_LEN`` has only its
bounded prefix scanned, with an ``…[unscanned:<N>]`` marker (TS parity).
"""
if text is None:
return ""
s = str(text)
if len(s) > _SCRUB_MAX_SCAN_LEN:
dropped_tail = len(s) - _SCRUB_MAX_SCAN_LEN
s = f"{_run_scrub_regexes(s[:_SCRUB_MAX_SCAN_LEN])}…[unscanned:{dropped_tail}]"
else:
s = _run_scrub_regexes(s)
encoded = s.encode("utf-8")
if len(encoded) > _MESSAGE_CAP:
s = encoded[:_MESSAGE_CAP].decode("utf-8", errors="ignore")
return s
# ── Envelope construction ──────────────────────────────────────────────────
_TEST_ID_HEADER = "x-test-id"
_AIMOCK_CONTEXT_HEADER = "x-aimock-context"
# UUIDv7 variant/version nibbles (RFC 9562) the schema regex requires.
_SLUG_FALLBACK = "unknown"
_DEMO_FALLBACK = "default"
def _uuid7() -> str:
"""Generate a lowercase-hyphenated UUIDv7 (RFC 9562) string.
48-bit Unix-ms timestamp, version nibble 7, variant 10 — matches the
schema ``TEST_ID_PATTERN``. Used as the fallback ``test_id`` when no
inbound ``x-test-id`` correlation header is present.
"""
unix_ms = int(time.time() * 1000) & ((1 << 48) - 1)
rand_a = secrets.randbits(12)
rand_b = secrets.randbits(62)
msb = (unix_ms << 16) | (0x7 << 12) | rand_a
lsb = (0b10 << 62) | rand_b
return str(uuid.UUID(int=(msb << 64) | lsb))
_UUID7_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)
def normalize_test_id(raw: Optional[str]) -> str:
"""Return a schema-valid lowercased UUIDv7, minting one if ``raw`` is
absent or not a well-formed UUIDv7."""
if raw:
candidate = raw.strip().lower()
if _UUID7_RE.match(candidate):
return candidate
return _uuid7()
def _span_id() -> str:
"""16-hex span id, unique per emit (schema ``SPAN_ID_PATTERN``)."""
return secrets.token_hex(8)
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
def _normalize_slug(raw: Optional[str]) -> str:
"""Coerce the inbound ``x-aimock-context`` slug into the closed slug shape
(``^[a-z][a-z0-9-]{0,63}$``), falling back to ``unknown`` when unusable."""
if raw:
candidate = raw.strip().lower()
if _SLUG_RE.match(candidate):
return candidate
return _SLUG_FALLBACK
def build_envelope(
*,
boundary: str,
outcome: str,
test_id: str,
slug: str,
demo: str,
metadata: Optional[Dict[str, Any]] = None,
edge_headers: Optional[Dict[str, Optional[str]]] = None,
duration_ms: Optional[int] = None,
parent_span_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Assemble a schema-v1 backend envelope (``layer=backend``).
All envelope-required fields are populated; ``edge_headers`` defaults to the
closed 9-key all-null bag when not supplied. ``metadata`` is passed through
verbatim — unknown keys are stamped ``_metadata_dropped`` by the schema
validator inside ``emit_cvdiag``.
"""
return {
"schema_version": 1,
"test_id": test_id,
"trace_id": test_id,
"span_id": _span_id(),
"parent_span_id": parent_span_id,
"layer": "backend",
"boundary": boundary,
"slug": slug,
"demo": demo,
"ts": _now_iso(),
"mono_ns": time.monotonic_ns(),
"duration_ms": duration_ms,
"outcome": outcome,
"edge_headers": edge_headers or {k: None for k in _EDGE_ALLOW},
"metadata": metadata or {},
}
def _now_iso() -> str:
"""ISO-8601 millisecond-precision timestamp with a ``Z`` suffix."""
# ``time.gmtime`` + manual ms keeps this dependency-free and 3.9-safe.
now = time.time()
secs = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now))
ms = int((now - int(now)) * 1000)
return f"{secs}.{ms:03d}Z"
def emit_backend_boundary(
boundary: str,
*,
outcome: str = "info",
test_id: str,
slug: str,
demo: str,
metadata: Optional[Dict[str, Any]] = None,
edge_headers: Optional[Dict[str, Optional[str]]] = None,
duration_ms: Optional[int] = None,
parent_span_id: Optional[str] = None,
) -> None:
"""Emit one backend boundary envelope, honoring the env gate + tier matrix.
No-op when the emitter is disabled or the active tier does not permit this
boundary. Never raises into the caller.
"""
if not cvdiag_backend_enabled():
return
if not _tier_permits(boundary):
return
try:
envelope = build_envelope(
boundary=boundary,
outcome=outcome,
test_id=test_id,
slug=slug,
demo=demo,
metadata=metadata,
edge_headers=edge_headers,
duration_ms=duration_ms,
parent_span_id=parent_span_id,
)
emit_cvdiag(envelope)
except Exception as err: # noqa: BLE001 - instrumentation must not throw
logger.warning("CVDIAG backend emit-failed boundary=%s error=%s", boundary, err)
# ── Per-request correlation context ─────────────────────────────────────────
class _RequestCtx:
"""Holds the per-request correlation identity + timing the boundaries share.
Carried on ``request.state`` so the middleware, the LLM hook, and the agent
hooks all stamp the same ``test_id`` / ``slug`` / ``demo`` onto their
envelopes.
"""
__slots__ = (
"test_id",
"slug",
"demo",
"ingress_mono_ns",
"sse_seq",
"first_byte_emitted",
"bytes_streamed",
)
def __init__(self, *, test_id: str, slug: str, demo: str) -> None:
self.test_id = test_id
self.slug = slug
self.demo = demo
self.ingress_mono_ns = time.monotonic_ns()
self.sse_seq = 0
self.first_byte_emitted = False
self.bytes_streamed = 0
def _demo_from_path(path: str) -> str:
"""Derive the ``demo`` label from the mounted sub-app path.
Each demo is mounted at ``/<demo>`` (e.g. ``/voice``, ``/byoc-hashbrown``);
the root agent serves the default demo. Strip the leading slash and any
trailing AG-UI segment so ``/byoc-hashbrown/`` → ``byoc-hashbrown`` and
``/`` → ``default``.
"""
trimmed = path.strip("/")
if not trimmed:
return _DEMO_FALLBACK
return trimmed.split("/", 1)[0] or _DEMO_FALLBACK
# ── HTTP middleware: ingress / first_byte / sse.event / sse.aborted /
# response.complete / error.caught ─────────────────────────────────────────
class CvdiagBackendMiddleware(BaseHTTPMiddleware):
"""Starlette middleware emitting the HTTP-observable backend boundaries.
Wires six of the eleven boundaries around the request lifecycle:
* ``backend.request.ingress`` on entry
* ``backend.sse.first_byte`` on the first streamed chunk
* ``backend.sse.event`` per streamed chunk (debug tier)
* ``backend.sse.aborted`` on premature stream termination
* ``backend.response.complete`` on clean stream close
* ``backend.error.caught`` on any exception escaping the inner app
The agent/LLM boundaries (``agent.enter``, ``llm.call.*``, ``agent.exit``)
are emitted by the agent hooks / LLM httpx hook installed separately, all
keyed on the same ``test_id`` this middleware stamps onto ``request.state``.
Inert when ``CVDIAG_BACKEND_EMITTER`` is off: the dispatch fast-paths to a
bare ``call_next`` with no envelope construction and no response wrapping.
"""
async def dispatch(self, request: Request, call_next) -> Response:
if not cvdiag_backend_enabled():
return await call_next(request)
headers = request.headers
ctx = _RequestCtx(
test_id=normalize_test_id(headers.get(_TEST_ID_HEADER)),
slug=_normalize_slug(headers.get(_AIMOCK_CONTEXT_HEADER)),
demo=_demo_from_path(request.url.path),
)
request.state.cvdiag = ctx
emit_backend_boundary(
"backend.request.ingress",
outcome="info",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
edge_headers=extract_edge_headers(headers),
metadata={
"method": request.method,
"path": request.url.path,
"content_length": _int_or_none(headers.get("content-length")),
},
)
try:
response = await call_next(request)
except Exception as exc: # noqa: BLE001 - observe then re-raise
emit_backend_boundary(
"backend.error.caught",
outcome="err",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
metadata={
"exception_type": type(exc).__name__,
"message_scrubbed": scrub(str(exc)),
"stack_brief": [],
"truncated": False,
},
)
raise
return self._wrap_response(request, response, ctx)
def _wrap_response(
self, request: Request, response: Response, ctx: "_RequestCtx"
) -> Response:
"""Wrap a streaming response so SSE boundaries fire as chunks flow.
Non-streaming responses are returned unwrapped after emitting
``backend.response.complete`` directly.
NOTE: ``BaseHTTPMiddleware`` re-wraps the inner ``StreamingResponse`` as
a private ``_StreamingResponse`` before it reaches us, so an
``isinstance(response, StreamingResponse)`` check is always False here.
Detect streaming by the presence of a ``body_iterator`` (which both the
public and the private response carry) instead.
"""
if not hasattr(response, "body_iterator"):
emit_backend_boundary(
"backend.response.complete",
outcome="ok",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
duration_ms=_elapsed_ms(ctx.ingress_mono_ns),
edge_headers=extract_edge_headers(response.headers),
metadata={
"http_status": response.status_code,
"content_length": _int_or_none(
response.headers.get("content-length")
),
"total_duration_ms": _elapsed_ms(ctx.ingress_mono_ns),
"sse_event_count": ctx.sse_seq,
},
)
return response
inner = response.body_iterator
edge = extract_edge_headers(response.headers)
status = response.status_code
async def _instrumented():
# ``completed`` distinguishes a clean stream exhaustion (→
# response.complete) from an early termination (→ sse.aborted).
#
# IMPORTANT (Starlette ``BaseHTTPMiddleware`` quirk): when the INNER
# endpoint generator raises mid-stream, Starlette swallows the error
# internally and our ``async for`` simply ends — we never see an
# exception there. The abort surface we CAN observe is the consumer
# tearing the stream down early (client disconnect), which closes
# this generator and raises ``GeneratorExit`` / ``CancelledError``
# into it. We therefore catch ``BaseException`` (not just
# ``Exception``) so a disconnect-driven abort is captured, and emit
# ``backend.response.complete`` only on a clean exhaustion.
completed = False
terminated_kind = "rst"
try:
async for chunk in inner:
ctx.bytes_streamed += len(chunk) if chunk else 0
if not ctx.first_byte_emitted:
ctx.first_byte_emitted = True
emit_backend_boundary(
"backend.sse.first_byte",
outcome="info",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
edge_headers=edge,
metadata={
"delta_ms_from_ingress": _elapsed_ms(
ctx.ingress_mono_ns
)
},
)
emit_backend_boundary(
"backend.sse.event",
outcome="info",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
metadata={
"event_type": "chunk",
"payload_size_bytes": len(chunk) if chunk else 0,
"sequence_num": ctx.sse_seq,
},
)
ctx.sse_seq += 1
yield chunk
completed = True
except BaseException as exc: # noqa: BLE001 - observe abort then re-raise
# GeneratorExit (disconnect) and CancelledError carry no
# message; an in-iterator error would. Pick a termination_kind.
terminated_kind = (
"rst"
if isinstance(exc, (GeneratorExit,))
else (
"timeout"
if isinstance(exc, asyncio.CancelledError)
else "chunk_error"
)
)
raise
finally:
if completed:
emit_backend_boundary(
"backend.response.complete",
outcome="ok",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
duration_ms=_elapsed_ms(ctx.ingress_mono_ns),
edge_headers=edge,
metadata={
"http_status": status,
"content_length": ctx.bytes_streamed,
"total_duration_ms": _elapsed_ms(ctx.ingress_mono_ns),
"sse_event_count": ctx.sse_seq,
},
)
else:
emit_backend_boundary(
"backend.sse.aborted",
outcome="err",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
edge_headers=edge,
metadata={
"termination_kind": terminated_kind,
"bytes_before_abort": ctx.bytes_streamed,
},
)
response.body_iterator = _instrumented()
return response
def _int_or_none(raw: Any) -> Optional[int]:
"""Parse an int header value, returning ``None`` on absence / malformed."""
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def _elapsed_ms(start_mono_ns: int) -> int:
"""Whole milliseconds elapsed since a ``time.monotonic_ns`` start mark."""
return max(0, (time.monotonic_ns() - start_mono_ns) // 1_000_000)
# ── Agent + LLM boundaries ──────────────────────────────────────────────────
# The LLM-call boundaries (start / heartbeat / response) and the agent
# enter/exit boundaries are emitted via the explicit helpers below. They are
# called from the agent factory's hook points (strands ``HookProvider``) and
# from the outbound httpx event hook, all keyed on the request ``ctx``.
def emit_agent_enter(ctx: "_RequestCtx", *, agent_name: str, model_id: str) -> None:
"""Emit ``backend.agent.enter`` (default tier)."""
emit_backend_boundary(
"backend.agent.enter",
outcome="info",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
metadata={"agent_name": agent_name, "model_id": model_id},
)
def emit_agent_exit(
ctx: "_RequestCtx", *, terminal_outcome: str, total_duration_ms: int
) -> None:
"""Emit ``backend.agent.exit`` (default tier)."""
emit_backend_boundary(
"backend.agent.exit",
outcome="ok" if terminal_outcome == "ok" else "err",
test_id=ctx.test_id,
slug=ctx.slug,
demo=ctx.demo,
duration_ms=total_duration_ms,
metadata={
"terminal_outcome": terminal_outcome,
"total_duration_ms": total_duration_ms,
},
)
class LlmCallScope:
"""Async context manager spanning one outbound LLM call.
On ``__aenter__`` emits ``backend.llm.call.start`` and launches a heartbeat
task that emits ``backend.llm.call.heartbeat`` every ``interval_s`` (≈10s)
while the call is outstanding (verbose tier). On ``__aexit__`` emits
``backend.llm.call.response`` with the measured latency.
All emission is gated/tiered through ``emit_backend_boundary``, so with the
emitter off or at default tier this scope is effectively free (the
heartbeat task still ticks but every emit is suppressed; callers that want
zero task overhead can skip the scope when ``cvdiag_backend_enabled()`` is
false).
"""
def __init__(
self,
ctx: "_RequestCtx",
*,
provider: str,
model: str,
prompt_token_count_estimate: int = 0,
interval_s: float = 10.0,
) -> None:
self._ctx = ctx
self._provider = provider
self._model = model
self._prompt_tokens = prompt_token_count_estimate
self._interval_s = interval_s
self._start_mono_ns = 0
self._hb_task: Optional[asyncio.Task] = None
async def __aenter__(self) -> "LlmCallScope":
self._start_mono_ns = time.monotonic_ns()
emit_backend_boundary(
"backend.llm.call.start",
outcome="info",
test_id=self._ctx.test_id,
slug=self._ctx.slug,
demo=self._ctx.demo,
metadata={
"provider": self._provider,
"model": self._model,
"prompt_token_count_estimate": self._prompt_tokens,
},
)
self._hb_task = asyncio.ensure_future(self._heartbeat())
return self
async def _heartbeat(self) -> None:
try:
while True:
await asyncio.sleep(self._interval_s)
emit_backend_boundary(
"backend.llm.call.heartbeat",
outcome="info",
test_id=self._ctx.test_id,
slug=self._ctx.slug,
demo=self._ctx.demo,
metadata={
"elapsed_ms_since_start": _elapsed_ms(self._start_mono_ns)
},
)
except asyncio.CancelledError: # normal shutdown on call completion
return
async def __aexit__(self, exc_type, exc, tb) -> bool:
if self._hb_task is not None:
hb_task = self._hb_task
self._hb_task = None
hb_task.cancel()
try:
await hb_task
except asyncio.CancelledError:
# Cooperative cancellation (was ``except (CancelledError,
# Exception)``, which swallowed the CALLER's cancel and broke
# cooperative cancellation). Suppress ONLY the heartbeat task's
# OWN cancellation — the one we just requested. If THIS task is
# being cancelled by the caller (a pending cancellation request,
# ``current_task().cancelling() > 0``), the CancelledError is the
# caller's and MUST propagate. ``Task.cancelling()`` is 3.11+
# (production runs 3.12); on older runtimes the attribute is
# absent and we degrade to suppressing (the legacy behavior).
current = asyncio.current_task()
cancelling = getattr(current, "cancelling", None)
if current is not None and cancelling is not None and cancelling() > 0:
raise
except Exception: # noqa: BLE001 - heartbeat body must never throw out
pass
emit_backend_boundary(
"backend.llm.call.response",
outcome="err" if exc_type is not None else "ok",
test_id=self._ctx.test_id,
slug=self._ctx.slug,
demo=self._ctx.demo,
duration_ms=_elapsed_ms(self._start_mono_ns),
metadata={
"provider": self._provider,
"model": self._model,
"response_token_count": None,
"latency_ms": _elapsed_ms(self._start_mono_ns),
"error_class": type(exc).__name__ if exc is not None else None,
},
)
return False # never suppress the underlying exception
@@ -0,0 +1,466 @@
"""Standalone header-forwarding shim for showcase integrations.
Forward CopilotKit request-context headers (e.g. ``x-aimock-context``)
onto outbound LLM/provider HTTP calls so the locally-served aimock test
server can match the right fixture for each in-flight showcase request.
This module is a SELF-CONTAINED port of the langgraph-python reference
shim at ``copilotkit/header_propagation.py`` plus a small Starlette HTTP
middleware that extracts inbound ``x-*`` headers at request scope.
It is intentionally duplicated into every Python showcase integration
that does NOT already depend on the ``copilotkit`` SDK so each backend
has a single self-contained file it can import without adding a heavy
``copilotkit`` (langchain-pulling) dependency.
What this module does
---------------------
Three things, kept deliberately small:
1. ``HeaderForwardingHTTPMiddleware`` — a Starlette/FastAPI HTTP
middleware that, on every inbound request, extracts ``x-*`` prefixed
headers and stashes them on a per-request ``contextvars.ContextVar``.
2. ``install_httpx_hook(client)`` — attaches an httpx request event hook
to the given LLM client's underlying httpx client (walking the
``._client`` chain that modern provider SDKs wrap their httpx client
behind). The hook copies the recorded headers onto outbound requests.
3. ``set_forwarded_headers`` / ``get_forwarded_headers`` — direct
ContextVar accessors for integrations that need to populate the
header set from a non-HTTP source (e.g. LangGraph's RunnableConfig
``configurable`` channel).
Scope and limits
----------------
* Only ``x-*`` prefixed headers are forwarded. ``authorization``,
``content-type``, and any other non-``x-*`` headers are dropped.
* Nothing is collected, persisted, or sent anywhere — the module only
attaches headers to an HTTP request that the caller was already going
to make. No telemetry, no out-of-band channel. (Diagnostic CVDIAG
breadcrumbs ARE logged via the stdlib ``logging`` module: header
PRESENCE plus a short value prefix only — never full header values.)
"""
from __future__ import annotations
import contextvars
import logging
import warnings
from typing import Any, Dict, Optional
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger(__name__)
# CVDIAG correlation-header instrumentation tag for this integration. Each
# showcase backend that copies this shim sets a distinct framework tag so the
# CVDIAG breadcrumb trail identifies which backend captured/forwarded headers.
_CVDIAG_FRAMEWORK = "ag2"
# Correlation headers carried end-to-end through the showcase request chain.
_DIAG_RUN_ID_HEADER = "x-diag-run-id"
_DIAG_HOPS_HEADER = "x-diag-hops"
_AIMOCK_CONTEXT_HEADER = "x-aimock-context"
_TEST_ID_HEADER = "x-test-id"
def _cvdiag(
boundary: str,
headers: Dict[str, str],
*,
status: str,
hop: object = "-",
error: str = "",
) -> None:
"""Emit a single standardized CVDIAG breadcrumb line.
Logs ONLY header presence + a short value prefix (never full header
values). ``headers`` is the lowercased ``x-*`` header mapping for the
current request context.
"""
slug = headers.get(_AIMOCK_CONTEXT_HEADER)
run_id = headers.get(_DIAG_RUN_ID_HEADER, "none")
test_id = headers.get(_TEST_ID_HEADER, "none")
present = slug is not None
prefix = (slug or "")[:12]
logger.info(
"CVDIAG component=backend-%s boundary=%s run_id=%s slug=%s "
"header_present=%s header_value_prefix=%s hop=%s status=%s "
"test_id=%s error=%s",
_CVDIAG_FRAMEWORK,
boundary,
run_id,
slug if present else "MISSING",
"true" if present else "false",
prefix,
hop,
status,
test_id,
error,
)
# Per-request storage for the headers the application has asked to forward
# onto outbound LLM/provider calls.
_forwarded_headers: contextvars.ContextVar[Dict[str, str]] = contextvars.ContextVar(
"copilotkit_forwarded_headers"
)
# Marker used to identify hooks we have already installed so the install
# call is idempotent across repeated invocations on the same client.
_HOOK_MARKER = "_copilotkit_forwarded_header_hook"
# Bound on how deep we'll walk a ``._client`` chain looking for event_hooks.
# Modern provider SDKs (OpenAI, Anthropic, pydantic-ai wrappers, agno's
# OpenAIChat, strands' OpenAIModel) wrap their httpx client behind 2-4
# layers of ``._client`` indirection; 5 hops is enough headroom without
# risking pathological loops.
_MAX_CHAIN_DEPTH = 5
def set_forwarded_headers(headers: Dict[str, str]) -> None:
"""Record headers to forward onto outbound LLM/provider calls.
Only ``x-*`` prefixed headers are kept; everything else is dropped.
"""
filtered = {k.lower(): v for k, v in headers.items() if k.lower().startswith("x-")}
_forwarded_headers.set(filtered)
def get_forwarded_headers() -> Dict[str, str]:
"""Return the headers recorded for the current request context."""
return _forwarded_headers.get({})
class HeaderForwardingHTTPMiddleware(BaseHTTPMiddleware):
"""Starlette/FastAPI middleware that captures inbound ``x-*`` headers.
On every inbound HTTP request, copies all ``x-*`` prefixed headers
onto the per-request ContextVar so any outbound httpx call made
inside the request scope (the LLM call hop 2) sees them via
``get_forwarded_headers()`` and the installed httpx event hook.
"""
async def dispatch(self, request: Request, call_next) -> Response:
headers = {
k: v for k, v in request.headers.items() if k.lower().startswith("x-")
}
set_forwarded_headers(headers)
captured = {k.lower(): v for k, v in headers.items()}
_cvdiag(
"contextvar-capture",
captured,
status="ok" if _AIMOCK_CONTEXT_HEADER in captured else "miss",
)
return await call_next(request)
def _find_event_hooks_target(client: Any) -> Optional[Any]:
"""Walk ``._client`` chain looking for the first httpx-style event_hooks.
Returns the target object, or ``None`` if not found within
``_MAX_CHAIN_DEPTH`` hops.
"""
current = client
for _ in range(_MAX_CHAIN_DEPTH + 1):
if current is None:
return None
if hasattr(current, "event_hooks"):
return current
nxt = getattr(current, "_client", None)
if nxt is current or nxt is None:
return None
current = nxt
return None
def _is_async_httpx_target(target: Any) -> bool:
"""Best-effort detection: is this an httpx async client?
Detection is HIGH-CONFIDENCE when ``isinstance`` against the real
``httpx.AsyncClient`` / ``httpx.Client`` succeeds. The MRO name-only
fallback (matching a class literally named ``AsyncClient``) is
LOW-CONFIDENCE: a wrapped/duck-typed client whose class happens to be
named ``AsyncClient`` (or that is async but is NOT so named) can be
misclassified, which would install a sync hook on an async client (an
un-awaited coroutine → silent header drop) or vice versa. Each path
emits a CVDIAG breadcrumb tagged with the chosen confidence so a
misdetection is greppable in the logs. The return values themselves are
unchanged — only the diagnostics are new.
"""
try:
import httpx
if isinstance(target, httpx.AsyncClient):
_cvdiag(
"async-detect",
{},
status="ok",
error="path=isinstance-async confidence=high",
)
return True
if isinstance(target, httpx.Client):
_cvdiag(
"async-detect",
{},
status="ok",
error="path=isinstance-sync confidence=high",
)
return False
except ImportError: # pragma: no cover
pass
# Fall back to exact class-name match for wrapped/duck-typed clients.
# LOW-CONFIDENCE: this can misdetect async-vs-sync for oddly-named
# wrappers; the breadcrumb records the fallback so a wrong hook kind is
# traceable to this path.
for cls in type(target).__mro__:
if cls.__name__ == "AsyncClient":
_cvdiag(
"async-detect",
{},
status="ok",
error=(
"path=mro-name-match confidence=low "
f"target_type={type(target).__name__}"
),
)
return True
_cvdiag(
"async-detect",
{},
status="ok",
error=(f"path=default-sync confidence=low target_type={type(target).__name__}"),
)
return False
def _inject_diag_hop(request: Any, headers: Dict[str, str]) -> None:
"""Append this backend's hop tag to ``x-diag-hops`` on the outbound
request and emit the ``outbound-llm`` CVDIAG breadcrumb.
``x-diag-hops`` is a comma-separated trail of the backends that touched
the request; appending ``backend-<framework>`` here records that this
integration forwarded the correlation headers onto the LLM/provider
call. ``x-diag-run-id`` is carried verbatim (already copied above via
the ``headers`` loop) the same way ``x-aimock-context`` is.
GATED on diagnostic-header presence: the breadcrumb append and the
outbound CVDIAG log fire ONLY when the forwarded headers carry a
diagnostic header (``x-diag-run-id`` OR ``x-aimock-context``). When
NEITHER is present this is a no-op, so the outbound request is
byte-identical to pre-instrumentation behavior.
"""
if _DIAG_RUN_ID_HEADER not in headers and _AIMOCK_CONTEXT_HEADER not in headers:
return
hop_tag = f"backend-{_CVDIAG_FRAMEWORK}"
existing = headers.get(_DIAG_HOPS_HEADER, "")
trail = [h for h in (existing.split(",") if existing else []) if h]
trail.append(hop_tag)
new_hops = ",".join(trail)
request.headers[_DIAG_HOPS_HEADER] = new_hops
_cvdiag(
"outbound-llm",
headers,
status="ok" if _AIMOCK_CONTEXT_HEADER in headers else "miss",
hop=len(trail),
)
def install_httpx_hook(client: Any) -> None:
"""Attach an httpx request event hook to ``client``'s httpx client.
Walks the ``._client`` chain to find the first object with an
``event_hooks`` mapping, then appends a request hook that copies the
ContextVar-recorded headers onto each outbound request.
Works with OpenAI / Anthropic / pydantic-ai / agno / strands client
wrappers (all wrap httpx internally), as well as raw
``httpx.Client`` / ``httpx.AsyncClient`` instances.
Idempotent: a marker attribute on the installed callable prevents
double-installation on the same target.
"""
target = _find_event_hooks_target(client)
if target is None:
msg = (
f"install_httpx_hook: client of type {type(client).__name__} has no "
"recognized event_hooks attribute; x-* headers will NOT be forwarded "
"for this client"
)
warnings.warn(msg, stacklevel=2)
# warnings.warn is invisible in most prod runtimes (filtered/once);
# ALSO log at WARNING so a non-forwarding client surfaces.
logger.warning("CVDIAG boundary=hook-install status=error error=%s", msg)
_cvdiag("hook-install", {}, status="error", error="no-event-hooks-target")
return
request_hooks = target.event_hooks.get("request", [])
# Idempotency: don't double-install on the same target.
for existing in request_hooks:
if getattr(existing, _HOOK_MARKER, False):
return
is_async = _is_async_httpx_target(target)
if is_async:
async def _inject_headers_async(request):
headers = get_forwarded_headers()
for key, value in headers.items():
request.headers[key] = value
_inject_diag_hop(request, headers)
setattr(_inject_headers_async, _HOOK_MARKER, True)
request_hooks.append(_inject_headers_async)
else:
def _inject_headers(request):
headers = get_forwarded_headers()
for key, value in headers.items():
request.headers[key] = value
_inject_diag_hop(request, headers)
setattr(_inject_headers, _HOOK_MARKER, True)
request_hooks.append(_inject_headers)
target.event_hooks["request"] = request_hooks
# Module-scope sentinel preventing repeated global patching.
_GLOBAL_HTTPX_PATCHED = False
def install_global_httpx_hook() -> None:
"""Patch ``httpx.Client`` / ``httpx.AsyncClient`` so EVERY future
instance auto-attaches the forwarded-header hook on construction.
Use this when the LLM client is buried behind opaque framework
machinery (AG2's ``ConversableAgent`` constructs OpenAI clients
lazily, CrewAI uses litellm which constructs httpx clients per-call,
etc.) and there is no single client instance to call
:func:`install_httpx_hook` on at startup.
Safe to call at import time. Idempotent: a module-scope sentinel
prevents repeated patching, and the per-instance idempotency check
in :func:`install_httpx_hook` prevents double-hooking on each new
client. Pre-existing ``httpx.Client`` instances are not retroactively
hooked — only those constructed AFTER this call.
"""
global _GLOBAL_HTTPX_PATCHED
if _GLOBAL_HTTPX_PATCHED:
return
try:
import httpx
except ImportError: # pragma: no cover
return
_orig_sync_init = httpx.Client.__init__
_orig_async_init = httpx.AsyncClient.__init__
def _patched_sync_init(self, *args, **kwargs):
_orig_sync_init(self, *args, **kwargs)
try:
install_httpx_hook(self)
except Exception as exc: # pragma: no cover - never break client construction
# A failed hook install means x-aimock-context silently never
# forwards (the whole point of this shim). Keep swallowing the
# exception so client construction never breaks, but FAIL LOUD:
# log at ERROR with the FULL detail (not 80-char-truncated) so a
# broken install is visible, not buried at INFO.
detail = f"sync-init {type(exc).__name__}: {exc}"
logger.error(
"CVDIAG boundary=hook-install status=error error=%s",
detail,
exc_info=True,
)
_cvdiag("hook-install", {}, status="error", error=detail)
def _patched_async_init(self, *args, **kwargs):
_orig_async_init(self, *args, **kwargs)
try:
install_httpx_hook(self)
except Exception as exc: # pragma: no cover
# See _patched_sync_init: swallow to protect construction, but
# FAIL LOUD at ERROR with full detail so a broken install (which
# silently drops x-aimock-context forwarding) is visible.
detail = f"async-init {type(exc).__name__}: {exc}"
logger.error(
"CVDIAG boundary=hook-install status=error error=%s",
detail,
exc_info=True,
)
_cvdiag("hook-install", {}, status="error", error=detail)
httpx.Client.__init__ = _patched_sync_init
httpx.AsyncClient.__init__ = _patched_async_init
_GLOBAL_HTTPX_PATCHED = True
# Module-scope sentinel preventing repeated executor patching.
_EXECUTOR_CTXVAR_PATCHED = False
def install_executor_contextvar_propagation() -> None:
"""Patch ``asyncio.events.AbstractEventLoop.run_in_executor`` so the
parent task's ContextVars are propagated into the executor thread.
Why this exists
---------------
autogen's ``ConversableAgent.a_generate_oai_reply`` dispatches the
underlying (sync) OpenAI/LiteLLM call onto the default thread pool
via ``loop.run_in_executor(None, functools.partial(...))``. The stock
``run_in_executor`` does NOT copy the caller's :pep:`567` context to
the worker thread — so the :class:`HeaderForwardingHTTPMiddleware`
ContextVar (set on the inbound request task) is empty inside the
executor, and our outbound httpx hook sees no headers to forward.
``asyncio.to_thread`` (Python 3.9+) does copy context the right way;
this patch makes plain ``run_in_executor`` behave the same. It only
affects functions submitted via ``run_in_executor`` — coroutines and
other constructs are unaffected.
Safe to call at import time. Idempotent via a module-scope sentinel.
Scope caveat: this patches ``asyncio.base_events.BaseEventLoop`` only.
Pre-existing *stdlib asyncio* event-loop instances inherit the patch
(``run_in_executor`` is defined on ``BaseEventLoop`` and resolved
per-call via normal method resolution). It is INERT under uvloop —
uvloop's loop does not subclass ``BaseEventLoop`` and resolves
``run_in_executor`` from its own C implementation, so the stdlib
method this patch rebinds is never consulted. Under uvloop, ContextVar
propagation into ``run_in_executor`` worker threads is NOT provided by
this shim.
"""
global _EXECUTOR_CTXVAR_PATCHED
if _EXECUTOR_CTXVAR_PATCHED:
return
import asyncio.base_events as _base_events
_orig_run_in_executor = _base_events.BaseEventLoop.run_in_executor
def _patched_run_in_executor(self, executor, func, *args):
# Capture the CURRENT task's context at submit time, then run the
# submitted callable inside that context on the worker thread.
ctx = contextvars.copy_context()
def _ctx_wrapper(*a, **kw):
return ctx.run(func, *a, **kw)
# Preserve __name__/__qualname__ for nicer tracebacks where possible.
try:
_ctx_wrapper.__wrapped__ = func # type: ignore[attr-defined]
except Exception: # pragma: no cover
pass
return _orig_run_in_executor(self, executor, _ctx_wrapper, *args)
_base_events.BaseEventLoop.run_in_executor = _patched_run_in_executor
_EXECUTOR_CTXVAR_PATCHED = True
@@ -0,0 +1,390 @@
"""AG-UI → autogen multimodal content normalization for the AG2 backend.
Problem
-------
The ``multimodal`` showcase cell sends user messages whose ``content`` is a
list of AG-UI ``InputContent`` parts. The shapes that actually arrive on
the wire are:
* Modern AG-UI image:
``{"type": "image", "source": {"type": "data" | "url", "value": "...",
"mimeType" | "mime_type": "image/png"}}``
* Modern AG-UI document (PDF, etc):
``{"type": "document", "source": {...}}``
* Legacy AG-UI binary mirror (appended by
``src/app/demos/multimodal/legacy-converter-shim.tsx``):
``{"type": "binary", "mimeType": "image/png", "data": "..." | "url": "..."}``
AG2's ``ConversableAgent`` runs every user message through
``autogen.code_utils.content_str``, which only accepts content-part types
``{"text", "input_text", "image_url", "input_image", "function",
"tool_call", "tool_calls"}``. Any other ``type`` raises
``ValueError("Wrong content format: unknown type <type> within the
content")`` BEFORE the request reaches the model — observed live in the
D6 ``multimodal`` probe (image turn errored out with that message; see
commit d8a0a25db for the symptom report and the original NSF
quarantine).
Fix
---
``NormalizingAGUIStream`` subclasses ``AGUIStream`` and overrides
``dispatch`` to normalise each user message's content list so AG-UI
image / document / binary parts become OpenAI Chat Completions
``image_url`` parts (which autogen accepts and forwards to the
vision-capable model natively).
The normalization runs AFTER ``RunAgentInput`` Pydantic parsing (which
accepts the standard AG-UI ``image``/``document``/``binary`` content
types) and BEFORE the messages are passed to ``AgentService``, which
serialises them via ``model_dump()`` into raw dicts and passes them to
``ConversableAgent``. That is the correct interception point: too early
(before Pydantic) would require rewriting ``image_url`` into the AG-UI
body, which ``RunAgentInput`` rejects; too late (inside ConversableAgent)
would require patching autogen internals.
Conversions:
* ``{"type": "image", "source": {"type": "data", value, mime_type}}`` →
``{"type": "image_url", "image_url": {"url": "data:<mime>;base64,<value>"}}``
* ``{"type": "image", "source": {"type": "url", value}}`` →
``{"type": "image_url", "image_url": {"url": value}}``
* ``{"type": "document", "source": ...}`` → ``image_url`` with the
document's mime preserved (data:application/pdf;base64,...). The vision
model still cannot natively read PDFs, but the request reaches the model
instead of being rejected upstream.
* ``{"type": "binary", mimeType, data | url}`` → ``image_url`` (the
legacy-shim parts ride through cleanly).
* ``{"type": "text", ...}`` and already-normalised ``image_url`` parts
pass through unchanged (idempotent on no-op turns).
Failure path: any normalization error is logged at WARNING and the
original message is replayed unchanged — autogen's own ``ValueError``
fires verbatim, preserving the failure surface.
The normalizer is mounted ONLY on the ``multimodal_app`` sub-app
(``agents/multimodal_agent.py``), not on the global FastAPI server in
``agent_server.py`` — keeping the blast radius scoped to the one route
that actually sees image content parts.
"""
from __future__ import annotations
import logging
from typing import Any, AsyncIterator
from autogen.ag_ui import AGUIStream, RunAgentInput
logger = logging.getLogger(__name__)
_IMAGE_URL_TYPE = "image_url"
_TEXT_TYPE = "text"
def _build_data_url(mime: str, payload: str) -> str:
"""Assemble a ``data:<mime>;base64,<payload>`` URL.
The OpenAI Chat Completions ``image_url`` part accepts either a
plain ``https://`` URL or an inline base64 data URL — both flow
through autogen's ``content_str`` allowed-types gate as
``image_url``. Building a data URL from the AG-UI ``data`` source
keeps the inline payload intact end-to-end.
"""
return f"data:{mime};base64,{payload}"
def _normalize_modern_part(part: dict[str, Any]) -> dict[str, Any] | None:
"""Convert a modern AG-UI ``image`` / ``document`` part to ``image_url``.
Returns ``None`` if the shape is unrecognised — the caller passes
the original part through unchanged in that case.
Modern AG-UI content shape (see ``ag_ui.core.types.ImageInputContent``):
``{"type": "image" | "document",
"source": {"type": "data" | "url",
"value": "<base64>" | "<https://...>",
"mime_type" | "mimeType": "..."}}``
"""
source = part.get("source")
if not isinstance(source, dict):
return None
value = source.get("value")
if not isinstance(value, str) or not value:
return None
# The AG-UI pydantic model uses ``mime_type``; the legacy converter
# shim and some hand-rolled payloads use ``mimeType``. Accept both
# so a frontend running either schema version round-trips cleanly.
mime = source.get("mime_type") or source.get("mimeType") or ""
if not isinstance(mime, str) or not mime:
# Fall back to a generic mime so the URL is at least well-formed
# data:URL syntax. The model side will likely ignore an unknown
# mime, but autogen's allowed-types gate only inspects ``type``.
mime = "application/octet-stream"
src_type = source.get("type")
if src_type == "url":
# Pass URL-source values through as the image_url url directly.
return {"type": _IMAGE_URL_TYPE, "image_url": {"url": value}}
if src_type == "data":
return {
"type": _IMAGE_URL_TYPE,
"image_url": {"url": _build_data_url(mime, value)},
}
return None
def _normalize_legacy_binary_part(part: dict[str, Any]) -> dict[str, Any] | None:
"""Convert a legacy AG-UI ``binary`` part to ``image_url``.
The frontend at ``src/app/demos/multimodal/legacy-converter-shim.tsx``
APPENDS one of these alongside every modern ``image``/``document``
part to feed the @ag-ui/langgraph converter (LangChain integrations
only understand the legacy shape). Those appended parts ride along
on the same payload that hits the AG2 backend, and autogen also
rejects ``binary`` as an unknown content type. Normalising them
here turns the round-trip into a no-op for AG2 instead of a hard
rejection.
Shape:
``{"type": "binary", "mimeType": "<mime>",
"data": "<base64>" | "url": "<https://...>"}``
"""
mime = part.get("mimeType") or part.get("mime_type") or "application/octet-stream"
if not isinstance(mime, str):
mime = "application/octet-stream"
data = part.get("data")
if isinstance(data, str) and data:
return {
"type": _IMAGE_URL_TYPE,
"image_url": {"url": _build_data_url(mime, data)},
}
url = part.get("url")
if isinstance(url, str) and url:
return {"type": _IMAGE_URL_TYPE, "image_url": {"url": url}}
return None
def _normalize_content_part(part: Any) -> Any:
"""Return an autogen-acceptable content part for ``part``.
Recognised conversions:
* ``{"type": "image", "source": ...}`` → ``image_url``
* ``{"type": "document", "source": ...}`` → ``image_url`` (data
URL with the original mime; vision model gets the raw bytes
and the system prompt steers it on what to do with them)
* ``{"type": "binary", ...}`` → ``image_url``
Everything else (``text``, already-normalised ``image_url``,
unknown shapes) passes through untouched. Returning the original
part on no-op keeps the rewrite idempotent and preserves any extra
keys autogen / the model might consume.
"""
if not isinstance(part, dict):
return part
part_type = part.get("type")
if part_type in ("image", "document", "audio", "video"):
normalized = _normalize_modern_part(part)
if normalized is not None:
return normalized
# Recognised modality with an unrecognised source — log and
# drop to a plain text placeholder so autogen accepts the
# part instead of choking. Without this, an empty/malformed
# source would survive as ``image``/``document`` and trip the
# exact ValueError we're working around.
logger.warning(
"[ag2:multimodal-normalize] dropping unrecognised %s source "
"shape; replacing with text placeholder",
part_type,
)
return {
"type": _TEXT_TYPE,
"text": f"[unreadable {part_type} attachment]",
}
if part_type == "binary":
normalized = _normalize_legacy_binary_part(part)
if normalized is not None:
return normalized
logger.warning(
"[ag2:multimodal-normalize] dropping unrecognised binary shape; "
"replacing with text placeholder",
)
return {
"type": _TEXT_TYPE,
"text": "[unreadable binary attachment]",
}
return part
def normalize_messages_for_autogen(messages: Any) -> Any:
"""Rewrite a list of message dicts so AG-UI multimodal parts are
converted to autogen-acceptable ``image_url`` parts.
Accepts the dict-serialised form produced by
``RunAgentInput.messages[i].model_dump(exclude_none=True)`` — the
same dicts that ``run_stream`` in autogen's AG-UI adapter passes to
``AgentService``.
Returns the input value untouched if it is not the expected list
shape. Otherwise returns a NEW list with rewritten user-message
content; non-user messages are forwarded as-is.
The function is pure: it never mutates the input.
"""
if not isinstance(messages, list):
return messages
rewritten: list[Any] = []
for msg in messages:
if not isinstance(msg, dict):
rewritten.append(msg)
continue
if msg.get("role") != "user":
rewritten.append(msg)
continue
content = msg.get("content")
if not isinstance(content, list):
# String content (plain text) and ``None`` pass through
# untouched. Autogen accepts both.
rewritten.append(msg)
continue
new_content = [_normalize_content_part(part) for part in content]
if new_content == content:
# No-op for this message — preserve the original dict so we
# never accidentally drop a key the downstream app reads.
rewritten.append(msg)
continue
new_msg = dict(msg)
new_msg["content"] = new_content
rewritten.append(new_msg)
return rewritten
class NormalizingAGUIStream(AGUIStream):
"""``AGUIStream`` subclass that normalises AG-UI multimodal content.
Overrides ``dispatch`` to call ``normalize_messages_for_autogen``
on the parsed ``RunAgentInput.messages`` (as serialised dicts) AFTER
Pydantic validation and BEFORE ``AgentService`` processes them. This
is the only correct interception point:
* Too early (ASGI body rewrite before Pydantic): ``RunAgentInput``
rejects ``image_url`` because it is not an AG-UI standard content
type — the validator only accepts ``image``, ``document``,
``binary``, ``text``, ``audio``, ``video``.
* Too late (inside ConversableAgent): requires patching autogen
internals that can change across versions.
The override patches ``autogen.ag_ui.adapter.run_stream`` at call
time by supplying pre-normalised messages via a thin
``RequestMessage`` shim, replacing only the ``messages`` field in
the ``AGStreamInput`` passed to the inherited ``dispatch`` machinery.
"""
async def dispatch(
self,
incoming: RunAgentInput,
*,
context: dict[str, Any] | None = None,
accept: str | None = None,
) -> AsyncIterator[str]:
# Serialise all messages to dicts (same as run_stream does) then
# normalise, then re-inject via a patched incoming object so the
# rest of the dispatch machinery sees image_url parts instead of
# AG-UI image/document/binary parts.
raw_msgs: list[dict[str, Any]] | None = None
try:
raw_msgs = [m.model_dump(exclude_none=True) for m in incoming.messages]
normalised_msgs = normalize_messages_for_autogen(raw_msgs)
except Exception as exc: # noqa: BLE001 — log + fall back to original
logger.warning(
"[ag2:multimodal-normalize] pre-dispatch normalization failed "
"(%s); forwarding original messages to autogen",
exc,
exc_info=True,
)
normalised_msgs = None
if (
normalised_msgs is not None
and raw_msgs is not None
and normalised_msgs is not raw_msgs
):
# Re-validate the normalised dicts back into Pydantic Message
# objects so the rest of AGUIStream.dispatch / run_stream can
# work with a properly typed RunAgentInput.
# We use model_validate (not model_validate_json) since we already
# have a Python dict. The normalised content uses image_url parts,
# which are NOT in the AG-UI InputContent union — so we re-validate
# just the message list using the raw dict form and pass it via a
# reconstructed RunAgentInput.
#
# IMPORTANT: we pass the normalised dicts as plain dicts; autogen's
# run_stream calls model_dump() on each message in
# command.incoming.messages. To avoid a double round-trip we
# instead *monkey-patch the model_dump contract* by building a
# lightweight wrapper list that returns the pre-normalised dict on
# model_dump() — keeping the rest of dispatch's typing clean.
incoming = _PatchedRunAgentInput(incoming, normalised_msgs)
# Delegate to the parent implementation with the (possibly patched)
# incoming object. AGUIStream.dispatch is a normal async generator so
# we must use "yield from" semantics via the async iterator protocol.
async for chunk in super().dispatch(incoming, context=context, accept=accept):
yield chunk
class _DictMessage:
"""Minimal message wrapper that returns a pre-computed dict on model_dump.
``run_stream`` in autogen's adapter calls
``m.model_dump(exclude_none=True)`` on each message in
``command.incoming.messages``. This wrapper satisfies that call
without the round-trip overhead of re-parsing the normalised dict
back through Pydantic (which would fail anyway since ``image_url``
is not an AG-UI content type).
"""
__slots__ = ("_d",)
def __init__(self, d: dict[str, Any]) -> None:
self._d = d
def model_dump(self, *, exclude_none: bool = False) -> dict[str, Any]: # noqa: ARG002
return self._d
class _PatchedRunAgentInput:
"""Thin wrapper around ``RunAgentInput`` that substitutes a pre-normalised
message list while forwarding all other attribute access to the original.
``AGUIStream.dispatch`` and ``run_stream`` read ``incoming.messages``,
``incoming.tools``, ``incoming.thread_id``, ``incoming.run_id``,
``incoming.state``, ``incoming.context``, and ``incoming.forwarded_props``
(plus optionally ``incoming.resume``). We override only ``messages``; all
others fall through to the real ``RunAgentInput`` object.
"""
__slots__ = ("_real", "_messages")
def __init__(
self,
real: RunAgentInput,
normalised_dicts: list[dict[str, Any]],
) -> None:
object.__setattr__(self, "_real", real)
object.__setattr__(
self,
"_messages",
[_DictMessage(d) for d in normalised_dicts],
)
@property
def messages(self) -> list[_DictMessage]:
return object.__getattribute__(self, "_messages")
def __getattr__(self, name: str) -> Any:
return getattr(object.__getattribute__(self, "_real"), name)
__all__ = [
"NormalizingAGUIStream",
"normalize_messages_for_autogen",
]
@@ -0,0 +1,311 @@
"""Per-request context capture for AG2 showcase backends.
Problem
-------
The AG2 showcase backends construct a single module-level
``ConversableAgent`` and re-use it across every inbound request (see
``agents/agent.py`` and ``agents/a2ui_dynamic.py``). Autogen mutates the
agent's ``chat_messages`` dict in place per turn, which means reading
"the latest user message" off ``agent.chat_messages`` is a cross-request
data race under any concurrency: a second request landing while the
first is still mid-tool-call observes the first request's messages.
The R2-A3 fix-cycle resolves this by reading the latest user prompt
directly from the per-request ``RunAgentInput.messages`` payload (the
runtime-supplied per-request body) instead of from autogen's shared
``chat_messages`` state. This module captures that payload at the HTTP
request boundary and exposes it via a ``contextvars.ContextVar`` so deep
tool-handler code (e.g. ``generate_a2ui``) can read it without threading
parameters through autogen's internal driver.
Mechanics
---------
1. ``RequestUserMessageMiddleware`` (Starlette/FastAPI ``BaseHTTPMiddleware``)
runs on every inbound POST. It reads the body (Starlette caches the
body internally so downstream handlers still see it), parses
``RunAgentInput.messages`` from the JSON payload, walks the list in
chronological order, and stores the most recent ``role == "user"``
message text in a per-request ``ContextVar``.
2. ``get_latest_user_message()`` returns the captured text (or ``""``).
Failures are intentionally NON-fatal: any parse error (non-JSON body,
missing ``messages``, schema drift, etc.) is logged at WARNING with the
exception type/message, and the ContextVar is set to ``""`` so callers
fall back to their hardcoded default. This is the R2-A2 fix discipline:
visibility into the fallback path rather than silent swallowing.
"""
from __future__ import annotations
import contextvars
import json
import logging
from typing import Any, Optional
from starlette.types import ASGIApp, Message, Receive, Scope, Send
logger = logging.getLogger(__name__)
_latest_user_message: contextvars.ContextVar[str] = contextvars.ContextVar(
"ag2_latest_user_message",
default="",
)
def get_latest_user_message() -> str:
"""Return the latest user message text captured for the current request.
Returns ``""`` when no message was captured (non-AG-UI request, parse
failure, empty ``messages`` array, an actually-empty user message,
etc.) — callers should treat the empty string as "fall back to the
hardcoded default prompt". The distinction between "user message
present but empty" and "no user message in payload" is preserved at
the ``_extract_latest_user_text`` boundary via ``Optional[str]`` but
collapsed at the ContextVar boundary since downstream callers all
fall back the same way.
"""
return _latest_user_message.get()
def _extract_latest_user_text(payload: Any) -> Optional[str]:
"""Walk a parsed ``RunAgentInput``-shaped dict for the last user message.
Iterates ``payload["messages"]`` in chronological order (the AG-UI
contract: the runtime sends the conversation history in order) and
returns the ``content`` of the last entry whose ``role == "user"``.
Return semantics:
* ``None`` — no user message present in the payload at all
(non-dict payload, missing/empty ``messages`` list, no entry
with ``role == "user"``, or every user entry had an
unrecognised content shape).
* ``""`` — a user message IS present but its content is the
empty string (legitimate empty turn from the runtime).
* non-empty ``str`` — the actual latest user text.
Distinguishing ``None`` from ``""`` lets the caller decide whether
to log "missing" vs "present but empty"; collapsing them at this
boundary would force a guess. Schema-drift early-returns log at
WARNING here (rather than via the caller wrapping in try/except)
because no exception is raised — there's nothing for the caller to
catch.
"""
if not isinstance(payload, dict):
logger.warning(
"[ag2:request-context] payload is not a dict (got %s); "
"no user message extractable",
type(payload).__name__,
)
return None
messages = payload.get("messages")
if not isinstance(messages, list):
logger.warning(
"[ag2:request-context] payload.messages missing or not a list "
"(got %s); no user message extractable",
type(messages).__name__,
)
return None
latest: Optional[str] = None
for msg in messages:
if not isinstance(msg, dict):
continue
if msg.get("role") != "user":
continue
content = msg.get("content")
if isinstance(content, str):
# Present-but-empty is a legitimate value; set unconditionally
# so the caller can distinguish "" (empty turn) from None
# (no user message at all).
latest = content
elif isinstance(content, list):
# Multimodal content: join the text parts, mirroring the
# coercion in reasoning_agent._coerce_content. An empty
# parts list collapses to "" — still "present but empty".
parts: list[str] = []
for part in content:
if isinstance(part, dict):
text = part.get("text")
elif hasattr(part, "text"):
text = getattr(part, "text", None)
else:
text = None
if isinstance(text, str):
parts.append(text)
latest = "".join(parts)
# Unknown content shapes (None, int, …) leave ``latest`` untouched
# so a later well-formed user message still wins.
if latest is None:
logger.warning(
"[ag2:request-context] no user message found in payload "
"(messages len=%d); leaving latest-user-message empty",
len(messages),
)
elif latest == "":
logger.warning("[ag2:request-context] latest user message is present but empty")
return latest
class RequestUserMessageMiddleware:
"""Capture the latest user message from each inbound ``RunAgentInput`` POST.
Implemented as a raw ASGI middleware (NOT
``starlette.middleware.base.BaseHTTPMiddleware``) so we can buffer the
inbound request body and replay it to the downstream ASGI app via a
wrapped ``receive`` callable. ``BaseHTTPMiddleware`` does not re-emit
consumed body chunks to the inner app, which would silently truncate
the request to autogen / AG-UI.
For POST requests with a JSON-ish body, parses ``RunAgentInput.messages``
and stores the chronologically last ``role == "user"`` message in a
per-request ContextVar. Non-POST requests and non-HTTP scopes pass
through untouched. Parse failures are logged at WARNING (R2-A2
visibility) and leave the ContextVar at its empty-string default.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
# R5-A2: Unconditionally reset the ContextVar at __call__ entry,
# BEFORE any branching by scope type or method. Autogen's
# ``install_executor_contextvar_propagation`` makes
# ``ThreadPoolExecutor`` workers inherit the dispatching request's
# Context, and those workers are reused across requests. Without
# this reset, a non-POST request, an empty-body POST, or any path
# that doesn't reach the body-parse ``.set(...)`` below would
# inherit whatever value the worker's prior request left in the
# ContextVar — leaking the previous request's prompt into this
# one. The body-parse path further down overrides this default
# when a real user message is parsed.
_latest_user_message.set("")
if scope["type"] != "http" or scope.get("method") != "POST":
await self.app(scope, receive, send)
return
# Buffer the entire request body so we can both inspect it AND
# replay it to the inner ASGI app via a wrapped ``receive``.
body_chunks: list[bytes] = []
more_body = True
client_disconnected = False
while more_body:
message = await receive()
if message["type"] == "http.request":
body_chunks.append(message.get("body", b"") or b"")
more_body = bool(message.get("more_body", False))
elif message["type"] == "http.disconnect":
# Client hung up before the body fully arrived. Do NOT
# invoke the downstream app with a truncated body: that
# would feed autogen / AG-UI half a JSON document and
# surface as a confusing parse error in the agent rather
# than the actual root cause. Short-circuit instead and
# log so the truncation is visible in the operator
# dashboard.
client_disconnected = True
more_body = False
else:
# Unknown message kind for an HTTP scope — pass it
# through unchanged and stop buffering.
more_body = False
raw = b"".join(body_chunks)
if client_disconnected:
logger.warning(
"[ag2:request-context] client disconnected before request "
"body fully received (%d bytes buffered); short-circuiting "
"without invoking downstream app",
len(raw),
)
return
if raw:
# NOTE: ``_extract_latest_user_text`` itself does NOT raise
# on shape violations — it logs at WARNING and returns
# ``None``. The try/except here is strictly for decoding
# failures (``json.loads`` / UTF-8). A previous version
# wrapped a broader ``(AttributeError, KeyError, TypeError)``
# branch around the extractor call, but the extractor never
# raises those — so the branch was dead code that hid the
# real source of any shape-drift signal. The extractor now
# owns its own logging on those paths.
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
logger.warning(
"[ag2:request-context] body is not valid JSON; "
"leaving latest-user-message empty: %s",
exc,
)
_latest_user_message.set("")
except UnicodeDecodeError as exc:
# ``json.loads`` accepts ``bytes`` and decodes them as
# UTF-8 internally; a non-UTF-8 payload (rare but
# possible from a misbehaving client) raises
# ``UnicodeDecodeError`` rather than ``JSONDecodeError``.
# Without this branch the exception escapes and crashes
# the request silently from the operator's perspective.
logger.warning(
"[ag2:request-context] body is not valid UTF-8; "
"leaving latest-user-message empty: %s",
exc,
exc_info=True,
)
_latest_user_message.set("")
else:
text = _extract_latest_user_text(payload)
# Collapse None → "" at the ContextVar boundary: callers
# all fall back to the hardcoded default the same way,
# so the present-but-empty vs missing distinction has
# already done its job via the extractor's WARNING logs.
_latest_user_message.set(text if text is not None else "")
replayed = False
original_receive = receive
async def _replay_receive() -> Message:
nonlocal replayed
if not replayed:
replayed = True
return {
"type": "http.request",
"body": raw,
"more_body": False,
}
# R7-A1: After the buffered body is delivered once, the inner
# app may keep calling ``receive()`` for the lifetime of the
# response — SSE / AG-UI streams in particular poll
# ``receive()`` (via Starlette's ``listen_for_disconnect``) to
# detect client disconnect. Per the ASGI spec, ANY
# ``http.disconnect`` message terminates the response stream:
# an earlier revision synthesised a single disconnect
# immediately after body drain and that one synthesised
# message was enough to cancel the SSE response prematurely.
# The correct behaviour is to NEVER synthesise disconnect
# post-drain and instead await ``original_receive()``, which
# uvicorn blocks on until the REAL client ``http.disconnect``
# arrives. That is precisely the long-poll semantics SSE /
# AG-UI streams require.
message = await original_receive()
# Defensive: uvicorn should not deliver further
# ``http.request`` messages after the body is drained (the
# buffering loop above consumed every chunk until
# ``more_body=False``), but the ASGI spec is not strictly
# enforced by every server. Log and continue awaiting so the
# inner app only ever observes ``http.disconnect`` (or other
# legitimate post-body messages) on this code path.
while message.get("type") == "http.request":
logger.warning(
"[ag2:request-context] unexpected http.request after "
"body drain (more_body=%s, body_len=%d); ignoring and "
"awaiting real disconnect",
message.get("more_body"),
len(message.get("body", b"") or b""),
)
message = await original_receive()
return message
await self.app(scope, _replay_receive, send)
@@ -0,0 +1,188 @@
"""AG2 agent for the Declarative Generative UI (A2UI Dynamic Schema) demo.
Mirrors the langgraph-python `a2ui_dynamic.py` pattern: the agent owns the
`generate_a2ui` tool explicitly. When called, it invokes a secondary LLM
bound to `render_a2ui` (tool_choice forced) using the registered client
catalog injected via the runtime's `copilotkit.context`. The tool result
returns an `a2ui_operations` container which the runtime's A2UI middleware
detects and forwards to the frontend renderer.
The dedicated runtime route (`api/copilotkit-declarative-gen-ui/route.ts`)
sets `injectA2UITool: false` so the runtime does not double-bind a second
A2UI tool on top of this one.
"""
from __future__ import annotations
import json
import logging
from typing import cast
import openai
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream # type: ignore[import-not-found] # runtime-only submodule (ag2[ag-ui] extra); not present in static type stubs
from fastapi import FastAPI
from openai.types.chat import ChatCompletionFunctionToolParam
from openai.types.shared_params import FunctionDefinition
from tools import (
build_a2ui_operations_from_tool_call,
RENDER_A2UI_TOOL_SCHEMA,
)
from ._header_forwarding import get_forwarded_headers
from ._request_context import get_latest_user_message
logger = logging.getLogger(__name__)
# Module-level async client: re-used across requests (httpx connection pool is
# thread-safe). Using AsyncOpenAI inside an `async def` avoids blocking the
# ASGI event loop on the secondary LLM call.
_async_openai_client = openai.AsyncOpenAI()
SYSTEM_PROMPT = (
"You are a demo assistant for Declarative Generative UI (A2UI — Dynamic "
"Schema). Whenever a response would benefit from a rich visual — a "
"dashboard, status report, KPI summary, card layout, info grid, a "
"pie/donut chart of part-of-whole breakdowns, a bar chart comparing "
"values across categories, or anything more structured than plain text — "
"call `generate_a2ui` to draw it. The registered catalog includes "
"`Card`, `StatusBadge`, `Metric`, `InfoRow`, `PrimaryButton`, `PieChart`, "
"and `BarChart` (in addition to the basic A2UI primitives). Prefer "
"`PieChart` for part-of-whole breakdowns (sales by region, traffic "
"sources, portfolio allocation) and `BarChart` for comparisons across "
"categories (quarterly revenue, headcount by team, signups per month). "
"`generate_a2ui` takes no arguments and handles the rendering "
"automatically. Keep chat replies to one short sentence; let the UI do "
"the talking."
)
async def generate_a2ui() -> str:
"""Generate dynamic A2UI components based on the conversation.
Takes NO arguments. The outer agent calls this tool with empty
arguments (``{}``); the per-request user prompt is read from the
``RequestUserMessageMiddleware`` ContextVar (see ``_request_context``)
rather than threaded through a tool parameter. This mirrors the
langgraph-python sibling, whose ``generate_a2ui`` also takes no args
(``a2ui_dynamic.py``), and keeps the tool schema aligned with the D6
fixtures, which emit ``generate_a2ui`` with ``arguments="{}"``. A
required ``context`` parameter here would make pydantic reject every
empty-args call and drive the outer agent into a retry hot loop.
A secondary LLM designs the UI schema and data using the `render_a2ui`
tool schema. The result is returned as an `a2ui_operations` container
for the runtime A2UI middleware to detect and forward to the frontend.
"""
# A4 / R2-A3: thread the latest user prompt from the outer conversation
# into the inner call so each pill's request body is byte-distinct
# (without this, all 4 declarative pills produce IDENTICAL wire payloads
# because the outer agent calls generate_a2ui with arguments="{}" →
# context defaults → system message is constant, and the user message
# below is hardcoded).
#
# The prompt is read from a per-request ContextVar populated by
# ``RequestUserMessageMiddleware`` at the inbound HTTP boundary — NOT
# from ``agent.chat_messages`` (which is shared module-level mutable
# state racing across concurrent requests). If the middleware did not
# capture anything (non-AG-UI request, parse failure already logged at
# WARNING) we fall back to the original hardcoded prompt so the inner
# LLM call still produces a sensible default.
user_prompt = get_latest_user_message() or (
"Generate a dynamic A2UI dashboard based on the conversation."
)
# The inner-call system message is constant; per-pill distinctness comes
# from ``user_prompt`` above (the outer conversation's latest user
# message, captured per-request). Previously this was the outer agent's
# ``context`` tool argument, but the outer agent calls ``generate_a2ui``
# with empty args ``{}`` (see the no-arg signature + the D6 fixtures),
# so a required ``context`` param only produced a pydantic hot loop.
inner_system_prompt = "Generate a useful dashboard UI."
# A13: forward inbound x-* headers via extra_headers as a defense in depth
# alongside the global httpx hook (see _header_forwarding.py). The hook
# patches httpx at module load, but extra_headers makes the intent
# explicit at the call site and is robust to alternative HTTP transports.
forwarded = get_forwarded_headers()
try:
response = await _async_openai_client.chat.completions.create(
model="gpt-4.1",
messages=[
{
"role": "system",
"content": inner_system_prompt,
},
{"role": "user", "content": user_prompt},
],
tools=[
ChatCompletionFunctionToolParam(
type="function",
# RENDER_A2UI_TOOL_SCHEMA is an untyped dict literal that
# conforms to the OpenAI FunctionDefinition TypedDict shape;
# cast so the type checker accepts it (no runtime change).
function=cast(FunctionDefinition, RENDER_A2UI_TOOL_SCHEMA),
)
],
tool_choice={"type": "function", "function": {"name": "render_a2ui"}},
extra_headers=forwarded or None,
)
except Exception as exc:
logger.error(
"generate_a2ui: inner LLM call failed type=%s err=%s",
type(exc).__name__,
exc,
exc_info=True,
)
return json.dumps({"error": f"inner LLM call failed: {type(exc).__name__}"})
if not response.choices:
logger.warning("generate_a2ui: LLM returned no choices")
return json.dumps({"error": "LLM returned no choices"})
choice = response.choices[0]
if not choice.message.tool_calls:
logger.warning("generate_a2ui: secondary LLM produced no render_a2ui tool call")
return json.dumps({"error": "LLM did not call render_a2ui"})
# tool_calls is a union of function- and custom-tool calls; only the
# function variant carries `.function`. `tool_choice` above forces the
# `render_a2ui` FUNCTION tool, so the first call is always the function
# variant at runtime — narrow on `.type` to make that explicit to the type
# checker (and degrade gracefully to the same error shape if it ever isn't).
first_call = choice.message.tool_calls[0]
if first_call.type != "function":
logger.warning(
"generate_a2ui: secondary LLM returned non-function tool call type=%s",
first_call.type,
)
return json.dumps({"error": "LLM did not call render_a2ui"})
try:
args = json.loads(first_call.function.arguments)
result = build_a2ui_operations_from_tool_call(args)
return json.dumps(result)
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
logger.error(
"generate_a2ui: failed to parse render_a2ui args type=%s err=%s",
type(exc).__name__,
exc,
exc_info=True,
)
return json.dumps(
{"error": f"failed to parse render_a2ui args: {type(exc).__name__}"}
)
agent = ConversableAgent(
name="declarative_gen_ui_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=8,
functions=[generate_a2ui],
)
stream = AGUIStream(agent)
a2ui_dynamic_app = FastAPI()
a2ui_dynamic_app.mount("", stream.build_asgi())
@@ -0,0 +1,112 @@
"""AG2 agent for the Declarative Generative UI (A2UI Fixed Schema) demo.
Fixed-schema A2UI: the component tree (schema) is authored ahead of time
as JSON and shipped with the backend. The agent only streams *data* into
the data model at runtime via the `display_flight` tool. The frontend
registers a matching catalog (see
`src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts`).
Mirrors the langgraph-python `a2ui_fixed.py` reference. The dedicated
runtime route at `api/copilotkit-a2ui-fixed-schema/route.ts` runs the
A2UI middleware with `injectA2UITool: false` because the backend owns
the rendering tool itself.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Annotated
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
CATALOG_ID = "copilotkit://flight-fixed-catalog"
SURFACE_ID = "flight-fixed-schema"
_SCHEMAS_DIR = Path(__file__).parent / "a2ui_schemas"
def _load_schema(filename: str) -> list[dict]:
"""Load an A2UI fixed schema from the local schemas directory."""
with open(_SCHEMAS_DIR / filename, "r", encoding="utf-8") as fh:
return json.load(fh)
FLIGHT_SCHEMA = _load_schema("flight_schema.json")
async def display_flight(
origin: Annotated[str, "Origin airport code, e.g. 'SFO'"],
destination: Annotated[str, "Destination airport code, e.g. 'JFK'"],
airline: Annotated[str, "Airline name, e.g. 'United'"],
price: Annotated[str, "Price string, e.g. '$289'"],
) -> str:
"""Show a flight card for the given trip.
Emits an `a2ui_operations` container the runtime A2UI middleware
detects in tool results and forwards to the frontend renderer. The
frontend catalog resolves component names against the local React
components.
"""
# A2UI v0.9 NESTED operation format (createSurface/updateComponents/
# updateDataModel keys) — the runtime A2UI middleware's
# getOperationSurfaceId and the frontend renderer only understand this
# shape (mirrors copilotkit.a2ui helpers in sdk-python/copilotkit/a2ui.py).
# The previous flat {"type": "create_surface", ...} form parsed as a
# container but produced ops the renderer could not apply, so the
# a2ui-fixed-card never mounted.
operations = [
{
"version": "v0.9",
"createSurface": {
"surfaceId": SURFACE_ID,
"catalogId": CATALOG_ID,
},
},
{
"version": "v0.9",
"updateComponents": {
"surfaceId": SURFACE_ID,
"components": FLIGHT_SCHEMA,
},
},
{
"version": "v0.9",
"updateDataModel": {
"surfaceId": SURFACE_ID,
"path": "/",
"value": {
"origin": origin,
"destination": destination,
"airline": airline,
"price": price,
},
},
},
]
return json.dumps({"a2ui_operations": operations})
SYSTEM_PROMPT = (
"You help users find flights. When asked about a flight, call "
"display_flight with origin (3-letter code), destination (3-letter "
"code), airline, and price (e.g. '$289'). Keep any chat reply to one "
"short sentence."
)
agent = ConversableAgent(
name="a2ui_fixed_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=4,
functions=[display_flight],
)
stream = AGUIStream(agent)
a2ui_fixed_app = FastAPI()
a2ui_fixed_app.mount("", stream.build_asgi())
@@ -0,0 +1,20 @@
[
{
"id": "root",
"component": "Column",
"gap": 8,
"children": ["title", "detail"]
},
{
"id": "title",
"component": "Text",
"text": { "path": "/title" },
"variant": "h2"
},
{
"id": "detail",
"component": "Text",
"text": { "path": "/detail" },
"variant": "body"
}
]
@@ -0,0 +1,77 @@
[
{
"id": "root",
"component": "Card",
"child": "content"
},
{
"id": "content",
"component": "Column",
"children": ["title", "route", "meta", "bookButton"]
},
{
"id": "title",
"component": "Title",
"text": "Flight Details"
},
{
"id": "route",
"component": "Row",
"justify": "spaceBetween",
"align": "center",
"children": ["from", "arrow", "to"]
},
{
"id": "from",
"component": "Airport",
"code": { "path": "/origin" }
},
{
"id": "arrow",
"component": "Arrow"
},
{
"id": "to",
"component": "Airport",
"code": { "path": "/destination" }
},
{
"id": "meta",
"component": "Row",
"justify": "spaceBetween",
"align": "center",
"children": ["airline", "price"]
},
{
"id": "airline",
"component": "AirlineBadge",
"name": { "path": "/airline" }
},
{
"id": "price",
"component": "PriceTag",
"amount": { "path": "/price" }
},
{
"id": "bookButton",
"component": "Button",
"variant": "primary",
"child": "bookButtonLabel",
"action": {
"event": {
"name": "book_flight",
"context": {
"origin": { "path": "/origin" },
"destination": { "path": "/destination" },
"airline": { "path": "/airline" },
"price": { "path": "/price" }
}
}
}
},
{
"id": "bookButtonLabel",
"component": "Text",
"text": "Book flight"
}
]
@@ -0,0 +1,257 @@
"""
AG2 agent with weather and sales tools for CopilotKit showcase.
Uses AG2's ConversableAgent with AGUIStream to expose
the agent via the AG-UI protocol.
"""
# @region[weather-tool-backend]
from __future__ import annotations
import json
import logging
from typing import Annotated, Any
import openai
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from dotenv import load_dotenv
from pydantic import ValidationError
load_dotenv()
# Import shared tool implementations
from tools import (
get_weather_impl,
query_data_impl,
manage_sales_todos_impl,
get_sales_todos_impl,
schedule_meeting_impl,
search_flights_impl,
build_a2ui_operations_from_tool_call,
RENDER_A2UI_TOOL_SCHEMA,
)
from tools.types import Flight
from ._header_forwarding import get_forwarded_headers
from ._request_context import get_latest_user_message
logger = logging.getLogger(__name__)
# Module-level async client: re-used across requests (httpx connection pool is
# thread-safe). Using AsyncOpenAI inside an `async def` avoids blocking the
# ASGI event loop on the secondary LLM call.
_async_openai_client = openai.AsyncOpenAI()
# =====
# Tools
# =====
async def get_weather(
location: Annotated[str, "City name to get weather for"],
) -> str:
"""Get current weather for a location."""
result = get_weather_impl(location)
# Return a JSON string (not a dict): autogen serializes dict returns with
# str(), producing a Python repr (single quotes) that the frontend's
# parseJsonResult/JSON.parse cannot parse — the weather card then renders
# "--" placeholders. Same pattern as search_flights below.
return json.dumps(
{
"city": result["city"],
"temperature": result["temperature"],
"feels_like": result["feels_like"],
"humidity": result["humidity"],
"wind_speed": result["wind_speed"],
"conditions": result["conditions"],
}
)
# @endregion[weather-tool-backend]
async def query_data(
query: Annotated[str, "Natural language query for financial data"],
) -> str:
"""Query financial database for chart data."""
# Return a JSON string (not a list): autogen serializes non-str returns
# with str(), producing a Python repr (single quotes) that the frontend's
# parseJsonResult/JSON.parse cannot parse. Same pattern as get_weather.
return json.dumps(query_data_impl(query))
async def manage_sales_todos(
todos: Annotated[list, "Complete list of sales todos"],
) -> str:
"""Manage the sales pipeline."""
# See contract comment on query_data above — return JSON, not dict.
# SalesTodo is a Pydantic model; coerce via model_dump for serialisability.
result = [t.model_dump() for t in manage_sales_todos_impl(todos)]
return json.dumps({"todos": result})
async def get_sales_todos() -> str:
"""Get the current sales pipeline."""
# See contract comment on query_data above — return JSON, not list.
# SalesTodo is a Pydantic model; coerce via model_dump for serialisability.
return json.dumps([t.model_dump() for t in get_sales_todos_impl(None)])
async def schedule_meeting(
reason: Annotated[str, "Reason for the meeting"],
) -> str:
"""Schedule a meeting with user approval."""
# See contract comment on query_data above — return JSON, not dict.
return json.dumps(schedule_meeting_impl(reason))
async def search_flights(
flights: Annotated[
list[dict[str, Any]], "List of flight objects to display as rich A2UI cards"
],
) -> str:
"""Search for flights and display the results as rich cards. Return exactly 2 flights.
Each flight must have: airline, airlineLogo, flightNumber, origin, destination,
date (short readable format like "Tue, Mar 18" -- use near-future dates),
departureTime, arrivalTime, duration (e.g. "4h 25m"),
status (e.g. "On Time" or "Delayed"),
statusColor (hex color for status dot),
price (e.g. "$289"), and currency (e.g. "USD").
For airlineLogo use Google favicon API:
https://www.google.com/s2/favicons?domain={airline_domain}&sz=128
"""
try:
typed_flights: list[Flight] = [Flight(**f) for f in flights]
except ValidationError as exc:
logger.warning(
"search_flights: invalid flight shape type=%s err=%s",
type(exc).__name__,
exc,
exc_info=True,
)
return json.dumps({"error": f"invalid flight shape: {exc}"})
result = search_flights_impl(typed_flights)
return json.dumps(result)
async def generate_a2ui(
context: Annotated[str, "Conversation context to generate UI for"],
) -> str:
"""Generate dynamic A2UI components based on the conversation.
A secondary LLM designs the UI schema and data. The result is
returned as an a2ui_operations container for the middleware to detect.
"""
# A13: AsyncOpenAI inside async def (was sync openai.OpenAI which blocks
# the ASGI event loop). Forward x-* headers via extra_headers in addition
# to the global httpx hook so aimock context routing is explicit at the
# call site.
#
# R2-A1 / A4: thread the latest user prompt from the inbound
# RunAgentInput.messages payload (captured into a per-request ContextVar
# by RequestUserMessageMiddleware — see agents/_request_context.py) into
# the inner LLM call so each pill's request body is byte-distinct.
# Without this, every pill landing on the omnibus agent (agentic-chat /
# tool-rendering / chat-customization-css / hitl) produces an IDENTICAL
# inner-LLM body and the aimock fixture cannot disambiguate. Falls back
# to the original hardcoded prompt when the middleware captured nothing
# (parse failure already logged at WARNING).
user_prompt = get_latest_user_message() or (
"Generate a dynamic A2UI dashboard based on the conversation."
)
forwarded = get_forwarded_headers()
try:
response = await _async_openai_client.chat.completions.create(
model="gpt-4.1",
messages=[
{
"role": "system",
"content": context or "Generate a useful dashboard UI.",
},
{
"role": "user",
"content": user_prompt,
},
],
tools=[
{
"type": "function",
"function": RENDER_A2UI_TOOL_SCHEMA,
}
],
tool_choice={"type": "function", "function": {"name": "render_a2ui"}},
extra_headers=forwarded or None,
)
except Exception as exc:
logger.error(
"generate_a2ui: inner LLM call failed type=%s err=%s",
type(exc).__name__,
exc,
exc_info=True,
)
return json.dumps({"error": f"inner LLM call failed: {type(exc).__name__}"})
if not response.choices:
logger.warning("generate_a2ui: LLM returned no choices")
return json.dumps({"error": "LLM returned no choices"})
choice = response.choices[0]
if not choice.message.tool_calls:
logger.warning("generate_a2ui: secondary LLM produced no render_a2ui tool call")
return json.dumps({"error": "LLM did not call render_a2ui"})
try:
args = json.loads(choice.message.tool_calls[0].function.arguments)
result = build_a2ui_operations_from_tool_call(args)
return json.dumps(result)
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
logger.error(
"generate_a2ui: failed to parse render_a2ui args type=%s err=%s",
type(exc).__name__,
exc,
exc_info=True,
)
return json.dumps(
{"error": f"failed to parse render_a2ui args: {type(exc).__name__}"}
)
# =====
# Agent
# =====
agent = ConversableAgent(
name="assistant",
system_message=(
"You are a helpful sales assistant. You can look up current weather "
"for any city using the get_weather tool, query financial data with "
"query_data, manage the sales pipeline with manage_sales_todos and "
"get_sales_todos, schedule meetings with schedule_meeting, search "
"flights and display rich A2UI cards with search_flights, and "
"generate dynamic A2UI dashboards with generate_a2ui. "
"When asked about the weather, always use the tool rather than guessing. "
"Be concise and friendly in your responses."
),
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
# Guard against infinite tool-call loops: AG2's ConversableAgent with
# human_input_mode="NEVER" will keep executing tool calls indefinitely
# if the LLM keeps requesting them. Without this limit the agent floods
# Railway's log stream (500 logs/sec rate-limit), becomes unresponsive
# to health probes, and gets killed by the watchdog.
max_consecutive_auto_reply=15,
functions=[
get_weather,
query_data,
manage_sales_todos,
get_sales_todos,
schedule_meeting,
search_flights,
generate_a2ui,
],
)
# AG-UI stream wrapper
stream = AGUIStream(agent)
@@ -0,0 +1,111 @@
"""AG2 agent backing the Agent Config Object demo.
Reads three forwarded properties — tone, expertise, responseLength — from
shared state (ContextVariables on each run) and adapts its responses
accordingly.
Wire format
-----------
The frontend uses `agent.setState({ tone, expertise, responseLength })` from
the demo page. AG2's AGUIStream maps that initial state into ContextVariables
on every run. The agent has a `get_current_config` tool that returns the
current rulebook for the assistant to consult before answering.
The system prompt instructs the agent to call `get_current_config` once at
the start of every conversation turn so the response style adapts to the
latest UI selection.
References:
- src/agents/shared_state_read_write.py — same ContextVariables pattern.
"""
import logging
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from autogen.agentchat import ContextVariables
from autogen.tools import tool
from fastapi import FastAPI
logger = logging.getLogger(__name__)
VALID_TONES = {"professional", "casual", "enthusiastic"}
VALID_EXPERTISE = {"beginner", "intermediate", "expert"}
VALID_RESPONSE_LENGTHS = {"concise", "detailed"}
DEFAULT_TONE = "professional"
DEFAULT_EXPERTISE = "intermediate"
DEFAULT_RESPONSE_LENGTH = "concise"
TONE_RULES = {
"professional": "Use neutral, precise language. No emoji. Short sentences.",
"casual": (
"Use friendly, conversational language. Contractions OK. Light humor welcome."
),
"enthusiastic": (
"Use upbeat, energetic language. Exclamation points OK. Emoji OK."
),
}
EXPERTISE_RULES = {
"beginner": "Assume no prior knowledge. Define jargon. Use analogies.",
"intermediate": ("Assume common terms are understood; explain specialized terms."),
"expert": ("Assume technical fluency. Use precise terminology. Skip basics."),
}
LENGTH_RULES = {
"concise": "Respond in 1-3 sentences.",
"detailed": ("Respond in multiple paragraphs with examples where relevant."),
}
SYSTEM_PROMPT = (
"You are a helpful assistant whose response style is governed by a UI-"
"supplied configuration object. Before answering ANY user question, "
"call the `get_current_config` tool exactly once to read the latest "
"tone / expertise / response-length rulebook. Then answer the user's "
"question, strictly following those rules. Never mention the tool call "
"or the configuration in your reply — just adapt your style."
)
@tool()
def get_current_config(context_variables: ContextVariables) -> str:
"""Return the current rulebook (tone / expertise / length) for the assistant.
Reads the forwarded ``tone``, ``expertise``, and ``responseLength``
properties from shared state, falling back to defaults for any missing
or unrecognized value.
"""
data = context_variables.data or {}
tone = data.get("tone", DEFAULT_TONE)
expertise = data.get("expertise", DEFAULT_EXPERTISE)
response_length = data.get("responseLength", DEFAULT_RESPONSE_LENGTH)
if tone not in VALID_TONES:
tone = DEFAULT_TONE
if expertise not in VALID_EXPERTISE:
expertise = DEFAULT_EXPERTISE
if response_length not in VALID_RESPONSE_LENGTHS:
response_length = DEFAULT_RESPONSE_LENGTH
return (
f"Tone ({tone}): {TONE_RULES[tone]}\n"
f"Expertise ({expertise}): {EXPERTISE_RULES[expertise]}\n"
f"Response length ({response_length}): {LENGTH_RULES[response_length]}"
)
agent_config_agent = ConversableAgent(
name="agent_config_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=5,
functions=[get_current_config],
)
agent_config_stream = AGUIStream(agent_config_agent)
agent_config_app = FastAPI()
agent_config_app.mount("/", agent_config_stream.build_asgi())
@@ -0,0 +1,142 @@
"""AG2 agent for the simplified Beautiful Chat demo.
This is a SIMPLIFIED port of the langgraph-python `beautiful_chat` graph.
The canonical version simultaneously exercises three big features:
1. A2UI Dynamic Schema (a `generate_a2ui` tool whose secondary LLM emits
schema-validated component compositions).
2. Open Generative UI (the runtime auto-registers `generateSandboxedUi`
on the frontend; the agent calls it for richer free-form widgets).
3. MCP Apps (an mcpApps server is mounted on the runtime; its tools and
UI resources are surfaced to the agent).
For AG2 we ship the FIRST TWO surfaces in a single cell: A2UI dynamic
generation for branded, component-bound visuals (KPIs, dashboards, status
reports, simple charts) AND Open Generative UI for free-form / educational
visualisations the catalog cannot express. We deliberately leave MCP out
to keep the AG2 port focused — `/demos/mcp-apps` already covers MCP on
its own.
The agent owns `generate_a2ui` explicitly (mirroring `a2ui_dynamic.py`).
The runtime route at `src/app/api/copilotkit-beautiful-chat/route.ts`
sets `a2ui.injectA2UITool: false` so the runtime doesn't double-bind a
second A2UI tool, and turns on `openGenerativeUI` for this agent so the
runtime injects `generateSandboxedUi` on the frontend.
"""
from __future__ import annotations
import json
import os
from typing import Annotated
import openai
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
from tools import (
build_a2ui_operations_from_tool_call,
RENDER_A2UI_TOOL_SCHEMA,
)
SYSTEM_PROMPT = """You are the Beautiful Chat assistant — a CopilotKit
showcase agent that answers user questions with rich, branded visuals.
You have TWO complementary visual surfaces. Pick whichever fits the
request best, but ALWAYS render something visual rather than replying
with plain text when the question warrants it.
1. `generate_a2ui` — for STRUCTURED, branded visuals composed from a
registered React catalog. Use it for:
- KPI dashboards (Metric + Card + Row/Column layouts)
- Status reports (StatusBadge / Card)
- Pie charts of part-of-whole breakdowns (PieChart)
- Bar charts comparing categories (BarChart)
- Info panels and quick summaries
Pass a single `context` argument summarising the conversation; the
secondary LLM will design the composition against the registered
catalog (Card, StatusBadge, Metric, InfoRow, PrimaryButton,
PieChart, BarChart, plus the basic A2UI primitives).
2. `generateSandboxedUi` — auto-registered by the frontend when Open
Generative UI is enabled. Use it for FREE-FORM visualisations the
catalog cannot express:
- Educational visualisations (algorithm walkthroughs, neural-net
activations, geometric proofs, physics simulations)
- Custom illustrations / diagrams
- Anything intricate that needs inline SVG, CSS animation, or an
interactive sandboxed widget
Output `initialHeight` (typically 480-560), a short
`placeholderMessages` array, complete `css`, then `html` with inline
SVG. No fetch / XHR / localStorage.
Decision rule of thumb: if the request maps to a chart, dashboard,
status report, or KPI summary, prefer `generate_a2ui`. If it asks for a
diagram, animation, or anything outside the catalog's components,
prefer `generateSandboxedUi`. Either way, keep the chat reply to one
short sentence — let the visual do the talking.
"""
async def generate_a2ui(
context: Annotated[
str, "Conversation context summary the secondary LLM should design UI from"
],
) -> str:
"""Generate dynamic A2UI components based on the conversation.
Mirrors `a2ui_dynamic.py`: a secondary LLM is bound to the
`render_a2ui` tool with `tool_choice` forced, and the resulting
arguments are wrapped into an `a2ui_operations` container the
runtime A2UI middleware detects and forwards to the frontend.
"""
client = openai.OpenAI()
response = client.chat.completions.create(
model="gpt-4.1",
messages=[
{
"role": "system",
"content": context or "Generate a useful dashboard UI.",
},
{
"role": "user",
"content": "Generate a dynamic A2UI dashboard based on the conversation.",
},
],
tools=[
{
"type": "function",
"function": RENDER_A2UI_TOOL_SCHEMA,
}
],
tool_choice={"type": "function", "function": {"name": "render_a2ui"}},
)
choice = response.choices[0]
if choice.message.tool_calls:
args = json.loads(choice.message.tool_calls[0].function.arguments)
result = build_a2ui_operations_from_tool_call(args)
return json.dumps(result)
return json.dumps({"error": "LLM did not call render_a2ui"})
agent = ConversableAgent(
name="beautiful_chat_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4.1", "stream": True}),
human_input_mode="NEVER",
# The agent may call generate_a2ui (its own backend tool) and
# generateSandboxedUi (frontend tool injected by the OGUI runtime
# middleware). Cap the loop to keep tool storms bounded.
max_consecutive_auto_reply=8,
functions=[generate_a2ui],
)
stream = AGUIStream(agent)
beautiful_chat_app = FastAPI()
beautiful_chat_app.mount("", stream.build_asgi())
@@ -0,0 +1,102 @@
"""AG2 agent backing the byoc-hashbrown demo.
Emits hashbrown-shaped structured output that the ported HashBrownDashboard
renderer (`src/app/demos/byoc-hashbrown/hashbrown-renderer.tsx`) progressively
parses via `@hashbrownai/react`'s `useJsonParser` + `useUiKit`.
Wire format
-----------
A single JSON object literal of the form:
{
"ui": [
{ "metric": { "props": { "label": "...", "value": "..." } } },
{ "pieChart": { "props": { "title": "...", "data": "[{...}]" } } },
{ "barChart": { "props": { "title": "...", "data": "[{...}]" } } },
{ "dealCard": { "props": { "title": "...", "stage": "prospect", "value": 100000 } } },
{ "Markdown": { "props": { "children": "## heading\\nbody" } } }
]
}
Every node is a single-key object `{tagName: {props: {...}}}`. `pieChart` and
`barChart` receive `data` as a JSON-encoded string (kept stable under partial
streaming).
"""
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
BYOC_HASHBROWN_SYSTEM_PROMPT = """\
You are a sales analytics assistant that replies by emitting a single JSON
object consumed by a streaming JSON parser on the frontend.
ALWAYS respond with a single JSON object of the form:
{
"ui": [
{ <componentName>: { "props": { ... } } },
...
]
}
Do NOT wrap the response in code fences. Do NOT include any preface or
explanation outside the JSON object. The response MUST be valid JSON.
Available components and their prop schemas:
- "metric": { "props": { "label": string, "value": string } }
A KPI card. `value` is a pre-formatted string like "$1.2M" or "248".
- "pieChart": { "props": { "title": string, "data": string } }
A donut chart. `data` is a JSON-encoded STRING (embedded JSON) of an
array of {label, value} objects with at least 3 segments, e.g.
"data": "[{\\"label\\":\\"Enterprise\\",\\"value\\":600000}]".
- "barChart": { "props": { "title": string, "data": string } }
A vertical bar chart. `data` is a JSON-encoded STRING of an array of
{label, value} objects with at least 3 bars, typically time-ordered.
- "dealCard": { "props": { "title": string, "stage": string, "value": number } }
A single sales deal. `stage` MUST be one of: "prospect", "qualified",
"proposal", "negotiation", "closed-won", "closed-lost". `value` is a
raw number (no currency symbol or comma).
- "Markdown": { "props": { "children": string } }
Short explanatory text. Use for section headings and brief summaries.
Standard markdown is supported in `children`.
Rules:
- Always produce plausible sample data when the user asks for a dashboard or
chart — do not refuse for lack of data.
- Prefer 3-6 rows of data in charts; keep labels short.
- Use "Markdown" for short headings or linking sentences between visual
components. Do not emit long prose.
- Do not emit components that are not listed above.
- `data` props on charts MUST be a JSON STRING — escape inner quotes.
Example response (sales dashboard):
{"ui":[{"Markdown":{"props":{"children":"## Q4 Sales Summary"}}},{"metric":{"props":{"label":"Total Revenue","value":"$1.2M"}}},{"metric":{"props":{"label":"New Customers","value":"248"}}},{"pieChart":{"props":{"title":"Revenue by Segment","data":"[{\\"label\\":\\"Enterprise\\",\\"value\\":600000},{\\"label\\":\\"SMB\\",\\"value\\":400000},{\\"label\\":\\"Startup\\",\\"value\\":200000}]"}}},{"barChart":{"props":{"title":"Monthly Revenue","data":"[{\\"label\\":\\"Oct\\",\\"value\\":350000},{\\"label\\":\\"Nov\\",\\"value\\":400000},{\\"label\\":\\"Dec\\",\\"value\\":450000}]"}}}]}
"""
byoc_hashbrown_agent = ConversableAgent(
name="byoc_hashbrown_assistant",
system_message=BYOC_HASHBROWN_SYSTEM_PROMPT,
llm_config=LLMConfig(
{
"model": "gpt-4o-mini",
"stream": True,
"response_format": {"type": "json_object"},
}
),
human_input_mode="NEVER",
max_consecutive_auto_reply=3,
functions=[],
)
byoc_hashbrown_stream = AGUIStream(byoc_hashbrown_agent)
byoc_hashbrown_app = FastAPI()
byoc_hashbrown_app.mount("/", byoc_hashbrown_stream.build_asgi())
@@ -0,0 +1,118 @@
"""AG2 agent backing the BYOC json-render demo.
Emits a single JSON object shaped like `@json-render/react`'s flat spec
format (`{ root, elements }`) so the frontend can feed it directly into
`<Renderer />` against a Zod-validated catalog of three components —
MetricCard, BarChart, PieChart.
"""
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
SYSTEM_PROMPT = """
You are a sales-dashboard UI generator for a BYOC json-render demo.
When the user asks for a UI, respond with **exactly one JSON object** and
nothing else — no prose, no markdown fences, no leading explanation. The
object must match this schema (the "flat element map" format consumed by
`@json-render/react`):
{
"root": "<id of the root element>",
"elements": {
"<id>": {
"type": "<component name>",
"props": { ... component-specific props ... },
"children": [ "<id>", ... ]
},
...
}
}
Available components (use each name verbatim as "type"):
- MetricCard
props: { "label": string, "value": string, "trend": string | null }
Example trend strings: "+12% vs last quarter", "-3% vs last month", null.
- BarChart
props: {
"title": string,
"description": string | null,
"data": [ { "label": string, "value": number }, ... ]
}
- PieChart
props: {
"title": string,
"description": string | null,
"data": [ { "label": string, "value": number }, ... ]
}
Rules:
1. Output **only** valid JSON. No markdown code fences. No text outside the object.
2. Every id referenced in `root` or any `children` array must be a key in `elements`.
3. For a multi-component dashboard, use a root MetricCard and list the charts in
its `children` array, OR pick any element as root and list the others as its
children. Do not emit orphan elements.
4. Use realistic sales-domain values (revenue, pipeline, conversion, categories,
months) — the demo is a sales dashboard.
5. `children` is optional but when present must be an array of strings.
6. Never invent component types outside the three listed above.
### Worked example — "Show me the sales dashboard with metrics and a revenue chart"
{
"root": "revenue-metric",
"elements": {
"revenue-metric": {
"type": "MetricCard",
"props": {
"label": "Revenue (Q3)",
"value": "$1.24M",
"trend": "+18% vs Q2"
},
"children": ["revenue-bar"]
},
"revenue-bar": {
"type": "BarChart",
"props": {
"title": "Monthly revenue",
"description": "Revenue by month across Q3",
"data": [
{ "label": "Jul", "value": 380000 },
{ "label": "Aug", "value": 410000 },
{ "label": "Sep", "value": 450000 }
]
}
}
}
}
Respond with the JSON object only.
"""
byoc_json_render_agent = ConversableAgent(
name="byoc_json_render_assistant",
system_message=SYSTEM_PROMPT.strip(),
llm_config=LLMConfig(
{
"model": "gpt-4o-mini",
"stream": True,
"temperature": 0.2,
"response_format": {"type": "json_object"},
}
),
human_input_mode="NEVER",
max_consecutive_auto_reply=3,
functions=[],
)
byoc_json_render_stream = AGUIStream(byoc_json_render_agent)
byoc_json_render_app = FastAPI()
byoc_json_render_app.mount("/", byoc_json_render_stream.build_asgi())
@@ -0,0 +1,126 @@
"""gen-ui-agent — minimal AG2 agent with explicit `steps` state schema.
Mirrors `langgraph-python/src/agents/gen_ui_agent.py` and
`ms-agent-python/src/agents/gen_ui_agent.py`. The frontend
(`src/app/demos/gen-ui-agent/page.tsx`) subscribes to
`agent.state.steps` via `useAgent` and renders a live progress card; the
backend's job is to plan exactly 3 steps and walk each
pending -> in_progress -> completed by calling the `set_steps` tool.
Every call to `set_steps` returns a `ReplyResult` whose
`context_variables` carry the updated `steps` array, which AG2's
`AGUIStream` surfaces back to the UI as a state snapshot so the
progress card re-renders in-place after every transition.
State shape (mirrors LGP `GenUiAgentState.steps`):
[
{"id": "...", "title": "...", "status": "pending" | "in_progress" | "completed"},
...
]
AG2 specifics:
- Uses `ContextVariables` + `ReplyResult` (same mechanism as
`shared_state_read_write.py`) to publish state. AG2's AG-UI adapter
emits a STATE_SNAPSHOT event after every `ReplyResult` so the
frontend sees the full `steps` list on each `set_steps` call.
- Mounts a dedicated FastAPI sub-app so this demo gets its own
ContextVariables slot, isolated from the shared default agent.
"""
import logging
from textwrap import dedent
from typing import Annotated, List
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from autogen.agentchat import ContextVariables, ReplyResult
from autogen.tools import tool
from fastapi import FastAPI
logger = logging.getLogger(__name__)
@tool()
async def set_steps(
context_variables: ContextVariables,
steps: Annotated[
List[dict],
(
"The complete source of truth for the plan: every step "
"with `id`, `title`, and `status` ('pending' | "
"'in_progress' | 'completed'). Always include the FULL "
"list on every call, never a diff."
),
],
) -> ReplyResult:
"""Publish the current plan and step statuses.
Call this every time a step transitions (including the first
enumeration of steps). Always include the full list of steps on
each call.
"""
# Normalize: keep only the fields the UI consumes, in case the LLM
# tacked on extras. Tolerant of missing fields so the agent doesn't
# hard-fail mid-run.
cleaned: list[dict] = []
for step in steps or []:
if not isinstance(step, dict):
continue
cleaned.append(
{
"id": str(step.get("id", "")),
"title": str(step.get("title", step.get("description", ""))),
"status": str(step.get("status", "pending")),
}
)
context_variables.update({"steps": cleaned})
return ReplyResult(
message=f"Published {len(cleaned)} step(s).",
context_variables=context_variables,
)
SYSTEM_PROMPT = dedent(
"""
You are an agentic planner. For each user request, follow this exact
sequence:
1. Plan exactly 3 concrete steps and call `set_steps` ONCE with all
three steps at status="pending".
2. Step 1: call `set_steps` with step 1 at status="in_progress",
then call `set_steps` again with step 1 at status="completed".
3. Step 2: call `set_steps` with step 2 at status="in_progress",
then call `set_steps` again with step 2 at status="completed".
4. Step 3: call `set_steps` with step 3 at status="in_progress",
then call `set_steps` again with step 3 at status="completed".
5. Send ONE final conversational assistant message summarizing the
plan, then stop. Do not call any more tools after step 3 is
completed.
Rules:
- Never call set_steps in parallel — always wait for one call to
return before the next.
- Always pass the COMPLETE list of steps on every call (existing +
updated), never a diff.
- Each step needs `id` (stable string id like "step-1"), `title`
(short human-readable description), and `status`
('pending' | 'in_progress' | 'completed').
- After all three steps are completed you MUST send a final
assistant message and terminate.
"""
).strip()
agent = ConversableAgent(
name="gen_ui_agent",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
# Nominal cost is ~7 set_steps cycles + 1 final model turn.
# 15 gives ~2x headroom for retries inside the LLM loop while still
# bounding pathological runaway behavior (Railway log-rate limits).
max_consecutive_auto_reply=15,
functions=[set_steps],
)
stream = AGUIStream(agent)
gen_ui_agent_app = FastAPI()
gen_ui_agent_app.mount("", stream.build_asgi())
@@ -0,0 +1,88 @@
"""AG2 agent backing the Headless Chat (Complete) demo.
The cell exists to prove that every CopilotKit rendering surface works
when the chat UI is composed manually (no <CopilotChatMessageView /> or
<CopilotChatAssistantMessage />). To exercise those surfaces we give
this agent two mock backend tools (``get_weather``, ``get_stock_price``)
which the frontend renders via app-registered ``useRenderTool``
renderers, plus a frontend-registered ``useComponent`` tool
(``highlight_note``) that the agent can invoke -- the UI flows through
the same ``useRenderToolCall`` path.
The system prompt nudges the model toward the right surface per user
question and falls back to plain text otherwise.
"""
from __future__ import annotations
from typing import Annotated
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
SYSTEM_PROMPT = (
"You are a helpful, concise assistant wired into a headless chat "
"surface that demonstrates CopilotKit's full rendering stack. Pick "
"the right surface for each user question and fall back to plain "
"text when none of the tools fit.\n\n"
"Routing rules:\n"
" - If the user asks about weather for a place, call `get_weather` "
"with the location.\n"
" - If the user asks about a stock or ticker (AAPL, TSLA, MSFT, "
"...), call `get_stock_price` with the ticker.\n"
" - If the user asks you to highlight, flag, or mark a short note "
"or phrase, call the frontend `highlight_note` tool with the text "
"and a color (yellow, pink, green, or blue). Do NOT ask the user "
"for the color -- pick a sensible one if they didn't say.\n"
" - Otherwise, reply in plain text.\n\n"
"After a tool returns, write one short sentence summarizing the "
"result. Never fabricate data a tool could provide."
)
async def get_weather(
location: Annotated[str, "City or place to look up the weather for"],
) -> dict:
"""Get the current weather for a given location.
Returns a mock payload with city, temperature in Fahrenheit, humidity,
wind speed, and conditions.
"""
return {
"city": location,
"temperature": 68,
"humidity": 55,
"wind_speed": 10,
"conditions": "Sunny",
}
async def get_stock_price(
ticker: Annotated[str, "Stock ticker symbol (e.g. AAPL, TSLA, MSFT)"],
) -> dict:
"""Get a mock current price for a stock ticker.
Returns a payload with the ticker symbol (uppercased), price in USD,
and percentage change for the day.
"""
return {
"ticker": ticker.upper(),
"price_usd": 189.42,
"change_pct": 1.27,
}
agent = ConversableAgent(
name="headless_complete_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=8,
functions=[get_weather, get_stock_price],
)
stream = AGUIStream(agent)
headless_complete_app = FastAPI()
headless_complete_app.mount("", stream.build_asgi())
@@ -0,0 +1,63 @@
"""
AG2 scheduling agent -- interrupt-adapted.
This agent powers two demos (gen-ui-interrupt, interrupt-headless) that in the
LangGraph showcase rely on the native `interrupt()` primitive with
checkpoint/resume. AG2 does NOT have that primitive, so we adapt using the
same "Strategy B" pattern as the MS Agent Framework port: the backend agent's
system prompt tells the LLM to call `schedule_meeting`, but no local
implementation is registered -- the tool is provided entirely by the frontend
via `useFrontendTool` with an async handler that returns a Promise resolving
only once the user picks a time slot (or cancels).
See `src/agents/agent.py` for the shared ConversableAgent used by most other
AG2 demos.
"""
# @region[backend-interrupt-tool]
from __future__ import annotations
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
# @region[backend-tool-call]
SYSTEM_PROMPT = (
"You are a scheduling assistant. Whenever the user asks you to book a call "
"or schedule a meeting, you MUST call the `schedule_meeting` tool. Pass a "
"short `topic` describing the purpose of the meeting and, if known, an "
"`attendee` describing who the meeting is with.\n\n"
"The `schedule_meeting` tool is implemented on the client: it surfaces a "
"time-picker UI to the user and returns the user's selection. After the "
"tool returns, briefly confirm whether the meeting was scheduled and at "
"what time, or note that the user cancelled. Do NOT ask for approval "
"yourself -- always call the tool and let the picker handle the decision.\n\n"
"Keep responses short and friendly. After you finish executing tools, "
"always send a brief final assistant message summarizing what happened so "
"the message persists."
)
interrupt_agent = ConversableAgent(
name="scheduling_agent",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=5,
# No backend tools. `schedule_meeting` is registered on the frontend
# via `useFrontendTool` and dispatched through the CopilotKit runtime.
# When the agent calls `schedule_meeting`, the request is routed to
# the frontend handler, which returns a Promise that only resolves
# once the user picks a slot -- equivalent to `interrupt()` in the
# LangGraph reference.
functions=[],
)
# @endregion[backend-tool-call]
# @endregion[backend-interrupt-tool]
# AG-UI stream wrapper
interrupt_stream = AGUIStream(interrupt_agent)
# FastAPI sub-app so agent_server.py can mount at /interrupt-adapted
interrupt_app = FastAPI(title="AG2 Interrupt Agent")
interrupt_app.mount("/", interrupt_stream.build_asgi())
@@ -0,0 +1,71 @@
"""AG2 agent for the CopilotKit MCP Apps demo.
This agent has no bespoke tools. The CopilotKit runtime (see
`src/app/api/copilotkit-mcp-apps/route.ts`) is wired with
``mcpApps: { servers: [...] }`` pointing at the public Excalidraw MCP
server. The runtime auto-applies the MCP Apps middleware: it merges the
remote MCP server's tools into the agent's tool list at request time and
emits the activity events that CopilotKit's built-in
``MCPAppsActivityRenderer`` renders inline as a sandboxed iframe.
Mirrors the langgraph-python `mcp_apps_agent.py` — a no-tools agent that
relies entirely on the runtime to inject MCP-backed tools.
"""
from __future__ import annotations
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
SYSTEM_PROMPT = """\
You draw simple diagrams in Excalidraw via the MCP tool.
SPEED MATTERS. Produce a correct-enough diagram fast; do not optimize
for polish. Target: one tool call, done in seconds.
When the user asks for a diagram:
1. Call `create_view` ONCE with 3-5 elements total: shapes + arrows +
an optional title text.
2. Use straightforward shapes (rectangle, ellipse, diamond) with plain
`label` fields (`{"text": "...", "fontSize": 18}`) on them.
3. Connect with arrows. Endpoints can be element centers or simple
coordinates — you don't need edge anchors / fixedPoint bindings.
4. Include ONE `cameraUpdate` at the END of the elements array that
frames the whole diagram. Use an approved 4:3 size (600x450 or
800x600). No opening camera needed.
5. Reply with ONE short sentence describing what you drew.
Every element needs a unique string `id` (e.g. `"b1"`, `"a1"`,
`"title"`). Standard sizes: rectangles 160x70, ellipses/diamonds
120x80, 40-80px gap between shapes.
Do NOT:
- Call `read_me`. You already know the basic shape API.
- Make multiple `create_view` calls.
- Iterate or refine. Ship on the first shot.
- Add decorative colors / fills / zone backgrounds unless the user
explicitly asks for them.
- Add labels on arrows unless crucial.
If the user asks for something specific (colors, more elements,
particular layout), follow their lead — but still in ONE call.
"""
agent = ConversableAgent(
name="mcp_apps_assistant",
system_message=SYSTEM_PROMPT,
# gpt-4o-mini for speed, mirroring the langgraph reference.
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=6,
# No bespoke tools — MCP server tools are injected by the runtime
# middleware at request time.
functions=[],
)
stream = AGUIStream(agent)
mcp_apps_app = FastAPI()
mcp_apps_app.mount("", stream.build_asgi())
@@ -0,0 +1,68 @@
"""AG2 agent backing the Multimodal Attachments demo.
Vision-capable AG2 ConversableAgent (gpt-4o) that accepts image + PDF
attachments. Images are forwarded to the model natively; PDFs are flattened
to inline text via `pypdf` so the model can read them without needing
file-part support.
The frontend (src/app/demos/multimodal/page.tsx) sends attachments as
AG-UI message content parts. AG2's ConversableAgent passes them through to
the underlying OpenAI API so vision adapters work natively.
Content-shape normalization
---------------------------
AG2's ``ConversableAgent`` runs every user message through
``autogen.code_utils.content_str``, which only accepts content-part
types in ``{"text", "input_text", "image_url", "input_image",
"function", "tool_call", "tool_calls"}``. CopilotChat / the AG-UI
runtime emits image and document attachments as the modern
``{"type": "image" | "document", "source": {...}}`` shape (and the
frontend at ``src/app/demos/multimodal/legacy-converter-shim.tsx``
APPENDS a legacy ``{"type": "binary", ...}`` mirror alongside it for
LangChain-based integrations). Both of those shapes trip the
allowed-types gate with::
ValueError("Wrong content format: unknown type image within the
content")
…before the request reaches the vision model (observed live in the D6
``multimodal`` probe; see commit d8a0a25db for the original NSF
quarantine). ``NormalizingAGUIStream`` (defined in
``_multimodal_normalize.py``) intercepts the parsed ``RunAgentInput``
messages AFTER Pydantic validation and rewrites the AG-UI content parts
to OpenAI ``image_url`` format before they reach autogen.
"""
from __future__ import annotations
from autogen import ConversableAgent, LLMConfig
from fastapi import FastAPI
from ._multimodal_normalize import NormalizingAGUIStream
SYSTEM_PROMPT = (
"You are a helpful assistant. The user may attach images or documents "
"(PDFs). When they do, analyze the attachment carefully and answer the "
"user's question. If no attachment is present, answer the text question "
"normally. Keep responses concise (1-3 sentences) unless asked to go deep."
)
multimodal_agent = ConversableAgent(
name="multimodal_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o", "stream": True, "temperature": 0.2}),
human_input_mode="NEVER",
max_consecutive_auto_reply=5,
functions=[],
)
# NormalizingAGUIStream wraps AGUIStream and normalises AG-UI
# image/document/binary content parts to OpenAI image_url format AFTER
# RunAgentInput Pydantic parsing, BEFORE AgentService processes them.
# See _multimodal_normalize.py for the full interception-point rationale.
multimodal_stream = NormalizingAGUIStream(multimodal_agent)
multimodal_app = FastAPI()
multimodal_app.mount("/", multimodal_stream.build_asgi())
@@ -0,0 +1,81 @@
"""AG2 agent for the Open-Ended Generative UI (Advanced) demo.
Extends the minimal Open Generative UI cell with sandbox-function
calling: the agent-authored, sandboxed UI invokes host-page functions
(see `src/app/demos/open-gen-ui-advanced/sandbox-functions.ts`) via
`Websandbox.connection.remote.<name>(...)` from inside the iframe.
The frontend passes `openGenerativeUI={{ sandboxFunctions }}` to the
provider; the runtime middleware injects descriptors of those functions
into agent context. The LLM reads the descriptors and emits HTML/JS that
calls into them.
Mirrors the langgraph-python `open_gen_ui_advanced_agent.py` reference.
"""
from __future__ import annotations
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
SYSTEM_PROMPT = """You are a UI-generating assistant for the Open Generative UI (Advanced) demo.
On every user turn you MUST call the `generateSandboxedUi` frontend tool
exactly once. The generated UI must be INTERACTIVE and must invoke the
available host-side sandbox functions described in your agent context
(delivered via `copilotkit.context`) in response to user interactions.
Sandbox-function calling contract (inside the generated iframe):
- Call a host function with:
await Websandbox.connection.remote.<functionName>(args)
The call returns a Promise; await it.
- Each handler returns a plain object. Read the return shape from the
function's description in your context and use the EXACT field names
it returns (e.g. if the description says the handler returns
`{ ok, value }`, read `res.value` — not `res.result`).
- Descriptions, names, and JSON-schema parameter shapes for every
available sandbox function are listed in your context. Read them
carefully and wire at least one interactive UI element to call one.
Sandbox iframe restrictions (CRITICAL):
- The iframe runs with `sandbox="allow-scripts"` ONLY. Forms are NOT
allowed. You MUST NOT use <form> elements or <button type="submit">.
Clicking a submit button inside a sandboxed form is blocked by the
browser BEFORE any onsubmit handler runs, so the sandbox-function call
never fires.
- Use plain <button type="button"> elements and wire them with
addEventListener('click', ...) or an inline click handler. Do the same
for "Enter" keypresses on inputs: attach a `keydown` listener that
checks `e.key === 'Enter'` and calls your handler directly — do NOT
wrap inputs in a <form>.
Generation guidance:
- Emit `initialHeight` and `placeholderMessages` first, then CSS, then
HTML, then `jsFunctions` / `jsExpressions` if helpful.
- Always include a visible result element (e.g. an output div) that you
UPDATE after the sandbox function resolves, so the user can *see* the
round-trip: "Button clicked -> remote call -> visible result".
- Use CDN scripts (Chart.js, D3, etc.) via <script> tags in the HTML head
when you need libraries.
- Do NOT use fetch/XHR, localStorage, or document.cookie — the sandbox has
no same-origin access. ONLY use `Websandbox.connection.remote.*` for
host-page interactions.
- Keep your own chat message brief (1 sentence max); the rendered UI is
the real output.
"""
agent = ConversableAgent(
name="open_gen_ui_advanced_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4.1", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=4,
functions=[],
)
stream = AGUIStream(agent)
open_gen_ui_advanced_app = FastAPI()
open_gen_ui_advanced_app.mount("", stream.build_asgi())
@@ -0,0 +1,63 @@
"""AG2 agent for the Open-Ended Generative UI (minimal) demo.
The agent has no tools. The frontend-registered `generateSandboxedUi`
tool (auto-registered by `CopilotKitProvider` when the runtime has
`openGenerativeUI` enabled) is merged into the agent's tool list at
request time by the AG-UI integration. When the LLM calls
`generateSandboxedUi`, the runtime's `OpenGenerativeUIMiddleware`
converts the streaming tool call into `open-generative-ui` activity
events the built-in renderer mounts inside a sandboxed iframe.
Mirrors the langgraph-python `open_gen_ui_agent.py` reference.
"""
from __future__ import annotations
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
SYSTEM_PROMPT = """You are a UI-generating assistant for an Open Generative UI demo
focused on intricate, educational visualisations (3D axes / rotations,
neural-network activations, sorting-algorithm walkthroughs, Fourier
series, wave interference, planetary orbits, etc.).
On every user turn you MUST call the `generateSandboxedUi` frontend tool
exactly once. Design a visually polished, self-contained HTML + CSS +
SVG widget that *teaches* the requested concept.
The frontend injects a detailed "design skill" as agent context
describing the palette, typography, labelling, and motion conventions
expected — follow it closely. Key invariants:
- Use inline SVG (or <canvas>) for geometric content, not stacks of <div>s.
- Every axis is labelled; every colour-coded series has a legend.
- Prefer CSS @keyframes / transitions over setInterval; loop cyclical
concepts with animation-iteration-count: infinite.
- Motion must teach — animate the actual step of the concept, not decoration.
- No fetch / XHR / localStorage — the sandbox has no same-origin access.
Output order:
- `initialHeight` (typically 480-560 for visualisations) first.
- A short `placeholderMessages` array (2-3 lines describing the build).
- `css` (complete).
- `html` (streams live — keep it tidy). CDN <script> tags for Chart.js /
D3 / etc. go inside the html.
Keep your own chat message brief (1 sentence) — the real output is the
rendered visualisation.
"""
agent = ConversableAgent(
name="open_gen_ui_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4.1", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=4,
functions=[],
)
stream = AGUIStream(agent)
open_gen_ui_app = FastAPI()
open_gen_ui_app.mount("", stream.build_asgi())
@@ -0,0 +1,339 @@
"""AG2 reasoning agent — emits AG-UI REASONING_MESSAGE_* events.
Backs two showcase cells (both share this one backend):
- reasoning-custom (custom amber ReasoningBlock slot)
- reasoning-default (CopilotKit's built-in reasoning card)
Mirrors `showcase/integrations/agno/src/agents/reasoning_agent.py` plus its
`/reasoning/agui` server mount in `agno/src/agent_server.py`, adapted to AG2.
Why a custom route instead of the stock AGUIStream
--------------------------------------------------
AG2's stock `AGUIStream` (autogen.ag_ui) streams the model's text as
TEXT_MESSAGE_CONTENT and emits NO REASONING_MESSAGE_* events. Worse,
autogen's `ConversableAgent` consumes only `delta.content` / `delta.tool_calls`
from the OpenAI chat-completions stream — it drops the `delta.reasoning_content`
side-channel entirely (the channel aimock fixtures populate via their
`reasoning` field, and that reasoning models emit in production). So the stock
adapter can never light up CopilotKit's reasoning slot.
This module builds a small custom `/reasoning` sub-app (mounted by
`agent_server.py`, mirroring agno's `_run_reasoning_agent`) that:
1. Calls the OpenAI-compatible chat-completions endpoint directly
(streaming) with the agent's system prompt plus the full prior
conversation history (so follow-up questions keep their context, parity
with the agno reference) — a single LLM call, so it stays
aimock-friendly (no multi-call CoT loop).
2. Buffers the FULL upstream response, accumulating BOTH
`delta.reasoning_content` (native reasoning channel, what aimock's
`reasoning` field feeds) AND `delta.content` (the answer); it does not
forward upstream deltas incrementally.
3. Falls back to parsing <reasoning>...</reasoning> tags out of the text
when no native reasoning channel is present (defensive parity with
agno's fallback path).
4. Emits each channel as a single CONTENT delta:
REASONING_MESSAGE_START/CONTENT/END for the buffered reasoning portion,
then TEXT_MESSAGE_START/CONTENT/END for the buffered answer.
The emitted channel is REASONING_MESSAGE_* (role "reasoning") — NOT THINKING_*,
which @ag-ui/client silently drops.
The global httpx hook installed in agent_server.py forwards the inbound
`x-aimock-context` header onto the outbound OpenAI call so aimock matches the
ag2-scoped fixture.
"""
from __future__ import annotations
import asyncio
import re
import sys
import traceback
import uuid
from typing import AsyncIterator
import openai
from ag_ui.core import (
BaseEvent,
EventType,
ReasoningMessageContentEvent,
ReasoningMessageEndEvent,
ReasoningMessageStartEvent,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
)
from ag_ui.encoder import EventEncoder
from fastapi import FastAPI
from starlette.endpoints import HTTPEndpoint
from starlette.requests import Request
from starlette.responses import StreamingResponse
SYSTEM_PROMPT = (
"You are a helpful assistant. For each user question, first think "
"step-by-step about the approach, then give a concise answer."
)
MODEL = "gpt-4o-mini"
_REASONING_PATTERN = re.compile(
r"<reasoning>(.*?)</reasoning>",
re.DOTALL | re.IGNORECASE,
)
def _coerce_content(content) -> str:
"""Coerce an AG-UI message's content into a plain string.
Handles the multimodal list shape (join the text parts) and the
None/non-string fallbacks — the same coercion the previous
single-turn `_extract_user_input` applied to the last user message.
"""
content = content or ""
if isinstance(content, str):
return content
if isinstance(content, list):
# Multimodal content: join the text parts. Coerce each part's text to
# a string — a None or non-str `text` (e.g. an image part) would make
# str.join raise TypeError, so fall back to "" for any non-str value.
def _part_text(part) -> str:
text = (
part.get("text", "")
if isinstance(part, dict)
else getattr(part, "text", "")
)
return text if isinstance(text, str) else ""
return "".join(_part_text(part) for part in content)
return str(content)
def _to_chat_messages(messages: list) -> list:
"""Map the AG-UI message list into chat-completions `messages`.
System prompt first, then every prior user/assistant turn (in order)
with its coerced text content. tool/system messages from the input are
skipped — only the conversation turns are threaded so follow-up
questions keep their context (parity with the agno reference, which
threads full history through Agno's Agent).
For a single user-message input this returns exactly
``[{system}, {user: <text>}]`` — byte-equal to the previous single-turn
behaviour, which the aimock D6 fixtures replay. When the input has no
user/assistant turns the result is ``[{system}, {user: ""}]`` (an empty
user turn), preserving the prior empty-input behaviour.
"""
chat: list = [{"role": "system", "content": SYSTEM_PROMPT}]
turns = [
{"role": role, "content": _coerce_content(getattr(msg, "content", ""))}
for msg in (messages or [])
for role in (getattr(msg, "role", None),)
if role in ("user", "assistant")
]
if turns:
chat.extend(turns)
else:
chat.append({"role": "user", "content": ""})
return chat
async def _run_reasoning_agent(
run_input: RunAgentInput,
) -> AsyncIterator[BaseEvent]:
"""Stream one reasoning run, synthesizing REASONING_MESSAGE_* events.
Mirrors agno's `_run_reasoning_agent`: buffer the full response, split
reasoning from answer, emit REASONING_MESSAGE_* then TEXT_MESSAGE_*.
"""
run_id = run_input.run_id or str(uuid.uuid4())
thread_id = run_input.thread_id
# Track the in-flight message frame so a mid-stream failure can close it
# with the matching *_END before RUN_ERROR. @ag-ui/client's verifyEvents
# rejects a RUN_FINISHED while a text/tool frame is open, and the apply
# layer otherwise leaves a half-built message in client state.
reasoning_msg_id: str | None = None
text_msg_id: str | None = None
try:
chat_messages = _to_chat_messages(run_input.messages or [])
yield RunStartedEvent(
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
)
# Single streaming chat-completions call. The global httpx hook
# (installed in agent_server.py) forwards x-aimock-context so aimock
# matches the ag2-scoped fixture. OPENAI_BASE_URL points the client at
# aimock in local/D6 runs and at the real API in production.
client = openai.AsyncOpenAI()
response_stream = await client.chat.completions.create(
model=MODEL,
messages=chat_messages,
stream=True,
)
# Accumulate both channels. autogen drops reasoning_content, so we read
# the chat-completions stream directly to capture it.
full_text = ""
native_reasoning = ""
async for chunk in response_stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta is None:
continue
# Native reasoning channel — aimock `reasoning` field / reasoning
# models surface this as delta.reasoning_content.
reasoning_piece = getattr(delta, "reasoning_content", None)
if reasoning_piece:
native_reasoning += reasoning_piece
if delta.content:
full_text += delta.content
native_reasoning = native_reasoning.strip()
if native_reasoning:
# Native channel present — gold-standard parity path. The answer is
# the streamed text minus any stray <reasoning> tags.
reasoning_text = native_reasoning
answer_text = _REASONING_PATTERN.sub("", full_text).strip()
else:
# Fallback: parse <reasoning>...</reasoning> tags from the text
# (non-reasoning models / no-native-reasoning fixtures).
match = _REASONING_PATTERN.search(full_text)
if match:
reasoning_text = match.group(1).strip()
answer_text = (
full_text[: match.start()] + full_text[match.end() :]
).strip()
else:
reasoning_text = ""
answer_text = full_text.strip()
# The stream completed successfully but yielded neither reasoning nor
# answer text — the run would otherwise emit RUN_STARTED→RUN_FINISHED
# with zero message frames and no diagnostics. Log one server-side warn
# so a silent-empty run is visible (no synthetic message frames).
if not reasoning_text and not answer_text:
print(
"[reasoning] WARN: stream completed but produced no reasoning"
" or answer text",
file=sys.stderr,
flush=True,
)
# Emit reasoning message if we have reasoning content.
if reasoning_text:
reasoning_msg_id = str(uuid.uuid4())
yield ReasoningMessageStartEvent(
type=EventType.REASONING_MESSAGE_START,
message_id=reasoning_msg_id,
role="reasoning",
)
yield ReasoningMessageContentEvent(
type=EventType.REASONING_MESSAGE_CONTENT,
message_id=reasoning_msg_id,
delta=reasoning_text,
)
yield ReasoningMessageEndEvent(
type=EventType.REASONING_MESSAGE_END,
message_id=reasoning_msg_id,
)
reasoning_msg_id = None
# Emit a text message (only when non-empty answer text exists) so
# CopilotKit renders an assistant bubble.
if answer_text:
text_msg_id = str(uuid.uuid4())
yield TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=text_msg_id,
role="assistant",
)
yield TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=text_msg_id,
delta=answer_text,
)
yield TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=text_msg_id,
)
text_msg_id = None
yield RunFinishedEvent(
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
)
except asyncio.CancelledError: # noqa: TRY302 — propagate cancellation
raise
except Exception as exc: # noqa: BLE001
# Log the full failure server-side (never sent to the browser).
print(f"[reasoning] run failed: {exc!r}", file=sys.stderr, flush=True)
traceback.print_exc(file=sys.stderr)
# Close any message frame opened before the failure so the terminal
# RUN_ERROR is protocol-clean (no dangling *_START in client state).
if text_msg_id is not None:
yield TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=text_msg_id,
)
if reasoning_msg_id is not None:
yield ReasoningMessageEndEvent(
type=EventType.REASONING_MESSAGE_END,
message_id=reasoning_msg_id,
)
# Generic client-facing message — no raw exception text (which can
# carry provider URLs / request details) reaches the SSE stream.
# RUN_ERROR is terminal: @ag-ui/client's verifyEvents rejects ANY
# event after it, so we do NOT emit RUN_FINISHED here.
yield RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"agent run failed: {type(exc).__name__} (see server logs)",
)
class ReasoningEndpoint(HTTPEndpoint):
"""Starlette HTTPEndpoint that emits REASONING_MESSAGE_* + TEXT_MESSAGE_*.
Mounted at the sub-app root (``reasoning_app.mount("/", ...)``) — the exact
same shape as AG2's stock ``AGUIStream.build_asgi()`` HTTPEndpoint that the
other ag2 sub-apps mount (see e.g. ``interrupt_agent.py``). agent_server
mounts this sub-app at ``/reasoning``; the HttpAgent posts to
``/reasoning/`` (route.ts ``createAgent("/reasoning/")``), so the outer
Mount strips ``/reasoning`` and the inner Mount at ``/`` resolves here.
"""
async def post(self, request: Request) -> StreamingResponse:
encoder = EventEncoder()
run_input = RunAgentInput.model_validate_json(await request.body())
async def _gen() -> AsyncIterator[str]:
async for event in _run_reasoning_agent(run_input):
yield encoder.encode(event)
return StreamingResponse(
_gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
)
# FastAPI sub-app so agent_server.py can mount at /reasoning. Mounting the
# HTTPEndpoint at "/" mirrors the stock AGUIStream sub-apps
# (``app.mount("/", stream.build_asgi())``) — the HttpAgent posts to
# ``/reasoning/`` so the outer Mount strips ``/reasoning`` and this inner
# Mount at ``/`` resolves the endpoint.
reasoning_app = FastAPI(title="AG2 Reasoning Agent")
reasoning_app.mount("/", ReasoningEndpoint)
@@ -0,0 +1,163 @@
"""AG2 agent for the Shared State (Read + Write) demo.
Demonstrates the full bidirectional shared-state pattern between UI and
agent using AG2's ContextVariables + ReplyResult mechanism:
- **UI -> agent (write)**: The UI owns a `preferences` object (the user's
profile) that it writes into agent state via `agent.setState({...})`.
AG2's AGUIStream maps incoming initial state into ContextVariables on
every run. The agent calls `get_current_preferences` to read them, and
the system prompt tells it to do so before answering.
- **agent -> UI (read)**: The agent calls `set_notes` to update the
`notes` slot in shared state. Each call returns a ReplyResult that
attaches the updated ContextVariables, which AGUIStream surfaces back
to the UI so `useAgent({ updates: [OnStateChanged] })` re-renders.
Together this gives bidirectional shared state: frontend writes,
backend reads AND writes, frontend re-renders.
"""
import logging
from textwrap import dedent
from typing import List, Optional
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from autogen.agentchat import ContextVariables, ReplyResult
from autogen.tools import tool
from fastapi import FastAPI
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class Preferences(BaseModel):
"""User preferences written by the UI into shared state."""
name: str = Field(default="", description="The user's preferred name")
tone: str = Field(
default="casual",
description="Preferred tone: 'formal', 'casual', or 'playful'",
)
language: str = Field(
default="English",
description="Preferred language (e.g. English, Spanish, ...)",
)
interests: List[str] = Field(
default_factory=list,
description="The user's interests (e.g. Cooking, Tech, Travel)",
)
class SharedSnapshot(BaseModel):
"""Full shape of the shared state slot.
Both the UI and the backend agree on this shape; it round-trips through
AG2's ContextVariables on every turn.
"""
preferences: Preferences = Field(default_factory=Preferences)
notes: List[str] = Field(default_factory=list)
def _load_snapshot(context_variables: ContextVariables) -> SharedSnapshot:
"""Best-effort load of the SharedSnapshot from context variables.
Falls back to an empty snapshot if state is missing or malformed —
this keeps the agent operational on the very first turn before the UI
has called ``agent.setState``.
"""
data = context_variables.data or {}
try:
return SharedSnapshot.model_validate(data)
except Exception as exc:
# Tolerant of partial state (e.g. only `preferences` set), but log
# WARNING so silent corruption is visible in server logs instead of
# quietly degrading to an empty snapshot.
logger.warning(
"shared_state_read_write: failed to validate SharedSnapshot "
"(%s: %s); attempting partial recovery from individual slots",
exc.__class__.__name__,
exc,
)
prefs_raw = data.get("preferences") or {}
notes_raw = data.get("notes") or []
try:
prefs = Preferences.model_validate(prefs_raw)
except Exception as prefs_exc:
logger.warning(
"shared_state_read_write: failed to validate Preferences "
"(%s: %s); falling back to defaults",
prefs_exc.__class__.__name__,
prefs_exc,
)
prefs = Preferences()
notes = [str(n) for n in notes_raw if isinstance(n, (str, int, float))]
return SharedSnapshot(preferences=prefs, notes=notes)
@tool()
async def get_current_preferences(context_variables: ContextVariables) -> str:
"""Return the user's preferences (name, tone, language, interests) as JSON.
Always call this BEFORE answering, so your reply respects the user's
preferred name, tone, language, and interests.
"""
snapshot = _load_snapshot(context_variables)
return snapshot.preferences.model_dump_json(indent=2)
@tool()
async def set_notes(
context_variables: ContextVariables,
notes: List[str],
) -> ReplyResult:
"""Replace the notes array in shared state with the FULL updated list.
Use this whenever the user asks you to "remember" something, or when you
have an observation worth surfacing in the UI's notes panel. Always
pass the FULL notes list (existing + new) — not a diff. Keep each note
short (< 120 chars).
"""
snapshot = _load_snapshot(context_variables)
cleaned = [str(n).strip() for n in notes if str(n).strip()]
snapshot.notes = cleaned
context_variables.update(snapshot.model_dump())
return ReplyResult(
message=f"Notes updated. Total notes: {len(cleaned)}.",
context_variables=context_variables,
)
agent = ConversableAgent(
name="shared_state_read_write_assistant",
system_message=dedent(
"""
You are a helpful, concise assistant.
Shared state contract:
- The UI writes the user's `preferences` (name, tone, language,
interests) into shared state. Call `get_current_preferences`
BEFORE answering, every turn, and tailor your reply to those
preferences. Address the user by name when appropriate.
- The UI displays a `notes` panel that mirrors a list you control.
When the user asks you to remember something, OR when you observe
something worth surfacing, call `set_notes` with the FULL updated
list of short note strings.
Rules:
- Never repeat preferences back at the user verbatim — just adapt.
- When calling `set_notes`, pass the COMPLETE list (existing +
new), never a diff.
- Keep messages short and respect the preferred tone.
"""
).strip(),
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=10,
functions=[get_current_preferences, set_notes],
)
stream = AGUIStream(agent)
shared_state_read_write_app = FastAPI()
shared_state_read_write_app.mount("", stream.build_asgi())
@@ -0,0 +1,316 @@
"""AG2 agent for the Sub-Agents demo.
Demonstrates multi-agent delegation with a visible delegation log.
A top-level "supervisor" ConversableAgent orchestrates three specialized
sub-agents — each itself a ConversableAgent — exposed as supervisor tools:
- `research_agent` — gathers facts
- `writing_agent` — drafts prose
- `critique_agent` — reviews drafts
Every delegation appends an entry to the `delegations` slot in shared
agent state (via AG2's ContextVariables + ReplyResult), so the UI can
render a live "delegation log" as the supervisor fans work out and
collects results. This is the canonical AG2 sub-agents-as-tools pattern,
adapted to surface delegation events to the frontend via AG-UI's
shared-state channel.
"""
# @region[supervisor-delegation-tools]
# @region[subagent-setup]
import asyncio
import logging
import uuid
from textwrap import dedent
from typing import List, Literal
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from autogen.agentchat import ContextVariables, ReplyResult
from autogen.tools import tool
from fastapi import FastAPI
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
SubAgentName = Literal["research_agent", "writing_agent", "critique_agent"]
DelegationStatus = Literal["running", "completed", "failed"]
class Delegation(BaseModel):
"""One entry in the delegation log shown by the UI."""
id: str
sub_agent: SubAgentName
task: str
status: DelegationStatus = "completed"
result: str = ""
class SubagentsSnapshot(BaseModel):
"""Shape of the shared `delegations` state slot rendered by the UI."""
delegations: List[Delegation] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Sub-agents (real ConversableAgents under the hood)
# ---------------------------------------------------------------------------
#
# Each sub-agent is its own LLM ConversableAgent with a focused system
# prompt. They don't share memory or tools with the supervisor — the
# supervisor only sees what each sub-agent's final reply produces.
_SUB_LLM_CONFIG = LLMConfig({"model": "gpt-4o-mini", "stream": False})
_research_agent = ConversableAgent(
name="research_sub_agent",
system_message=dedent(
"""
You are a research sub-agent. Given a topic, produce a concise
bulleted list of 3-5 key facts. No preamble, no closing.
"""
).strip(),
llm_config=_SUB_LLM_CONFIG,
human_input_mode="NEVER",
max_consecutive_auto_reply=1,
)
_writing_agent = ConversableAgent(
name="writing_sub_agent",
system_message=dedent(
"""
You are a writing sub-agent. Given a brief and optional source
facts, produce a polished 1-paragraph draft. Be clear and
concrete. No preamble.
"""
).strip(),
llm_config=_SUB_LLM_CONFIG,
human_input_mode="NEVER",
max_consecutive_auto_reply=1,
)
_critique_agent = ConversableAgent(
name="critique_sub_agent",
system_message=dedent(
"""
You are an editorial critique sub-agent. Given a draft, produce
2-3 crisp, actionable critiques. No preamble.
"""
).strip(),
llm_config=_SUB_LLM_CONFIG,
human_input_mode="NEVER",
max_consecutive_auto_reply=1,
)
# @endregion[subagent-setup]
async def _invoke_sub_agent(sub_agent: ConversableAgent, task: str) -> str:
"""Run a sub-agent on `task` and return its final reply text.
`generate_reply` produces a single LLM completion against a one-shot
user message. AG2's ``generate_reply`` is synchronous and performs a
blocking LLM round-trip, so we offload it to a worker thread to keep
the asyncio event loop responsive while the call is in flight.
"""
reply = await asyncio.to_thread(
sub_agent.generate_reply,
messages=[{"role": "user", "content": task}],
)
if reply is None:
return ""
if isinstance(reply, dict):
# ConversableAgent.generate_reply may return {"content": "..."}.
return str(reply.get("content") or "")
return str(reply)
def _load_snapshot(context_variables: ContextVariables) -> SubagentsSnapshot:
"""Best-effort load of the SubagentsSnapshot from context variables.
Logs at WARNING when state fails validation so silent corruption is
visible in server logs instead of degrading to an empty snapshot
without a trace.
"""
data = context_variables.data or {}
try:
return SubagentsSnapshot.model_validate(data)
except Exception as exc:
logger.warning(
"subagents: failed to validate SubagentsSnapshot from context "
"variables (%s: %s); falling back to empty snapshot",
exc.__class__.__name__,
exc,
)
return SubagentsSnapshot()
def _record_delegation(
context_variables: ContextVariables,
sub_agent: SubAgentName,
task: str,
result: str,
status: DelegationStatus = "completed",
) -> ReplyResult:
"""Append a delegation entry to shared state and return ReplyResult."""
snapshot = _load_snapshot(context_variables)
snapshot.delegations.append(
Delegation(
id=str(uuid.uuid4()),
sub_agent=sub_agent,
task=task,
status=status,
result=result,
)
)
context_variables.update(snapshot.model_dump())
return ReplyResult(
message=result,
context_variables=context_variables,
)
async def _run_delegation(
context_variables: ContextVariables,
sub_agent_name: SubAgentName,
sub_agent: ConversableAgent,
task: str,
) -> ReplyResult:
"""Invoke a sub-agent and record the outcome (completed or failed).
If the underlying ``generate_reply`` raises (transport error, quota,
SDK bug, ...), we record the delegation with ``status='failed'`` and
return a sane ReplyResult so the supervisor can recover instead of
crashing the turn. The full traceback is logged server-side; the
user-facing ``result`` text only mentions the exception class to
avoid leaking internals.
"""
try:
result = await _invoke_sub_agent(sub_agent, task)
except Exception as exc:
logger.exception(
"subagents: sub-agent %s failed while handling task", sub_agent_name
)
failure_message = (
f"sub-agent call failed: {exc.__class__.__name__} (see server logs)"
)
return _record_delegation(
context_variables,
sub_agent_name,
task,
failure_message,
status="failed",
)
return _record_delegation(
context_variables,
sub_agent_name,
task,
result,
status="completed",
)
# ---------------------------------------------------------------------------
# Supervisor tools (each tool delegates to one sub-agent)
# ---------------------------------------------------------------------------
# Each @tool wraps a sub-agent invocation. The supervisor LLM "calls"
# these tools to delegate work; each call asynchronously runs the
# matching sub-agent, records the delegation into shared state via
# ContextVariables, and returns a ReplyResult the supervisor reads as
# its tool output on the next step.
@tool()
async def research_agent(
context_variables: ContextVariables,
task: str,
) -> ReplyResult:
"""Delegate a research task to the research sub-agent.
Use for: gathering facts, background, definitions, statistics. Returns
a bulleted list of key facts.
Args:
task: The specific research question or topic to investigate.
"""
return await _run_delegation(
context_variables, "research_agent", _research_agent, task
)
@tool()
async def writing_agent(
context_variables: ContextVariables,
task: str,
) -> ReplyResult:
"""Delegate a drafting task to the writing sub-agent.
Use for: producing a polished paragraph, draft, or summary. Pass
relevant facts from prior research inside ``task``.
Args:
task: The brief plus any relevant facts the writer should use.
"""
return await _run_delegation(
context_variables, "writing_agent", _writing_agent, task
)
@tool()
async def critique_agent(
context_variables: ContextVariables,
task: str,
) -> ReplyResult:
"""Delegate a critique task to the critique sub-agent.
Use for: reviewing a draft and suggesting concrete improvements.
Args:
task: The draft to critique (paste it directly into ``task``).
"""
return await _run_delegation(
context_variables, "critique_agent", _critique_agent, task
)
# @endregion[supervisor-delegation-tools]
# ---------------------------------------------------------------------------
# Supervisor (the agent we export)
# ---------------------------------------------------------------------------
supervisor = ConversableAgent(
name="supervisor",
system_message=dedent(
"""
You are a supervisor agent that coordinates three specialized
sub-agents to produce high-quality deliverables.
Available sub-agents (call them as tools):
- research_agent: gathers facts on a topic.
- writing_agent: turns facts + a brief into a polished draft.
- critique_agent: reviews a draft and suggests improvements.
For most non-trivial user requests, delegate in sequence:
research -> write -> critique. Pass the relevant facts/draft
through the `task` argument of each tool. Keep your own messages
short — explain the plan once, delegate, then return a concise
summary once done. The UI shows the user a live log of every
sub-agent delegation, so don't repeat sub-agent output verbatim
in your final reply — just summarize.
"""
).strip(),
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
# Limit supervisor steps to bound delegation fan-out.
max_consecutive_auto_reply=8,
functions=[research_agent, writing_agent, critique_agent],
)
stream = AGUIStream(supervisor)
subagents_app = FastAPI()
subagents_app.mount("", stream.build_asgi())
@@ -0,0 +1,111 @@
"""AG2 agent for the Tool Rendering (Reasoning Chain) demo.
A travel & lifestyle concierge that chains 2+ tool calls in succession
when relevant. The frontend wires renderers for `get_weather` and
`search_flights` plus a custom catch-all for the rest.
Note: AG2's ConversableAgent does not natively emit AG-UI
REASONING_MESSAGE_* events the way LangGraph's `deepagents` does, so the
reasoning slot may not show streaming "thinking…" text. The cell still
exercises the full tool-rendering chain and the custom reasoning slot
plumbing — the slot simply renders empty/skeletal until/if a reasoning
event arrives.
"""
from __future__ import annotations
import json
from random import choice, randint
from typing import Annotated
from autogen import ConversableAgent, LLMConfig
from autogen.ag_ui import AGUIStream
from fastapi import FastAPI
async def get_weather(
location: Annotated[str, "City or place to look up the weather for"],
) -> dict:
"""Get the current weather for a given location."""
return {
"city": location,
"temperature": 68,
"humidity": 55,
"wind_speed": 10,
"conditions": "Sunny",
}
async def search_flights(
origin: Annotated[str, "Origin airport code, e.g. 'SFO'"],
destination: Annotated[str, "Destination airport code, e.g. 'JFK'"],
) -> str:
"""Search mock flights from an origin airport to a destination."""
payload = {
"origin": origin,
"destination": destination,
"flights": [
{
"airline": "United",
"flight": "UA231",
"depart": "08:15",
"arrive": "16:45",
"price_usd": 348,
},
{
"airline": "Delta",
"flight": "DL412",
"depart": "11:20",
"arrive": "19:55",
"price_usd": 312,
},
{
"airline": "JetBlue",
"flight": "B6722",
"depart": "17:05",
"arrive": "01:30",
"price_usd": 289,
},
],
}
return json.dumps(payload)
async def get_stock_price(
ticker: Annotated[str, "Stock ticker symbol (e.g. AAPL, TSLA, MSFT)"],
) -> dict:
"""Get a mock current price for a stock ticker."""
return {
"ticker": ticker.upper(),
"price_usd": round(100 + randint(0, 400) + randint(0, 99) / 100, 2),
"change_pct": round(choice([-1, 1]) * (randint(0, 300) / 100), 2),
}
async def roll_dice(
sides: Annotated[int, "Number of sides on the die (default 6)"] = 6,
) -> dict:
"""Roll a single die with the given number of sides."""
return {"sides": sides, "result": randint(1, max(2, sides))}
SYSTEM_PROMPT = (
"You are a travel & lifestyle concierge. When a user asks a question, "
"reason step-by-step and call 2+ tools in succession when relevant. "
"For weather + travel questions, call get_weather then search_flights. "
"Keep the final summary to one short sentence."
)
agent = ConversableAgent(
name="tool_rendering_reasoning_chain_assistant",
system_message=SYSTEM_PROMPT,
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
human_input_mode="NEVER",
max_consecutive_auto_reply=10,
functions=[get_weather, search_flights, get_stock_price, roll_dice],
)
stream = AGUIStream(agent)
tool_rendering_reasoning_chain_app = FastAPI()
tool_rendering_reasoning_chain_app.mount("", stream.build_asgi())
@@ -0,0 +1,52 @@
// Dedicated runtime for the A2UI — Fixed Schema cell. Splitting into its
// own endpoint lets us set `a2ui.injectA2UITool: false` — the backend AG2
// agent owns the `display_flight` tool which emits its own
// `a2ui_operations` container directly in the tool result.
//
// Reference:
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-a2ui-fixed-schema/route.ts
// - src/agents/a2ui_fixed.py (the AG2 backend)
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const a2uiFixedSchemaAgent = new HttpAgent({
url: `${AGENT_URL}/a2ui-fixed-schema/`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "a2ui-fixed-schema": a2uiFixedSchemaAgent },
a2ui: {
// The backend agent emits its own `a2ui_operations` container inside
// `display_flight` (see src/agents/a2ui_fixed.py). We still run the A2UI
// middleware so it detects the container in tool results and forwards
// surfaces to the frontend — but we do NOT inject a runtime
// `render_a2ui` tool on top of the agent's existing tools.
injectA2UITool: false,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-a2ui-fixed-schema",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,204 @@
// Dedicated runtime for the Agent Config Object demo (AG2).
//
// The page at src/app/demos/agent-config/page.tsx points its `runtimeUrl` at
// this endpoint and sets `agent="agent-config-demo"` (the slug registered
// below). The backing AG2 agent is a FastAPI sub-app mounted at
// `/agent-config` in src/agent_server.py, with its AGUIStream at the mount
// root — hence the trailing-slash URL, matching the sibling
// copilotkit-multimodal route's convention.
//
// Wire-contract bridge:
// The CopilotKit runtime forwards `CopilotKitCore.properties` as flat
// top-level keys on `forwardedProps`. To keep the wire contract identical
// across framework showcases (LangGraph / LlamaIndex / AG2 / etc.), we repack
// any non-structural forwardedProps key into
// `forwardedProps.config.configurable.properties` before forwarding the
// request to the Python backend. This mirrors the LlamaIndex showcase's
// agent-config route (see llamaindex/src/app/api/copilotkit-agent-config/
// route.ts) so a single TS-side wire contract serves all frameworks. (The
// AG2 demo page itself relays config via `useAgentContext` → shared state →
// ContextVariables, so the repack is a pass-through unless provider
// `properties` are supplied.)
//
// References:
// - src/agents/agent_config_agent.py — the AG2 agent + AGUIStream sub-app
// - src/app/demos/agent-config/page.tsx — the provider config
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
// Shape of the AG-UI run input we care about. We avoid a direct import of
// `RunAgentInput` from `@ag-ui/client` so this route has no additional
// peer-dep on internal AG-UI packages — the field we touch (`forwardedProps`)
// is part of the stable AG-UI protocol contract.
type RunInputWithForwardedProps = {
forwardedProps?: Record<string, unknown> | undefined;
[k: string]: unknown;
};
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Keys on `forwardedProps` that AG-UI treats as reserved stream-payload
// fields (e.g. `config`, `command`, `streamMode`). These must NOT be
// repacked under `configurable.properties` — they are structural fields.
// Anything else on `forwardedProps` is user-supplied frontend state that
// needs to reach the Python agent.
//
// Kept in sync with ag-ui/langgraph/typescript/src/agent.ts
// `RunAgentExtendedInput["forwardedProps"]`. AG2's stream uses a subset of
// these, but the superset is safe: structural keys present in the request
// body pass through to AG-UI's canonical shape regardless of which backend
// consumes them.
const RESERVED_FORWARDED_PROPS_KEYS = new Set<string>([
"config",
"command",
"streamMode",
"streamSubgraphs",
"nodeName",
"threadMetadata",
"checkpointId",
"checkpointDuring",
"interruptBefore",
"interruptAfter",
"multitaskStrategy",
"ifNotExists",
"afterSeconds",
"onCompletion",
"onDisconnect",
"webhook",
"feedbackKeys",
"metadata",
]);
/**
* Wrapper around `HttpAgent` that repacks the CopilotKit provider's
* `properties` (which arrive as top-level keys on `forwardedProps`) into
* `forwardedProps.config.configurable.properties`.
*
* Why this bridge exists: the CopilotKit runtime forwards
* `CopilotKitCore.properties` as `forwardedProps` (see core's run-handler).
* For wire-contract consistency with the LangGraph showcase, we stash them
* under `forwardedProps.config.configurable.properties` so a Python-side
* recomposer can read them from a single canonical location instead of
* sniffing top-level keys.
*/
class AgentConfigHttpAgent extends HttpAgent {
// Passthrough constructor so TS sees the same signature HttpAgent
// accepts ({ url }). Without this, subclassing narrows the inferred
// constructor to zero-arg when @ag-ui/client isn't fully resolvable in
// isolated typecheck passes.
constructor(...args: ConstructorParameters<typeof HttpAgent>) {
super(...args);
}
// Intercept each run() to repack provider `properties` (which land on
// `forwardedProps`) into `forwardedProps.config.configurable.properties`.
run(input: Parameters<HttpAgent["run"]>[0]): ReturnType<HttpAgent["run"]> {
const repacked = repackForwardedPropsIntoConfigurable(
input as unknown as RunInputWithForwardedProps,
);
return super.run(repacked as unknown as Parameters<HttpAgent["run"]>[0]);
}
}
function repackForwardedPropsIntoConfigurable<
T extends RunInputWithForwardedProps,
>(input: T): T {
const fp = (input.forwardedProps ?? {}) as Record<string, unknown>;
if (!fp || typeof fp !== "object") return input;
// Split forwardedProps into (structural) and (user-supplied) halves.
const userProps: Record<string, unknown> = {};
const structural: Record<string, unknown> = {};
for (const [key, value] of Object.entries(fp)) {
if (RESERVED_FORWARDED_PROPS_KEYS.has(key)) {
structural[key] = value;
} else {
userProps[key] = value;
}
}
if (Object.keys(userProps).length === 0) return input;
const existingConfig = (structural.config ?? {}) as {
configurable?: Record<string, unknown>;
[k: string]: unknown;
};
const existingConfigurable =
(existingConfig.configurable as Record<string, unknown> | undefined) ?? {};
const existingProperties =
(existingConfigurable.properties as Record<string, unknown> | undefined) ??
{};
const mergedConfig = {
...existingConfig,
configurable: {
...existingConfigurable,
properties: {
...existingProperties,
...userProps,
},
},
};
return {
...input,
forwardedProps: {
...structural,
config: mergedConfig,
},
} as T;
}
// Trailing-slash mount root: src/agent_server.py mounts the agent-config
// FastAPI sub-app at /agent-config, and the sub-app mounts its AGUIStream
// at "/" (same shape as the multimodal agent).
const agentConfigAgent = new AgentConfigHttpAgent({
url: `${AGENT_URL}/agent-config/`,
});
const agents = {
"agent-config-demo": agentConfigAgent,
default: agentConfigAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-agent-config",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-expect-error -- see main route.ts; published CopilotRuntime's `agents`
// type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects
// plain Records. Fixed in source, pending release.
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
// Log full details server-side (operators grep `errorId` to correlate),
// but never echo `err.message` / `err.stack` back to the HTTP client —
// that leaks internal paths, dependency versions, and stack traces.
const err = error instanceof Error ? error : new Error(String(error));
const errorId = crypto.randomUUID();
console.error(
JSON.stringify({
at: new Date().toISOString(),
level: "error",
scope: "copilotkit-agent-config/route",
errorId,
message: err.message,
stack: err.stack,
}),
);
return NextResponse.json(
{ error: "internal runtime error", errorId },
{ status: 500 },
);
}
};
@@ -0,0 +1,58 @@
// Dedicated runtime for the /demos/auth cell.
//
// Demonstrates framework-native request authentication via the V2 runtime's
// `onRequest` hook, which runs before routing and can short-circuit the
// request by throwing a Response. We validate a static `Authorization: Bearer
// <DEMO_TOKEN>` header; mismatch throws 401 before the request reaches the
// AG2 backend.
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Reuse the neutral default AG2 agent for the authenticated path. The
// point of this demo is the gate mechanism, not per-user agent branching.
const authDemoAgent = new HttpAgent({ url: `${AGENT_URL}/` });
const runtime = new CopilotRuntime({
agents: {
"auth-demo": authDemoAgent,
default: authDemoAgent,
},
});
const BASE_PATH = "/api/copilotkit-auth";
const handler = createCopilotRuntimeHandler({
runtime,
basePath: BASE_PATH,
hooks: {
onRequest: ({ request }) => {
const authHeader = request.headers.get("authorization");
if (authHeader !== DEMO_AUTH_HEADER) {
throw new Response(
JSON.stringify({
error: "unauthorized",
message:
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
}),
{
status: 401,
headers: { "content-type": "application/json" },
},
);
}
},
},
});
export const POST = (req: NextRequest) => handler(req);
export const GET = (req: NextRequest) => handler(req);
export const PUT = (req: NextRequest) => handler(req);
export const DELETE = (req: NextRequest) => handler(req);
@@ -0,0 +1,74 @@
// Dedicated runtime for the (simplified) Beautiful Chat showcase cell.
//
// Beautiful Chat combines TWO of the canonical reference's three flagship
// features in a single cell:
// - A2UI Dynamic Schema (branded React catalog, agent-owned `generate_a2ui`)
// - Open Generative UI (auto-injected `generateSandboxedUi` frontend tool)
//
// Splitting into its own endpoint matters because:
// - `openGenerativeUI` flips a global probe flag that, on the shared
// `/api/copilotkit` route, would wipe per-cell `useFrontendTool` /
// `useComponent` registrations (see comment in `copilotkit-ogui/route.ts`).
// - `a2ui.injectA2UITool: false` is required so the runtime doesn't
// double-bind a second A2UI tool over the agent-owned `generate_a2ui`.
//
// References:
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-beautiful-chat/route.ts
// - src/app/api/copilotkit-declarative-gen-ui/route.ts (a2ui scoping pattern)
// - src/app/api/copilotkit-ogui/route.ts (openGenerativeUI scoping pattern)
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const beautifulChatAgent = new HttpAgent({
url: `${AGENT_URL}/beautiful-chat/`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "beautiful-chat": beautifulChatAgent },
// The agent owns `generate_a2ui` explicitly (see
// src/agents/beautiful_chat.py). The runtime middleware still serialises
// the registered client catalog into agent context and detects
// `a2ui_operations` containers in the tool result; it just must NOT bind
// a second A2UI tool on top.
a2ui: {
injectA2UITool: false,
// Models follow the tool-usage guide and omit `catalogId`, and the
// middleware then falls back to the unregistered spec basic catalog
// ("Catalog not found" render error). Pin the catalog the page registers.
defaultCatalogId: "copilotkit://app-dashboard-catalog",
},
// Turn on Open Generative UI for this agent. The runtime middleware
// injects `generateSandboxedUi` as a frontend tool the LLM can call,
// and converts streaming tool-call deltas into `open-generative-ui`
// activity events the built-in renderer mounts in a sandboxed iframe.
openGenerativeUI: {
agents: ["beautiful-chat"],
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-beautiful-chat",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,47 @@
// Dedicated runtime for the byoc-hashbrown demo (AG2).
//
// The demo page wraps CopilotChat in the HashBrownDashboard provider and
// overrides the assistant message slot with a renderer that consumes
// hashbrown-shaped structured output via `@hashbrownai/react`'s `useUiKit` +
// `useJsonParser`. The agent behind this endpoint (`byoc_hashbrown`) has a
// system prompt tuned to emit that shape — see
// `src/agents/byoc_hashbrown_agent.py`.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const byocHashbrownAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-hashbrown/`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts; published agents type generic mismatch.
agents: {
"byoc-hashbrown-demo": byocHashbrownAgent,
default: byocHashbrownAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-byoc-hashbrown",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,40 @@
// Dedicated runtime for the BYOC json-render demo (AG2).
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const byocJsonRenderAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-json-render/`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts; published agents type generic mismatch.
agents: {
byoc_json_render: byocJsonRenderAgent,
default: byocJsonRenderAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-byoc-json-render",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,59 @@
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic Schema)
// cell. The backend is the dedicated `a2ui_dynamic.py` agent mounted at
// `/declarative-gen-ui` (NOT the root catch-all `agent.py`): it owns the
// `generate_a2ui` tool explicitly and runs its own secondary `render_a2ui`
// LLM pass, returning an `a2ui_operations` container that the A2UI
// middleware detects and streams to the frontend. This mirrors the sibling
// dedicated routes (`/a2ui-fixed-schema/`, `/beautiful-chat/`, etc.) which
// all point at their named mount, and matches the D6 fixtures + PARITY_NOTES.
//
// `injectA2UITool: false` — the agent already owns `generate_a2ui`, so the
// runtime must NOT double-bind a second injected A2UI tool over it.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: {
"declarative-gen-ui": new HttpAgent({
url: `${AGENT_URL}/declarative-gen-ui/`,
}),
},
a2ui: {
// The dedicated agent owns `generate_a2ui` and produces the
// `a2ui_operations` container itself; do not inject a second A2UI tool.
injectA2UITool: false,
// Pin the catalog the page registers (mirrors the sibling
// `/copilotkit-beautiful-chat` and `/copilotkit-a2ui-fixed-schema`
// routes). The agent's emitted ops already carry this catalogId, but
// pinning it guards against any op that omits it falling back to the
// unregistered basic catalog ("Catalog not found" → surface never mounts).
defaultCatalogId: "declarative-gen-ui-catalog",
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-gen-ui",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,73 @@
// CopilotKit runtime for the MCP Apps cell.
//
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware to the
// agent: when the agent calls a tool backed by an MCP UI resource, the
// middleware fetches the resource and emits the activity event that the
// built-in `MCPAppsActivityRenderer` renders in the chat as a sandboxed iframe.
//
// Reference:
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-mcp-apps/route.ts
// - src/agents/mcp_apps_agent.py (the AG2 backend, no bespoke tools)
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const mcpAppsAgent = new HttpAgent({ url: `${AGENT_URL}/mcp-apps/` });
const headlessCompleteAgent = new HttpAgent({
url: `${AGENT_URL}/headless-complete/`,
});
// @region[runtime-mcpapps-config]
// The `mcpApps.servers` config is all you need server-side. The runtime
// auto-applies the MCP Apps middleware to every registered agent: on each
// MCP tool call it fetches the associated UI resource and emits an
// `activity` event that the built-in `MCPAppsActivityRenderer` renders
// inline in the chat.
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: {
"mcp-apps": mcpAppsAgent,
// headless-complete shares this runtime because its cell also exercises
// MCP Apps rendering (via a hand-rolled `useRenderActivityMessage` in
// `use-rendered-messages.tsx`).
"headless-complete": headlessCompleteAgent,
},
mcpApps: {
servers: [
{
type: "http",
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
// Pin a stable `serverId`. Without it CopilotKit hashes the URL and
// a URL change silently breaks restoration of persisted MCP Apps in
// prior conversation threads.
serverId: "excalidraw",
},
],
},
});
// @endregion[runtime-mcpapps-config]
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-mcp-apps",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,44 @@
// Dedicated runtime for the Multimodal Attachments demo (AG2).
//
// The backing AG2 agent runs gpt-4o (vision-capable). A dedicated route keeps
// vision cost scoped to this cell.
//
// The page at src/app/demos/multimodal/page.tsx points its `runtimeUrl` at
// this endpoint and sets `agent="multimodal-demo"` (the slug registered below).
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const multimodalAgent = new HttpAgent({ url: `${AGENT_URL}/multimodal/` });
const agents = {
"multimodal-demo": multimodalAgent,
default: multimodalAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-multimodal",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- see main route.ts; published agents type generic mismatch.
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,60 @@
// Dedicated runtime for the Open Generative UI demos.
//
// Isolated here because the `openGenerativeUI` runtime flag sets
// `openGenerativeUIEnabled: true` globally on the probe response, which
// causes the CopilotKit provider's setTools effect to wipe per-demo
// `useFrontendTool`/`useComponent` registrations in the default runtime.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const openGenUiAgent = new HttpAgent({ url: `${AGENT_URL}/open-gen-ui/` });
const openGenUiAdvancedAgent = new HttpAgent({
url: `${AGENT_URL}/open-gen-ui-advanced/`,
});
const agents = {
"open-gen-ui": openGenUiAgent,
"open-gen-ui-advanced": openGenUiAdvancedAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-ogui",
serviceAdapter: new ExperimentalEmptyAdapter(),
// @region[advanced-runtime-config]
// @region[minimal-runtime-flag]
// Server-side config is identical for the minimal and advanced cells —
// the advanced behaviour (sandbox -> host function calls) is wired
// entirely on the frontend via `openGenerativeUI.sandboxFunctions` on
// the provider. The single `openGenerativeUI` flag below turns on
// Open Generative UI for the listed agent(s); the runtime middleware
// converts each agent's streamed `generateSandboxedUi` tool call into
// `open-generative-ui` activity events.
runtime: new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
openGenerativeUI: {
agents: ["open-gen-ui", "open-gen-ui-advanced"],
},
}),
// @endregion[minimal-runtime-flag]
// @endregion[advanced-runtime-config]
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,79 @@
// Dedicated runtime for the /demos/voice cell (AG2).
//
// Goals
// -----
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
// composer renders the mic button.
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`).
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured.
//
// Wires the V2 `CopilotRuntime` directly because the V1 wrapper drops the
// `transcriptionService` option. V2 URL-routes on `/info`, `/agent/:id/run`,
// `/transcribe`, etc., so the route lives at `[[...slug]]/route.ts`.
// @region[voice-runtime]
// @region[transcription-service-guard]
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
TranscriptionService,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
import OpenAI from "openai";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const voiceDemoAgent = new HttpAgent({ url: `${AGENT_URL}/` });
class GuardedOpenAITranscriptionService extends TranscriptionService {
private delegate: TranscriptionServiceOpenAI | null;
constructor() {
super();
const apiKey = process.env.OPENAI_API_KEY;
this.delegate = apiKey
? new TranscriptionServiceOpenAI({ openai: new OpenAI({ apiKey }) })
: null;
}
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
if (!this.delegate) {
throw new Error(
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
"Set OPENAI_API_KEY to enable voice transcription.",
);
}
return this.delegate.transcribeFile(options);
}
}
// @endregion[transcription-service-guard]
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
function getHandler(): (req: Request) => Promise<Response> {
if (cachedHandler) return cachedHandler;
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts; published agents type generic mismatch
agents: {
"voice-demo": voiceDemoAgent,
default: voiceDemoAgent,
},
transcriptionService: new GuardedOpenAITranscriptionService(),
});
cachedHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-voice",
});
return cachedHandler;
}
export const POST = (req: NextRequest) => getHandler()(req);
export const GET = (req: NextRequest) => getHandler()(req);
export const PUT = (req: NextRequest) => getHandler()(req);
export const DELETE = (req: NextRequest) => getHandler()(req);
// @endregion[voice-runtime]
@@ -0,0 +1,182 @@
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
// The agent backend runs as a separate process on port 8000.
// This runtime proxies CopilotKit requests to it via AG-UI protocol.
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
console.log("[copilotkit/route] Initializing CopilotKit runtime");
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
// Per-request request/response logging is gated behind this flag (default off).
// Under d6 probe fan-out, unconditional per-request logs flooded Railway's
// 500-logs/sec cap and killed the replica ("Messages dropped" → container stop).
// Set SHOWCASE_ROUTE_DEBUG=1 to re-enable verbose per-request tracing locally.
const ROUTE_DEBUG =
process.env.SHOWCASE_ROUTE_DEBUG === "1" ||
process.env.SHOWCASE_ROUTE_DEBUG === "true";
function createAgent(path = "/") {
return new HttpAgent({ url: `${AGENT_URL}${path}` });
}
// Register the same default agent under all shared names used by demo
// pages. AG2's AGUIStream wraps a single ConversableAgent; most names
// proxy to the same backend process. Frontend-only variations (slots,
// sidebar, CSS theming, headless chat, tool rendering wildcards, etc.)
// all reuse the shared `agent.py` ConversableAgent under a unique
// registered name.
const sharedAgentNames = [
"agentic_chat",
"human_in_the_loop",
"tool-rendering",
"gen-ui-tool-based",
"shared-state-read",
"shared-state-write",
"shared-state-streaming",
// Frontend-only variants (Batch 1) — same ConversableAgent, different UI.
"prebuilt-sidebar",
"prebuilt-popup",
"chat-slots",
"chat-customization-css",
"headless-simple",
"readonly-state-agent-context",
"tool-rendering-default-catchall",
"tool-rendering-custom-catchall",
"frontend_tools",
"frontend-tools-async",
"hitl-in-app",
"hitl-in-chat",
];
// Reasoning agent names — backed by the reasoning-enabled AG2 agent at
// /reasoning. Emits AG-UI REASONING_MESSAGE_* events that the frontend
// renders via the `reasoningMessage` slot (built-in card for
// `reasoning-default`, custom amber ReasoningBlock for `reasoning-custom`).
// The demo pages use the ids `reasoning-default` / `reasoning-custom`; both
// share the one reasoning backend. `agentic-chat-reasoning` and
// `reasoning-default-render` are legacy aliases kept for any cell that still
// references them.
const reasoningAgentNames = [
"reasoning-default",
"reasoning-custom",
"reasoning-default-render",
"agentic-chat-reasoning",
];
// Demos that own a dedicated FastAPI sub-app (mounted at a named path
// in `agent_server.py`). Each gets its own HttpAgent pointed at that
// path so its ContextVariables state slot is isolated from the shared
// default agent.
const dedicatedAgents: Record<string, string> = {
"shared-state-read-write": "/shared-state-read-write/",
subagents: "/subagents/",
"headless-complete": "/headless-complete/",
"tool-rendering-reasoning-chain": "/tool-rendering-reasoning-chain/",
"agent-config-demo": "/agent-config/",
"gen-ui-agent": "/gen-ui-agent/",
};
// Interrupt-adapted demos: gen-ui-interrupt and interrupt-headless share the
// same AG2 scheduling agent at /interrupt-adapted. The agent has tools=[];
// `schedule_meeting` is provided by the frontend via `useFrontendTool`.
const interruptAgentNames = ["gen-ui-interrupt", "interrupt-headless"];
const agents: Record<string, AbstractAgent> = {};
for (const name of sharedAgentNames) {
agents[name] = createAgent();
}
for (const name of reasoningAgentNames) {
agents[name] = createAgent("/reasoning/");
}
for (const [name, path] of Object.entries(dedicatedAgents)) {
agents[name] = createAgent(path);
}
for (const name of interruptAgentNames) {
agents[name] = createAgent("/interrupt-adapted/");
}
agents["default"] = createAgent();
console.log(
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
);
export const POST = async (req: NextRequest) => {
const url = req.url;
const contentType = req.headers.get("content-type");
if (ROUTE_DEBUG) {
console.log(
`[copilotkit/route] POST ${url} (content-type: ${contentType})`,
);
}
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
}),
});
const response = await handleRequest(req);
if (!response.ok) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
} else if (ROUTE_DEBUG) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
}
return response;
} catch (error: unknown) {
// Log full details server-side (operators grep `errorId` to correlate),
// but never echo `err.message` / `err.stack` back to the HTTP client —
// that leaks internal paths, dependency versions, and stack traces.
const err = error instanceof Error ? error : new Error(String(error));
const errorId = crypto.randomUUID();
console.error(
JSON.stringify({
at: new Date().toISOString(),
level: "error",
scope: "copilotkit/route",
errorId,
message: err.message,
stack: err.stack,
}),
);
return NextResponse.json(
{ error: "internal runtime error", errorId },
{ status: 500 },
);
}
};
export const GET = async () => {
if (ROUTE_DEBUG) {
console.log("[copilotkit/route] GET /api/copilotkit (health probe)");
}
let agentStatus = "unknown";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
} catch (e: unknown) {
agentStatus = `unreachable (${(e as Error).message})`;
}
return NextResponse.json({
status: "ok",
agent_url: AGENT_URL,
agent_status: agentStatus,
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
NODE_ENV: process.env.NODE_ENV,
},
});
};
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
const token =
req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
if (!expectedToken || !token || token !== expectedToken) {
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
}
const AGENT_URL = process.env.AGENT_URL || "unknown";
// Agent connectivity
let agentStatus = "unknown";
let agentDetail = "";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "ok" : "error";
agentDetail = `HTTP ${res.status}`;
} catch (e: unknown) {
agentStatus = "down";
agentDetail = (e as Error).message;
}
const uptime = process.uptime();
const mem = process.memoryUsage();
return NextResponse.json({
integration: "ag2",
uptime: `${Math.floor(uptime / 60)}m ${Math.floor(uptime % 60)}s`,
agent: { url: AGENT_URL, status: agentStatus, detail: agentDetail },
memory: {
rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)}MB`,
},
env: {
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
LANGSMITH_API_KEY: process.env.LANGSMITH_API_KEY ? "set" : "NOT SET",
},
nodeVersion: process.version,
});
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "ok",
integration: "ag2",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "ag2";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function GET() {
const start = Date.now();
// Hit our own /api/copilotkit endpoint — tests the full deployed stack
const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL ||
`http://localhost:${process.env.PORT || 3000}`;
try {
const res = await fetch(`${baseUrl}/api/copilotkit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: "agent/run",
params: { agentId: "agentic_chat" },
body: {
threadId: `smoke-${Date.now()}`,
runId: `smoke-run-${Date.now()}`,
state: {},
messages: [
{
id: `smoke-msg-${Date.now()}`,
role: "user",
content: "Respond with exactly: OK",
},
],
tools: [],
context: [],
forwardedProps: {},
},
}),
signal: AbortSignal.timeout(45000),
});
const latency = Date.now() - start;
if (!res.ok) {
const errBody = await res.text().catch(() => "");
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "runtime_response",
error: `Runtime returned ${res.status}: ${errBody.slice(0, 200)}`,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
// TTFB: read first chunk only to confirm SSE stream started, then cancel
const reader = res.body?.getReader();
if (!reader) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned no readable body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
const { value, done } = await reader.read();
reader.cancel();
if (done || !value || value.length === 0) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned empty response body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
return NextResponse.json({
status: "ok",
integration: INTEGRATION_SLUG,
latency_ms: latency,
timestamp: new Date().toISOString(),
});
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
const latency = Date.now() - start;
let stage = "unknown";
if (err.name === "AbortError" || err.message.includes("timeout"))
stage = "timeout";
else if (
err.message.includes("fetch") ||
err.message.includes("ECONNREFUSED")
)
stage = "agent_unreachable";
else stage = "pipeline_error";
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage,
error: err.message,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
}
@@ -0,0 +1,55 @@
// Shared fallback time-slot generator for the interrupt demos
// (`gen-ui-interrupt`, `interrupt-headless`). The interrupt backend
// (`src/agents/interrupt_agent.py`) supplies its own candidate slots
// inside the interrupt payload — these fallbacks only run if the
// payload arrives without them. Generating relative to `Date.now()`
// keeps the fallback from rotting, which previously had hardcoded
// dates that decayed within a week of being authored.
export interface TimeSlot {
label: string;
iso: string;
}
function atLocal(date: Date, hour: number, minute = 0): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
hour,
minute,
0,
0,
);
}
function nextMonday(from: Date): Date {
// `getDay()` is 0=Sun, 1=Mon, ..., 6=Sat. We want the next Monday
// that's at LEAST 2 days away — otherwise "Monday" would collide
// with "Tomorrow" on Sunday (offset would be 1) or with itself on
// Monday (offset would be 0). Mirrors interrupt_agent.py.
const day = from.getDay();
let offset = (1 - day + 7) % 7;
if (offset <= 1) offset += 7;
const next = new Date(from);
next.setDate(from.getDate() + offset);
return next;
}
export function generateFallbackSlots(now: Date = new Date()): TimeSlot[] {
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const monday = nextMonday(now);
const candidates: Array<[string, Date]> = [
["Tomorrow 10:00 AM", atLocal(tomorrow, 10)],
["Tomorrow 2:00 PM", atLocal(tomorrow, 14)],
["Monday 9:00 AM", atLocal(monday, 9)],
["Monday 3:30 PM", atLocal(monday, 15, 30)],
];
return candidates.map(([label, date]) => ({
label,
iso: date.toISOString(),
}));
}
@@ -0,0 +1,12 @@
// Coerces a tool-call result into a typed object. Tool results arrive
// as strings when the agent emits JSON or as already-parsed objects
// when the runtime decoded them upstream — this helper handles both
// shapes and returns `{}` if the result is missing or unparseable.
export function parseJsonResult<T>(result: unknown): T {
if (!result) return {} as T;
try {
return (typeof result === "string" ? JSON.parse(result) : result) as T;
} catch {
return {} as T;
}
}
@@ -0,0 +1,21 @@
// Helper for the CopilotChat slot overrides. The slot prop types in
// `@copilotkit/react-core` are nominally typed against the *exact*
// default component identity, but a custom wrapper that returns a
// structurally compatible ReactElement is functionally a drop-in. This
// helper names that fact and centralizes the type assertion in one
// place — readers see `makeSlotOverride` and know it's about the slot
// contract, not arbitrary type-system gymnastics. Once the slot prop
// types accept structural compatibility, this helper can be deleted
// and the casts will resolve automatically.
import type { ComponentType } from "react";
// `any` on the input is intentional: the helper's purpose is to accept
// any component shape and assert it as the slot's expected type. A
// stricter constraint would defeat the whole point.
export function makeSlotOverride<TDefault>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>,
): TDefault {
return component as unknown as TDefault;
}
@@ -0,0 +1,31 @@
import * as React from "react";
/**
* ShadCN-style Badge primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "secondary" | "outline" | "success";
const variantClasses: Record<Variant, string> = {
default: "border-transparent bg-neutral-900 text-neutral-50",
secondary: "border-transparent bg-neutral-100 text-neutral-900",
outline: "border-neutral-200 text-neutral-700 bg-white",
success: "border-transparent bg-emerald-100 text-emerald-700",
};
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: Variant;
}
export function Badge({
className = "",
variant = "default",
...props
}: BadgeProps) {
return (
<div
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium tracking-wide ${variantClasses[variant]} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,48 @@
import * as React from "react";
/**
* ShadCN-style Button primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "outline" | "secondary" | "ghost" | "success";
type Size = "default" | "sm" | "lg";
const baseClasses =
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60";
const variantClasses: Record<Variant, string> = {
default: "bg-neutral-900 text-neutral-50 shadow-sm hover:bg-neutral-800",
outline:
"border border-neutral-200 bg-white text-neutral-900 shadow-sm hover:bg-neutral-100",
secondary: "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-200",
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
success:
"bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm hover:bg-emerald-50",
};
const sizeClasses: Record<Size, string> = {
default: "h-10 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-6",
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className = "", variant = "default", size = "default", ...props },
ref,
) => {
return (
<button
ref={ref}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
/>
);
},
);
Button.displayName = "Button";
@@ -0,0 +1,61 @@
import * as React from "react";
/**
* ShadCN-style Card primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
export const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm ${className}`}
{...props}
/>
));
Card.displayName = "Card";
export const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex flex-col space-y-1.5 p-5 pb-3 ${className}`}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
export const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className = "", ...props }, ref) => (
<h3
ref={ref}
className={`text-base font-semibold leading-none tracking-tight text-neutral-900 ${className}`}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
export const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div ref={ref} className={`p-5 pt-0 ${className}`} {...props} />
));
CardContent.displayName = "CardContent";
export const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex items-center p-5 pt-0 ${className}`}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
@@ -0,0 +1,26 @@
import * as React from "react";
/**
* ShadCN-style Separator primitive (inline-cloned for this demo).
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
*/
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical";
}
export function Separator({
className = "",
orientation = "horizontal",
...props
}: SeparatorProps) {
const orientationClasses =
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
return (
<div
role="separator"
aria-orientation={orientation}
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,23 @@
"use client";
/**
* Fixed A2UI catalog — wires definitions to renderers.
*
* `includeBasicCatalog: true` merges CopilotKit's built-in components
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
* compose custom and basic components interchangeably.
*/
// @region[catalog-creation]
import { createCatalog } from "@copilotkit/a2ui-renderer";
import { definitions } from "./definitions";
import { renderers } from "./renderers";
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
export const catalog = createCatalog(definitions, renderers, {
catalogId: CATALOG_ID,
includeBasicCatalog: true,
});
// @endregion[catalog-creation]
@@ -0,0 +1,107 @@
/**
* A2UI catalog DEFINITIONS — platform-agnostic.
*
* Each entry declares a component name + its Zod props schema. The basic
* catalog (Card, Column, Row, Text, Button, …) ships with CopilotKit and
* is mixed in via `createCatalog(..., { includeBasicCatalog: true })`, so
* we only declare the project-specific additions and the visual overrides
* here. (Custom entries with the same name as a basic component override
* the basic one — Catalog dedupes by `comp.name`, last-write-wins.)
*
* IMPORTANT — path bindings: fields that can be bound to a data-model path
* (e.g. `{ path: "/origin" }` in the fixed schema JSON) must declare their
* Zod type as a union of `z.string()` and `z.object({ path: z.string() })`.
* The A2UI `GenericBinder` uses this union to detect the field as dynamic
* and resolve the path against the current data model at render time. Using
* plain `z.string()` causes the raw `{ path }` object to reach the
* renderer, which React then throws on (error #31 "object with keys {path}").
* This matches the canonical catalog's `DynString` helper:
* examples/integrations/langgraph-python/src/app/declarative-generative-ui/definitions.ts
*/
// @region[definitions-types]
import { z } from "zod";
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
/**
* Dynamic string: literal OR a data-model path binding. The GenericBinder
* resolves path bindings to the actual value at render time.
*/
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
export const definitions = {
/**
* Card override: gives the outer flight-card container a ShadCN look
* (rounded-xl, neutral-200 border, soft shadow). The basic catalog's
* Card uses inline styles; overriding here lets the demo's renderer
* adopt the demo's Tailwind aesthetic without touching the schema JSON.
*/
Card: {
description: "A container card with a single child.",
props: z.object({
child: z.string(),
}),
},
Title: {
description: "A prominent heading for the flight card.",
props: z.object({
text: DynString,
}),
},
Airport: {
description: "A 3-letter airport code, displayed large.",
props: z.object({
code: DynString,
}),
},
Arrow: {
description: "A right-pointing arrow used between airports.",
props: z.object({}),
},
AirlineBadge: {
description: "A pill-styled airline name tag.",
props: z.object({
name: DynString,
}),
},
PriceTag: {
description: "A stylized price display (e.g. '$289').",
props: z.object({
amount: DynString,
}),
},
/**
* Button override: swaps in an ActionButton renderer that tracks
* its own `done` state so clicking "Book flight" visually updates to
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
* so without this override the click fires the action but the button
* looks unchanged. Mirrors the pattern in beautiful-chat
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
*/
Button: {
description:
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
props: z.object({
child: z
.string()
.describe(
"The ID of the child component (e.g. a Text component for the label).",
),
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
} satisfies CatalogDefinitions;
// @endregion[definitions-types]
export type Definitions = typeof definitions;
@@ -0,0 +1,110 @@
"use client";
/**
* A2UI catalog RENDERERS — React implementations for the custom components
* declared in `./definitions`. TypeScript enforces that the renderer map's
* keys and prop shapes match the definitions exactly.
*
* Visual style: ShadCN aesthetic (neutral palette, rounded-xl, subtle
* borders, clean typography). Tailwind utility classes only — no `cn()` /
* `cva` helpers, no shadcn CLI install. Inline-cloned primitives live in
* `../_components/`.
*/
import React from "react";
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
import type { Definitions } from "./definitions";
import { Card } from "../_components/card";
import { Badge } from "../_components/badge";
import { Button as UIButton } from "../_components/button";
import { Separator } from "../_components/separator";
// `DynString` props are typed as `string | { path }` (see definitions.ts), but
// the A2UI binder resolves path bindings before render — renderers only ever
// see resolved strings. One shared helper keeps that narrowing in one place.
const s = (v: unknown): string => (typeof v === "string" ? v : "");
// @region[renderers-tsx]
export const renderers: CatalogRenderers<Definitions> = {
/**
* Card override: ShadCN-style outer container. The basic catalog's Card
* uses inline styles; overriding here keeps the demo's tailwind aesthetic.
* The flight schema renders Card > Column > [Title, Row, …]; the inner
* Column adds the vertical spacing.
*/
Card: ({ props, children }) => (
<Card className="w-full max-w-md p-5" data-testid="a2ui-fixed-card">
{props.child ? children(props.child) : null}
</Card>
),
Title: ({ props }) => (
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
Itinerary
</p>
<h3 className="text-base font-semibold leading-none tracking-tight text-neutral-900">
{s(props.text)}
</h3>
</div>
<Badge variant="outline" className="font-mono">
1-stop · economy
</Badge>
</div>
),
Airport: ({ props }) => (
<div className="flex flex-col items-center">
<span className="font-mono text-2xl font-semibold tracking-wider text-neutral-900">
{s(props.code)}
</span>
</div>
),
Arrow: () => (
<div className="flex flex-1 items-center px-3">
<Separator className="flex-1 bg-neutral-200" />
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mx-1 text-neutral-400"
aria-hidden
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
<Separator className="flex-1 bg-neutral-200" />
</div>
),
AirlineBadge: ({ props }) => (
<Badge variant="secondary" className="uppercase tracking-[0.08em]">
{s(props.name)}
</Badge>
),
PriceTag: ({ props }) => (
<div className="flex items-baseline gap-1">
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
Total
</span>
<span className="font-mono text-base font-semibold text-neutral-900">
{s(props.amount)}
</span>
</div>
),
/**
* Button override: this is a pure-presentation demo, so the button just
* renders its label. The schema declares an `action` for visual fidelity,
* but the click handler is inert until the Python SDK exposes
* `action_handlers=` on `a2ui.render` (see `src/agents/a2ui_fixed.py`).
*/
Button: ({ props, children }) => (
<UIButton className="w-full">
{props.child ? children(props.child) : null}
</UIButton>
),
};
// @endregion[renderers-tsx]
@@ -0,0 +1,11 @@
"use client";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { useA2UIFixedSchemaSuggestions } from "./suggestions";
export function Chat() {
useA2UIFixedSchemaSuggestions();
return (
<CopilotChat agentId="a2ui-fixed-schema" className="h-full rounded-2xl" />
);
}
@@ -0,0 +1,41 @@
"use client";
/**
* Declarative Generative UI — A2UI Fixed Schema demo.
*
* In the fixed-schema flavor of A2UI, the component tree (schema) lives on
* the frontend and the agent only streams *data* into the data model. The
* flight card is ASSEMBLED from small sub-components in
* `src/agents/a2ui_schemas/flight_schema.json` (Card > Column > [Title, Row, …]).
*
* - Definitions (zod schemas): `./a2ui/definitions.ts`
* - Renderers (React): `./a2ui/renderers.tsx`
* - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog)
* - Agent: `src/agents/a2ui_fixed.py` (emits an `a2ui_operations` container)
*
* Reference:
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
*/
import React from "react";
import { CopilotKit } from "@copilotkit/react-core/v2";
import { catalog } from "./a2ui/catalog";
import { Chat } from "./chat";
export default function A2UIFixedSchemaDemo() {
return (
// `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.
<CopilotKit
runtimeUrl="/api/copilotkit-a2ui-fixed-schema"
agent="a2ui-fixed-schema"
a2ui={{ catalog: catalog }}
>
<div className="flex justify-center items-center h-screen w-full bg-neutral-50">
<div className="h-full w-full max-w-4xl border-x border-neutral-200 bg-white">
<Chat />
</div>
</div>
</CopilotKit>
);
}
@@ -0,0 +1,13 @@
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export function useA2UIFixedSchemaSuggestions() {
useConfigureSuggestions({
suggestions: [
{
title: "Find SFO → JFK",
message: "Find me a flight from SFO to JFK on United for $289.",
},
],
available: "always",
});
}
@@ -0,0 +1,91 @@
"use client";
import type { ChangeEvent } from "react";
import {
type AgentConfig,
EXPERTISE_OPTIONS,
type Expertise,
RESPONSE_LENGTH_OPTIONS,
type ResponseLength,
TONE_OPTIONS,
type Tone,
} from "./config-types";
interface ConfigCardProps {
config: AgentConfig;
onToneChange: (tone: Tone) => void;
onExpertiseChange: (expertise: Expertise) => void;
onResponseLengthChange: (length: ResponseLength) => void;
}
export function ConfigCard({
config,
onToneChange,
onExpertiseChange,
onResponseLengthChange,
}: ConfigCardProps) {
return (
<div
data-testid="agent-config-card"
className="flex flex-col gap-2 rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-4 text-sm"
>
<h2 className="text-sm font-semibold">Agent Config</h2>
<p className="text-xs text-[var(--text-muted)]">
Change these and send a message to see the agent adapt.
</p>
<div className="flex flex-wrap gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Tone</span>
<select
data-testid="agent-config-tone-select"
value={config.tone}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onToneChange(e.target.value as Tone)
}
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
>
{TONE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Expertise</span>
<select
data-testid="agent-config-expertise-select"
value={config.expertise}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onExpertiseChange(e.target.value as Expertise)
}
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
>
{EXPERTISE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Response length</span>
<select
data-testid="agent-config-length-select"
value={config.responseLength}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onResponseLengthChange(e.target.value as ResponseLength)
}
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
>
{RESPONSE_LENGTH_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
"use client";
/**
* Publishes the current agent-config toggles to the agent runtime via
* `useAgentContext`. Lives inside the `<CopilotKit>` provider so the
* context store is reachable. The middleware on the Python side reads
* this entry off the agent's runtime context on every turn and routes
* it into the model's prompt.
*/
import { useAgentContext } from "@copilotkit/react-core/v2";
import type { AgentConfig } from "./config-types";
export function ConfigContextRelay({ config }: { config: AgentConfig }) {
useAgentContext({
description:
"Agent response preferences. Apply tone, expertise level, and response length to every reply.",
value: {
tone: config.tone,
expertise: config.expertise,
responseLength: config.responseLength,
},
});
return null;
}
@@ -0,0 +1,26 @@
export type Tone = "professional" | "casual" | "enthusiastic";
export type Expertise = "beginner" | "intermediate" | "expert";
export type ResponseLength = "concise" | "detailed";
export interface AgentConfig {
tone: Tone;
expertise: Expertise;
responseLength: ResponseLength;
}
export const DEFAULT_AGENT_CONFIG: AgentConfig = {
tone: "professional",
expertise: "intermediate",
responseLength: "concise",
};
export const TONE_OPTIONS: Tone[] = ["professional", "casual", "enthusiastic"];
export const EXPERTISE_OPTIONS: Expertise[] = [
"beginner",
"intermediate",
"expert",
];
export const RESPONSE_LENGTH_OPTIONS: ResponseLength[] = [
"concise",
"detailed",
];

Some files were not shown because too many files have changed in this diff Show More