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
+8
View File
@@ -0,0 +1,8 @@
# Agno uses OpenAI as the model provider
OPENAI_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/
+81
View File
@@ -0,0 +1,81 @@
# Stage 1: Build Next.js frontend
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"]
+189
View File
@@ -0,0 +1,189 @@
# Agno — Parity Notes
Tracking notes for feature-matrix parity between this package and
`showcase/integrations/langgraph-python/` (canonical reference).
## Ported
See `manifest.yaml` for the authoritative list.
### Initial parity push
- `prebuilt-sidebar`, `prebuilt-popup` — chrome demos using the shared main agent
- `chat-slots`, `chat-customization-css` — chat customization paths
- `headless-simple` — minimal useAgent surface
- `frontend-tools`, `frontend-tools-async` — useFrontendTool (sync + async handlers)
- `readonly-state-agent-context` — useAgentContext read-only context
- `tool-rendering-default-catchall`, `tool-rendering-custom-catchall` — wildcard-only tool rendering variants (new `get_stock_price` + `roll_dice` tools added to the Agno main agent)
- `hitl-in-chat` booking flow — useHumanInTheLoop with a new `book_call` external-execution tool
- `hitl-in-app` — frontend-tool + app-level approval dialog (frontend-only)
### Second pass (deferred-demo recovery)
- `agentic-chat-reasoning`, `reasoning-default-render`,
`tool-rendering-reasoning-chain` — reasoning family. Verified Agno's AGUI
interface emits `REASONING_MESSAGE_*` events (`agno/os/interfaces/agui/utils.py`
imports `ReasoningMessageStartEvent` / `ReasoningMessageContentEvent` /
`ReasoningMessageEndEvent`). Added a new `reasoning_agent` Python module
with `reasoning=True` plus a second `AGUI` interface mounted at prefix
`/reasoning`. The Next.js runtime aliases the three reasoning agent names to
an `HttpAgent` targeting `/reasoning/agui`.
- `headless-complete` — full chat from scratch on `useAgent` +
`CopilotChatConfigurationProvider` + manual `useRenderToolCall` /
`useRenderActivityMessage` / `useRenderCustomMessages` composition. Reuses
the Agno main agent via the default `/api/copilotkit` endpoint. MCP-Apps
activity surface is intentionally omitted — Agno's AGUI adapter doesn't
expose an MCP-Apps runtime. Every other generative-UI branch (per-tool
renderers, `useComponent` frontend tools, reasoning, custom messages,
wildcard catch-all) is wired in.
- `auth` — dedicated `/api/copilotkit-auth` runtime using
`createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` with an
`onRequest` hook that rejects requests lacking a static Bearer token.
Authenticated target is the Agno main agent at `/agui`.
### Fourth pass (manifest fill — first half)
- `cli-start` — informational demo entry (no route/agent) advertising the
copy-paste starter command for Agno. Mirrors the canonical
`langgraph-python` cli-start cell.
- `gen-ui-tool-based` — already shipped as a haiku-renderer demo (frontend-only
via `useFrontendTool` + `render`); now declared in `manifest.yaml` so the
showcase picks it up. Wired to the main agent under the `gen-ui-tool-based`
alias in `src/app/api/copilotkit/route.ts`.
- `hitl-in-chat-booking` — manifest entry pointing at the existing
`hitl-in-chat` time-picker booking surface (same files, distinct cell). The
Agno main agent's `book_call` external-execution tool already drives this
flow.
### Fifth pass (manifest fill — second half)
- `mcp-apps` — runtime `mcpApps.servers` middleware against the public
Excalidraw MCP server. Backed by a no-tools Agno agent
(`mcp_apps_agent.py`) mounted at `/mcp-apps/agui` so the LLM only sees the
MCP-injected toolset.
- `open-gen-ui` / `open-gen-ui-advanced` — shared dedicated runtime
(`/api/copilotkit-ogui`) with the `openGenerativeUI` flag that wires the
middleware. Backed by a no-tools Agno agent (`open_gen_ui_agent.py`)
mounted at `/open-gen-ui/agui`. Advanced cell adds host-side sandbox
functions (`evaluateExpression`, `notifyHost`) on the provider.
- `agent-config` — typed config object (tone/expertise/responseLength)
forwarded via the provider's `properties` prop. The Agno backend mounts a
custom AGUI handler at `/agent-config/agui` (`agent_server.py::
_run_agent_config`) that reads `RunAgentInput.forwarded_props` and builds
a per-request Agno Agent from `agents.agent_config_agent.build_agent(...)`
before delegating to the stock AGUI stream mapper. Agno has no
LangGraph-style configurable channel, so the per-request factory is the
cleanest path to dynamic system prompts here.
- `voice` — V2 runtime under `/api/copilotkit-voice` with a guarded
`TranscriptionServiceOpenAI`. Targets the Agno main agent at `/agui` for
the chat side; transcription is purely runtime-side.
- `multimodal` — vision-capable Agno agent (`multimodal_agent.py`, gpt-4o)
on its own `/multimodal/agui` interface, scoped via
`/api/copilotkit-multimodal`. Image attachments forward natively; PDF
flattening helper (`_maybe_flatten_pdf_part`) lives next to the agent for
use if the AGUI converter needs assistance.
- `byoc-hashbrown` — dedicated `/api/copilotkit-byoc-hashbrown` runtime +
`byoc_hashbrown_agent.py` whose system prompt steers the LLM toward the
hashbrown UI-kit envelope shape (`{ "ui": [...] }`).
- `byoc-json-render` — dedicated `/api/copilotkit-byoc-json-render` runtime
- `byoc_json_render_agent.py` whose system prompt steers the LLM toward
the json-render flat element-tree spec (`{ root, elements }`).
### Sixth pass (A2UI fixed schema)
- `a2ui-fixed-schema` — dedicated `/api/copilotkit-a2ui-fixed-schema`
runtime with `injectA2UITool: false`. New `a2ui_fixed_agent.py`
mounted at `/a2ui-fixed-schema/agui` ships
`flight_schema.json` + `booked_schema.json` and a single
`display_flight` tool that emits an `a2ui_operations` container
_directly_ — no secondary LLM call — so the LLM only fills in data
(origin/destination/airline/price). `booked_schema.json` is shipped
as a sibling for when the SDK exposes per-button action handlers
for fixed-schema surfaces.
### Seventh pass (A2UI dynamic schema)
- `declarative-gen-ui` — dedicated `/api/copilotkit-declarative-gen-ui`
runtime with `injectA2UITool: false`. New `a2ui_dynamic_agent.py`
mounted at `/declarative-gen-ui/agui` owns its own `generate_a2ui`
tool. Unlike the main agent's hardcoded-catalog `generate_a2ui`, this
agent's tool reads the registered client catalog from
`run_context.session_state["copilotkit"]["context"]` and feeds it to
the secondary OpenAI client, so the rendered components stay in sync
with whatever catalog the frontend registers via `<CopilotKit
a2ui={{ catalog }}>`. See `src/app/demos/declarative-gen-ui/README.md`
for the differences vs. the main-agent path.
### Third pass (state + multi-agent recovery)
- `shared-state-read-write` — bidirectional shared state with the UI
writing `preferences` via `agent.setState(...)` and the agno agent
writing `notes` back via a `set_notes` tool that mutates
`run_context.session_state["notes"]`. Backed by a new
`shared_state_read_write` agent module + a custom AGUI handler in
`agent_server.py` mounted at `/shared-state-rw/agui`. The custom
handler is a thin shim around `agno.os.interfaces.agui.utils`'s
stream mapper that additionally emits a `StateSnapshotEvent` with the
final `session_state` immediately before `RunFinishedEvent` — Agno's
stock AGUI router does not emit state events, so without this shim
agent-side state writes are invisible to a frontend subscribed via
`useAgent({ updates: [OnStateChanged] })`.
- `subagents` — supervisor agno agent delegating to three specialized
sub-agents (research / writing / critique). Each sub-agent is itself
an Agno `Agent(...)` with its own system prompt, invoked via the
delegation tools' `_invoke_sub_agent` helper. Every delegation
appends an entry to `session_state["delegations"]` (with `running`
status pre-flight, then flipped to `completed`/`failed` post-flight).
Reuses the same `/subagents/agui` state-aware AGUI handler so the
delegation log re-renders live.
## Skipped
The following demos from the canonical LangGraph-Python reference are intentionally
NOT ported to this package. Each has a concrete reason tied to a genuine framework
capability difference or infrastructure requirement that we couldn't validate in
this blitz pass.
### LangGraph-specific primitives (no direct Agno equivalent)
- `gen-ui-interrupt` — Uses LangGraph's `interrupt()` primitive to pause the graph
mid-run and resolve from the UI. Agno's AgentOS AGUI adapter does not expose an
equivalent long-running-resume primitive at this time. We already ship
`hitl-in-chat-booking` + `hitl-in-app`, which cover the user-facing HITL scenario
via Agno's native tool-approval path.
- `interrupt-headless` — Same root cause as `gen-ui-interrupt`. Headless resume
from a button grid requires a pause/resume handle the Agno AGUI adapter does
not currently surface.
### Require dedicated runtimes we haven't wired yet
These demos depend on dedicated `/api/copilotkit-<variant>/route.ts` runtimes in
the canonical reference. They are portable in principle — they just need new
route files each and supporting Python wiring — but doing them right requires
exercising Agno's runtime config paths we haven't validated yet. Deferred for a
follow-up parity pass rather than faked in.
- `beautiful-chat` — combined runtime (openGenerativeUI + a2ui + mcpApps)
- `byoc-hashbrown` — dedicated `/api/copilotkit-byoc-hashbrown` runtime, requires
Agno structured-output streaming matching the hashbrown Zod catalog
- `byoc-json-render` — dedicated `/api/copilotkit-byoc-json-render` runtime,
requires streaming JSON-schema-constrained output from Agno
- `multimodal` — dedicated `/api/copilotkit-multimodal` runtime; Agno supports
multimodal input via `UserMessage(images=[...])` but wiring vision to an
AGUI-served agent needs a runtime surface we haven't built
- `voice` — dedicated `/api/copilotkit-voice` runtime + `@copilotkit/voice`;
voice STT is a frontend concern independent of the Agno agent
- `open-gen-ui`, `open-gen-ui-advanced` — dedicated `/api/copilotkit-ogui`
runtime; requires openGenerativeUI middleware on a V2 runtime talking to
an Agno agent
- `agent-config` — dedicated `/api/copilotkit-agent-config` runtime with typed
config forwarding; needs Agno dynamic-system-prompt wiring per-request
- `mcp-apps` — requires Agno MCP client/server wiring; Agno has
`agno.tools.mcp.MCPTools` but integration with the AGUI adapter's
activity-message surface wasn't verified
### Not a real demo
- `cli-start` — Copy-paste starter command rendered by the dashboard as a
command card with no route/agent. The equivalent starter for Agno is already
advertised via `manifest.yaml`'s `starter:` section.
+1
View File
@@ -0,0 +1 @@
../_shared
@@ -0,0 +1,37 @@
{
"framework": "agno",
"features": {
"agentic-chat": {
"og_docs_url": "https://docs.copilotkit.ai/agno/frontend-tools",
"shell_docs_path": "/frontend-tools"
},
"hitl-in-chat": {
"og_docs_url": "https://docs.copilotkit.ai/agno/human-in-the-loop",
"shell_docs_path": "/generative-ui/your-components/interactive"
},
"tool-rendering": {
"og_docs_url": "https://docs.copilotkit.ai/agno/generative-ui/tool-rendering",
"shell_docs_path": "/generative-ui/tool-rendering"
},
"gen-ui-tool-based": {
"og_docs_url": "https://docs.copilotkit.ai/agno/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/generative-ui/state-rendering",
"shell_docs_path": "/generative-ui/state-rendering"
},
"shared-state-read-write": {
"og_docs_url": "https://docs.copilotkit.ai/shared-state",
"shell_docs_path": "/shared-state"
},
"shared-state-streaming": {
"og_docs_url": "https://docs.copilotkit.ai/shared-state",
"shell_docs_path": "/shared-state"
},
"subagents": {
"og_docs_url": "https://docs.copilotkit.ai/agno",
"shell_docs_path": "/multi-agent/subagents"
}
}
}
+117
View File
@@ -0,0 +1,117 @@
#!/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: agno"
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..."
# --loop asyncio pins uvicorn to the stdlib asyncio event loop instead of its
# default `auto`, which would select uvloop (pulled in by uvicorn[standard]).
# This is load-bearing: the secondary OpenAI call inside the sync `generate_a2ui`
# tool runs on a `loop.run_in_executor(...)` worker thread, and the
# install_executor_contextvar_propagation() shim only propagates the
# forwarded-header ContextVar into that thread under a stdlib BaseEventLoop —
# it is inert under uvloop, so under uvloop the secondary call drops
# x-aimock-context and aimock returns 503. This is the prod launch path
# (agent_server.main()'s agent_os.serve(loop="asyncio") is not used here).
python -u -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 --loop asyncio &> >(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
+579
View File
@@ -0,0 +1,579 @@
name: Agno
slug: agno
category: emerging
language: python
logo: /logos/agno.svg
description: >-
CopilotKit integration with Agno. 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/agno
copilotkit_version: 2.0.0
deployed: true
docs_mode: authored
sort_order: 90
generative_ui:
- constrained-explicit
- a2ui-fixed-schema
- a2ui-dynamic-schema
interaction_modalities:
- sidebar
- embedded
- chat
features:
- cli-start
- agentic-chat
- tool-rendering
- gen-ui-agent
- gen-ui-tool-based
- shared-state-read-write
- subagents
- prebuilt-sidebar
- prebuilt-popup
- chat-slots
- chat-customization-css
- headless-simple
- frontend-tools
- frontend-tools-async
- hitl
- hitl-in-chat
- hitl-in-chat-booking
- hitl-in-app
- readonly-state-agent-context
- mcp-apps
- open-gen-ui
- open-gen-ui-advanced
- agent-config
- voice
- multimodal
- byoc-hashbrown
- byoc-json-render
- tool-rendering-default-catchall
- tool-rendering-custom-catchall
- reasoning-custom
- reasoning-default
- headless-complete
- auth
- beautiful-chat
- a2ui-fixed-schema
- declarative-gen-ui
- shared-state-read
not_supported_features:
- shared-state-streaming
- gen-ui-interrupt
- interrupt-headless
- reasoning-default-render
- agentic-chat-reasoning
- tool-rendering-reasoning-chain
agent_config_pattern: shared-state
a2ui_pattern: schema-loading
auth_pattern: runtime-onrequest
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 agno"
- 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/main.py
- src/app/demos/agentic-chat/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/main.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: 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/main.py
- src/app/demos/gen-ui-agent/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/main.py
- src/app/demos/shared-state-streaming/page.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
tags:
- agent-state
route: /demos/shared-state-read-write
animated_preview_url:
highlight:
- src/agents/shared_state_read_write.py
- src/agent_server.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: Multiple agents with visible task delegation
tags:
- multi-agent
route: /demos/subagents
animated_preview_url:
highlight:
- src/agents/subagents.py
- src/agent_server.py
- src/app/demos/subagents/page.tsx
- src/app/demos/subagents/delegation-log.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/main.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/main.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/main.py
- src/app/demos/chat-slots/page.tsx
- src/app/api/copilotkit/route.ts
- src/app/demos/chat-slots/slot-wrappers.tsx
- id: chat-customization-css
name: Chat Customization (CSS)
description: Default CopilotChat re-themed via CopilotKit CSS variables
tags:
- chat-ui
route: /demos/chat-customization-css
animated_preview_url:
highlight:
- src/agents/main.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/main.py
- src/app/demos/headless-simple/page.tsx
- src/app/api/copilotkit/route.ts
- id: hitl
name: In-Chat Human in the Loop
description: User approves agent actions before execution
tags:
- interactivity
route: /demos/hitl
animated_preview_url:
highlight:
- src/app/demos/hitl/page.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-chat
name: In-Chat Human in the Loop (Time Picker)
description: Agent proposes a meeting time; user confirms or adjusts via an
in-chat card before the agent proceeds
tags:
- interactivity
route: /demos/hitl-in-chat
animated_preview_url:
highlight:
- src/agents/main.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/main.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: 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/main.py
- src/app/demos/frontend-tools/page.tsx
- src/app/api/copilotkit/route.ts
- 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/main.py
- src/app/demos/readonly-state-agent-context/page.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering-default-catchall
name: Tool Rendering (Default Catch-all)
description: Out-of-the-box default tool-call card via useDefaultRenderTool()
tags:
- generative-ui
route: /demos/tool-rendering-default-catchall
animated_preview_url:
highlight:
- src/agents/main.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
tags:
- generative-ui
route: /demos/tool-rendering-custom-catchall
animated_preview_url:
highlight:
- src/agents/main.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: frontend-tools-async
name: Frontend Tools (Async)
description:
useFrontendTool with an async handler — agent awaits a client-side
notes-DB query and uses the returned result
tags:
- interactivity
route: /demos/frontend-tools-async
animated_preview_url:
highlight:
- src/agents/main.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: reasoning-custom
name: Agentic Chat (Reasoning)
description: Visible reasoning / thinking chain rendered via a custom
reasoningMessage slot on the reasoning-enabled Agno agent
tags:
- chat-ui
- reasoning
route: /demos/reasoning-custom
animated_preview_url:
highlight:
- src/agents/reasoning_agent.py
- src/app/demos/reasoning-custom/page.tsx
- src/app/demos/reasoning-custom/reasoning-block.tsx
- src/app/api/copilotkit/route.ts
- id: reasoning-default
name: Reasoning (Default Render)
description: Zero-config rendering of the agent's reasoning chain via
CopilotKit's built-in reasoning message
tags:
- chat-ui
- reasoning
route: /demos/reasoning-default
animated_preview_url:
highlight:
- src/agents/reasoning_agent.py
- src/app/demos/reasoning-default/page.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering-reasoning-chain
name: Tool Rendering (Reasoning Chain)
description: Reasoning card + sequential tool-call renderers (per-tool +
catch-all) on the same reasoning-enabled Agno agent
tags:
- agent-capabilities
- reasoning
route: /demos/tool-rendering-reasoning-chain
animated_preview_url:
highlight:
- src/agents/reasoning_agent.py
- src/app/demos/tool-rendering-reasoning-chain/page.tsx
- src/app/demos/tool-rendering-reasoning-chain/reasoning-block.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/api/copilotkit/route.ts
- id: headless-complete
name: Headless Chat (Complete)
description:
Full chat built from scratch on useAgent — custom bubbles, input
bar, and manual generative-UI composition via useRenderToolCall
tags:
- chat-ui
route: /demos/headless-complete
animated_preview_url:
highlight:
- src/agents/main.py
- src/app/demos/headless-complete/page.tsx
- src/app/api/copilotkit/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: 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 — Agno 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 — Agno 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: auth
name: Authentication
description: Bearer-token gate via the V2 runtime onRequest hook — dedicated
/api/copilotkit-auth route rejects unauthenticated requests
tags:
- runtime
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: gen-ui-tool-based
name: Tool-Based Generative UI
description: Agent uses tools to trigger UI generation — frontend renders a
haiku card from a generate_haiku tool call
tags:
- generative-ui
route: /demos/gen-ui-tool-based
animated_preview_url:
highlight:
- src/app/demos/gen-ui-tool-based/page.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, branded for the booking
scenario
tags:
- interactivity
route: /demos/hitl-in-chat
animated_preview_url:
highlight:
- src/agents/main.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: mcp-apps
name: MCP Apps
description: MCP-provided tools rendered inline as sandboxed UI via the
runtime's mcpApps middleware
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
description:
Agent-authored HTML + CSS streamed into a sandboxed iframe via the
runtime's openGenerativeUI middleware
tags:
- generative-ui
route: /demos/open-gen-ui
animated_preview_url:
highlight:
- 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 with host-side sandbox functions the agent's UI
can call back into via Websandbox.connection.remote
tags:
- generative-ui
route: /demos/open-gen-ui-advanced
animated_preview_url:
highlight:
- 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: agent-config
name: Agent Config Object
description:
Frontend forwards a typed config object (tone, expertise, response
length) the Agno agent reads per turn to compose its system prompt
tags:
- runtime
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/api/copilotkit-agent-config/route.ts
- id: voice
name: Voice Input
description: Microphone + sample-audio button → /transcribe endpoint → text
injected into the chat composer
tags:
- chat-ui
route: /demos/voice
animated_preview_url:
highlight:
- 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 + PDF attachments forwarded to a vision-capable Agno agent
via CopilotChat's attachments config
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: Polished landing-style chat shell over the shared Agno agent —
gradient background, suggestion pills, and an example dashboard surface
tags:
- chat-ui
route: /demos/beautiful-chat
animated_preview_url:
highlight:
- src/agents/main.py
- src/app/demos/beautiful-chat/page.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/api/copilotkit/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/hooks/use-example-suggestions.tsx
- src/app/demos/beautiful-chat/hooks/use-generative-ui-examples.tsx
- id: declarative-gen-ui
name: "Declarative Generative UI (A2UI Dynamic)"
description:
Agent dynamically composes UI per turn from a registered frontend
catalog — secondary LLM call inside the backend `generate_a2ui` tool emits
an a2ui_operations container that the A2UI middleware forwards to the
catalog renderer
tags:
- generative-ui
- a2ui
route: /demos/declarative-gen-ui
animated_preview_url:
highlight:
- src/agents/a2ui_dynamic_agent.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:
Schema is authored ahead of time as JSON; the agent only streams
data into the data model. `display_flight` emits an a2ui_operations
container directly with no secondary LLM call
tags:
- generative-ui
- a2ui
route: /demos/a2ui-fixed-schema
animated_preview_url:
highlight:
- src/agents/a2ui_fixed_agent.py
- src/agents/a2ui_schemas/flight_schema.json
- src/agents/a2ui_schemas/booked_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
managed_platform:
name: Agent OS
url: https://os.agno.com
+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-agno",
"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.59.1",
"@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": "agno",
},
},
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,28 @@
# QA: Agentic Chat (Reasoning) — Agno
## Prerequisites
- Demo deployed at `/demos/agentic-chat-reasoning`
- Agno agent backend healthy with the `reasoning_agent` module loaded
(served at `/reasoning/agui`)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/agentic-chat-reasoning`
- [ ] Send "Explain why the sky is blue in two short steps"
- [ ] Verify a custom amber reasoning card (`data-testid="reasoning-block"`)
appears BEFORE the final assistant answer
- [ ] Verify the reasoning card shows a "Reasoning" pill and streamed thinking
content
### 2. Feature-Specific Checks
- [ ] While the agent is running, the reasoning card shows "Thinking…"
- [ ] After the run completes, the reasoning card shows "Agent reasoning"
- [ ] The reasoning content is italic / visually distinct from the answer
### 3. Error Handling
- [ ] No uncaught console errors
@@ -0,0 +1,62 @@
# QA: Agentic Chat — Agno
## 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
+68
View File
@@ -0,0 +1,68 @@
# QA: Authentication — Agno
## Prerequisites
- Demo deployed at `/demos/auth`
- Dedicated runtime at `/api/copilotkit-auth`
- Agno main agent backend healthy at `/agui`
## Test Steps
### 1. Initial authenticated state
- [ ] Navigate to /demos/auth
- [ ] Verify the banner is visible with a green/success appearance (data-testid="auth-banner", data-authenticated="true")
- [ ] Verify auth-status text reads "✓ Signed in as demo user"
- [ ] Verify the "Sign out" button is visible and enabled (data-testid="auth-sign-out-button")
- [ ] Verify the "Sign in" button is NOT present
- [ ] Verify <CopilotChat /> is mounted below the banner
- [ ] Verify no auth-demo-error surface is shown (data-testid="auth-demo-error" absent)
- [ ] Verify no console errors on page load (the `/info` handshake should succeed)
### 2. Authenticated send → assistant response
- [ ] Type "Hello" and click send
- [ ] Within 30 seconds, an assistant response is rendered in the transcript
- [ ] No auth-demo-error surface appears
### 3. Sign out flips the banner and surfaces 401 without crashing
- [ ] Click "Sign out"
- [ ] Within 1 second, the banner flips to amber/warning appearance (data-authenticated="false")
- [ ] Verify auth-status text reads "⚠ Signed out — the agent will reject your messages until you sign in."
- [ ] Verify the "Sign in" button is visible (data-testid="auth-authenticate-button")
- [ ] Verify the "Sign out" button is no longer present
- [ ] Type "Hello again" and click send
- [ ] Within 15 seconds, the page-level error surface appears:
- `data-testid="auth-demo-error"` visible with text containing "401" and/or "Unauthorized"
- [ ] Verify the banner is STILL visible — the page must not white-screen
- [ ] Verify no assistant response appears for the unauthenticated send
### 4. Sign in clears the error and restores sends
- [ ] Click "Sign in"
- [ ] Within 1 second, the banner flips back to green (data-authenticated="true")
- [ ] Verify the auth-demo-error surface is cleared
- [ ] Type "Hello" and click send
- [ ] Within 30 seconds, an assistant response is rendered
### 5. Refresh resets state to authenticated
- [ ] Hard-reload the page
- [ ] Banner is green on first render (default state is authenticated; state does NOT persist)
- [ ] No error surface on first render
### 6. Error Handling
- [ ] With DevTools Network panel blocking /api/copilotkit-auth, send a message while authenticated
- [ ] Verify a network-level error surfaces cleanly (no uncaught promise rejection in console)
- [ ] Restore network; verify sends work again without a page reload
## Expected Results
- Page loads authenticated by default — no 401 crash on initial `/info` fetch
- Banner state flips within 1s of Sign out / Sign in clicks
- Post-sign-out sends produce a visible 401 error within 15s via auth-demo-error
- Page never white-screens after sign out — banner and composer remain mounted
- Authenticated sends produce an assistant response within 30s
- Refresh fully resets auth state (back to authenticated)
@@ -0,0 +1,37 @@
# QA: Chat Customization (CSS) — Agno
## Prerequisites
- Demo deployed at `/demos/chat-customization-css`
- Agent backend healthy (`/api/health`)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/chat-customization-css`
- [ ] Verify the `.chat-css-demo-scope` wrapper is visible
- [ ] Verify the themed chat input (`data-testid="copilot-chat-input"`) is inside the scope
### 2. Feature-Specific Checks
#### CSS Variables
- [ ] Read computed styles on the scope:
- `--copilot-kit-primary-color` = `#ff006e`
- `--copilot-kit-background-color` = `#fff8f0`
- `--copilot-kit-secondary-color` = `#fde047`
#### Input Font
- [ ] Verify the textarea uses Georgia serif font
#### Round-Trip Styling
- [ ] Send "hello"
- [ ] Verify the user bubble background is a hot-pink linear-gradient containing `rgb(255, 0, 110)`
- [ ] Verify the assistant bubble background is `rgb(253, 224, 71)` (amber `#fde047`)
### 3. Error Handling
- [ ] No uncaught console errors
@@ -0,0 +1,35 @@
# QA: Chat Slots — Agno
## Prerequisites
- Demo deployed at `/demos/chat-slots`
- Agent backend healthy (`/api/health`)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/chat-slots`
- [ ] Verify the custom welcome screen slot renders (`data-testid="custom-welcome-screen"`)
- [ ] Verify the heading "Welcome to the Slots demo" is visible
- [ ] Verify the "Custom Slot" badge is visible inside the welcome card
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Write a sonnet" suggestion pill renders
- [ ] Verify "Tell me a joke" suggestion pill renders
- [ ] Click "Tell me a joke"; verify an assistant bubble appears wrapped by `data-testid="custom-assistant-message"`
#### Custom Disclaimer Slot
- [ ] After the first assistant turn, verify `data-testid="custom-disclaimer"` is visible below the input
#### Persistence Across Turns
- [ ] Send a second message; verify at least two `data-testid="custom-assistant-message"` wrappers exist
### 3. Error Handling
- [ ] No uncaught console errors during the entire flow
@@ -0,0 +1,24 @@
# QA: Frontend Tools (Async) — Agno
## Prerequisites
- Demo deployed at `/demos/frontend-tools-async`
- Agent backend healthy
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/frontend-tools-async`
- [ ] Verify the chat renders
### 2. Feature-Specific Checks
- [ ] Click the "Find project-planning notes" suggestion pill
- [ ] Verify a `data-testid="notes-card"` appears
- [ ] Verify `data-testid="notes-keyword"` shows the searched keyword
- [ ] Verify matching notes (`data-testid="note-n1"` etc.) are rendered
### 3. Error Handling
- [ ] No uncaught console errors
@@ -0,0 +1,23 @@
# QA: Frontend Tools — Agno
## Prerequisites
- Demo deployed at `/demos/frontend-tools`
- Agent backend healthy
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/frontend-tools`
- [ ] Verify the chat renders with placeholder "Type a message"
- [ ] Verify `data-testid="background-container"` is visible with the default background
### 2. Feature-Specific Checks
- [ ] Ask "Change the background to a blue-to-purple gradient"
- [ ] Verify the `change_background` frontend tool is invoked and the background-container style changes
### 3. Error Handling
- [ ] No uncaught console errors
@@ -0,0 +1,62 @@
# QA: Agentic Generative UI — Agno
## 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 — Agno
## 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,43 @@
# QA: Headless Chat (Complete) — Agno
## Prerequisites
- Demo deployed at `/demos/headless-complete`
- Agno main agent backend healthy (served at `/agui`)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/headless-complete`
- [ ] Verify the custom chrome renders — header, scrollable messages area
(`data-testid="headless-complete-messages"`), input bar
- [ ] Verify empty-state hint: "Try weather, a stock, or a highlighted note."
### 2. Feature-Specific Checks
#### Weather card via backend tool
- [ ] Send "What's the weather in Tokyo?"
- [ ] Verify a compact `WeatherCard` renders inside an assistant bubble
- [ ] Verify the typing indicator shows while the agent is running
#### Stock card via backend tool
- [ ] Send "AAPL stock price"
- [ ] Verify a compact `StockCard` renders with ticker, price, delta
#### Highlight note via frontend tool (useComponent)
- [ ] Send "Highlight 'meeting at 3pm' in yellow"
- [ ] Verify a yellow `HighlightNote` card renders inline
#### Stop button
- [ ] Send a long-running prompt
- [ ] Verify the Stop button appears while the agent is running
- [ ] Click Stop and verify the agent halts
### 3. Error Handling
- [ ] No uncaught console errors
@@ -0,0 +1,27 @@
# QA: Headless Chat (Simple) — Agno
## Prerequisites
- Demo deployed at `/demos/headless-simple`
- Agent backend healthy
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/headless-simple`
- [ ] Verify the heading "Headless Chat (Simple)" is visible
- [ ] Verify the textarea + "Send" button render
### 2. Feature-Specific Checks
- [ ] Type "Say hi" and click Send
- [ ] Verify the user bubble appears followed by an assistant text bubble
- [ ] Ask "show a card about cats"
- [ ] Verify a `ShowCard` renders in the transcript (title + body)
### 3. Error Handling
- [ ] Empty input disables the Send button
- [ ] Shift+Enter does not submit
- [ ] No uncaught console errors
@@ -0,0 +1,31 @@
# QA: In-App HITL (frontend-tool + app-level modal) — Agno
## Prerequisites
- Demo deployed at `/demos/hitl-in-app`
- Agent backend healthy
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/hitl-in-app`
- [ ] Verify the support-inbox panel and three tickets (`#12345`, `#12346`, `#12347`) render
- [ ] Verify chat renders on the right
### 2. Feature-Specific Checks
- [ ] Click the "Approve refund for #12345" suggestion
- [ ] Verify the approval dialog (`data-testid="approval-dialog"`) appears OUTSIDE the chat (overlays the page)
- [ ] Click "Approve"
- [ ] Verify the dialog closes and the agent continues with a follow-up
#### Reject path
- [ ] Click "Downgrade plan for #12346"
- [ ] When the dialog appears, click "Reject"
- [ ] Verify the agent acknowledges the rejection
### 3. Error Handling
- [ ] No uncaught console errors
@@ -0,0 +1,38 @@
# QA: In-Chat HITL (useHumanInTheLoop) — Agno
## Prerequisites
- Demo deployed at `/demos/hitl-in-chat`
- Agent backend healthy
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/hitl-in-chat`
- [ ] Verify chat renders with "Book a call with sales" and "Schedule a 1:1 with Alice" suggestion pills
### 2. Feature-Specific Checks
- [ ] Click "Book a call with sales"
- [ ] Verify `data-testid="time-picker-card"` is rendered with a grid of time slots
- [ ] Click one of the time slot buttons
- [ ] Verify `data-testid="time-picker-picked"` appears with the chosen slot label
- [ ] Verify the agent receives the chosen time (assistant follow-up references the booking)
#### Cancel Path
- [ ] Re-run with "Schedule a 1:1 with Alice"
- [ ] Click "None of these work"
- [ ] Verify `data-testid="time-picker-cancelled"` appears
### 3. Error Handling
- [ ] No uncaught console errors
## Note on the legacy /demos/hitl cell
The older `/demos/hitl` page in this package uses a StepSelector pattern driven by
`generate_task_steps` and a different HITL hook; it remains in the manifest as
`hitl-in-chat` (for UI-card parity). This file documents the newer `/demos/hitl-in-chat`
route that mirrors the canonical langgraph-python `hitl-in-chat` demo.
@@ -0,0 +1,35 @@
# QA: Pre-Built Popup — Agno
## Prerequisites
- Demo is deployed at `/demos/prebuilt-popup`
- Agent backend healthy (`/api/health`)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/prebuilt-popup`; verify heading "Popup demo — look for the floating launcher" is visible
- [ ] Verify the popup is OPEN by default and exposes a themed placeholder "Ask the popup anything..."
- [ ] Verify the floating toggle launcher is present
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Say hi" suggestion pill renders
- [ ] Click the pill; verify "Say hi from the popup!" sends and an assistant text response appears within 30s
#### Chat Round-Trip
- [ ] Type "Hello" into the popup input and click the send button; verify an assistant bubble appears
#### Popup Toggle
- [ ] Click the popup close button; verify the popup unmounts / hides
- [ ] Click the floating launcher; verify the popup re-mounts
### 3. Error Handling
- [ ] Empty message submit is a no-op
- [ ] Console has no uncaught errors
@@ -0,0 +1,43 @@
# QA: Pre-Built Sidebar — Agno
## Prerequisites
- Demo is deployed and accessible at `/demos/prebuilt-sidebar`
- Agent backend is healthy (`/api/health` or `/api/copilotkit` GET)
- The underlying Agno agent (`src/agents/main.py`) is the shared showcase agent registered to the `prebuilt-sidebar` name on the runtime route
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/prebuilt-sidebar`; verify the main content renders with heading (h1 "Sidebar demo — click the launcher") and a paragraph mentioning `<CopilotSidebar />`
- [ ] Verify the `<CopilotSidebar />` is rendered docked to one edge of the viewport and OPEN by default (`defaultOpen={true}`)
- [ ] Verify the sidebar contains a chat input and its own launcher/toggle button
### 2. Feature-Specific Checks
#### Sidebar Toggle
- [ ] Click the sidebar close button; verify the sidebar collapses and `aria-hidden` flips to `true`
- [ ] Click the launcher; verify it re-opens (`aria-hidden` returns to `false`)
#### Suggestions (`useConfigureSuggestions`)
- [ ] Verify a pill titled "Say hi" is rendered
- [ ] Click the pill; verify "Say hi!" sends and an assistant response appears within 30s
#### Chat Round-Trip
- [ ] Type "Hello" and submit; verify an assistant bubble appears
### 3. Error Handling
- [ ] Attempt to send an empty message; verify it is a no-op
- [ ] DevTools console shows no uncaught errors during any flow above
## Expected Results
- Page + sidebar render within 3 seconds
- Assistant response within 30 seconds
- Sidebar toggle is instant with no layout jank
- No uncaught console errors
@@ -0,0 +1,24 @@
# QA: Readonly State (Agent Context) — Agno
## Prerequisites
- Demo deployed at `/demos/readonly-state-agent-context`
- Agent backend healthy
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/readonly-state-agent-context`
- [ ] Verify the `data-testid="context-card"` is visible (left sidebar)
- [ ] Verify the JSON preview (`data-testid="ctx-state-json"`) shows the initial context
### 2. Feature-Specific Checks
- [ ] Change the name input (`data-testid="ctx-name"`) to "Alice"
- [ ] Verify the JSON preview updates to show "Alice"
- [ ] Ask the assistant "What do you know about me?" and verify it references the context
### 3. Error Handling
- [ ] No uncaught console errors
@@ -0,0 +1,26 @@
# QA: Reasoning (Default Render) — Agno
## Prerequisites
- Demo deployed at `/demos/reasoning-default-render`
- Agno reasoning agent served at `/reasoning/agui`
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/reasoning-default-render`
- [ ] Send "Why is the sky blue? Think step by step."
- [ ] Verify the built-in `CopilotChatReasoningMessage` renders a collapsible
"Thought for X seconds" card
- [ ] Verify the final assistant answer appears after the reasoning card
### 2. Feature-Specific Checks
- [ ] Expand the reasoning card; verify step-by-step content is visible
- [ ] No custom slot override was required — this is zero-config reasoning
rendering
### 3. Error Handling
- [ ] No uncaught console errors
@@ -0,0 +1,61 @@
# QA: Shared State (Read + Write) — Agno
## Prerequisites
- Demo is deployed and accessible at `/demos/shared-state-read-write` on the dashboard host
- Agent backend is healthy (`/api/health`); `OPENAI_API_KEY` is set; the Agno agent server exposes the `/shared-state-rw/agui` endpoint
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/shared-state-read-write`; verify the page renders within 3s with the left sidebar (preferences + notes cards) and the right-side `CopilotChat` pane
- [ ] Verify `data-testid="preferences-card"` is visible with heading "Your preferences"
- [ ] Verify `data-testid="notes-card"` is visible with heading "Agent notes" and empty-state `data-testid="notes-empty"` reading "No notes yet. Ask the agent to remember something."
- [ ] Verify the chat input placeholder is "Chat with the agent..."
- [ ] Verify all 3 suggestion pills are visible with verbatim titles: "Greet me", "Remember something", "Plan a weekend"
- [ ] Send "Hello" and verify an assistant text response appears within 10s
### 2. Feature-Specific Checks
#### UI Writes -> Agent Reads (preferences via `agent.setState`)
- [ ] Type "Atai" into `data-testid="pref-name"`; verify `data-testid="pref-state-json"` updates synchronously to include `"name": "Atai"`
- [ ] Change `data-testid="pref-tone"` to `formal`; verify the JSON preview reflects `"tone": "formal"`
- [ ] Change `data-testid="pref-language"` to `Spanish`; verify the JSON preview reflects `"language": "Spanish"`
- [ ] Click the `Cooking` and `Travel` interest pills; verify both show the selected style (border `#BEC2FF`, bg `#BEC2FF1A`) and the JSON preview's `interests` array contains both entries
- [ ] Send "What do you know about me?"; verify within 10s the assistant reply references the name "Atai", a formal tone, Spanish, and the Cooking/Travel interests (Agno's dynamic instructions function reads `session_state["preferences"]` and prepends a preferences block on every turn)
- [ ] Click the "Plan a weekend" suggestion; verify the reply is tailored to the selected interests
#### Agent Writes -> UI Reads (notes via `set_notes` tool)
- [ ] Click the "Remember something" suggestion (sends "Remember that I prefer morning meetings and that I don't eat dairy.")
- [ ] Within 15s verify `data-testid="notes-list"` appears in the notes card and contains at least 2 `data-testid="note-item"` entries mentioning "morning meetings" and "dairy"
- [ ] Verify `data-testid="notes-empty"` is no longer rendered
- [ ] Send "Also remember I live in Berlin."; verify within 15s the notes list grows (previous notes preserved, new note added) — confirms the agent passes the FULL updated list per `set_notes` contract (the tool REPLACES the array)
#### UI Writes Back to Agent-Authored Slice (clear notes)
- [ ] With notes present, verify `data-testid="notes-clear-button"` is visible
- [ ] Click the Clear button; verify the notes list disappears and `data-testid="notes-empty"` re-renders
- [ ] Ask "What do you remember about me?"; verify the agent no longer cites the cleared notes (state was written back by the UI via `agent.setState({ notes: [] })`)
#### Multi-Turn State Persistence
- [ ] Change tone to `playful` and add the `Music` interest; send "Write me a one-line haiku greeting."; verify the reply is playful and references music
- [ ] Send a follow-up "Do it again in French."; verify the reply remains playful, switches to French, and still acknowledges the music interest — confirms preferences persist across turns (Agno reuses the same `session_id`/`thread_id`)
- [ ] Reload the page; verify preferences reset to defaults (`tone: casual`, `language: English`, empty interests, empty name) and notes reset to empty (state is per-session, seeded by the page's `useEffect`)
### 3. Error Handling
- [ ] Attempt to send an empty message; verify it is a no-op (no user bubble, no assistant response)
- [ ] Deselect all interests and clear the name; send "Who am I?"; verify the agent answers without crashing (the dynamic instructions function skips the preferences block when none are recognized)
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
## Expected Results
- Page loads within 3 seconds; assistant text response within 10 seconds
- Preferences writes are reflected in `pref-state-json` synchronously on change
- Agent-authored notes appear in `notes-card` within 15 seconds of a "remember" prompt, and the full prior list is preserved on subsequent `set_notes` calls (the custom AGUI router emits a `StateSnapshotEvent` after every run so `useAgent` re-renders with the updated state)
- Clear button round-trips UI -> agent state and the agent loses access to the cleared notes on the next turn
- No UI layout breaks, no uncaught console errors
@@ -0,0 +1,75 @@
# QA: Shared State (Reading) — Agno
## 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 — Agno
## 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) — Agno
## 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
@@ -0,0 +1,53 @@
# QA: Sub-Agents — Agno
## Prerequisites
- Demo is deployed and accessible at `/demos/subagents` on the dashboard host
- Agent backend is healthy (`/api/health`); `OPENAI_API_KEY` is set; the Agno agent server exposes the `/subagents/agui` endpoint
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/subagents`; verify the page renders within 3s with a left-side delegation log panel and a right-side `CopilotChat` pane
- [ ] Verify `data-testid="delegation-log"` is visible with heading "Sub-agent delegations"
- [ ] Verify the empty-state `data-testid="delegation-empty"` reads "No delegations yet. Ask the supervisor to plan a deliverable."
- [ ] Verify `data-testid="delegation-count"` reads "0 calls"
- [ ] Verify the chat input placeholder is "Give the supervisor a task..."
- [ ] Verify all 3 suggestion pills are visible with verbatim titles: "Write a blog post", "Explain a topic", "Summarize a topic"
### 2. Feature-Specific Checks
#### Live delegation log (research → write → critique)
- [ ] Click the "Write a blog post" suggestion (sends a request that asks for research → writing → critique on cold exposure training)
- [ ] Within 5s verify `data-testid="supervisor-running"` appears next to the heading (the supervisor's `agent.isRunning` is true)
- [ ] Within 30s verify at least 3 `data-testid="delegation-entry"` cards have appeared, in order: Research → Writing → Critique (matching the supervisor's instructed sequence)
- [ ] Verify each card shows a sub-agent badge (Research blue, Writing emerald, Critique purple) and a status badge that ends in `completed` (emerald) once the sub-agent returns
- [ ] During delegation, verify in-flight cards show `data-status="running"` with an amber border, an animated spinner, and the text "Sub-agent is working…"
- [ ] After the run finishes, verify each completed card shows a non-empty result body (research bullets, draft paragraph, critique bullets) — the agno custom AGUI router emits a `StateSnapshotEvent` carrying `delegations` after each run, so the UI re-renders the final state
- [ ] Verify `data-testid="delegation-count"` updates to reflect the total number of delegations made
- [ ] Verify the supervisor's final chat message returns a concise summary referencing the work done
#### Sub-agent variety
- [ ] Click "Explain a topic"; verify a fresh `Research``Writing``Critique` sequence appears (delegations from the prior run remain in the log; the supervisor instructs `set_notes`-style replacement only on the supervisor agent — sub-agent log is append-only)
- [ ] Click "Summarize a topic"; verify another sequence appears, growing the log
#### Failure handling
- [ ] (Best-effort, hard to provoke) If a sub-agent ever fails (network blip, provider error), verify the corresponding entry transitions to `data-status="failed"` with a red border and the body text reads `sub-agent call failed: <ExceptionClassName> (see server logs for details)` — no provider URLs / request IDs leaked to the UI
### 3. Error Handling
- [ ] Send an empty message; verify it is a no-op
- [ ] Reload the page; verify the delegation log resets to its empty state
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
## Expected Results
- Page loads within 3 seconds
- First delegation entry appears within 10 seconds of a non-trivial task being submitted
- Full research → write → critique chain completes within 60 seconds for typical prompts
- Delegations list grows in real time, with `running` status visible while a sub-agent is working and `completed`/`failed` once it returns
- No UI layout breaks, no uncaught console errors
@@ -0,0 +1,25 @@
# QA: Tool Rendering (Custom Catch-all) — Agno
## Prerequisites
- Demo deployed at `/demos/tool-rendering-custom-catchall`
- Agent backend healthy
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/tool-rendering-custom-catchall`
- [ ] Verify chat renders with suggestion pills
### 2. Feature-Specific Checks
- [ ] Click "Weather in SF"
- [ ] Verify a `data-testid="custom-catchall-card"` renders (branded catch-all UI)
- [ ] Verify `data-testid="custom-catchall-tool-name"` shows `get_weather`
- [ ] Verify `data-testid="custom-catchall-status"` eventually shows "done"
- [ ] Verify `data-testid="custom-catchall-args"` and `data-testid="custom-catchall-result"` render
### 3. Error Handling
- [ ] No uncaught console errors
@@ -0,0 +1,23 @@
# QA: Tool Rendering (Default Catch-all) — Agno
## Prerequisites
- Demo deployed at `/demos/tool-rendering-default-catchall`
- Agent backend healthy
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/tool-rendering-default-catchall`
- [ ] Verify chat renders with placeholder "Type a message"
- [ ] Verify "Weather in SF", "Find flights", "Roll a d20" pills render
### 2. Feature-Specific Checks
- [ ] Click "Weather in SF"; verify a default tool-call card is rendered showing the tool name `get_weather`
- [ ] Click "Roll a d20"; verify a tool-call card appears for `roll_dice`
### 3. Error Handling
- [ ] No uncaught console errors
@@ -0,0 +1,38 @@
# QA: Tool Rendering (Reasoning Chain) — Agno
## Prerequisites
- Demo deployed at `/demos/tool-rendering-reasoning-chain`
- Agno reasoning agent served at `/reasoning/agui`, with `get_weather`,
`search_flights`, `get_stock_price`, `roll_dice` tools available
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/tool-rendering-reasoning-chain`
- [ ] Send "What's the weather in Tokyo?"
- [ ] Verify the custom amber reasoning card
(`data-testid="reasoning-block"`) appears
- [ ] Verify the `WeatherCard` (`data-testid="weather-card"`) renders after
the tool completes
### 2. Feature-Specific Checks
#### Catch-all renderer
- [ ] Send "Roll a 20-sided die"
- [ ] Verify the custom catch-all card
(`data-testid="custom-catchall-card"` with `data-tool-name="roll_dice"`)
renders with arguments and result
#### Flight chain
- [ ] Send "Find flights from SFO to JFK"
- [ ] Verify the `FlightListCard` (`data-testid="flight-list-card"`) renders
with at least one flight row
- [ ] Verify origin / destination labels match the user's message
### 3. Error Handling
- [ ] No uncaught console errors
@@ -0,0 +1,61 @@
# QA: Tool Rendering — Agno
## 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,7 @@
agno==2.6.19
openai>=1.88.0
fastapi>=0.115.13
uvicorn[standard]>=0.34.3
ag-ui-protocol>=0.1.8
python-dotenv>=1.0.0
pypdf>=4.0.0
@@ -0,0 +1,922 @@
"""
Agent Server for Agno
Uses AgentOS with the AG-UI interface to serve multiple Agno agents.
The Next.js CopilotKit runtime proxies requests to each interface via AG-UI.
Interfaces:
/agui → main agent (sales assistant, most demos)
Custom handler that forwards tool results
from AGUI messages so HITL round-trips work.
/reasoning/agui → reasoning-capable agent
/shared-state-rw/agui → bidirectional shared-state agent
(custom router emits STATE_SNAPSHOT)
/subagents/agui → supervisor with research/writing/critique
sub-agents (custom router emits STATE_SNAPSHOT)
"""
import asyncio
import os
import uuid
from typing import Any, AsyncIterator, List, Optional, Set, Union
# 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
# (NOT agno), so it is safe to run before the agent imports below.
import _shared.cvdiag_bootstrap # noqa: F401,E402 (first non-stdlib import — bootstrap side effects)
# ORDER-CRITICAL: install the global httpx hook BEFORE any agent module
# imports. Agno constructs its ``OpenAIChat`` client at agent-module
# import time, so the patch must be in place before those imports run.
from agents._cvdiag_backend import CvdiagBackendMiddleware
from agents._header_forwarding import (
HeaderForwardingHTTPMiddleware,
install_executor_contextvar_propagation,
install_global_httpx_hook,
)
install_global_httpx_hook()
# Agno dispatches SYNC tools (e.g. the declarative gen-ui `generate_a2ui`
# tool, which makes a secondary OpenAI 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 secondary call's outbound httpx hook fires, and aimock
# can't match the right fixture for the request.
install_executor_contextvar_propagation()
import dotenv
from ag_ui.core import (
BaseEvent,
EventType,
ReasoningMessageContentEvent,
ReasoningMessageEndEvent,
ReasoningMessageStartEvent,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
)
from ag_ui.core.types import Message as AGUIMessage
from ag_ui.encoder import EventEncoder
from agno.agent import Agent, RemoteAgent
from agno.models.message import Message
from agno.os import AgentOS
from agno.os.interfaces.agui import AGUI
# TODO: migrate to agno 2.6.20+ API once agui.utils replacement is identified
from agno.os.interfaces.agui.utils import (
async_stream_agno_response_as_agui_events,
extract_agui_user_input,
validate_agui_state,
)
from agno.utils.log import log_debug, log_warning
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from agents.a2ui_dynamic_agent import agent as a2ui_dynamic_agent
from agents.a2ui_fixed_agent import agent as a2ui_fixed_agent
from agents.agent_config_agent import (
agent as agent_config_agent,
build_agent as build_agent_config_agent,
)
from agents.byoc_hashbrown_agent import agent as byoc_hashbrown_agent
from agents.byoc_json_render_agent import agent as byoc_json_render_agent
from agents.gen_ui_agent import agent as gen_ui_agent
from agents.interrupt_agent import agent as interrupt_agent
from agents.main import agent as main_agent
from agents.mcp_apps_agent import agent as mcp_apps_agent
from agents.multimodal_agent import agent as multimodal_agent
from agents.open_gen_ui_agent import agent as open_gen_ui_agent
from agents.reasoning_agent import agent as reasoning_agent
from agents.shared_state_read_write import agent as shared_state_rw_agent
from agents.subagents import agent as subagents_supervisor
dotenv.load_dotenv()
# ---------------------------------------------------------------------------
# AGUI message conversion for HITL tool-result forwarding
# ---------------------------------------------------------------------------
#
# agno >= 2.5.17 changed the stock AGUI router to use
# `extract_agui_user_input()` which passes ONLY the last user message to
# the agent. This works for simple chat but breaks Human-in-the-Loop
# flows: the second request (after the user confirms/rejects in the HITL
# UI) carries the tool result as an AGUI "tool" role message. Since
# `extract_agui_user_input` discards all non-user messages, the tool
# result never reaches the LLM and the agent just re-calls the tool and
# pauses again.
#
# The helper below converts AGUI messages to agno Messages — the same
# thing `convert_agui_messages_to_agno_messages` did in older agno
# releases — so we can detect tool results and pass the full conversation
# to the agent when they exist.
def _has_tool_results(messages: List[AGUIMessage]) -> bool:
"""Return True if the message list contains any tool-result messages."""
return any(msg.role == "tool" for msg in messages)
def _convert_agui_messages(messages: List[AGUIMessage]) -> List[Message]:
"""Convert AG-UI messages to Agno messages (full conversation).
Mirrors the old `convert_agui_messages_to_agno_messages` from
agno < 2.5.17. The LLM (and OpenAI's API) requires assistant
``tool_calls`` and their ``tool`` results to stay paired: an
orphan ``tool`` message with no matching assistant ``tool_calls``
is rejected with a 400, and an assistant ``tool_calls`` whose
result never arrived confuses the model. So we drop orphans on
BOTH sides together, keeping only complete pairs.
A ``tool`` message with a falsy ``tool_call_id`` (empty string, or
``None`` if the message bypassed pydantic validation) can never be
paired, so it is skipped consistently across both passes — never
added to the result and never allowed to poison dedup.
"""
# First pass: collect the tool_call ids that BOTH sides agree on —
# an id that appears on an assistant ``tool_calls`` AND on a ``tool``
# result message. Only these ids yield a complete, emittable pair.
assistant_tool_call_ids: Set[str] = set()
tool_result_ids: Set[str] = set()
for msg in messages:
if msg.role == "tool" and msg.tool_call_id:
tool_result_ids.add(msg.tool_call_id)
elif msg.role == "assistant" and msg.tool_calls:
for tc in msg.tool_calls:
if tc.id:
assistant_tool_call_ids.add(tc.id)
paired_ids: Set[str] = assistant_tool_call_ids & tool_result_ids
result: List[Message] = []
seen_tool_ids: Set[str] = set()
for msg in messages:
if msg.role == "tool":
tool_call_id = msg.tool_call_id
# Drop orphans: falsy ids can never pair, and an id with no
# matching assistant tool_call would be a 400 from OpenAI.
if not tool_call_id or tool_call_id not in paired_ids:
continue
# Dedup retained ids only — falsy ids were already skipped,
# so they can no longer poison this set.
if tool_call_id in seen_tool_ids:
continue
seen_tool_ids.add(tool_call_id)
result.append(
Message(
role="tool",
tool_call_id=tool_call_id,
content=msg.content,
)
)
elif msg.role == "assistant":
tool_calls = None
if msg.tool_calls:
# Keep only tool_calls whose result is present (paired).
filtered = [tc for tc in msg.tool_calls if tc.id in paired_ids]
if filtered:
tool_calls = [tc.model_dump(exclude_none=True) for tc in filtered]
if not msg.content and tool_calls is None:
# Drop an empty assistant turn (no content + all tool_calls
# orphaned): OpenAI rejects {role:"assistant"} with neither
# content nor tool_calls, and it pollutes HITL history.
continue
result.append(
Message(
role="assistant",
content=msg.content,
tool_calls=tool_calls,
)
)
elif msg.role == "user":
result.append(Message(role="user", content=msg.content))
# system messages are skipped — agent builds its own
return result
# ---------------------------------------------------------------------------
# HITL-aware AGUI handler for the main agent
# ---------------------------------------------------------------------------
#
# The stock AGUI handler passes only the last user message to the agent,
# relying on agno's session DB for history. This works for standard chat
# but breaks HITL: the second leg (tool-result) is dropped.
#
# This custom handler detects tool results in the incoming AGUI messages
# and, when present, passes the full message list to the agent instead.
# For first-leg requests (no tool results) it falls back to the stock
# `extract_agui_user_input` behaviour.
async def _run_main_agent_hitl_aware(
agent: Union[Agent, RemoteAgent], run_input: RunAgentInput
) -> AsyncIterator[BaseEvent]:
"""Stream one agent run, forwarding tool results when present."""
run_id = run_input.run_id or str(uuid.uuid4())
thread_id = run_input.thread_id
try:
messages = run_input.messages or []
has_results = _has_tool_results(messages)
if has_results:
# Second leg: convert full conversation so the LLM sees the
# tool result and can generate a follow-up response.
agent_input = _convert_agui_messages(messages)
log_debug(
"HITL-aware handler: forwarding full messages (tool results present)"
)
else:
# First leg: extract only the user message (stock behaviour).
agent_input = extract_agui_user_input(messages)
log_debug("HITL-aware handler: extracting user input (no tool results)")
yield RunStartedEvent(
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
)
user_id: Optional[str] = None
if run_input.forwarded_props and isinstance(run_input.forwarded_props, dict):
user_id = run_input.forwarded_props.get("user_id")
session_state = validate_agui_state(run_input.state, thread_id) or {}
response_stream = agent.arun( # type: ignore[attr-defined]
input=agent_input,
session_id=thread_id,
stream=True,
stream_events=True,
user_id=user_id,
session_state=session_state,
run_id=run_id,
# When we pass full messages (HITL second leg), disable session
# history to avoid duplicating messages the caller already sent.
add_history_to_context=not has_results,
)
async for event in async_stream_agno_response_as_agui_events(
response_stream=response_stream, # type: ignore[arg-type]
thread_id=thread_id,
run_id=run_id,
):
yield event
except asyncio.CancelledError: # noqa: TRY302
raise
except Exception as exc: # noqa: BLE001
yield RunErrorEvent(type=EventType.RUN_ERROR, message=str(exc))
def _attach_hitl_aware_route(app: FastAPI, agent: Agent, prefix: str) -> None:
"""Mount a HITL-aware AGUI POST endpoint at `<prefix>/agui`."""
encoder = EventEncoder()
route = f"{prefix.rstrip('/')}/agui"
async def _handler(run_input: RunAgentInput) -> StreamingResponse:
async def _gen():
async for event in _run_main_agent_hitl_aware(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": "*",
},
)
app.post(route, name=f"agui_hitl_aware_{prefix.strip('/')}")(_handler)
# ---------------------------------------------------------------------------
# State-aware AGUI handler
# ---------------------------------------------------------------------------
#
# Agno's stock AGUI router (`agno.os.interfaces.agui`) does NOT emit
# `StateSnapshotEvent` events back to the client. This means tools that
# mutate `session_state` are invisible to a UI subscribed via
# `useAgent({ updates: [OnStateChanged] })` — the round-trip is broken.
#
# For the shared-state-read-write and subagents demos we replicate the
# stock router's behavior but emit a `StateSnapshotEvent` carrying the
# final `session_state` immediately before the closing `RunFinishedEvent`.
# That gives the UI the canonical bidirectional contract its langgraph-
# python and google-adk siblings already have.
async def _run_agent_with_state_snapshot(
agent: Union[Agent, RemoteAgent], run_input: RunAgentInput
) -> AsyncIterator[BaseEvent]:
"""Stream one agent run, emitting STATE_SNAPSHOT before RUN_FINISHED.
Mirrors `agno.os.interfaces.agui.router.run_agent` but inserts a
`StateSnapshotEvent` after the inner Agno stream completes. We also
suppress the inner stream's `RunFinishedEvent` and emit our own at
the very end so the snapshot lands inside the run window.
"""
run_id = run_input.run_id or str(uuid.uuid4())
thread_id = run_input.thread_id
try:
user_input = extract_agui_user_input(run_input.messages or [])
yield RunStartedEvent(
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
)
user_id: Optional[str] = None
if run_input.forwarded_props and isinstance(run_input.forwarded_props, dict):
user_id = run_input.forwarded_props.get("user_id")
session_state = validate_agui_state(run_input.state, thread_id) or {}
response_stream = agent.arun( # type: ignore[attr-defined]
input=user_input,
session_id=thread_id,
stream=True,
stream_events=True,
user_id=user_id,
session_state=session_state,
run_id=run_id,
)
async for event in async_stream_agno_response_as_agui_events(
response_stream=response_stream, # type: ignore[arg-type]
thread_id=thread_id,
run_id=run_id,
):
# Suppress the inner RUN_STARTED / RUN_FINISHED — we already
# emitted RUN_STARTED above and will emit RUN_FINISHED after
# the snapshot. Yield everything else (text, tool calls,
# reasoning, errors) verbatim.
if event.type in (EventType.RUN_STARTED, EventType.RUN_FINISHED):
continue
yield event
# Snapshot the final session_state from the agent's session DB.
# `agent.arun` mutates `session_state` in-place when tools call
# `run_context.session_state[...] = ...`, but we read back via
# the agent's own getter so we pick up any merged DB state too.
final_state: Any = session_state
try:
getter = getattr(agent, "aget_session_state", None)
if getter is not None:
final_state = await getter(session_id=thread_id) # type: ignore[misc]
else:
sync_getter = getattr(agent, "get_session_state", None)
if sync_getter is not None:
final_state = sync_getter(session_id=thread_id)
except Exception: # noqa: BLE001 — fall back to in-memory snapshot
final_state = session_state
if not isinstance(final_state, dict):
final_state = session_state if isinstance(session_state, dict) else {}
yield StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=final_state)
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
yield RunErrorEvent(type=EventType.RUN_ERROR, message=str(exc))
def _attach_state_aware_route(app: FastAPI, agent: Agent, prefix: str) -> None:
"""Mount a single state-aware AGUI POST endpoint at `<prefix>/agui`."""
encoder = EventEncoder()
route = f"{prefix.rstrip('/')}/agui"
async def _handler(run_input: RunAgentInput) -> StreamingResponse:
async def _gen():
async for event in _run_agent_with_state_snapshot(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": "*",
},
)
app.post(route, name=f"agui_state_aware_{prefix.strip('/')}")(_handler)
# ---------------------------------------------------------------------------
# Per-request agent factory (Agent Config Object demo)
# ---------------------------------------------------------------------------
#
# The CopilotKit provider's `properties` prop arrives as top-level keys on
# `RunAgentInput.forwarded_props`. The Agent Config Object cell reads three
# of those keys (tone, expertise, responseLength) and composes a fresh
# system prompt per turn. Agno doesn't have a LangGraph-style configurable
# channel, so we mount a custom AGUI handler that builds a per-request
# Agno Agent and runs it through the stock AGUI stream mapper.
async def _run_agent_config(run_input: RunAgentInput) -> AsyncIterator[BaseEvent]:
"""Stream one Agent-Config run with a freshly-built system prompt."""
run_id = run_input.run_id or str(uuid.uuid4())
thread_id = run_input.thread_id
forwarded = (
run_input.forwarded_props
if isinstance(run_input.forwarded_props, dict)
else None
)
try:
user_input = extract_agui_user_input(run_input.messages or [])
yield RunStartedEvent(
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
)
per_request_agent = build_agent_config_agent(forwarded)
session_state = validate_agui_state(run_input.state, thread_id) or {}
response_stream = per_request_agent.arun( # type: ignore[attr-defined]
input=user_input,
session_id=thread_id,
stream=True,
stream_events=True,
session_state=session_state,
run_id=run_id,
)
async for event in async_stream_agno_response_as_agui_events(
response_stream=response_stream, # type: ignore[arg-type]
thread_id=thread_id,
run_id=run_id,
):
# The inner stream emits its own RUN_STARTED/RUN_FINISHED; we
# already emitted RUN_STARTED and will close out below.
if event.type in (EventType.RUN_STARTED, EventType.RUN_FINISHED):
continue
yield event
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
yield RunErrorEvent(type=EventType.RUN_ERROR, message=str(exc))
def _attach_agent_config_route(app: FastAPI, prefix: str) -> None:
"""Mount a single Agent-Config AGUI POST endpoint at `<prefix>/agui`."""
encoder = EventEncoder()
route = f"{prefix.rstrip('/')}/agui"
async def _handler(run_input: RunAgentInput) -> StreamingResponse:
async def _gen():
async for event in _run_agent_config(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": "*",
},
)
app.post(route, name=f"agui_agent_config_{prefix.strip('/')}")(_handler)
# ---------------------------------------------------------------------------
# Reasoning-aware AGUI handler
# ---------------------------------------------------------------------------
#
# Agno's stock AGUI handler emits STEP_STARTED/STEP_FINISHED events for
# reasoning, not the REASONING_MESSAGE_* events that CopilotKit expects.
# And reasoning=True triggers a multi-call CoT loop that breaks with
# aimock/fixture environments.
#
# This custom handler:
# 1. Runs the agent with reasoning=False (single LLM call)
# 2. Captures the model's NATIVE reasoning channel — OpenAI-compatible
# chat-completions stream a `delta.reasoning_content` field which Agno's
# OpenAIChat model surfaces on each `RunContentEvent.reasoning_content`.
# That is the channel aimock fixtures populate (via their `reasoning`
# field), and it is what reasoning models emit in production. Agno's
# AGUI stream mapper (`async_stream_agno_response_as_agui_events`) DROPS
# that channel entirely, so we tee the raw stream to accumulate it.
# 3. Collects the streamed text content (the answer)
# 4. Emits REASONING_MESSAGE_* events for the reasoning block — preferring
# the native channel, then falling back to <reasoning>...</reasoning>
# tag parsing of the text for no-native-reasoning fixtures
# 5. Emits TEXT_MESSAGE_* events for the answer
#
# Mirrors the claude-sdk-python /reasoning handler, which forwards Anthropic
# `thinking`/`thinking_delta` blocks as REASONING_MESSAGE_* directly. The
# emitted channel is REASONING_MESSAGE_* (role "reasoning") — NOT THINKING_*,
# which @ag-ui/client silently drops.
import re
from agno.run.agent import RunEvent
_REASONING_PATTERN = re.compile(
r"<reasoning>(.*?)</reasoning>",
re.DOTALL | re.IGNORECASE,
)
async def _tee_native_reasoning(
# Carries Agno run events (RunContentEvent etc.) plus the terminal
# RunOutput — NOT AG-UI's BaseEvent. Annotated ``Any`` because Agno's
# concrete union (``RunOutputEvent | RunOutput``) does not line up with
# the ``RunOutputEvent | TeamRunOutputEvent`` the downstream AGUI mapper
# consumes, and a precise annotation here only moves the mismatch.
response_stream: AsyncIterator[Any],
reasoning_sink: dict,
) -> AsyncIterator[Any]:
"""Pass an Agno run stream through verbatim, accumulating the model's
native ``reasoning_content`` channel into ``reasoning_sink["text"]``.
OpenAI-compatible providers (and aimock fixtures' ``reasoning`` field)
stream reasoning as ``delta.reasoning_content`` deltas, which Agno's
``OpenAIChat`` model surfaces on each ``RunContentEvent.reasoning_content``.
Agno's AGUI stream mapper ignores that field, so we capture it here while
forwarding every chunk untouched to the downstream mapper. No chunk is
consumed or altered — the mapper still produces TEXT/TOOL events exactly
as before; we only read the reasoning side-channel.
"""
async for chunk in response_stream:
if getattr(chunk, "event", None) == RunEvent.run_content:
delta = getattr(chunk, "reasoning_content", None)
if delta:
reasoning_sink["text"] = reasoning_sink.get("text", "") + delta
yield chunk
async def _run_reasoning_agent(
agent: Union[Agent, RemoteAgent], run_input: RunAgentInput
) -> AsyncIterator[BaseEvent]:
"""Stream one reasoning agent run, synthesizing REASONING_MESSAGE events."""
run_id = run_input.run_id or str(uuid.uuid4())
thread_id = run_input.thread_id
try:
user_input = extract_agui_user_input(run_input.messages or [])
yield RunStartedEvent(
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
)
user_id: Optional[str] = None
if run_input.forwarded_props and isinstance(run_input.forwarded_props, dict):
user_id = run_input.forwarded_props.get("user_id")
session_state = validate_agui_state(run_input.state, thread_id) or {}
response_stream = agent.arun( # type: ignore[attr-defined]
input=user_input,
session_id=thread_id,
stream=True,
stream_events=True,
user_id=user_id,
session_state=session_state,
run_id=run_id,
)
# Collect the full text from the agent stream — we need to see the
# complete response before we can split reasoning from answer, so
# text and tool-call events are BUFFERED here and flushed after the
# reasoning/answer split below (tool events are NOT interleaved with
# the text in real time; they are emitted after the answer bubble).
full_text = ""
tool_events: list[BaseEvent] = []
# Tee the raw Agno stream to capture the model's native reasoning
# channel (`RunContentEvent.reasoning_content`) — the channel aimock
# fixtures populate and that the AGUI mapper drops. Accumulated here,
# preferred over <reasoning>-tag parsing below.
native_reasoning: dict = {}
async for event in async_stream_agno_response_as_agui_events(
response_stream=_tee_native_reasoning(
response_stream,
native_reasoning,
),
thread_id=thread_id,
run_id=run_id,
):
if event.type in (EventType.RUN_STARTED, EventType.RUN_FINISHED):
continue
# Propagate an inner-stream error instead of silently dropping
# it — otherwise the run would report success with an empty or
# partial message. Forward the RUN_ERROR and stop the run.
if event.type == EventType.RUN_ERROR:
yield event
return
# Accumulate text content
if event.type == EventType.TEXT_MESSAGE_CONTENT:
full_text += event.delta # type: ignore[attr-defined]
# Buffer tool-call events; they're flushed after the answer.
# TOOL_CALL_RESULT must be buffered too — the reasoning agent has
# tools, and dropping the result loses the tool-result render in
# the reasoning-chain demo. Results follow their START/ARGS/END so
# the post-answer flush order below preserves correct sequencing.
elif event.type in (
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_RESULT,
):
tool_events.append(event)
# Skip text start/end — we'll re-emit with reasoning split
native_reasoning_text = (native_reasoning.get("text") or "").strip()
if native_reasoning_text:
# Native reasoning channel present (reasoning model / aimock
# `reasoning` fixture field). This is the production path and the
# gold-standard parity channel — use it directly. The answer is
# the streamed text minus any stray <reasoning> tags (defensive:
# a native-reasoning fixture shouldn't also embed tags, but strip
# them so they never leak into the visible answer bubble).
reasoning_text = native_reasoning_text
answer_text = _REASONING_PATTERN.sub("", full_text).strip()
else:
# Fallback: parse <reasoning>...</reasoning> tags from the text
# (no-native-reasoning fixtures / non-reasoning models).
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:
# Fallback: check for "Reasoning:" prefix pattern (aimock
# fixtures)
lower = full_text.lower()
if lower.startswith("reasoning:") or lower.startswith("reasoning step"):
# Treat the whole text as containing reasoning — emit as
# reasoning message so the ReasoningBlock renders, then
# re-emit as a text message so CopilotKit's conversation
# view has an assistant bubble the D5 probe can read.
reasoning_text = full_text.strip()
answer_text = full_text.strip()
else:
reasoning_text = ""
answer_text = full_text.strip()
# 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,
)
# Always emit a text message so CopilotKit renders an assistant
# bubble in the conversation. Without this the frontend shows
# nothing (reasoning events alone don't produce a visible message
# in the default CopilotChat transcript).
text_msg_id = str(uuid.uuid4())
if answer_text or tool_events:
yield TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=text_msg_id,
role="assistant",
)
if answer_text:
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,
)
# Emit any tool-call events that were collected
for te in tool_events:
yield te
yield RunFinishedEvent(
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
)
except asyncio.CancelledError: # noqa: TRY302
raise
except Exception as exc: # noqa: BLE001
yield RunErrorEvent(type=EventType.RUN_ERROR, message=str(exc))
def _attach_reasoning_route(app: FastAPI, agent: Agent, prefix: str) -> None:
"""Mount a reasoning-aware AGUI POST endpoint at `<prefix>/agui`."""
encoder = EventEncoder()
route = f"{prefix.rstrip('/')}/agui"
async def _handler(run_input: RunAgentInput) -> StreamingResponse:
async def _gen():
async for event in _run_reasoning_agent(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": "*",
},
)
app.post(route, name=f"agui_reasoning_{prefix.strip('/')}")(_handler)
# ---------------------------------------------------------------------------
# AgentOS bootstrap
# ---------------------------------------------------------------------------
agent_os = AgentOS(
agents=[
main_agent,
interrupt_agent,
a2ui_dynamic_agent,
a2ui_fixed_agent,
agent_config_agent,
byoc_hashbrown_agent,
byoc_json_render_agent,
gen_ui_agent,
mcp_apps_agent,
multimodal_agent,
open_gen_ui_agent,
reasoning_agent,
shared_state_rw_agent,
subagents_supervisor,
],
interfaces=[
# main_agent is mounted separately below via _attach_hitl_aware_route
# so it can forward tool results for HITL round-trips.
# reasoning_agent is mounted separately below via
# _attach_reasoning_route so /reasoning/agui emits REASONING_MESSAGE_*
# events instead of the stock AGUI STEP_STARTED/STEP_FINISHED.
# No-tools agent for the MCP Apps cell. The CopilotKit runtime's
# `mcpApps.servers` middleware injects MCP server tools at request
# time, so the LLM only sees the MCP-provided toolset.
AGUI(agent=mcp_apps_agent, prefix="/mcp-apps"), # -> /mcp-apps/agui
# No-tools agent for the Open Generative UI cells. The runtime's
# `openGenerativeUI` middleware injects the `generateSandboxedUi`
# tool the LLM uses to author HTML+CSS for the sandboxed iframe.
AGUI(agent=open_gen_ui_agent, prefix="/open-gen-ui"), # -> /open-gen-ui/agui
# Vision-capable agent (gpt-4o) for the Multimodal Attachments cell.
AGUI(agent=multimodal_agent, prefix="/multimodal"), # -> /multimodal/agui
# BYOC: hashbrown — agent emits a hashbrown UI-kit envelope as a single
# JSON object that the frontend renderer parses progressively.
AGUI(agent=byoc_hashbrown_agent, prefix="/byoc-hashbrown"),
# BYOC: json-render — agent emits a json-render spec the frontend
# renderer mounts against a Zod-validated catalog.
AGUI(agent=byoc_json_render_agent, prefix="/byoc-json-render"),
# A2UI dynamic schema — agent owns `generate_a2ui` which calls a
# secondary OpenAI client bound to `render_a2ui` and emits an
# `a2ui_operations` container the runtime A2UI middleware forwards
# to the frontend renderer.
AGUI(agent=a2ui_dynamic_agent, prefix="/declarative-gen-ui"),
# A2UI fixed schema — agent's `display_flight` tool emits an
# `a2ui_operations` container directly (no secondary LLM) bound to
# the pre-authored `flight_schema.json`.
AGUI(agent=a2ui_fixed_agent, prefix="/a2ui-fixed-schema"),
],
)
app = agent_os.get_app()
# HITL-aware route for the main agent. Replaces the stock AGUI interface
# (``AGUI(agent=main_agent)``) so tool results from the CopilotKit runtime
# are forwarded to the LLM on the second leg of HITL flows instead of being
# silently dropped by ``extract_agui_user_input()``.
_attach_hitl_aware_route(app, main_agent, "")
# 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.
_attach_hitl_aware_route(app, interrupt_agent, "/interrupt-adapted")
# Reasoning-aware route. Replaces the stock AGUI interface
# (``AGUI(agent=reasoning_agent, prefix="/reasoning")``) so /reasoning/agui
# emits REASONING_MESSAGE_* events (what CopilotKit's
# CopilotChatReasoningMessage / custom reasoning slots render) instead of the
# stock STEP_STARTED/STEP_FINISHED events.
_attach_reasoning_route(app, reasoning_agent, "/reasoning")
# State-aware routes (bidirectional shared state via StateSnapshotEvent).
# Mounted directly on the AgentOS FastAPI app so they share routing and
# CORS with the stock AGUI interfaces above.
_attach_state_aware_route(app, shared_state_rw_agent, "/shared-state-rw")
_attach_state_aware_route(app, subagents_supervisor, "/subagents")
# gen-ui-agent: planner that walks 3 steps through pending -> in_progress
# -> completed via the `set_steps` tool. Each set_steps call mutates
# session_state["steps"], and the state-aware router emits a
# StateSnapshotEvent after the run so the UI's useAgent picks it up.
_attach_state_aware_route(app, gen_ui_agent, "/gen-ui-agent")
# Agent Config Object cell — builds a per-request Agno Agent whose system
# prompt is composed from the CopilotKit provider's forwarded properties
# (tone / expertise / responseLength).
_attach_agent_config_route(app, "/agent-config")
# Serve /health via middleware so it short-circuits BEFORE route resolution.
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)
app.add_middleware(HealthMiddleware)
# Capture inbound CopilotKit ``x-*`` headers (e.g. ``x-aimock-context``) so
# downstream LLM/provider httpx calls inside the request scope copy them
# onto their outbound requests. Paired with ``install_global_httpx_hook``
# at the top of this file.
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 LAST so
# it is the OUTERMOST layer: it observes ingress before any inner layer mutates
# the request and wraps the response stream so SSE boundaries fire as chunks
# flow. 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)
def main():
"""Run the uvicorn server.
``loop="asyncio"`` pins uvicorn's event-loop factory to the stdlib
``asyncio`` loop instead of letting its default ``loop="auto"`` select
uvloop (installed transitively via ``uvicorn[standard]``). This is
load-bearing for header forwarding on the SECONDARY OpenAI call inside
the sync ``generate_a2ui`` tool: agno dispatches that sync tool onto a
worker thread via ``loop.run_in_executor(...)``, and
``install_executor_contextvar_propagation()`` (called at import time)
only propagates the forwarded-header ContextVar into that worker thread
when the loop is a stdlib ``BaseEventLoop`` subclass. Under uvloop the
shim is inert (uvloop's loop is not a ``BaseEventLoop``), so the
ContextVar is empty in the executor thread and the secondary call drops
``x-aimock-context`` → aimock 503. ``AgentOS.serve(**kwargs)`` forwards
``loop`` straight through to ``uvicorn.run``. The prod entrypoint, which
launches uvicorn via its CLI rather than this ``serve`` path, pins the
same loop via ``--loop asyncio`` in ``entrypoint.sh``.
"""
port = int(os.getenv("PORT", "8000"))
agent_os.serve(
app="agent_server:app",
host="0.0.0.0",
port=port,
reload=True,
loop="asyncio",
)
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 = "agno"
# ── 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,467 @@
"""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 = "agno"
# 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
---------------
Frameworks that dispatch a SYNC callable (e.g. agno running a sync
tool, or a hand-rolled secondary LLM call) onto the default thread
pool via ``loop.run_in_executor(None, functools.partial(...))`` lose
the caller's context: 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,154 @@
"""Agno 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
OpenAI client bound to `render_a2ui` (tool_choice forced) using the
registered client catalog injected via the runtime's
`state.copilotkit.context`. The tool result returns an `a2ui_operations`
container which the runtime's A2UI middleware detects and forwards to the
frontend renderer.
Why a separate agent (vs reusing the main agent's `generate_a2ui` tool)?
The main agent uses a hardcoded internal catalog ID
(`copilotkit://app-dashboard-catalog`) and ignores any runtime catalog the
frontend registers via `<CopilotKit a2ui={{ catalog }}>`. This dedicated
agent reads the runtime catalog from `session_state["copilotkit"]
["context"]` so it stays in sync with the frontend renderer's catalog.
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 openai
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from agno.run import RunContext
from dotenv import load_dotenv
from agents._header_forwarding import get_forwarded_headers
from tools import (
build_a2ui_operations_from_tool_call,
RENDER_A2UI_TOOL_SCHEMA,
)
load_dotenv()
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 a single `context` argument summarising the "
"conversation. Keep chat replies to one short sentence; let the UI do "
"the talking."
)
def generate_a2ui(run_context: RunContext, context: str) -> 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 A2UI middleware
to detect and forward to the frontend renderer.
The runtime A2UI middleware injects the registered client catalog
schema into `state.copilotkit.context` automatically. We pull it out
of the per-run `session_state` (Agno's `validate_agui_state` mirrors
`RunAgentInput.state` into `session_state`) so the secondary LLM
knows which components are available — staying in sync with the
frontend catalog.
Args:
run_context: Agno run context (provides session_state).
context (str): Conversation context summary the secondary LLM
should design UI from.
Returns:
str: A2UI operations as JSON.
"""
state = getattr(run_context, "session_state", None) or {}
context_entries = []
if isinstance(state, dict):
ck = state.get("copilotkit") or {}
if isinstance(ck, dict):
entries = ck.get("context") or []
if isinstance(entries, list):
context_entries = entries
context_text_parts: list[str] = []
for entry in context_entries:
if isinstance(entry, dict):
value = entry.get("value")
if isinstance(value, str) and value:
context_text_parts.append(value)
catalog_context = "\n\n".join(context_text_parts)
system_prompt = (
catalog_context if catalog_context else "Generate a useful dashboard UI."
)
if context and context.strip():
system_prompt = f"{system_prompt}\n\nConversation context:\n{context}"
# Forward the request-scoped CopilotKit ``x-*`` headers (notably
# ``x-aimock-context``) onto this SECONDARY OpenAI call explicitly.
# agno runs under uvloop in prod, where the executor-contextvar shim is
# inert; relying on the global httpx hook + ContextVar alone is fragile
# across loop types. Reading the captured headers here (on the request
# context that invoked the tool) and passing them as ``default_headers``
# makes the secondary call carry the context REGARDLESS of loop type, so
# aimock can match the fixture instead of returning 503 on an empty
# ``x-aimock-context``.
forwarded_headers = get_forwarded_headers()
client = openai.OpenAI(default_headers=forwarded_headers or None)
response = client.chat.completions.create(
model="gpt-4.1",
messages=[
{"role": "system", "content": system_prompt},
{
"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 = Agent(
model=OpenAIChat(id="gpt-4o", timeout=120),
tools=[generate_a2ui],
tool_call_limit=4,
description=SYSTEM_PROMPT,
)
@@ -0,0 +1,106 @@
"""Agno 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 — it emits an `a2ui_operations` container
directly without a secondary LLM call.
"""
from __future__ import annotations
import json
from pathlib import Path
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from agno.tools import tool
from dotenv import load_dotenv
load_dotenv()
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")
# `booked_schema.json` is shipped alongside `flight_schema.json` so the
# schema is ready to wire up once the SDK exposes per-button action handlers
# for fixed-schema surfaces (matching the langgraph-python reference).
BOOKED_SCHEMA = _load_schema("booked_schema.json")
@tool
def display_flight(origin: str, destination: str, airline: str, price: str):
"""Show a flight card for the given trip.
Emits an `a2ui_operations` container directly — NO secondary LLM
call. The runtime A2UI middleware detects the container in the tool
result and forwards the surface to the frontend renderer. The
frontend catalog resolves component names against the local React
components.
Args:
origin (str): Origin airport code (e.g. "SFO").
destination (str): Destination airport code (e.g. "JFK").
airline (str): Airline name (e.g. "United").
price (str): Price string (e.g. "$289").
Returns:
str: A2UI operations as JSON.
"""
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 = Agent(
model=OpenAIChat(id="gpt-4o-mini", timeout=120),
tools=[display_flight],
tool_call_limit=4,
description=SYSTEM_PROMPT,
)
@@ -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,119 @@
"""Agno agent backing the Agent Config Object demo.
Reads three forwarded properties — ``tone``, ``expertise``,
``responseLength`` — that the CopilotKit provider forwards on every run,
and composes the system prompt dynamically per turn.
Agno does not have a LangGraph-style ``configurable`` channel; instead the
custom AGUI handler in ``agent_server.py`` (mounted at
``/agent-config/agui``) reads ``RunAgentInput.forwarded_props``, builds a
fresh system prompt, and constructs a per-request Agno ``Agent`` with that
prompt before invoking it. The factory in this module produces those
per-request agents.
"""
from typing import Literal
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from dotenv import load_dotenv
load_dotenv()
Tone = Literal["professional", "casual", "enthusiastic"]
Expertise = Literal["beginner", "intermediate", "expert"]
ResponseLength = Literal["concise", "detailed"]
DEFAULT_TONE: Tone = "professional"
DEFAULT_EXPERTISE: Expertise = "intermediate"
DEFAULT_RESPONSE_LENGTH: ResponseLength = "concise"
VALID_TONES: set[str] = {"professional", "casual", "enthusiastic"}
VALID_EXPERTISE: set[str] = {"beginner", "intermediate", "expert"}
VALID_RESPONSE_LENGTHS: set[str] = {"concise", "detailed"}
def read_properties(forwarded_props: dict | None) -> dict[str, str]:
"""Read the forwarded ``properties`` dict with defensive defaults.
The CopilotKit provider forwards its ``properties`` prop as top-level
keys on ``forwarded_props`` (see the runtime's run handler). This
function never raises — every unrecognized value falls back to the
matching ``DEFAULT_*`` constant.
"""
props = forwarded_props or {}
tone = props.get("tone", DEFAULT_TONE)
expertise = props.get("expertise", DEFAULT_EXPERTISE)
response_length = props.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 {
"tone": tone,
"expertise": expertise,
"response_length": response_length,
}
def build_system_prompt(tone: str, expertise: str, response_length: str) -> str:
"""Compose a system prompt from the three axes."""
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."),
}
return (
"You are a helpful assistant.\n\n"
f"Tone: {tone_rules[tone]}\n"
f"Expertise level: {expertise_rules[expertise]}\n"
f"Response length: {length_rules[response_length]}"
)
def build_agent(forwarded_props: dict | None) -> Agent:
"""Build a per-request Agno agent whose system prompt reflects the
forwarded provider properties.
Constructed fresh on each run so the system prompt is current; the
Agno session DB still tracks history via ``session_id`` (the AGUI
handler passes ``thread_id`` through).
"""
props = read_properties(forwarded_props)
system_prompt = build_system_prompt(
props["tone"], props["expertise"], props["response_length"]
)
return Agent(
model=OpenAIChat(id="gpt-4o-mini", temperature=0.4, timeout=120),
tools=[],
description=system_prompt,
)
# A neutral default so AgentOS' agent-registry init doesn't fail before the
# first run materialises a per-request agent.
agent = build_agent(None)
@@ -0,0 +1,51 @@
"""Agno agent backing the byoc-hashbrown demo.
Emits structured output shaped for `@hashbrownai/react`'s `useUiKit` /
`useJsonParser` pipeline. The frontend renderer parses the streaming
content progressively and renders MetricCard / PieChart / BarChart /
DealCard / Markdown components against the kit schema.
The agent has a heavy system prompt and no native tools — its sole job is
to produce a single JSON object that the frontend kit can interpret.
"""
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from dotenv import load_dotenv
load_dotenv()
SYSTEM_PROMPT = """
You are a sales-dashboard composer. Your output MUST be a SINGLE valid JSON
object — no markdown fences, no commentary — shaped exactly like the
hashbrown UI kit envelope:
{
"ui": [
{ "metric": { "props": { "label": "<string>", "value": "<string>" } } },
{ "pieChart": { "props": { "title": "<string>", "data": "<JSON-string of [{label,value}, ...]>" } } },
{ "barChart": { "props": { "title": "<string>", "data": "<JSON-string of [{label,value}, ...]>" } } },
{ "dealCard": { "props": { "title": "<string>", "stage": "<one of: prospect|qualified|proposal|negotiation|closed-won|closed-lost>", "value": <number> } } },
{ "Markdown": { "props": { "children": "<string>" } } }
]
}
Rules:
- Output ONE top-level object with a "ui" array of component invocations.
- Each entry in the "ui" array has exactly one key — the component name —
whose value is `{ "props": { ... } }`.
- For pieChart and barChart, the `data` prop is a JSON-encoded *string*,
not a real array. Example:
"data": "[{\\"label\\":\\"Enterprise\\",\\"value\\":600000}]"
- Metric values are formatted strings (e.g. "$1.2M", "247 deals").
- Use Markdown entries to add concise prose between visual components.
- Pick realistic-looking sample numbers; do not call any tools.
""".strip()
agent = Agent(
model=OpenAIChat(id="gpt-4o", timeout=120),
tools=[],
description=SYSTEM_PROMPT,
)
@@ -0,0 +1,61 @@
"""Agno agent backing the byoc-json-render demo.
Emits a json-render spec the frontend renderer mounts via
`@json-render/react` against a Zod-validated catalog (MetricCard,
BarChart, PieChart).
"""
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from dotenv import load_dotenv
load_dotenv()
SYSTEM_PROMPT = """
You are a dashboard composer. Your output MUST be a SINGLE valid JSON
object matching the @json-render flat element-tree spec:
{
"root": "<id>",
"elements": {
"<id>": {
"type": "MetricCard" | "BarChart" | "PieChart",
"props": { ... },
"children": ["<id>", ...] // optional
},
...
}
}
Available components (use as the element "type"):
- MetricCard
props: { label: string, value: string, trend: string | 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:
- Output ONE valid JSON object — no markdown fences, no commentary.
- Every element has a string id; "root" must reference one of them.
- Pick realistic-looking sample numbers; do not call any tools.
- For dashboards with multiple elements, place a MetricCard at the root
and chain other elements via the root's "children" array.
""".strip()
agent = Agent(
model=OpenAIChat(id="gpt-4o", timeout=120),
tools=[],
description=SYSTEM_PROMPT,
)
@@ -0,0 +1,100 @@
"""Agno agent backing the gen-ui-agent (Agentic Generative UI) demo.
Mirrors `langgraph-python/src/agents/gen_ui_agent.py` and
`ms-agent-python/src/agents/gen_ui_agent.py`. The agent plans a task as
3 steps and walks each `pending` -> `in_progress` -> `completed`,
calling `set_steps` after every transition. The frontend
(`src/app/demos/gen-ui-agent/page.tsx`) subscribes to
`agent.state.steps` via `useAgent({ updates: [OnStateChanged] })` and
renders a live progress card.
State shape (mirrors LGP `GenUiAgentState.steps`):
{
"steps": [
{"id": "...", "title": "...",
"status": "pending" | "in_progress" | "completed"},
...
]
}
Wiring: this agent is mounted via `_attach_state_aware_route` in
`agent_server.py` at `/gen-ui-agent/agui`. That custom router emits a
`StateSnapshotEvent` carrying the final `session_state` immediately
before the closing `RunFinishedEvent`, which is what makes the agent's
state writes visible to the UI's `useAgent({ updates: [OnStateChanged] })`
subscription. Stock Agno AGUI does NOT emit STATE_SNAPSHOT — same
pattern as `shared_state_read_write` and `subagents`.
"""
from __future__ import annotations
from textwrap import dedent
import dotenv
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from agno.run import RunContext
dotenv.load_dotenv()
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 include the FULL list of steps
on each call (set_steps replaces the steps array, it does not
append). After all three steps are completed you MUST send a final
assistant message and terminate.
"""
).strip()
def set_steps(run_context: RunContext, steps: list[dict]) -> str:
"""Publish the current plan + step statuses to shared state.
Always pass the FULL list of steps (every step with `id`, `title`,
and `status` ∈ {'pending', 'in_progress', 'completed'}). This call
REPLACES `session_state["steps"]` — the custom AGUI router in
`agent_server.py` then emits a `StateSnapshotEvent` carrying the
new state, which the frontend's `useAgent` subscription picks up
and renders as the live progress card.
"""
if run_context.session_state is None:
run_context.session_state = {}
# Tolerate stray non-dict entries (defensive: avoid serialization
# crashes mid-stream if the model emits something off-shape).
cleaned: list[dict] = []
for s in steps or []:
if isinstance(s, dict):
cleaned.append(s)
run_context.session_state["steps"] = cleaned
return f"Published {len(cleaned)} step(s)."
agent = Agent(
model=OpenAIChat(id="gpt-4o-mini", timeout=120),
tools=[set_steps],
instructions=SYSTEM_PROMPT,
description=(
"Plans 3 steps and walks each pending -> in_progress -> completed "
"via set_steps. Drives the gen-ui-agent demo's live progress card."
),
# 3 steps * 2 transitions + 1 initial enumeration = 7 expected calls;
# give a small headroom for retries while bounding runaway loops.
tool_call_limit=12,
)
@@ -0,0 +1,54 @@
"""
Agno 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. Agno 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/main.py` for the shared Agno agent used by most other demos.
"""
# @region[backend-interrupt-tool]
from __future__ import annotations
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from dotenv import load_dotenv
load_dotenv()
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."
)
agent = Agent(
model=OpenAIChat(id="gpt-4o-mini", timeout=120),
# 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.
tools=[],
tool_call_limit=5,
description="Scheduling assistant for the interrupt-adapted demos.",
instructions=SYSTEM_PROMPT,
)
# @endregion[backend-interrupt-tool]
@@ -0,0 +1,322 @@
"""Agno Sales Pipeline Agent with shared tools for showcase demos."""
# @region[weather-tool-backend]
import json
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from agno.tools import tool
from dotenv import load_dotenv
from tools import (
get_weather_impl,
query_data_impl,
schedule_meeting_impl,
search_flights_impl,
build_a2ui_operations_from_tool_call,
RENDER_A2UI_TOOL_SCHEMA,
)
from tools.types import Flight
load_dotenv()
@tool
def get_weather(location: str):
"""
Get the weather for a given location. Ensure location is fully spelled out.
Args:
location (str): The location to get the weather for.
Returns:
str: Weather data as JSON.
"""
return json.dumps(get_weather_impl(location))
# @endregion[weather-tool-backend]
@tool
def query_data(query: str):
"""
Query financial database for chart data. Returns data suitable for pie or bar charts.
Args:
query (str): The query to run against the financial database.
Returns:
str: Query results as JSON.
"""
return json.dumps(query_data_impl(query))
@tool(external_execution=True)
def manage_sales_todos(todos: list[dict]):
"""
Manage the sales pipeline. Pass the complete list of sales todos.
Always pass the COMPLETE list of todos.
Args:
todos (list[dict]): The complete list of sales todos to maintain.
"""
@tool
def schedule_meeting(reason: str):
"""
Schedule a meeting with user approval. Returns available time slots.
Args:
reason (str): Reason for scheduling the meeting.
Returns:
str: Meeting scheduling data as JSON.
"""
return json.dumps(schedule_meeting_impl(reason))
@tool(external_execution=True, external_execution_silent=True)
def request_user_approval(message: str, context: str = ""):
"""
Ask the operator to approve or reject an action before you take it.
The operator will respond via an in-app modal dialog that appears
OUTSIDE the chat surface. The tool returns an object of the shape
{ approved: boolean, reason?: string }.
Args:
message (str): Short summary of the action needing approval (include concrete numbers / IDs).
context (str): Optional extra context — e.g. the ticket ID or policy rule.
"""
@tool(external_execution=True)
def change_background(background: str):
"""
Change the background color of the chat.
ONLY call this tool when the user explicitly asks to change the background.
Never call it proactively or as part of another response.
Can be anything that the CSS background attribute accepts. Prefer gradients.
Args:
background (str): The CSS background value. Prefer gradients.
"""
@tool(external_execution=True, external_execution_silent=True)
def book_call(topic: str, name: str):
"""
Ask the user to pick a time slot for a call. The picker UI presents
fixed candidate slots; the user's choice is returned to the agent.
Args:
topic (str): What the call is about (e.g. "Intro with sales").
name (str): Name of the attendee (e.g. "Alice").
"""
@tool(external_execution=True, external_execution_silent=True)
def generate_task_steps(steps: list[dict]):
"""
Generates a list of steps for the user to perform.
Each step should have a description and status.
Args:
steps (list[dict]): A list of step objects, each with 'description' (str)
and 'status' ('enabled' or 'disabled').
"""
@tool
def search_flights(flights: list[dict]):
"""
Search for flights and display the results as rich A2UI cards.
Return exactly 2 flights.
Each flight must have: airline, airlineLogo, flightNumber, origin, destination,
date (short readable format like "Tue, Mar 18"),
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
Args:
flights (list[dict]): List of flight objects to display.
Returns:
str: A2UI operations as JSON.
"""
typed_flights = [Flight(**f) for f in flights]
result = search_flights_impl(typed_flights)
return json.dumps(result)
@tool
def get_stock_price(ticker: str):
"""
Get a mock current price for a stock ticker.
When the user asks about a single ticker, also consider pulling a
related ticker for context (e.g. if they ask about 'AAPL', also
fetch 'MSFT' or 'GOOGL' so the reply can compare).
Args:
ticker (str): The ticker symbol to look up.
Returns:
str: Mock price data as JSON.
"""
from random import choice, randint
return json.dumps(
{
"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),
}
)
@tool
def roll_dice(sides: int = 6):
"""
Roll a single die with the given number of sides.
When the user asks for a roll, consider rolling twice with different
numbers of sides so the reply can show a contrast (e.g. a d6 AND a d20).
Args:
sides (int): The number of sides on the die. Defaults to 6.
Returns:
str: Dice roll result as JSON.
"""
from random import randint
return json.dumps({"sides": sides, "result": randint(1, max(2, sides))})
@tool
def generate_a2ui(context: 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.
Args:
context (str): Conversation context to generate UI for.
Returns:
str: A2UI operations as JSON.
"""
import openai
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 = Agent(
# Raise the HTTP timeout so requests routed through aimock don't time out
# under normal load. The default httpx timeout is too short when aimock
# is proxying to the upstream LLM — observed "Request timed out" errors
# that crash the agent run and trigger watchdog restarts.
model=OpenAIChat(id="gpt-4o", timeout=120),
tools=[
get_weather,
query_data,
manage_sales_todos,
schedule_meeting,
change_background,
book_call,
generate_task_steps,
request_user_approval,
search_flights,
get_stock_price,
roll_dice,
generate_a2ui,
],
# Prevent runaway tool-call loops — same guard as the ag2 package.
tool_call_limit=15,
description="You are a helpful sales assistant for the CopilotKit showcase demos.",
instructions="""
SALES PIPELINE:
When a user asks you to do anything regarding sales todos or the pipeline,
use the manage_sales_todos tool. Always pass the COMPLETE LIST of todos.
Be helpful in managing sales pipeline items.
After using the tool, provide a brief summary of what you created, removed, or changed.
WEATHER:
Only call the get_weather tool if the user asks about the weather.
If the user does not specify a location, use "Everywhere ever in the whole wide world".
QUERY DATA:
Use the query_data tool when the user asks for financial data, charts, or analytics.
SCHEDULE MEETING:
Use the schedule_meeting tool when the user wants to schedule a meeting.
BACKGROUND:
Only call change_background when the user explicitly asks to change colors/background.
BOOK CALL (HITL):
When the user asks to book a call / schedule an intro / 1:1, call
book_call with the topic and the person's name. The frontend renders a
time picker; the user's choice is returned as the tool result.
TASK STEPS (HITL):
When asked to plan something, use the generate_task_steps tool with a list of steps.
Each step should have a description and status of "enabled".
FLIGHT SEARCH:
Use search_flights when the user asks about flights. Generate 2 realistic flights.
STOCK PRICES:
Use get_stock_price when the user asks about a ticker. Consider
fetching a second related ticker for comparison when helpful.
DICE:
Use roll_dice when the user asks to roll a die. Consider rolling a
second time with a different number of sides for contrast.
DYNAMIC A2UI:
Use generate_a2ui when the user asks for a dashboard or dynamic UI.
USER APPROVAL (HITL):
When asked to take any action that affects a customer — for example
issuing a refund, updating a plan, cancelling a subscription,
escalating a ticket, or sending a credit — call request_user_approval
FIRST with a short summary and optional context. Follow the tool
result: if approved, confirm in one short sentence; if rejected,
acknowledge and do not retry.
""",
)
@@ -0,0 +1,33 @@
"""No-tools Agno agent for the MCP Apps demo.
The CopilotKit runtime's `mcpApps.servers` config auto-applies the MCP Apps
middleware which fetches an MCP server's tools at request time and injects
them into the agent's catalog. By giving this agent no native tools we keep
the demo focused: the LLM only sees the MCP-provided tools, so any tool
invocation visibly exercises the MCP Apps surface.
"""
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from dotenv import load_dotenv
load_dotenv()
agent = Agent(
# Same timeout treatment as the main agent — keeps aimock-proxied requests
# from timing out under load.
model=OpenAIChat(id="gpt-4o", timeout=120),
tools=[],
tool_call_limit=15,
description=(
"You are a helpful assistant. The host runtime injects MCP-provided "
"tools into your toolbox at request time; use them when appropriate "
"to fulfil the user's request."
),
instructions="""
When the user asks you to draw, sketch, or visualize something,
prefer to use the MCP-provided drawing tools (e.g. Excalidraw)
rather than describing the result in prose.
""",
)
@@ -0,0 +1,124 @@
"""Vision-capable Agno agent for the Multimodal Attachments demo.
Wave 2b design: a dedicated vision-capable agent scoped to `/demos/multimodal`
so other demos keep their cheaper text-only model. Backed by ``gpt-4o`` so
attached images are read natively. PDFs are flattened to text on the
Python side via ``pypdf`` before the message is forwarded to the model.
Wire format
-----------
Attachments arrive here after travelling through:
CopilotChat → AG-UI message content parts (image / document)
→ Agno AGUI converter (extracts data URLs)
→ this agent
CopilotChat emits modern multimodal parts (`{ type: "image", source: { ... }}`,
`{ type: "document", source: { ... }}`). The Agno AGUI router preserves
the structured attachment payload via its tool/converter machinery; we
inspect each user message and rewrite document parts (PDFs) into inline
text before invoking the model.
"""
from __future__ import annotations
import base64
import io
from typing import Any
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from dotenv import load_dotenv
load_dotenv()
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."
)
def _extract_data_url_parts(url: str) -> tuple[str, str]:
"""Split a ``data:<mime>;base64,<payload>`` URL into (mime, payload)."""
if not url.startswith("data:"):
return "", url
header, _, payload = url.partition(",")
if ":" not in header:
return "", payload
meta = header.split(":", 1)[1]
mime = meta.split(";", 1)[0] if ";" in meta else meta
return mime, payload
def _extract_pdf_text(b64: str) -> str:
"""Decode an inline-base64 PDF and extract its text. Returns "" on error."""
try:
raw = base64.b64decode(b64, validate=False)
except Exception as exc: # pragma: no cover - defensive
print(f"[multimodal_agent] base64 decode failed: {exc}")
return ""
try:
from pypdf import PdfReader # type: ignore[import-not-found]
except ImportError as exc: # pragma: no cover - defensive
print(
"[multimodal_agent] pypdf not installed — PDF text extraction "
f"unavailable: {exc}",
)
return ""
try:
reader = PdfReader(io.BytesIO(raw))
pages = [page.extract_text() or "" for page in reader.pages]
return "\n\n".join(pages).strip()
except Exception as exc: # pragma: no cover - defensive
print(f"[multimodal_agent] pypdf extraction failed: {exc}")
return ""
def _maybe_flatten_pdf_part(part: Any) -> Any:
"""Flatten a PDF document content part into a plain text part."""
if not isinstance(part, dict):
return part
part_type = part.get("type")
if part_type == "document":
source = part.get("source") or {}
if not isinstance(source, dict):
return part
if source.get("type") != "data":
return part
mime = source.get("mimeType") or ""
value = source.get("value")
if not isinstance(value, str) or not isinstance(mime, str):
return part
if "pdf" not in mime.lower():
return part
text = _extract_pdf_text(value)
if not text:
return {
"type": "text",
"text": "[Attached document: PDF could not be read.]",
}
return {"type": "text", "text": f"[Attached document]\n{text}"}
if part_type == "image_url":
# Pass-through — gpt-4o reads image_url parts natively.
return part
return part
# Vision-capable model. gpt-4o consumes image content parts natively.
agent = Agent(
model=OpenAIChat(id="gpt-4o", timeout=120),
tools=[],
description=SYSTEM_PROMPT,
)
__all__ = ["agent", "_maybe_flatten_pdf_part"]
@@ -0,0 +1,35 @@
"""No-tools Agno agent for the Open Generative UI demos.
The CopilotKit runtime's `openGenerativeUI` flag wires an
OpenGenerativeUIMiddleware onto the listed agents. The middleware injects a
single `generateSandboxedUi` tool into the run; the agent calls it, the
middleware streams the call's HTML+CSS arguments out as
`open-generative-ui` activity events, and the built-in
`OpenGenerativeUIActivityRenderer` mounts the result inside a sandboxed
iframe.
Giving the agent no native tools keeps the demo focused — the only tool the
LLM ever sees is the one the OGUI middleware injects.
"""
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from dotenv import load_dotenv
load_dotenv()
agent = Agent(
model=OpenAIChat(id="gpt-4o", timeout=120),
tools=[],
tool_call_limit=10,
description=(
"You are a designer that authors small, self-contained sandboxed UIs "
"via the generateSandboxedUi tool the runtime injects."
),
instructions="""
Always satisfy a user UI request by calling the generateSandboxedUi
tool the runtime injects. Do not describe the UI in prose; call the
tool with complete HTML + CSS so the iframe renders the result.
""",
)
@@ -0,0 +1,172 @@
"""Reasoning-capable Agno agent for the reasoning family of demos.
Backs three showcase cells:
- reasoning-custom (custom amber ReasoningBlock slot)
- reasoning-default (CopilotKit's built-in reasoning card)
- tool-rendering-reasoning-chain (reasoning + sequential tool calls)
Mirrors `showcase/integrations/langgraph-python/src/agents/reasoning_agent.py`
(shared across the three reasoning demos there).
Uses reasoning=False with a custom AGUI handler in agent_server.py that
emits REASONING_MESSAGE_* AG-UI events. The primary channel is Agno's
native `reasoning_content` (chat-completions stream a `delta.reasoning_content`
field surfaced on each `RunContentEvent.reasoning_content`), tee'd into the
AG-UI reasoning stream; parsing of <reasoning>...</reasoning> XML tags in the
model output remains as a defensive fallback when the native channel is empty.
Running with reasoning=False avoids Agno's multi-call CoT loop (which breaks
aimock fixtures) while still producing the proper AG-UI events that
CopilotKit's frontend renders via the reasoningMessage slot.
For the reasoning-chain demo we also expose the same shared backend tools
(`get_weather`, `search_flights`, `get_stock_price`, `roll_dice`) as the
primary agent so the catch-all tool renderer can observe a full
reasoning -> tool call -> reasoning -> tool call chain.
"""
from __future__ import annotations
import json
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from agno.tools import tool
from dotenv import load_dotenv
from tools import (
get_weather_impl,
search_flights_impl,
)
from tools.types import Flight
load_dotenv()
@tool
def get_weather(location: str):
"""
Get the weather for a given location. Ensure location is fully spelled out.
Args:
location (str): The location to get the weather for.
Returns:
str: Weather data as JSON.
"""
return json.dumps(get_weather_impl(location))
@tool
def search_flights(flights: list[dict]):
"""
Search for flights and display the results as rich A2UI cards.
Return exactly 2 flights.
Args:
flights (list[dict]): List of flight objects to display.
Returns:
str: A2UI operations as JSON.
"""
typed_flights = [Flight(**f) for f in flights]
result = search_flights_impl(typed_flights)
return json.dumps(result)
@tool
def get_stock_price(ticker: str):
"""
Get a mock current price for a stock ticker.
Args:
ticker (str): The ticker symbol to look up.
Returns:
str: Mock price data as JSON.
"""
from random import choice, randint
return json.dumps(
{
"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),
}
)
@tool
def roll_dice(sides: int = 6):
"""
Roll a single die with the given number of sides.
Args:
sides (int): The number of sides on the die. Defaults to 6.
Returns:
str: Dice roll result as JSON.
"""
from random import randint
return json.dumps({"sides": sides, "result": randint(1, max(2, sides))})
# NOTE: reasoning=False (the default) is used here intentionally.
#
# Agno's reasoning=True triggers a multi-call Chain-of-Thought loop that
# makes up to `reasoning_max_steps` sequential LLM calls. This breaks in
# proxy/fixture environments (aimock, D5 probes) where only the first
# call matches a fixture — subsequent calls don't match and either fall
# through to the real API (slow, non-deterministic) or fail entirely.
#
# Instead, the custom AGUI handler in agent_server.py synthesizes
# REASONING_MESSAGE_* AG-UI events from the agent's response text. The
# system prompt instructs the model to prefix its answer with a reasoning
# block delimited by <reasoning>...</reasoning> tags. The custom handler
# parses those tags and emits proper AG-UI reasoning events that
# CopilotKit's frontend renders via the reasoningMessage slot.
#
# This approach:
# - Works with aimock (single LLM call)
# - Emits proper AG-UI REASONING_MESSAGE_* events (unlike Agno's stock
# AGUI handler which only emits STEP_STARTED/STEP_FINISHED)
# - Keeps the demo visually identical to native reasoning models
agent = Agent(
model=OpenAIChat(id="gpt-4o-mini", timeout=120),
tools=[get_weather, search_flights, get_stock_price, roll_dice],
reasoning=False,
tool_call_limit=10,
description=(
"You are a helpful assistant. For each user question, first think "
"step-by-step about the approach, then answer concisely. When the "
"question calls for a tool, call it explicitly rather than guessing."
),
instructions="""
REASONING STYLE:
Always begin your response with a reasoning block wrapped in
<reasoning>...</reasoning> XML tags. Inside the tags, think
step-by-step (two to four short steps is plenty). After the closing
tag, give your concise final answer. Example:
<reasoning>
Step 1: Identify what the user is asking.
Step 2: Consider which tool to use.
Step 3: Formulate the answer.
</reasoning>
Here is my answer...
TOOLS (reasoning-chain cell):
- get_weather: use when the user asks about weather.
- search_flights: use when the user asks about flights. Generate 2
realistic flights. Flight shape: airline, airlineLogo (Google
favicon URL), flightNumber, origin, destination, date
("Tue, Mar 18"), departureTime, arrivalTime, duration ("4h 25m"),
status ("On Time"|"Delayed"), statusColor (hex), price ("$289"),
currency ("USD").
- get_stock_price: use when the user asks about a ticker. Consider
fetching a second related ticker for comparison.
- roll_dice: use when the user asks to roll a die. Consider rolling
twice with different numbers of sides.
""",
)
@@ -0,0 +1,122 @@
"""Agno agent backing the Shared State (Read + Write) demo.
Mirrors `langgraph-python/src/agents/shared_state_read_write.py` and
`google-adk/src/agents/shared_state_read_write_agent.py`.
Demonstrates the canonical bidirectional shared-state pattern between UI
and agent:
- **UI -> agent (write)**: The UI owns a `preferences` object that it
writes into agent state via `agent.setState(...)`. The Agno agent's
dynamic instructions function reads `session_state["preferences"]`
every turn and prepends a preferences block so the LLM adapts.
- **agent -> UI (read)**: The agent calls the `set_notes` tool to
replace `session_state["notes"]`. Our custom AGUI router (see
`agent_server.py`) emits a `StateSnapshotEvent` after every run so the
UI's `useAgent({ updates: [OnStateChanged] })` reflects the change.
Together this matches the canonical bidirectional shared-state contract
the langgraph-python and google-adk references implement.
"""
from __future__ import annotations
from textwrap import dedent
from typing import Any
import dotenv
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from agno.run import RunContext
dotenv.load_dotenv()
PREFS_BLOCK_HEADER = "[shared-state-read-write] preferences:"
def _format_preferences(prefs: Any) -> str:
"""Build the preferences block injected into the system prompt."""
if not isinstance(prefs, dict) or not prefs:
return ""
lines = [PREFS_BLOCK_HEADER]
if prefs.get("name"):
lines.append(f"- Name: {prefs['name']}")
if prefs.get("tone"):
lines.append(f"- Preferred tone: {prefs['tone']}")
if prefs.get("language"):
lines.append(f"- Preferred language: {prefs['language']}")
interests = prefs.get("interests") or []
if interests:
lines.append(f"- Interests: {', '.join(interests)}")
if len(lines) == 1:
# Truthy dict but no recognized keys — emit nothing rather than a
# bare header. Mirrors the same guard used in google-adk's
# shared_state_read_write_agent._build_prefs_block.
return ""
lines.append(
"Tailor every response to these preferences. "
"Address the user by name when appropriate."
)
return "\n".join(lines)
def build_instructions(run_context: RunContext) -> str:
"""Dynamic instructions: read latest preferences from session_state.
Agno re-evaluates this function on every run when `cache_callables`
is False, so writes the UI makes via `agent.setState({preferences})`
take effect on the very next turn.
"""
base = dedent(
"""
You are a helpful, concise assistant. The user's preferences are
supplied via shared state and added as a system message at the start
of every turn — always respect them.
When the user asks you to remember something, or you observe
something worth surfacing in the UI's notes panel, call `set_notes`
with the FULL updated list of short note strings (existing notes +
any new ones). Keep each note under 120 characters. Always pass the
complete list — `set_notes` REPLACES the notes array, it does not
append.
"""
).strip()
prefs_block = _format_preferences(getattr(run_context, "session_state", None) or {})
if prefs_block:
return f"{prefs_block}\n\n{base}"
return base
def set_notes(run_context: RunContext, notes: list[str]) -> str:
"""Replace the notes array in shared state with the full updated list.
Always pass the FULL list of short note strings (existing notes + new),
not a diff. Keep each note short (< 120 chars).
"""
if run_context.session_state is None:
run_context.session_state = {}
# Coerce all entries to plain strings — tolerate models that pass
# through stray dict/None entries from earlier turns rather than
# crash the AGUI router with a serialization failure mid-stream.
cleaned = [str(n) for n in (notes or []) if n is not None]
run_context.session_state["notes"] = cleaned
return f"Notes updated. ({len(cleaned)} total)"
agent = Agent(
model=OpenAIChat(id="gpt-4o-mini", timeout=120),
tools=[set_notes],
# Re-evaluate instructions on every run so UI writes to
# session_state["preferences"] are visible to the LLM on the very
# next turn (rather than being cached at agent construction time).
cache_callables=False,
instructions=build_instructions,
description=(
"You adapt your responses to the user's stored preferences and use "
"the set_notes tool to surface things worth remembering."
),
tool_call_limit=5,
)
@@ -0,0 +1,274 @@
"""Agno agent backing the Sub-Agents demo.
Mirrors `langgraph-python/src/agents/subagents.py` and
`google-adk/src/agents/subagents_agent.py`.
A supervisor Agno agent delegates work to three specialized sub-agents
(research / writing / critique) exposed as tools. Each delegation
appends an entry to `session_state["delegations"]` so the UI can render
a live delegation log via `useAgent({ updates: [OnStateChanged] })`.
Each sub-agent is itself a full `Agent(...)` with its own system prompt
— the supervisor only sees the sub-agent's final text response. This is
the canonical Agno multi-agent pattern, surfaced to the frontend via
shared state.
"""
# @region[supervisor-delegation-tools]
# @region[subagent-setup]
from __future__ import annotations
import logging
import uuid
from typing import Any
import dotenv
from agno.agent.agent import Agent
from agno.models.openai import OpenAIChat
from agno.run import RunContext
dotenv.load_dotenv()
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Sub-agents (real Agno agents under the hood)
# ---------------------------------------------------------------------------
_SUB_MODEL_ID = "gpt-4o-mini"
# Each sub-agent is a full Agno `Agent(...)` with its own system prompt.
# They don't share memory or tools with the supervisor — the supervisor
# only sees their final text response, which is returned via the
# delegation tool below.
_research_agent = Agent(
model=OpenAIChat(id=_SUB_MODEL_ID, timeout=120),
description="Research sub-agent.",
instructions=(
"You are a research sub-agent. Given a topic, produce a concise "
"bulleted list of 3-5 key facts. No preamble, no closing."
),
)
_writing_agent = Agent(
model=OpenAIChat(id=_SUB_MODEL_ID, timeout=120),
description="Writing sub-agent.",
instructions=(
"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."
),
)
_critique_agent = Agent(
model=OpenAIChat(id=_SUB_MODEL_ID, timeout=120),
description="Critique sub-agent.",
instructions=(
"You are an editorial critique sub-agent. Given a draft, give "
"2-3 crisp, actionable critiques. No preamble."
),
)
# @endregion[subagent-setup]
def _invoke_sub_agent(sub_agent: Agent, task: str) -> str:
"""Run a sub-agent on `task` and return its final message content."""
result = sub_agent.run(input=task)
content = getattr(result, "content", None)
if isinstance(content, str):
return content.strip()
if content is None:
return ""
return str(content).strip()
# ---------------------------------------------------------------------------
# Shared-state helpers
# ---------------------------------------------------------------------------
def _append_delegation(
run_context: RunContext,
*,
sub_agent: str,
task: str,
status: str,
result: str,
) -> str:
"""Append a delegation entry and return its id."""
if run_context.session_state is None:
run_context.session_state = {}
delegations = list(run_context.session_state.get("delegations") or [])
entry_id = str(uuid.uuid4())
delegations.append(
{
"id": entry_id,
"sub_agent": sub_agent,
"task": task,
"status": status,
"result": result,
}
)
run_context.session_state["delegations"] = delegations
return entry_id
def _update_delegation(
run_context: RunContext,
*,
entry_id: str,
status: str,
result: str,
) -> None:
"""Mutate the delegation entry with `entry_id` in shared state.
If the entry has gone missing (another part of the system replaced
`session_state["delegations"]`), log loudly and skip rather than
appending a synthetic entry. Mirrors the conservative behavior used
in the google-adk reference.
"""
if run_context.session_state is None:
run_context.session_state = {}
delegations = list(run_context.session_state.get("delegations") or [])
for entry in delegations:
if entry.get("id") == entry_id:
entry["status"] = status
entry["result"] = result
run_context.session_state["delegations"] = delegations
return
logger.warning(
"subagents: delegation entry %s missing on update — final %s "
"state (result_length=%d) will not be rendered",
entry_id,
status,
len(result),
)
def _delegate(
run_context: RunContext,
*,
sub_agent_name: str,
sub_agent: Agent,
task: str,
) -> dict[str, Any]:
"""Common delegation flow: append running entry → invoke → update final."""
entry_id = _append_delegation(
run_context,
sub_agent=sub_agent_name,
task=task,
status="running",
result="",
)
try:
result = _invoke_sub_agent(sub_agent, task)
except Exception as exc: # noqa: BLE001 — sub-agent transport can fail anywhere
logger.exception("subagents: sub-agent %s failed", sub_agent_name)
# Surface only the exception class to the supervisor / frontend —
# provider error strings can carry URLs / request IDs / partial
# credentials. The full traceback stays in server logs.
message = (
f"sub-agent call failed: {exc.__class__.__name__} "
"(see server logs for details)"
)
_update_delegation(
run_context, entry_id=entry_id, status="failed", result=message
)
return {"status": "failed", "error": message}
_update_delegation(
run_context, entry_id=entry_id, status="completed", result=result
)
return {"status": "completed", "result": result}
# ---------------------------------------------------------------------------
# Supervisor tools (each tool delegates to one sub-agent)
# ---------------------------------------------------------------------------
# Each function is a tool exposed to the supervisor agent. The supervisor
# LLM "calls" these to delegate work; each call synchronously runs the
# matching sub-agent, records the delegation into shared state, and
# returns the sub-agent's output as the tool result the supervisor reads
# on its next step.
def research_agent(run_context: RunContext, task: str) -> dict[str, Any]:
"""Delegate a research task to the research sub-agent.
Use for: gathering facts, background, definitions, statistics.
Returns {status, result} on success or {status: "failed", error} on
sub-agent failure.
"""
return _delegate(
run_context,
sub_agent_name="research_agent",
sub_agent=_research_agent,
task=task,
)
def writing_agent(run_context: RunContext, task: str) -> dict[str, Any]:
"""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`. Same return shape
as research_agent.
"""
return _delegate(
run_context,
sub_agent_name="writing_agent",
sub_agent=_writing_agent,
task=task,
)
def critique_agent(run_context: RunContext, task: str) -> dict[str, Any]:
"""Delegate a critique task to the critique sub-agent.
Use for: reviewing a draft and suggesting concrete improvements.
Same return shape as research_agent.
"""
return _delegate(
run_context,
sub_agent_name="critique_agent",
sub_agent=_critique_agent,
task=task,
)
# @endregion[supervisor-delegation-tools]
# ---------------------------------------------------------------------------
# Supervisor (the agent we export)
# ---------------------------------------------------------------------------
_SUPERVISOR_INSTRUCTION = (
"You are a supervisor agent that coordinates three specialized "
"sub-agents to produce high-quality deliverables.\n\n"
"Available sub-agents (call them as tools):\n"
" - research_agent: gathers facts on a topic.\n"
" - writing_agent: turns facts + a brief into a polished draft.\n"
" - critique_agent: reviews a draft and suggests improvements.\n\n"
"For most non-trivial user requests, delegate in sequence: "
"research -> write -> critique. Pass the relevant facts/draft "
"through the `task` argument of each tool. Each tool returns a dict "
"shaped {status: 'completed' | 'failed', result?: str, error?: str}. "
"If a sub-agent fails, surface the failure briefly to the user "
"(don't fabricate a result) and decide whether to retry. 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, including the in-flight 'running' state."
)
agent = Agent(
model=OpenAIChat(id=_SUB_MODEL_ID, timeout=120),
tools=[research_agent, writing_agent, critique_agent],
description="Supervisor agent coordinating research / writing / critique sub-agents.",
instructions=_SUPERVISOR_INSTRUCTION,
tool_call_limit=10,
)
@@ -0,0 +1,53 @@
// Dedicated runtime for the A2UI — Fixed Schema cell. Splitting into its
// own endpoint lets us set `a2ui.injectA2UITool: false` — the backend Agno
// agent owns the `display_flight` tool which emits its own
// `a2ui_operations` container directly in the tool result (no secondary
// LLM call).
//
// Reference:
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-a2ui-fixed-schema/route.ts
// - src/agents/a2ui_fixed_agent.py (the Agno 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/agui`,
});
const runtime = new CopilotRuntime({
// @ts-expect-error -- 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_agent.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,52 @@
// Dedicated runtime for the Agent Config Object demo (Agno).
//
// The page wraps <CopilotKit properties={...}>; CopilotKit forwards those
// properties as top-level keys on `RunAgentInput.forwarded_props`, which
// the Agno-side custom AGUI handler at `/agent-config/agui` reads to
// build a per-request system prompt (see
// `src/agent_server.py::_run_agent_config`).
//
// Scoped to its own endpoint so non-demo cells don't pay the cost of the
// per-request agent factory and so the request-body propagation can be
// asserted against exactly one URL.
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 agentConfigAgent = new HttpAgent({
url: `${AGENT_URL}/agent-config/agui`,
});
const runtime = new CopilotRuntime({
agents: {
// @ts-expect-error -- see main route.ts
"agent-config-demo": agentConfigAgent,
// @ts-expect-error -- see main route.ts
default: agentConfigAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-agent-config",
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,76 @@
// 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
// agent.
//
// Implementation note: the V1 Next.js adapter
// (`copilotRuntimeNextJSAppRouterEndpoint`) does NOT forward the `hooks`
// option to the V2 fetch handler. To get `onRequest` wired, this route uses
// `createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` directly — the
// framework-agnostic fetch handler that returns a plain
// `(Request) => Promise<Response>`, which composes cleanly with a Next.js
// App Router route export.
//
// The authenticated target is the Agno main agent served at /agui on the
// AgentOS process. The point of this demo is the gate mechanism, not
// per-user agent branching — authenticated users get the same behavior as
// any other neutral demo.
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 Agno main agent for the authenticated path.
const authDemoAgent = new HttpAgent({ url: `${AGENT_URL}/agui` });
const runtime = new CopilotRuntime({
agents: {
"auth-demo": authDemoAgent,
// Fallback: useAgent() with no args resolves "default" — alias to the
// same agent so hooks in the demo page resolve cleanly.
default: authDemoAgent,
},
});
const BASE_PATH = "/api/copilotkit-auth";
// Framework-agnostic fetch handler with the auth gate wired up.
const handler = createCopilotRuntimeHandler({
runtime,
basePath: BASE_PATH,
hooks: {
onRequest: ({ request }) => {
const authHeader = request.headers.get("authorization");
if (authHeader !== DEMO_AUTH_HEADER) {
// Throwing a Response short-circuits the pipeline. The runtime maps
// thrown Responses to the HTTP response verbatim (status + body).
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" },
},
);
}
},
},
});
// Next.js App Router bindings. The handler is framework-agnostic — it takes
// a web Request and returns a web Response — so it drops straight into the
// POST/GET exports without any adapter shim.
export const POST = (req: NextRequest) => handler(req);
export const GET = (req: NextRequest) => handler(req);
@@ -0,0 +1,75 @@
// Dedicated runtime for the Beautiful Chat flagship showcase cell (Agno).
//
// Beautiful Chat exercises A2UI (dynamic + fixed schema) and Open
// Generative UI. The canonical langgraph-python reference ships MCP Apps
// on the same runtime as well; the Agno port routes the cell at the shared
// `main` Agno agent (`/agui`) since there is no dedicated beautiful-chat
// backend endpoint — the flagship behavior comes from the runtime flags
// below plus the frontend's per-cell registrations.
//
// Isolated on its own endpoint (mirroring beautiful-chat in the canonical)
// because the `openGenerativeUI` / `a2ui` runtime flags set global state on
// the probe response that would otherwise leak into the other cells sharing
// the default `/api/copilotkit` runtime.
//
// Mirrors showcase/integrations/pydantic-ai/src/app/api/copilotkit-beautiful-chat/route.ts.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import type { AbstractAgent } from "@ag-ui/client";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// The beautiful-chat page resolves <CopilotKit agent="beautiful-chat">
// here; internal components (headless-chat, example-canvas) also call
// `useAgent()` with no args, which defaults to agentId "default". Alias
// default to the same backend so those hooks resolve.
const agents: Record<string, AbstractAgent> = {
"beautiful-chat": new HttpAgent({ url: `${AGENT_URL}/agui` }),
default: new HttpAgent({ url: `${AGENT_URL}/agui` }),
};
const runtime = new CopilotRuntime({
// @ts-expect-error -- see main route.ts
agents,
openGenerativeUI: true,
a2ui: {
// The targeted `main` Agno agent (`/agui`) already registers the
// `generate_a2ui` tool itself (src/agents/main.py), so the runtime must
// NOT inject a second copy — that would double-bind the render tool.
// Matches pydantic-ai's beautiful-chat (this file's mirror source) and
// the other Agno a2ui routes, all of which set this false.
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",
},
});
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 };
console.error(
`[copilotkit-beautiful-chat/route] ERROR: ${e.message}`,
e.stack,
);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,71 @@
// Dedicated runtime for the byoc-hashbrown demo (Agno).
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
console.log(`[copilotkit-byoc-hashbrown/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 createByocHashbrownAgent() {
return new HttpAgent({
url: `${AGENT_URL}/byoc-hashbrown/agui`,
});
}
// Register both the named agent and a default fallback so the runtime
// can always resolve regardless of which agent name the frontend sends.
const agents: Record<string, AbstractAgent> = {
"byoc-hashbrown-demo": createByocHashbrownAgent(),
default: createByocHashbrownAgent(),
};
export const POST = async (req: NextRequest) => {
const url = req.url;
if (ROUTE_DEBUG) {
console.log(`[copilotkit-byoc-hashbrown/route] POST ${url}`);
}
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-byoc-hashbrown",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-expect-error -- see main route.ts
agents,
}),
});
const response = await handleRequest(req);
if (!response.ok) {
console.log(
`[copilotkit-byoc-hashbrown/route] Response status: ${response.status}`,
);
} else if (ROUTE_DEBUG) {
console.log(
`[copilotkit-byoc-hashbrown/route] Response status: ${response.status}`,
);
}
return response;
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
console.error(`[copilotkit-byoc-hashbrown/route] ERROR: ${e.message}`);
console.error(`[copilotkit-byoc-hashbrown/route] Stack: ${e.stack}`);
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,37 @@
// Dedicated runtime for the byoc-json-render demo (Agno).
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/agui`,
});
const runtime = new CopilotRuntime({
// @ts-expect-error -- see main route.ts
agents: { byoc_json_render: 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,62 @@
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic Schema)
// cell. Splitting into its own endpoint (mirroring beautiful-chat in the
// langgraph-python reference) lets us set `a2ui.injectA2UITool: false` —
// the backend Agno agent owns the `generate_a2ui` tool itself, so
// double-binding from the runtime would duplicate the tool slot and confuse
// the LLM.
//
// Reference:
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-declarative-gen-ui/route.ts
// - src/agents/a2ui_dynamic_agent.py (the Agno backend)
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 declarativeGenUiAgent = new HttpAgent({
url: `${AGENT_URL}/declarative-gen-ui/agui`,
});
const runtime = new CopilotRuntime({
// @ts-expect-error -- see main route.ts
agents: { "declarative-gen-ui": declarativeGenUiAgent },
a2ui: {
// The backend agent owns `generate_a2ui` explicitly (see
// src/agents/a2ui_dynamic_agent.py), so the runtime MUST NOT
// auto-inject its own A2UI tool on top. The A2UI middleware still runs
// — it serialises the registered client catalog into the agent's
// `state.copilotkit.context` so the secondary LLM inside
// `generate_a2ui` knows which components to emit — and it still
// detects the `a2ui_operations` container in the tool result and
// streams rendered surfaces to the frontend.
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: "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,78 @@
// Dedicated runtime for the declarative-hashbrown demo (Agno).
//
// The demo folder + route + agent slug were renamed from `byoc-hashbrown`
// to the canonical `declarative-hashbrown` surface; the underlying Agno
// agent still mounts at `/byoc-hashbrown/agui` (see src/agent_server.py).
// The demo page mounts <CopilotKit agent="declarative-hashbrown-demo">.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
console.log(`[copilotkit-declarative-hashbrown/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 createDeclarativeHashbrownAgent() {
return new HttpAgent({
url: `${AGENT_URL}/byoc-hashbrown/agui`,
});
}
// Register both the named agent and a default fallback so the runtime
// can always resolve regardless of which agent name the frontend sends.
const agents: Record<string, AbstractAgent> = {
"declarative-hashbrown-demo": createDeclarativeHashbrownAgent(),
default: createDeclarativeHashbrownAgent(),
};
export const POST = async (req: NextRequest) => {
const url = req.url;
if (ROUTE_DEBUG) {
console.log(`[copilotkit-declarative-hashbrown/route] POST ${url}`);
}
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-hashbrown",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-expect-error -- see main route.ts
agents,
}),
});
const response = await handleRequest(req);
if (!response.ok) {
console.log(
`[copilotkit-declarative-hashbrown/route] Response status: ${response.status}`,
);
} else if (ROUTE_DEBUG) {
console.log(
`[copilotkit-declarative-hashbrown/route] Response status: ${response.status}`,
);
}
return response;
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
console.error(
`[copilotkit-declarative-hashbrown/route] ERROR: ${e.message}`,
);
console.error(`[copilotkit-declarative-hashbrown/route] Stack: ${e.stack}`);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,55 @@
// Dedicated runtime for the declarative-json-render demo (Agno).
//
// The demo page renders the agent's JSON output into a frontend-owned
// component catalog via @json-render/react. The underlying Agno agent mounts
// at `/byoc-json-render/agui` (see src/agent_server.py). The demo folder +
// route surface were renamed from `byoc-json-render` to the canonical
// `declarative-json-render`; the agent ID retains its legacy
// `byoc_json_render` name.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { AbstractAgent, 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/agui`,
});
// Register both the named agent and a `default` fallback so the runtime can
// resolve regardless of whether the frontend sends an explicit agentId.
const agents: Record<string, AbstractAgent> = {
byoc_json_render: byocJsonRenderAgent,
default: byocJsonRenderAgent,
};
const runtime = new CopilotRuntime({
// @ts-expect-error -- see main route.ts
agents,
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-json-render",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
console.error(
`[copilotkit-declarative-json-render/route] ERROR: ${e.message}`,
e.stack,
);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,82 @@
// CopilotKit runtime for the MCP Apps cell (Agno).
//
// 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` (registered by CopilotKit internally)
// renders in the chat as a sandboxed iframe.
//
// We attach a no-tools Agno agent here (the main agent at /agui has its own
// rich tool catalog; an MCP-only cell is cleaner with no competing tools).
// The MCP Apps middleware injects the MCP server's tools into the agent's
// catalog at request time so the LLM can call them.
//
// Reference:
// https://docs.copilotkit.ai/integrations/agno/generative-ui/mcp-apps
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";
// Backed by the Agno main AGUI interface. The MCP Apps middleware wires
// MCP server tools into the run alongside whatever tools the agent already
// has — for the cleanest UX the dedicated `/mcp-apps/agui` interface (see
// agent_server.py) registers a no-tools Agno agent so the LLM only sees
// the MCP-provided toolset.
const mcpAppsAgent = new HttpAgent({ url: `${AGENT_URL}/mcp-apps/agui` });
// headless-complete shares this runtime (its page wires
// runtimeUrl="/api/copilotkit-mcp-apps") but is backed by the main Agno
// agent at /agui — the same backend the main route registers it against.
const headlessCompleteAgent = new HttpAgent({ url: `${AGENT_URL}/agui` });
// @region[runtime-mcpapps-config]
// The `mcpApps.servers` config is all you need server-side. The runtime
// auto-applies the MCP Apps middleware: 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({
agents: {
// @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.
"headless-complete": headlessCompleteAgent,
"mcp-apps": mcpAppsAgent,
},
mcpApps: {
servers: [
{
type: "http",
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
// Always 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,48 @@
// Dedicated runtime for the Multimodal Attachments demo (Agno).
//
// Why its own route? The backing graph (multimodal_agent) runs a vision-
// capable model (gpt-4o). Other showcase cells use cheaper text-only models;
// scoping the vision capability — and its cost — to exactly the cell that
// exercises it matches the pattern used by the LangGraph reference.
//
// The page at src/app/demos/multimodal/page.tsx points its `runtimeUrl` at
// this endpoint and sets `agent="multimodal-demo"` (resolved 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/agui`,
});
const agents = {
"multimodal-demo": multimodalAgent,
default: multimodalAgent,
} as const;
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-multimodal",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-expect-error -- see main route.ts
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,64 @@
// Dedicated runtime for the Open Generative UI demos (Agno).
//
// 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.
//
// The Agno backend exposes a no-tools agent at `/open-gen-ui/agui` (see
// `agent_server.py`); the runtime middleware turns each
// `generateSandboxedUi` tool call the agent emits into an
// `open-generative-ui` activity event the built-in renderer mounts inside a
// sandboxed iframe.
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/agui` });
const openGenUiAdvancedAgent = new HttpAgent({
url: `${AGENT_URL}/open-gen-ui/agui`,
});
const agents = {
"open-gen-ui": openGenUiAgent,
"open-gen-ui-advanced": openGenUiAdvancedAgent,
} as const;
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-ogui",
serviceAdapter: new ExperimentalEmptyAdapter(),
// @region[minimal-runtime-flag]
// @region[advanced-runtime-config]
// 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).
runtime: new CopilotRuntime({
// @ts-expect-error -- see main route.ts
agents,
openGenerativeUI: {
agents: ["open-gen-ui", "open-gen-ui-advanced"],
},
}),
// @endregion[advanced-runtime-config]
// @endregion[minimal-runtime-flag]
});
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,82 @@
// Dedicated runtime for the /demos/voice cell (Agno).
//
// 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 typed 401 when `OPENAI_API_KEY` isn't set so the UI surfaces a
// clean error instead of a 5xx.
//
// Implementation: wires the V2 `CopilotRuntime` directly (the V1 wrapper
// drops the `transcriptionService` option on the floor). V2 URL-routes on
// `/info`, `/agent/:id/run`, `/transcribe`, etc., so the route lives at
// `[[...slug]]/route.ts` to catch every sub-path.
// @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 { TranscriptionServiceOpenAI } from "@copilotkit/voice";
import { HttpAgent } from "@ag-ui/client";
import OpenAI from "openai";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const voiceDemoAgent = new HttpAgent({ url: `${AGENT_URL}/agui` });
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) {
// "api key" substring → handleTranscribe maps to AUTH_FAILED → 401.
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({
agents: {
// @ts-expect-error -- see main route.ts
"voice-demo": voiceDemoAgent,
// @ts-expect-error -- see main route.ts
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,190 @@
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 createMainAgent() {
return new HttpAgent({ url: `${AGENT_URL}/agui` });
}
function createReasoningAgent() {
return new HttpAgent({ url: `${AGENT_URL}/reasoning/agui` });
}
// State-aware agents are served by a custom AGUI handler in agent_server.py
// that emits StateSnapshotEvent after every run. Stock Agno AGUI does NOT
// emit state events, so demos that depend on agent-side state writes
// (set_notes, delegations) must point at these dedicated routes.
function createSharedStateRWAgent() {
return new HttpAgent({ url: `${AGENT_URL}/shared-state-rw/agui` });
}
function createSubagentsAgent() {
return new HttpAgent({ url: `${AGENT_URL}/subagents/agui` });
}
// gen-ui-agent: agent owns its own state schema (`steps`) and mutates it
// via the `set_steps` tool. Needs the state-aware AGUI router so the
// frontend's `useAgent({ updates: [OnStateChanged] })` receives the
// StateSnapshotEvent each run — stock Agno AGUI does not emit one.
function createGenUiAgent() {
return new HttpAgent({ url: `${AGENT_URL}/gen-ui-agent/agui` });
}
// Main agent backs most demos. The Next.js runtime aliases the single
// Agno `main` agent under every demo cell name so per-cell frontend
// tool/component registrations scope correctly.
const mainAgentNames = [
"agentic_chat",
"human_in_the_loop",
"hitl-in-chat",
"hitl-in-app",
"tool-rendering",
"tool-rendering-default-catchall",
"tool-rendering-custom-catchall",
"gen-ui-tool-based",
"shared-state-read",
"shared-state-write",
"shared-state-streaming",
// Neutral / chrome demos reusing the default agent.
"prebuilt-sidebar",
"prebuilt-popup",
"chat-slots",
"chat-customization-css",
"headless-simple",
"headless-complete",
"frontend_tools",
"frontend-tools-async",
"readonly-state-agent-context",
"agent-config",
];
// Interrupt-adapted demos: gen-ui-interrupt and interrupt-headless share
// the same Agno scheduling agent at /interrupt-adapted/agui. The agent has
// tools=[]; `schedule_meeting` is provided by the frontend via
// `useFrontendTool` with an async Promise handler.
function createInterruptAgent() {
return new HttpAgent({ url: `${AGENT_URL}/interrupt-adapted/agui` });
}
const interruptAgentNames = ["gen-ui-interrupt", "interrupt-headless"];
// Reasoning agent names — backed by the reasoning-enabled Agno agent at
// /reasoning/agui. Emits AG-UI REASONING_MESSAGE_* events that the
// frontend renders via CopilotChatReasoningMessage (or a custom slot).
const reasoningAgentNames = [
"agentic-chat-reasoning",
"reasoning-default-render",
"tool-rendering-reasoning-chain",
"reasoning-default",
"reasoning-custom",
];
const agents: Record<string, AbstractAgent> = {};
for (const name of mainAgentNames) {
agents[name] = createMainAgent();
}
for (const name of interruptAgentNames) {
agents[name] = createInterruptAgent();
}
for (const name of reasoningAgentNames) {
agents[name] = createReasoningAgent();
}
// Bidirectional shared-state agent — UI writes preferences, agent writes
// notes back via set_notes and the custom AGUI router emits a
// StateSnapshotEvent that the frontend's useAgent picks up.
// gen-ui-agent — owns its own `steps` state schema and mutates it via the
// `set_steps` tool. Routes to the state-aware AGUI handler that emits a
// StateSnapshotEvent each run so the frontend's progress card updates.
agents["gen-ui-agent"] = createGenUiAgent();
agents["shared-state-read-write"] = createSharedStateRWAgent();
// Sub-agents supervisor — appends to state["delegations"] every time a
// research / writing / critique sub-agent is delegated to. Same custom
// AGUI router emits the StateSnapshotEvent needed for the live log.
agents["subagents"] = createSubagentsAgent();
agents["default"] = createMainAgent();
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-expect-error -- 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) {
const err = error as Error;
console.error(`[copilotkit/route] ERROR: ${err.message}`);
console.error(`[copilotkit/route] Stack: ${err.stack}`);
return NextResponse.json(
{ error: err.message, stack: err.stack },
{ 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 || "http://localhost:8000";
// 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: "agno",
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: "agno",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,120 @@
import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "agno";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function GET() {
const start = Date.now();
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",
];
@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { ConfigCard } from "./config-card";
import type { AgentConfig } from "./config-types";
interface DemoLayoutProps {
config: AgentConfig;
onToneChange: (tone: AgentConfig["tone"]) => void;
onExpertiseChange: (expertise: AgentConfig["expertise"]) => void;
onResponseLengthChange: (length: AgentConfig["responseLength"]) => void;
}
export function DemoLayout({
config,
onToneChange,
onExpertiseChange,
onResponseLengthChange,
}: DemoLayoutProps) {
return (
<div className="flex h-screen flex-col gap-3 p-6">
<ConfigCard
config={config}
onToneChange={onToneChange}
onExpertiseChange={onExpertiseChange}
onResponseLengthChange={onResponseLengthChange}
/>
<div className="flex-1 overflow-hidden rounded-md border border-[var(--border)]">
<CopilotChat
agentId="agent-config-demo"
className="h-full rounded-md"
/>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
/**
* Agent Config Object — typed config knobs (tone / expertise / responseLength)
* forwarded from the provider into the agent so its behavior changes per turn.
*
* Wiring: the toggles live in `useAgentConfig`. Each render the resolved
* config is published to the agent via `useAgentContext` — the v2 idiom
* for "frontend → agent runtime context" in LangGraph 0.6+. The Python
* graph picks it up through `CopilotKitMiddleware`, which routes the
* context entry into the model's prompt before each call.
*
* (LangGraph 0.6 deprecated `configurable` in favor of `context`; the
* `properties` prop on `<CopilotKit>` still works for v1-style relays
* but goes through `forwardedProps` and does not land in `RunnableConfig`
* in @ag-ui/langgraph 0.0.31. `useAgentContext` is the supported path.)
*/
import { CopilotKit } from "@copilotkit/react-core/v2";
import { DemoLayout } from "./demo-layout";
import { ConfigContextRelay } from "./config-context-relay";
import { useAgentConfig } from "./use-agent-config";
export default function AgentConfigDemoPage() {
const { config, setTone, setExpertise, setResponseLength } = useAgentConfig();
return (
// @region[provider-setup]
<CopilotKit
runtimeUrl="/api/copilotkit-agent-config"
agent="agent-config-demo"
>
<ConfigContextRelay config={config} />
<DemoLayout
config={config}
onToneChange={setTone}
onExpertiseChange={setExpertise}
onResponseLengthChange={setResponseLength}
/>
</CopilotKit>
// @endregion[provider-setup]
);
}
@@ -0,0 +1,39 @@
"use client";
import { useCallback, useState } from "react";
import {
type AgentConfig,
DEFAULT_AGENT_CONFIG,
type Expertise,
type ResponseLength,
type Tone,
} from "./config-types";
export interface UseAgentConfigHandle {
config: AgentConfig;
setTone: (tone: Tone) => void;
setExpertise: (expertise: Expertise) => void;
setResponseLength: (length: ResponseLength) => void;
reset: () => void;
}
export function useAgentConfig(): UseAgentConfigHandle {
const [config, setConfig] = useState<AgentConfig>(DEFAULT_AGENT_CONFIG);
const setTone = useCallback(
(tone: Tone) => setConfig((prev) => ({ ...prev, tone })),
[],
);
const setExpertise = useCallback(
(expertise: Expertise) => setConfig((prev) => ({ ...prev, expertise })),
[],
);
const setResponseLength = useCallback(
(responseLength: ResponseLength) =>
setConfig((prev) => ({ ...prev, responseLength })),
[],
);
const reset = useCallback(() => setConfig(DEFAULT_AGENT_CONFIG), []);
return { config, setTone, setExpertise, setResponseLength, reset };
}
@@ -0,0 +1,28 @@
# Agentic Chat
## What This Demo Shows
The simplest CopilotKit surface: a plain agentic chat backed by a LangGraph (Python) agent.
- **Natural Conversation**: Chat with your Copilot in a familiar chat interface
- **Streaming Responses**: Assistant messages stream in token-by-token via AG-UI
- **Suggestion Chips**: A starter suggestion is rendered as a quick-action chip
## How to Interact
Click the suggestion chip, or type your own prompt. For example:
- "Write a short sonnet about AI"
- "Explain the difference between an LLM and an agent"
- "Give me three ideas for a weekend project"
## Technical Details
**Provider**`CopilotKit` wires the page to the runtime:
- `runtimeUrl="/api/copilotkit"` points at the Next.js route that proxies to the agent
- `agent="agentic_chat"` selects the LangGraph agent defined in `langgraph.json`
**Chat surface**`CopilotChat` renders the full chat UI with input, message list, and streaming.
**Suggestions**`useConfigureSuggestions` registers a static suggestion that appears as a clickable chip below the chat input.

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