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
@@ -0,0 +1,12 @@
**/node_modules
.next
__pycache__
*.pyc
.git
**/vitest.config.ts
**/test-setup.ts
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
@@ -0,0 +1,9 @@
# API Keys (shared across integrations)
OPENAI_API_KEY=replace-with-your-key
ANTHROPIC_API_KEY=replace-with-your-key
# Agent backend URL (for the CopilotKit runtime proxy)
AGENT_URL=http://localhost:8000
# Showcase
NEXT_PUBLIC_BASE_URL=http://localhost:3000
@@ -0,0 +1,8 @@
# Sample multimodal demo binaries (PNG + PDF) must stay as regular
# binaries — not LFS pointers — so deploy environments without `git lfs pull`
# (Railway, in particular) serve the actual files. LFS tracking for other
# PNG/PDFs under this package inherits from the repo-root .gitattributes.
public/demo-files/sample.png -filter -diff -merge binary
public/demo-files/sample.pdf -filter -diff -merge binary
# Voice demo sample audio follows the same convention.
public/demo-audio/sample.wav -filter -diff -merge binary
+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/
+85
View File
@@ -0,0 +1,85 @@
# Stage 1: Build Next.js frontend
# (Lockfile regenerated 2026-05-20 via npm install --package-lock-only after
# the May-19 main-branch sync produced invalid JSON; see commit 65a26ebc7.)
FROM node:22-slim AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps
COPY src/ ./src/
COPY public/ ./public/
COPY next.config.ts tsconfig.json postcss.config.mjs manifest.yaml ./
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 in repo, resolved by Docker build context)
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"]
@@ -0,0 +1,144 @@
# AWS Strands — LangGraph-Python Parity Notes
This file documents the status of each showcase demo relative to the
canonical LangGraph-Python showcase package (`showcase/integrations/langgraph-python`).
The overall architectural difference between the two packages:
- **LangGraph-Python** ships one `src/agents/<demo>.py` module per demo, each
bound to its own LangGraph graph via `langgraph.json`.
- **AWS Strands** ships a single shared Strands agent (`src/agents/agent.py`)
registered under many agent names in the AG-UI runtime. All demos in the
Strands package reuse the same backend; per-demo differentiation happens
almost entirely on the frontend via `useFrontendTool`, `useRenderTool`,
`useHumanInTheLoop`, `useAgentContext`, and A2UI catalogs.
This keeps the Strands code base dramatically smaller without sacrificing
user-visible functionality — the demo URLs, pages, and interactive flows are
all present.
## Skipped demos
These demos depend on LangGraph-specific primitives that AWS Strands does not
expose at this time:
- **gen-ui-interrupt** — Built on `useLangGraphInterrupt`, which hooks directly
into the LangGraph interrupt lifecycle. Strands does not provide an
equivalent first-class interrupt primitive. The ergonomic replacement is
`hitl-in-chat` (implemented), which uses `useHumanInTheLoop` on top of a
regular frontend tool — Strands supports that natively. Surfaced as a stub
page (`src/app/demos/gen-ui-interrupt/`) and as
`not_supported_features.gen-ui-interrupt` in the manifest.
- **interrupt-headless** — Same rationale as `gen-ui-interrupt`. Requires
`useLangGraphInterrupt`'s resolve/respond primitive. Not portable.
Surfaced as a stub page (`src/app/demos/interrupt-headless/`) and as
`not_supported_features.interrupt-headless` in the manifest.
## MCP Apps — now ported (wave-2 follow-up)
- **mcp-apps** — **shipped (simplified)**. Dedicated
`/api/copilotkit-mcp-apps` route configures
`mcpApps.servers: [{ type: "http", url: ..., serverId: "excalidraw" }]`.
The Strands shared agent has no bespoke MCP tools — the runtime
middleware advertises the MCP server's tools to the agent at request
time and emits the activity events that CopilotKit's built-in
`MCPAppsActivityRenderer` paints inline as a sandboxed iframe. Mirrors
the langgraph-python sibling pattern.
Wave-2 port status for the previously deferred demos:
- **byoc-hashbrown** — **shipped**. Dedicated `/api/copilotkit-byoc-hashbrown`
route, hashbrown renderer + catalog, MetricCard/PieChart/BarChart/DealCard
components. The strict hashbrown JSON envelope prompt lives in
`src/agents/byoc_hashbrown.py` and is injected into the shared Strands
agent as `useAgentContext`. Incorporates PR #4271 fix from the start
(JSON envelope — NOT XML).
- **byoc-json-render** — **shipped**. Dedicated `/api/copilotkit-byoc-json-render`
route, `@json-render/react` renderer with `<JSONUIProvider>` wrap (PR #4271
fix). Registry forwards `children` through the MetricCard wrapper so
nested dashboards render. Output prompt lives in
`src/agents/byoc_json_render.py` and is mirrored on the frontend via
`useAgentContext`.
- **open-gen-ui** — **shipped**. Dedicated `/api/copilotkit-ogui` route with
`openGenerativeUI: { agents: ["open-gen-ui", "open-gen-ui-advanced"] }`.
Minimal variant uses `openGenerativeUI.designSkill` to steer the LLM
toward intricate, educational visualisations.
- **open-gen-ui-advanced** — **shipped**. Same route as open-gen-ui; adds
`openGenerativeUI.sandboxFunctions` (evaluateExpression, notifyHost) so
the agent-authored iframe can invoke host functions via
`Websandbox.connection.remote.<name>(...)`.
- **beautiful-chat** — **shipped (simplified)** in the wave-2 follow-up.
Polished landing-style chat shell with brand theming and seeded
suggestions, sitting on top of the shared Strands agent. Pattern
mirrors the spring-ai sibling
(`showcase/integrations/spring-ai/src/app/demos/beautiful-chat/`).
Porting the full canonical surface (ExampleCanvas, GenerativeUIExamples,
declarative A2UI catalog, theme provider, dedicated runtime that
enables `openGenerativeUI` + `a2ui` + `mcpApps` simultaneously) remains
out-of-scope future work — see the LangGraph-Python reference in
`showcase/integrations/langgraph-python/src/app/demos/beautiful-chat/`
for the full surface area.
### Per-demo prompt specialization caveat
The Strands showcase uses one shared Strands Agent backend
(`agent_server.py`). Wave-2's BYOC demos specialize the LLM's output shape
(hashbrown envelope / json-render spec) by injecting the canonical system
prompt via `useAgentContext` on the frontend, rather than by spinning up
dedicated Strands Agent instances per demo. The canonical prompts live in
`src/agents/byoc_hashbrown.py` and `src/agents/byoc_json_render.py` as the
single source of truth; the frontend strings mirror them. This keeps the
Strands backend topology simple while letting each demo specialize its
output contract.
All other LangGraph-Python demos are ported below.
## Ported demos
Existing (pre-blitz):
- `agentic-chat`, `hitl` (ergonomic HITL), `tool-rendering`, `gen-ui-tool-based`,
`gen-ui-agent`, `shared-state-read-write`, `shared-state-streaming`, `subagents`.
Added in this blitz:
- `cli-start` — manifest-only start command.
- `chat-customization-css` — scoped CSS re-theme of `<CopilotChat />`.
- `prebuilt-sidebar``<CopilotSidebar />`.
- `prebuilt-popup``<CopilotPopup />`.
- `chat-slots` — slot-system chat customization.
- `headless-simple` — minimal chat built on `useAgent`.
- `headless-complete` — full headless chat implementation.
- `agentic-chat-reasoning` — reasoning chain rendered via a custom slot.
- `reasoning-default-render` — built-in `CopilotChatReasoningMessage` render.
- `frontend-tools``useFrontendTool` background-change demo.
- `frontend-tools-async` — async `useFrontendTool` handler.
- `hitl-in-chat``useHumanInTheLoop` ergonomic HITL.
- `hitl-in-app` — app-level modal HITL via async `useFrontendTool`.
- `tool-rendering-default-catchall` — zero-config wildcard tool render.
- `tool-rendering-custom-catchall` — branded wildcard renderer via `useDefaultRenderTool`.
- `tool-rendering-reasoning-chain` — tool renders + reasoning tokens side-by-side.
- `readonly-state-agent-context``useAgentContext` read-only context.
- `declarative-gen-ui` — dynamic A2UI via custom catalog.
- `a2ui-fixed-schema` — A2UI rendered against a known client-side schema.
- `multimodal` — image + PDF attachments.
- `auth` — bearer-token gated runtime.
- `voice` — voice input via `@copilotkit/voice`.
- `agent-config` — typed config object forwarded to agent.
- `gen-ui-tool-based` — tool-triggered generative UI (haiku generator) via
`useFrontendTool` with a custom render. Manifest entry added; the page
was already in place from a prior wave.
- `tool-rendering-default-catchall` — zero-config wildcard tool render via
`useDefaultRenderTool()`. Manifest entry added; page already shipped.
- `tool-rendering-custom-catchall` — branded wildcard render. Manifest
entry added; page already shipped.
- `hitl-in-chat-booking` — manifest alias of `hitl-in-chat`; both feature
ids point to the same `/demos/hitl-in-chat` route, mirroring the
langgraph-python manifest topology so the harness's per-feature live
status surfaces the booking flow as its own row.
The Strands shared agent (`src/agents/agent.py`) already exposes the tools
all of the above need (weather, flights, query_data, schedule_meeting,
manage_sales_todos, set_theme_color, generate_a2ui). New demos that need
additional agent-side surface are documented inline in their respective demo
folders.
+1
View File
@@ -0,0 +1 @@
../_shared
@@ -0,0 +1,43 @@
{
"framework": "strands",
"features": {
"agentic-chat": {
"og_docs_url": "https://docs.copilotkit.ai/aws-strands/prebuilt-components",
"shell_docs_path": "/prebuilt-components"
},
"hitl-in-chat": {
"og_docs_url": "https://docs.copilotkit.ai/human-in-the-loop",
"shell_docs_path": "/generative-ui/your-components/interactive"
},
"tool-rendering": {
"og_docs_url": "https://docs.copilotkit.ai/aws-strands/generative-ui/tool-rendering",
"shell_docs_path": "/generative-ui/tool-rendering"
},
"gen-ui-tool-based": {
"og_docs_url": "https://docs.copilotkit.ai/aws-strands/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/aws-strands/generative-ui/state-rendering",
"shell_docs_path": "/generative-ui/state-rendering"
},
"shared-state-read-write": {
"og_docs_url": "https://docs.copilotkit.ai/aws-strands/shared-state/in-app-agent-write",
"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/aws-strands",
"shell_docs_path": "/multi-agent/subagents"
}
},
"missing": [
{
"feature": "all-shell-paths",
"reason": "shell-docs has no strands-scoped tree. Shell paths point at framework-agnostic content rendered under /strands/unselected/... with snippets resolved for the framework when tagged."
}
]
}
+108
View File
@@ -0,0 +1,108 @@
#!/bin/bash
set -e
cleanup() {
kill $AGENT_PID $NEXTJS_PID $WATCHDOG_PID 2>/dev/null || true
}
trap cleanup EXIT
# Disable Python stdout buffering so the FastAPI/uvicorn agent flushes
# tracebacks and log lines immediately. Without this a silent crash during
# module import can sit in Python's userspace buffer until the process
# exits, by which point the container is already gone.
export PYTHONUNBUFFERED=1
echo "========================================="
echo "[entrypoint] Starting showcase package: strands"
echo "[entrypoint] Time: $(date -u)"
echo "[entrypoint] PORT=${PORT:-not set}"
echo "[entrypoint] NODE_ENV=${NODE_ENV:-not set}"
echo "========================================="
if [ -z "$OPENAI_API_KEY" ]; then
echo "[entrypoint] WARNING: OPENAI_API_KEY is not set! Agent will fail."
else
echo "[entrypoint] OPENAI_API_KEY: set (${#OPENAI_API_KEY} chars)"
fi
# Start agent backend on :8000 with log prefixing so its output is
# distinguishable from Next.js in the Railway log stream.
#
# Belt-and-suspenders log flushing: `PYTHONUNBUFFERED=1` above exports the env
# var, but a child process could in principle un-export or override it. The
# `-u` flag to the Python interpreter forces unbuffered stdout/stderr at the
# interpreter level and is not overridable by user code. Combined with the
# `fflush()` inside the awk pipe below, this guarantees uvicorn request lines
# and tracebacks reach Railway's log stream line-at-a-time rather than
# block-buffered in pipe buffers.
echo "[entrypoint] Starting Python agent on port 8000..."
python -u -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 &> >(awk '{print "[agent] " $0; fflush()}') &
AGENT_PID=$!
sleep 2
if kill -0 $AGENT_PID 2>/dev/null; then
echo "[entrypoint] Agent started (PID: $AGENT_PID)"
else
echo "[entrypoint] ERROR: Agent failed to start — exiting"
exit 1
fi
echo "========================================="
echo "[entrypoint] Starting Next.js frontend on port ${PORT:-10000}..."
echo "========================================="
PORT=${PORT:-10000}
# Scope NODE_ENV=production to the Next.js invocation ONLY, not the whole
# container environment. `ENV NODE_ENV=production` at the image level would
# leak into every child process (Python agent, shell, healthchecks). `env`
# prefix binds the value to this single exec.
env NODE_ENV=production npx next start --port $PORT &> >(awk '{print "[nextjs] " $0; fflush()}') &
NEXTJS_PID=$!
echo "[entrypoint] Next.js started (PID: $NEXTJS_PID)"
# Watchdog: Railway deploys of showcase packages have been observed to hit a
# silent agent hang — the Python process stays alive (so `wait -n` never
# fires and the container never restarts) but stops responding on :8000.
# Poll the agent's /health endpoint every 30s; after 3 consecutive failures
# (90s of unreachable agent), kill the agent process so `wait -n` returns
# and Railway restarts the container. We kill the agent (not the whole
# script) first so `set -e` + `wait -n; exit $?` handles the restart
# through the normal path rather than a forced `exit` that would bypass
# logging. Generalized from showcase/integrations/crewai-crews/entrypoint.sh
# (PRs #4114 + #4115).
(
FAILS=0
while sleep 30; do
if ! kill -0 $AGENT_PID 2>/dev/null; then
# Agent already dead — wait -n in the main shell will handle it.
break
fi
if curl -fsS --max-time 5 http://127.0.0.1:8000/health > /dev/null 2>&1; then
FAILS=0
else
FAILS=$((FAILS + 1))
echo "[watchdog] Agent health probe failed (count=$FAILS)"
if [ $FAILS -ge 3 ]; then
echo "[watchdog] Agent unresponsive for ~90s — killing PID $AGENT_PID to trigger container restart"
kill -9 $AGENT_PID 2>/dev/null || true
break
fi
fi
done
) &
WATCHDOG_PID=$!
echo "[entrypoint] Watchdog started (PID: $WATCHDOG_PID)"
echo "[entrypoint] All processes running. Waiting..."
wait -n $AGENT_PID $NEXTJS_PID
EXIT_CODE=$?
if ! kill -0 $AGENT_PID 2>/dev/null; then
echo "[entrypoint] Agent (PID: $AGENT_PID) exited with code $EXIT_CODE"
elif ! kill -0 $NEXTJS_PID 2>/dev/null; then
echo "[entrypoint] Next.js (PID: $NEXTJS_PID) exited with code $EXIT_CODE"
else
echo "[entrypoint] A process exited with code $EXIT_CODE"
fi
exit $EXIT_CODE
+567
View File
@@ -0,0 +1,567 @@
name: AWS Strands (Python)
slug: strands
category: emerging
language: python
logo: /logos/strands.svg
description: CopilotKit integration with AWS Strands
managed_platform:
name: AWS Bedrock AgentCore
url: https://aws.amazon.com/bedrock/agents/
partner_docs: null
repo: https://github.com/CopilotKit/CopilotKit/tree/main/showcase/integrations/strands
copilotkit_version: 2.0.0
deployed: true
docs_mode: generated
sort_order: 130
generative_ui:
- constrained-explicit
- a2ui-fixed-schema
- a2ui-dynamic-schema
interaction_modalities:
- sidebar
- embedded
- chat
not_supported_features:
- shared-state-streaming
# QUARANTINED pending a @copilotkit/react-core release. gen-ui-interrupt /
# interrupt-headless fail turn-2 on a useInterrupt/useHeadlessInterrupt
# RESUME-PATH hook bug in @copilotkit/react-core/v2: backend resumes + streams
# (HTTP 200) but the frontend never appends the confirmation assistant bubble,
# so the harness DOM settle-check times out. Fix is a published-package change
# (out of scope here); honestly marked not-supported (skipped-incapable, not
# green, not red) rather than a regression. Demos remain wired below.
- gen-ui-interrupt
- interrupt-headless
- reasoning-default-render
- agentic-chat-reasoning
- tool-rendering-reasoning-chain
features:
- cli-start
- agentic-chat
- chat-customization-css
- prebuilt-sidebar
- prebuilt-popup
- chat-slots
- headless-simple
- headless-complete
- frontend-tools
- frontend-tools-async
- hitl
- hitl-in-chat
- hitl-in-chat-booking
- hitl-in-app
- tool-rendering
- tool-rendering-default-catchall
- tool-rendering-custom-catchall
- gen-ui-agent
- gen-ui-tool-based
- declarative-gen-ui
- a2ui-fixed-schema
- a2ui-recovery
- shared-state-read-write
- readonly-state-agent-context
- subagents
- multimodal
- voice
- auth
- agent-config
- declarative-hashbrown
- declarative-json-render
- open-gen-ui
- open-gen-ui-advanced
- mcp-apps
- beautiful-chat
- shared-state-read
a2ui_pattern: llm-driven
interrupt_pattern: promise-based
agent_config_pattern: shared-state
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 strands"
- id: agentic-chat
name: Agentic Chat
description: Natural conversation with frontend tool execution
tags:
- chat-ui
route: /demos/agentic-chat
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/agentic-chat/page.tsx
- src/app/api/copilotkit/route.ts
- id: chat-customization-css
name: Chat Customization (CSS)
description: Default CopilotChat re-themed via CopilotKitCSSProperties
tags:
- chat-ui
route: /demos/chat-customization-css
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/chat-customization-css/page.tsx
- src/app/demos/chat-customization-css/theme.css
- src/app/api/copilotkit/route.ts
- id: prebuilt-sidebar
name: "Pre-Built: Sidebar"
description: Docked sidebar chat via <CopilotSidebar />
tags:
- chat-ui
route: /demos/prebuilt-sidebar
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/prebuilt-sidebar/page.tsx
- src/app/api/copilotkit/route.ts
- id: prebuilt-popup
name: "Pre-Built: Popup"
description: Floating popup chat via <CopilotPopup />
tags:
- chat-ui
route: /demos/prebuilt-popup
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/prebuilt-popup/page.tsx
- src/app/api/copilotkit/route.ts
- id: chat-slots
name: Chat Customization (Slots)
description: Customize CopilotChat via its slot system
tags:
- chat-ui
route: /demos/chat-slots
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/chat-slots/page.tsx
- src/app/api/copilotkit/route.ts
- src/app/demos/chat-slots/slot-wrappers.tsx
- id: headless-simple
name: Headless Chat (Simple)
description: Minimal custom chat surface built on useAgent
tags:
- chat-ui
route: /demos/headless-simple
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/headless-simple/page.tsx
- src/app/api/copilotkit/route.ts
- id: headless-complete
name: Headless Chat (Complete)
description: Full chat implementation built from scratch on useAgent
tags:
- chat-ui
route: /demos/headless-complete
animated_preview_url:
highlight:
- src/agents/agent.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: frontend-tools
name: Frontend Tools (In-App Actions)
description: Agent invokes client-side handlers registered with useFrontendTool
tags:
- interactivity
route: /demos/frontend-tools
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/frontend-tools/page.tsx
- src/app/api/copilotkit/route.ts
- id: frontend-tools-async
name: Frontend Tools (Async)
description: useFrontendTool with an async handler
tags:
- interactivity
route: /demos/frontend-tools-async
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/frontend-tools-async/page.tsx
- src/app/demos/frontend-tools-async/notes-card.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-chat
name: In-Chat HITL (useHumanInTheLoop)
description: Inline approval/decision surface via the ergonomic useHumanInTheLoop hook
tags:
- interactivity
route: /demos/hitl-in-chat
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/hitl-in-chat/page.tsx
- src/app/demos/hitl-in-chat/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-chat-booking
name: In-Chat HITL (Booking)
description: Time-picker card rendered inline via useHumanInTheLoop for a booking flow
tags:
- interactivity
route: /demos/hitl-in-chat
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/hitl-in-chat/page.tsx
- src/app/demos/hitl-in-chat/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-app
name: In-App Human in the Loop
description:
Agent requests approval via async useFrontendTool; UI pops as an
app-level modal
tags:
- interactivity
route: /demos/hitl-in-app
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/hitl-in-app/page.tsx
- src/app/demos/hitl-in-app/approval-dialog.tsx
- src/app/api/copilotkit/route.ts
- id: hitl
name: In-Chat HITL (Steps)
description: Agent pauses for user approval via step-based human-in-the-loop
tags:
- interactivity
route: /demos/hitl
animated_preview_url:
highlight:
- src/app/demos/hitl/page.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering
name: Tool Rendering
description: Custom render for tool calls inline in the chat stream
tags:
- generative-ui
route: /demos/tool-rendering
animated_preview_url:
highlight:
- src/agents/agent.py
- tools/get_weather.py
- tools/query_data.py
- tools/schedule_meeting.py
- tools/search_flights.py
- src/app/demos/tool-rendering/page.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering-default-catchall
name: Tool Rendering (Default Catch-all)
description: Out-of-the-box tool rendering — backend defines the tools; the
frontend adds zero custom renderers and relies on CopilotKit's built-in
default UI
tags:
- generative-ui
route: /demos/tool-rendering-default-catchall
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/tool-rendering-default-catchall/page.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering-custom-catchall
name: Tool Rendering (Custom Catch-all)
description: Single branded wildcard renderer via useDefaultRenderTool — the
same app-designed card paints every tool call
tags:
- generative-ui
route: /demos/tool-rendering-custom-catchall
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/tool-rendering-custom-catchall/page.tsx
- src/app/demos/tool-rendering-custom-catchall/custom-catchall-renderer.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering-reasoning-chain
name: Tool Rendering + Reasoning Chain
description: Sequential tool calls with reasoning tokens rendered side-by-side
tags:
- generative-ui
route: /demos/tool-rendering-reasoning-chain
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/tool-rendering-reasoning-chain/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:
- id: gen-ui-tool-based
name: Tool-Based Generative UI
description: Agent uses tools to trigger UI generation
tags:
- generative-ui
route: /demos/gen-ui-tool-based
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/gen-ui-tool-based/page.tsx
- src/app/api/copilotkit/route.ts
- id: declarative-gen-ui
name: Declarative Generative UI (A2UI)
description: Dynamic A2UI BYOC via a custom catalog
tags:
- generative-ui
route: /demos/declarative-gen-ui
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/declarative-gen-ui/page.tsx
- src/app/demos/declarative-gen-ui/a2ui/catalog.ts
- src/app/api/copilotkit-declarative-gen-ui/route.ts
- id: a2ui-fixed-schema
name: Declarative Generative UI (A2UI — Fixed Schema)
description: A2UI rendering against a known client-side schema
tags:
- generative-ui
route: /demos/a2ui-fixed-schema
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/a2ui-fixed-schema/page.tsx
- src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts
- src/app/demos/a2ui-fixed-schema/a2ui/definitions.ts
- src/app/demos/a2ui-fixed-schema/a2ui/renderers.tsx
- src/app/api/copilotkit-a2ui-fixed-schema/route.ts
- id: a2ui-recovery
name: A2UI Error Recovery
description: Makes the A2UI validate->retry recovery loop visible — an invalid first render heals to a valid one, and an always-invalid render shows a graceful recovery-exhausted fallback. The Strands adapter runs the recovery loop on its auto-inject path; reuses the declarative-gen-ui catalog.
tags:
- generative-ui
route: /demos/a2ui-recovery
animated_preview_url:
highlight:
- src/agents/recovery_agent.py
- src/app/demos/a2ui-recovery/page.tsx
- src/app/demos/a2ui-recovery/suggestions.ts
- src/app/api/copilotkit-a2ui-recovery/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/agent.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: shared-state-read
name: "Shared State: Read-only"
description: "Recipe editor publishes form state via agent.setState; the agent reads the recipe context but does not mutate it (no backend tool — neutral default agent)."
tags:
- agent-state
route: /demos/shared-state-read
animated_preview_url:
highlight:
- src/app/demos/shared-state-read/page.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/agent.py
- src/app/demos/subagents/page.tsx
- src/app/demos/subagents/delegation-log.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:
- id: readonly-state-agent-context
name: Readonly State (Agent Context)
description: Frontend provides read-only context to the agent via useAgentContext
tags:
- agent-state
route: /demos/readonly-state-agent-context
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/readonly-state-agent-context/page.tsx
- src/app/api/copilotkit/route.ts
- id: multimodal
name: Multimodal Attachments
description: Image and PDF uploads via CopilotChat attachments
tags:
- chat-ui
route: /demos/multimodal
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/multimodal/page.tsx
- src/app/demos/multimodal/sample-attachment-buttons.tsx
- src/app/api/copilotkit-multimodal/route.ts
- id: voice
name: Voice Input
description: Speech-to-text via @copilotkit/voice with a bundled sample audio button
tags:
- chat-ui
route: /demos/voice
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/voice/page.tsx
- src/app/demos/voice/sample-audio-button.tsx
- src/app/api/copilotkit-voice/[[...slug]]/route.ts
- id: auth
name: Authentication
description: Bearer-token gate via runtime onRequest hook
tags:
- chat-ui
route: /demos/auth
animated_preview_url:
highlight:
- src/app/demos/auth/page.tsx
- src/app/demos/auth/auth-banner.tsx
- src/app/demos/auth/use-demo-auth.ts
- src/app/demos/auth/demo-token.ts
- src/app/api/copilotkit-auth/[[...slug]]/route.ts
- id: agent-config
name: Agent Config Object
description:
Forward a typed config object (tone / expertise / response length)
from provider to agent
tags:
- platform
route: /demos/agent-config
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/agent-config/page.tsx
- src/app/demos/agent-config/config-card.tsx
- src/app/demos/agent-config/use-agent-config.ts
- src/app/demos/agent-config/config-types.ts
- src/app/api/copilotkit-agent-config/route.ts
- id: declarative-hashbrown
name: "Declarative UI: Hashbrown"
description: Streaming structured output via @hashbrownai/react, rendering a sales dashboard catalog (MetricCard + PieChart + BarChart)
tags:
- generative-ui
route: /demos/declarative-hashbrown
animated_preview_url:
highlight:
- src/agents/byoc_hashbrown.py
- src/app/demos/declarative-hashbrown/page.tsx
- src/app/demos/declarative-hashbrown/hashbrown-renderer.tsx
- src/app/demos/declarative-hashbrown/metric-card.tsx
- src/app/demos/declarative-hashbrown/charts/bar-chart.tsx
- src/app/demos/declarative-hashbrown/charts/pie-chart.tsx
- src/app/demos/declarative-hashbrown/types.ts
- src/app/demos/declarative-hashbrown/suggestions.ts
- src/app/api/copilotkit-declarative-hashbrown/route.ts
- id: declarative-json-render
name: "Declarative UI: json-render"
description: Streaming hierarchical JSON UI spec rendered via @json-render/react, with a Zod-validated catalog (MetricCard + PieChart + BarChart)
tags:
- generative-ui
route: /demos/declarative-json-render
animated_preview_url:
highlight:
- src/agents/byoc_json_render.py
- src/app/demos/declarative-json-render/page.tsx
- src/app/demos/declarative-json-render/json-render-renderer.tsx
- src/app/demos/declarative-json-render/registry.tsx
- src/app/demos/declarative-json-render/catalog.ts
- src/app/demos/declarative-json-render/metric-card.tsx
- src/app/demos/declarative-json-render/charts/bar-chart.tsx
- src/app/demos/declarative-json-render/charts/pie-chart.tsx
- src/app/demos/declarative-json-render/types.ts
- src/app/demos/declarative-json-render/suggestions.ts
- src/app/api/copilotkit-declarative-json-render/route.ts
- id: open-gen-ui
name: Open Generative UI (Minimal)
description: Agent-authored HTML/CSS/SVG in a sandboxed iframe, minimal variant
tags:
- generative-ui
route: /demos/open-gen-ui
animated_preview_url:
highlight:
- src/agents/open_gen_ui.py
- src/app/demos/open-gen-ui/page.tsx
- src/app/api/copilotkit-ogui/route.ts
- id: open-gen-ui-advanced
name: Open Generative UI (Advanced)
description:
Sandboxed iframe that invokes host-registered sandbox functions via
Websandbox.connection.remote
tags:
- generative-ui
route: /demos/open-gen-ui-advanced
animated_preview_url:
highlight:
- src/agents/open_gen_ui.py
- src/app/demos/open-gen-ui-advanced/page.tsx
- src/app/demos/open-gen-ui-advanced/sandbox-functions.ts
- src/app/api/copilotkit-ogui/route.ts
- id: mcp-apps
name: MCP Apps
description: MCP server-driven UI via activity renderers
tags:
- generative-ui
route: /demos/mcp-apps
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/mcp-apps/page.tsx
- src/app/api/copilotkit-mcp-apps/route.ts
- id: beautiful-chat
name: Beautiful Chat
description: Polished landing-style chat surface with brand theming and suggestion pills
tags:
- chat-ui
route: /demos/beautiful-chat
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/beautiful-chat/page.tsx
- src/app/api/copilotkit/route.ts
- id: gen-ui-interrupt
name: In-Chat HITL (useInterrupt — low-level primitive)
description: Interactive component rendered inline in the chat via the
lower-level `useInterrupt` primitive — direct control over the interrupt
lifecycle
tags:
- generative-ui
route: /demos/gen-ui-interrupt
animated_preview_url:
highlight:
- src/agents/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 (testing)
description: Resolve interrupts from a plain button grid — no chat, no
useInterrupt render prop
tags:
- interactivity
route: /demos/interrupt-headless
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/interrupt-headless/page.tsx
- src/app/api/copilotkit/route.ts
@@ -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
@@ -0,0 +1,64 @@
{
"name": "@copilotkit/showcase-strands",
"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/react-ui": "1.61.2",
"@copilotkit/runtime": "1.61.2",
"@copilotkit/shared": "1.61.2",
"@copilotkit/voice": "1.61.2",
"@hashbrownai/core": "0.5.0-beta.4",
"@hashbrownai/react": "0.5.0-beta.4",
"@json-render/core": "0.18.0",
"@json-render/react": "0.18.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-separator": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^1.14.0",
"next": "^15.5.15",
"openai": "5.9.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"recharts": "^2.15.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"yaml": "^2.8.4",
"zod": "^3.24.0"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^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": "strands",
},
},
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,22 @@
# Demo Files — Multimodal Demo
This directory bundles sample files referenced by the `/demos/multimodal` page.
Required files (must be committed as binaries; see `.gitattributes` at repo root):
- `sample.png` — a small (< 50 KB) PNG that the "Try with sample image" button
injects into the chat. A CopilotKit logo or other recognizable brand mark
works well so the vision-capable agent has something to describe.
- `sample.pdf` — a small (< 50 KB) one-page PDF that the "Try with sample PDF"
button injects. The content must mention "CopilotKit" so E2E soft
assertions hold (e.g. a one-page export of the CopilotKit quickstart).
The page at `src/app/demos/multimodal/page.tsx` fetches these via the public
path (`/demo-files/sample.png`, `/demo-files/sample.pdf`), wraps the fetched
blob in a `File`, and routes it through the same `AttachmentsConfig.onUpload`
callback the paperclip button uses — so the sample path exercises the exact
same queueing code as a real user upload.
If these files are missing, the demo page still renders but the sample
buttons will surface a fetch error. The paperclip / drag-and-drop paths
continue to work without them.
@@ -0,0 +1,80 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 9 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
>>
endobj
7 0 obj
<<
/Author (CopilotKit) /CreationDate (D:20260424104452+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260424104452+02'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (CopilotKit Quickstart) /Trapped /False
>>
endobj
8 0 obj
<<
/Count 1 /Kids [ 5 0 R ] /Type /Pages
>>
endobj
9 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 942
>>
stream
Gat=)gMRoa&:N^l7S$NN`GHCmThV;Z2^rJ`P):Y@]XYQU3:,AeX>cqAr-QI2/=2N"LfPX,loA_(*1m\5btm94UD2YX&6drn#XTAg+m:hr0`277L6Xugi+Zi-%K4(Zk-_X)UIg0#"[s_$PB)p"JhpIS$sekH(N!]Wm13pi-C<+2Z\%NM%.@X"c6;!4Z\-]lQ_FW]'b$98<b+A;AK8VL'gsRZ%'52u3[a]V,-i>M8Rs*Je-b?d:1;&?$,`ad5V$Xf0gWUq#g$2OoROl&VT9iG>Z7e:(6,L^5qWRe9f&aq&5@k4gFJA#"jE(QdPamJY0Hpqn>r_e:*FVtpYPMT?RZaJ8P7Ca0<':`;qM'##`XXPXO9RF6i%iKqn<L:oPEI$hcphT25S/nN3<DAK4NVdiJL>7#+eNHaXE@Y[_>W6oJG]uZn6;#DFBr_^iD&OGjVg%"ij8a:&7/qaE+7a=d``5hdQO":;.r]$4@TOAQt"^6*,kR%u>XHZrCjf@5l+)<U?'C_6MX"fJq<J@]>%B@BumKlTA+:A9AG5jqSXcFh[j7G)iN0D\N/+I[;hq]7D]ZAE\d8UW,N3+.Br<N@#$kD%W'f-CmKrYTKkeeuX8:pui+(3#6dK@@XC>.sl!SW4(X]/!H@1dH0Qp(55E^KJk1]UY=9;_"eb*-cL!Qq,hJ?,?l*JeVfts!/i_[Ejo:h;fDQSkbMmd[X?rTSO%M5Qeh<MleEs/@&O$fl?bGg9%rX0LW>9/:K"H3?p7"G:0Ha3Qj[6jL<;9nL312;])jCZe6\\@E*Ttr?Xg2lNp?68b2o5,cF;--/kSU`mMnHk40JE<",2f4YKj@2GX^Q[V>`.2@i]-rEiq2ARgR#W*>Hsb'riEWK24?;X\G?nL'id4l2fR3ocbEr3QJOgLr..O8N<DDWc>P*6r.',3Q1&LkGF@<;hrL[kDNqL~>endstream
endobj
xref
0 10
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000000331 00000 n
0000000436 00000 n
0000000629 00000 n
0000000697 00000 n
0000000982 00000 n
0000001041 00000 n
trailer
<<
/ID
[<ad0be0ae447f61082137085c690fb230><ad0be0ae447f61082137085c690fb230>]
% ReportLab generated PDF document -- digest (opensource)
/Info 7 0 R
/Root 6 0 R
/Size 10
>>
startxref
2073
%%EOF
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

@@ -0,0 +1,24 @@
# QA — a2ui-fixed-schema
## Scope
Manual QA checklist for the `a2ui-fixed-schema` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/a2ui-fixed-schema`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `a2ui-fixed-schema` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,44 @@
# QA: A2UI Error Recovery — AWS Strands (Python)
## Prerequisites
- Demo is deployed and accessible at `/demos/a2ui-recovery` on the dashboard host
- Agent backend is healthy; `OPENAI_API_KEY` is set; `AGENT_URL` points at the Strands agent server; the recovery agent is mounted at `AGENT_URL/a2ui-recovery/` (registered as agent name `a2ui-recovery` — see `src/app/api/copilotkit-a2ui-recovery/route.ts` and `src/agent_server.py`)
- Requires `ag_ui_strands` with A2UI recovery (the validate→retry loop + `a2ui_recovery_exhausted` hard-fail envelope run on the adapter's auto-inject path) and the `@copilotkit` A2UI renderer (the `building`/`retrying`/`failed` lifecycle rendering)
- Wiring: the page's provider catalog auto-enables A2UI tool injection; the Strands adapter auto-injects `generate_a2ui`, drives the `render_a2ui` planner, and runs the recovery loop itself (no explicit backend tool, unlike the langgraph/ADK siblings — see `src/agents/recovery_agent.py`)
- Reuses the **declarative-gen-ui** catalog (`catalogId: "declarative-gen-ui-catalog"`) and the Vantage Threads sales context — no new components
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/a2ui-recovery`; verify the page renders within 3s and a single `CopilotChat` pane is centered (max-width ~896px, rounded-2xl, full-height)
- [ ] Verify the chat is wired to `runtimeUrl="/api/copilotkit-a2ui-recovery"` and `agent="a2ui-recovery"` (DevTools → Network: sending a message hits that endpoint, not `/api/copilotkit`)
- [ ] Verify both suggestion pills are visible with verbatim titles:
- "Recover a bad render"
- "Show an unrecoverable failure"
### 2. Healing path
- [ ] Click "Recover a bad render" ("Render my Q2 sales dashboard, recovering if the first attempt is malformed.")
- [ ] The inner `render_a2ui` returns **free-form / sloppy** A2UI args (components & data as JSON strings rather than structured arrays). Verify the middleware **heals** them via `parse_and_fix` into a valid surface that paints (no broken surface, no error banner)
- [ ] Verify the **painted** surface is valid: a `declarative-metric` row ("Quarterly Revenue $4.2M", "Win Rate 31%")
- [ ] DevTools → Network: verify the final tool result carries an `a2ui_operations` container (no `a2ui_recovery_exhausted`)
- [ ] Verify the chat reply is one short sentence noting the heal
### 3. Hard-fail (recovery exhausted) path
- [ ] Click "Show an unrecoverable failure" ("Render a dashboard that keeps failing validation so I can see the fallback.")
- [ ] Verify the lifecycle ends in a tasteful `failed` state (NOT a broken/half-rendered surface and NOT a silent drop)
- [ ] DevTools → Network: verify `render_a2ui` was attempted up to the cap (3 attempts, all invalid) and the tool returned an `a2ui_recovery_exhausted` envelope (no `a2ui_operations` painted)
- [ ] Verify the chat reply gracefully explains the fallback (one short sentence)
### 4. Regression / isolation
- [ ] Verify the recovery demo does not affect the declarative-gen-ui or beautiful-chat demos (separate routes/agents)
- [ ] Re-run each pill a second time and verify the same lifecycle
## Notes
- The malformed renders are forced by aimock fixtures (`showcase/aimock/d6/strands/a2ui-recovery.json`): the inner `render_a2ui` call is matched by `userMessage` + `toolName=render_a2ui`. Healing itself is performed live by the toolkit recovery loop inside the Strands adapter.
- AWS-Strands sibling of the langgraph-python `a2ui-recovery` demo. On Strands the recovery loop runs on the adapter's auto-inject path, so no explicit `get_a2ui_tools` wiring is needed.
@@ -0,0 +1,24 @@
# QA — agent-config
## Scope
Manual QA checklist for the `agent-config` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/agent-config`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `agent-config` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — agentic-chat-reasoning
## Scope
Manual QA checklist for the `agentic-chat-reasoning` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/agentic-chat-reasoning`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `agentic-chat-reasoning` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,62 @@
# QA: Agentic Chat — AWS Strands
## 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
+24
View File
@@ -0,0 +1,24 @@
# QA — auth
## Scope
Manual QA checklist for the `auth` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/auth`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `auth` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,36 @@
# QA — byoc-hashbrown
## Scope
Manual QA checklist for the `byoc-hashbrown` demo in the AWS Strands
showcase. The agent emits a hashbrown JSON envelope that `@hashbrownai/react`
progressively parses and renders via the MetricCard / PieChart / BarChart /
DealCard / Markdown catalog.
## Happy path
- [ ] Navigate to `/demos/byoc-hashbrown`.
- [ ] Page renders with header "BYOC: Hashbrown" and no console errors.
- [ ] Suggestion pills appear in the composer (Sales dashboard, Revenue by
category, Expense trend).
- [ ] Click "Sales dashboard" — the assistant replies with a progressively
streaming dashboard that includes at least one metric card, one pie
chart, and one bar chart.
- [ ] Click "Revenue by category" — the assistant replies with a pie chart
with 4+ segments.
- [ ] Click "Expense trend" — the assistant replies with a bar chart.
## Regression
- [ ] The assistant's response is rendered as a visual dashboard (NOT as
raw JSON in a chat bubble).
- [ ] `data-testid="metric-card"`, `data-testid="pie-chart"`, and
`data-testid="bar-chart"` are present on their respective elements.
- [ ] No hydration warnings.
## Known gaps
- The Strands backend uses the shared agent from `agent.py`; the
hashbrown JSON envelope prompt is injected via `useAgentContext` on the
frontend. The canonical prompt lives in `src/agents/byoc_hashbrown.py`
as documentation.
@@ -0,0 +1,33 @@
# QA — byoc-json-render
## Scope
Manual QA checklist for the `byoc-json-render` demo. The agent emits a
`{ root, elements }` JSON spec that `@json-render/react`'s `<Renderer>`
mounts against a Zod-validated catalog.
## Happy path
- [ ] Navigate to `/demos/byoc-json-render`.
- [ ] Page renders, chat composer shows suggestion pills.
- [ ] Click "Sales dashboard" — the assistant replies with a rendered
dashboard (MetricCard + BarChart) rather than raw JSON.
- [ ] Click "Revenue by category" — the assistant replies with a PieChart
with 4 segments.
- [ ] Click "Expense trend" — the assistant replies with a BarChart.
## Regression
- [ ] `data-testid="json-render-root"` is present on the rendered
assistant message wrapper.
- [ ] `data-testid="metric-card"` appears when a MetricCard is the root.
- [ ] Nested children of MetricCard (e.g. the BarChart in the Sales
Dashboard worked example) render — they are NOT silently dropped
(PR #4271 fix).
- [ ] No "useVisibility must be used within a VisibilityProvider" crash
(PR #4271 fix — `<JSONUIProvider>` wraps `<Renderer>`).
## Known gaps
- Uses the shared Strands backend via the prompt injected on the frontend;
canonical prompt lives in `src/agents/byoc_json_render.py`.
@@ -0,0 +1,24 @@
# QA — chat-customization-css
## Scope
Manual QA checklist for the `chat-customization-css` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/chat-customization-css`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `chat-customization-css` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — chat-slots
## Scope
Manual QA checklist for the `chat-slots` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/chat-slots`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `chat-slots` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — declarative-gen-ui
## Scope
Manual QA checklist for the `declarative-gen-ui` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/declarative-gen-ui`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `declarative-gen-ui` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — frontend-tools-async
## Scope
Manual QA checklist for the `frontend-tools-async` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/frontend-tools-async`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `frontend-tools-async` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — frontend-tools
## Scope
Manual QA checklist for the `frontend-tools` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/frontend-tools`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `frontend-tools` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,62 @@
# QA: Agentic Generative UI — AWS Strands
## 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 — AWS Strands
## 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,24 @@
# QA — headless-complete
## Scope
Manual QA checklist for the `headless-complete` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/headless-complete`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `headless-complete` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — headless-simple
## Scope
Manual QA checklist for the `headless-simple` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/headless-simple`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `headless-simple` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — hitl-in-app
## Scope
Manual QA checklist for the `hitl-in-app` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/hitl-in-app`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `hitl-in-app` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,57 @@
# QA: Human in the Loop — AWS Strands
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the HITL demo page (`/demos/hitl`) > _Note: the URL path `/demos/hitl` is intentionally shorter than the feature id `hitl-in-chat`._
- [ ] Verify the chat interface loads in a centered max-w-4xl container
- [ ] Verify the chat input placeholder "Type a message" is visible
- [ ] Send a basic message
- [ ] Verify the agent responds
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Simple plan" suggestion button is visible
- [ ] Verify "Complex plan" suggestion button is visible
- [ ] Click the "Simple plan" suggestion
- [ ] Verify it triggers a message about planning a trip to Mars in 5 steps
#### Step Selection and Approval
The HITL demo renders a single StepSelector card regardless of whether
the underlying flow uses an interrupt hook or a frontend HITL tool. The
framework-specific hook name is an implementation detail covered in each
package's demo source; QA below validates the user-visible card behavior.
- [ ] Send "Plan a trip to Mars in 5 steps"
- [ ] Verify the StepSelector card appears (`data-testid="select-steps"`)
- [ ] Verify step items are displayed with checkboxes (`data-testid="step-item"`)
- [ ] Verify step text is visible (`data-testid="step-text"`)
- [ ] Verify the selected count display shows "N/N selected"
- [ ] Toggle a step checkbox off and verify the count decreases
- [ ] Toggle it back on and verify the count increases
- [ ] Click "Perform Steps (N)" / "Confirm (N)" button
- [ ] Verify the agent continues processing after confirmation
- [ ] In a new conversation, trigger the same flow and click "Reject" (where present)
- [ ] Verify the card reflects the decision (Accepted / Rejected) and buttons disable
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Step selector renders with toggleable checkboxes
- Accept/Reject flow completes without errors
- No UI errors or broken layouts
@@ -0,0 +1,24 @@
# QA — multimodal
## Scope
Manual QA checklist for the `multimodal` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/multimodal`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `multimodal` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,28 @@
# QA — open-gen-ui-advanced
## Scope
Manual QA for the advanced Open Generative UI demo. Builds on open-gen-ui
by adding `openGenerativeUI.sandboxFunctions` (evaluateExpression,
notifyHost) that the agent-authored iframe can invoke via
`Websandbox.connection.remote.<name>(...)`.
## Happy path
- [ ] Navigate to `/demos/open-gen-ui-advanced`.
- [ ] Composer shows three sandbox-function suggestion pills.
- [ ] Click "Calculator (calls evaluateExpression)" — a calculator mounts
in a sandboxed iframe. Press a few digits and `=` — the display
shows the evaluated result.
- [ ] Click "Ping the host (calls notifyHost)" — a card mounts with a
"Say hi to the host" button. Clicking it shows a host-returned
confirmation with a `receivedAt` timestamp.
## Regression
- [ ] Check the browser console for `[open-gen-ui/advanced]` log lines
proving the sandbox -> host round trip fired.
## Known gaps
- Same Strands backend as the minimal variant.
@@ -0,0 +1,29 @@
# QA — open-gen-ui
## Scope
Manual QA for the minimal Open Generative UI demo. The agent emits a
`generateSandboxedUi` tool call on every turn; the runtime's
OpenGenerativeUIMiddleware converts that into activity events that mount
the authored HTML/CSS in a sandboxed iframe.
## Happy path
- [ ] Navigate to `/demos/open-gen-ui`.
- [ ] Composer shows the four visualization suggestion pills.
- [ ] Click "3D axis visualization (model airplane)" — a sandboxed iframe
mounts showing an airplane cycling through pitch/yaw/roll rotations.
- [ ] Click "How a neural network works" — an iframe mounts showing a
layered network with forward-pass activations.
## Regression
- [ ] The rendered scenes include axis labels, a legend, and a title.
- [ ] No console errors from the sandboxed iframe.
## Known gaps
- Strands backend uses the shared agent; the `generateSandboxedUi`
frontend-registered tool is resolved via ag_ui_strands' frontend-tool
proxy. The design skill prompt lives on the frontend via
`openGenerativeUI.designSkill`.
@@ -0,0 +1,24 @@
# QA — prebuilt-popup
## Scope
Manual QA checklist for the `prebuilt-popup` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/prebuilt-popup`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `prebuilt-popup` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — prebuilt-sidebar
## Scope
Manual QA checklist for the `prebuilt-sidebar` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/prebuilt-sidebar`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `prebuilt-sidebar` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — readonly-state-agent-context
## Scope
Manual QA checklist for the `readonly-state-agent-context` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/readonly-state-agent-context`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `readonly-state-agent-context` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — reasoning-default-render
## Scope
Manual QA checklist for the `reasoning-default-render` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/reasoning-default-render`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `reasoning-default-render` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,61 @@
# QA: Shared State (Read + Write) — AWS Strands
## 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 on Railway; the Strands agent server (`agent_server.py`) is reachable at `AGENT_URL`
## 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 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 (the `build_state_prompt` Strands `state_context_builder` injects these into the user message each 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 and the `state_from_args` hook publishes a full snapshot
#### 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 without being re-sent
- [ ] 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 (`build_state_prompt` skips injection when `preferences` is empty)
- [ ] 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 `notes_state_from_args` hook returns the FULL updated list as a `StateSnapshotEvent`)
- 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) — AWS Strands
## 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 — AWS Strands
## 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) — AWS Strands
## 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,54 @@
# QA: Sub-Agents — AWS Strands
## 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 Strands agent server (`agent_server.py`) is reachable at `AGENT_URL`
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/subagents`; verify the page renders within 3s with a wide delegation log on the left and a `CopilotChat` pane on the right
- [ ] Verify `data-testid="delegation-log"` is visible with heading "Sub-agent delegations"
- [ ] Verify the empty state reads "Ask the supervisor to complete a task. Every sub-agent it calls will appear here."
- [ ] 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: "Write a blog post", "Explain a topic", "Summarize a topic"
### 2. Feature-Specific Checks
#### Supervisor delegates to sub-agents (research → write → critique)
- [ ] Click the "Write a blog post" suggestion (sends a research/write/critique sequence prompt)
- [ ] Within 5s verify `data-testid="supervisor-running"` appears next to the heading with the pulsing indicator
- [ ] Within 60s verify `data-testid="delegation-count"` reads at least "3 calls" and at least 3 `data-testid="delegation-entry"` rows are rendered
- [ ] Verify the first entry has the `🔎 Research` badge, a non-empty `Task:` line, and a result body containing 3-5 bullet points
- [ ] Verify a subsequent entry has the `✍️ Writing` badge with a polished paragraph in the result body
- [ ] Verify a subsequent entry has the `🧐 Critique` badge with 2-3 actionable critiques
- [ ] Verify each entry shows status `completed` (green) once the sub-agent has returned
- [ ] Once the supervisor returns a final assistant text message, verify `supervisor-running` disappears
#### Live updates during the run
- [ ] Click the "Explain a topic" suggestion
- [ ] Verify entries appear ONE-AT-A-TIME (the count climbs from 0 → 1 → 2 → 3 over multiple seconds rather than all appearing at once) — this confirms each sub-agent's `state_from_result` hook emits an independent `StateSnapshotEvent`
- [ ] Verify entry numbers (`#1`, `#2`, `#3`) are sequential and stable
#### Multi-turn persistence
- [ ] After the first run completes, send "Now do the same for solar panels." — verify the delegation log GROWS (existing rows kept; new rows appended); the count should continue from where it left off (e.g. "3 calls" → "6 calls")
### 3. Error Handling
- [ ] Send an empty message — verify it is a no-op
- [ ] Send "Hello" (no task to delegate); verify the supervisor responds with a short text reply and NO new delegation entries are added
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
## Expected Results
- Page loads within 3 seconds
- First delegation entry appears within 15s of submitting a task; full research → write → critique loop completes within 60s
- Each sub-agent invocation produces exactly one delegation entry with a non-empty `task` and `result`
- Supervisor's final summary references the work that was delegated
- No UI layout breaks, no uncaught console errors
@@ -0,0 +1,24 @@
# QA — tool-rendering-custom-catchall
## Scope
Manual QA checklist for the `tool-rendering-custom-catchall` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/tool-rendering-custom-catchall`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `tool-rendering-custom-catchall` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — tool-rendering-default-catchall
## Scope
Manual QA checklist for the `tool-rendering-default-catchall` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/tool-rendering-default-catchall`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `tool-rendering-default-catchall` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,24 @@
# QA — tool-rendering-reasoning-chain
## Scope
Manual QA checklist for the `tool-rendering-reasoning-chain` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/tool-rendering-reasoning-chain`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `tool-rendering-reasoning-chain` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,61 @@
# QA: Tool Rendering — AWS Strands
## 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
+24
View File
@@ -0,0 +1,24 @@
# QA — voice
## Scope
Manual QA checklist for the `voice` demo in the AWS Strands showcase. The
Strands package reuses a single shared agent for every demo, so this stub
exists primarily to document the human verification path.
## Happy path
- [ ] Navigate to `/demos/voice`.
- [ ] Verify the page renders without console errors.
- [ ] Exercise the demo's primary interaction (see README for the
LangGraph-Python equivalent demo — same user flow).
## Regression
- [ ] No hydration warnings in the browser console.
- [ ] The shared Strands agent responds with text within a few seconds.
## Known gaps
- Port of the LangGraph-Python `voice` demo; backend differentiation is
collapsed into the shared `src/agents/agent.py`.
@@ -0,0 +1,11 @@
ag-ui-protocol==0.1.18
ag_ui_strands==0.2.2
strands-agents[OpenAI]==1.18.0
strands-agents-tools==0.2.16
copilotkit==0.1.94
langchain==1.2.15
langchain-openai==1.1.9
openai==1.109.1
fastapi>=0.115.0
uvicorn>=0.34.0
python-dotenv>=1.0.1
@@ -0,0 +1,256 @@
"""
Agent Server for AWS Strands
FastAPI server that hosts the Strands agent backend.
The Next.js CopilotKit runtime proxies requests here via AG-UI protocol.
IMPORTANT: Do NOT import ``ag_ui_strands`` or ``strands`` (directly or
transitively via ``agents.agent``) above the ``_disabled_instrument`` patch
below. The patch MUST be installed before strands' Tracer is constructed,
otherwise ``ThreadingInstrumentor().instrument()`` runs with the unpatched
implementation and causes recursive ThreadPoolExecutor wrapping.
"""
import os
import sys
# 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 strands), so it is safe to run before the OTel ThreadingInstrumentor
# patch below — it does not pull ``strands`` into ``sys.modules``.
import _shared.cvdiag_bootstrap # noqa: F401,E402 (first non-stdlib import — bootstrap side effects)
# HACK: strands-agents (observed on 1.35.0, requirements.txt floors at 1.15.0)
# unconditionally calls ``ThreadingInstrumentor().instrument()`` when its
# Tracer is constructed (strands/telemetry/tracer.py). In combination with
# strands' async model client dispatching work onto ThreadPoolExecutor, this
# wraps ThreadPoolExecutor.submit in a way that re-enters itself recursively,
# producing ``RecursionError: maximum recursion depth exceeded`` during
# tool-rendering requests and surfacing as an OpenAI APIConnectionError.
#
# Disabling the autoload env var (OTEL_PYTHON_DISABLED_INSTRUMENTATIONS)
# does not help because strands imports and instruments the class
# directly, bypassing the entry_point-based autoloader.
#
# Neutralize the instrument() call before strands imports the module.
# Remove this block once ``strands-agents >= X.Y.Z`` is pinned in
# requirements.txt, where X.Y.Z is the version that makes OTel
# instrumentation opt-in (not yet released as of strands-agents 1.35.0).
from opentelemetry.instrumentation.threading import ( # noqa: E402 (must precede ag_ui_strands / strands imports)
ThreadingInstrumentor as _ThreadingInstrumentor,
)
# Import-order guard: if ``strands`` was already imported above this line
# (directly or transitively), the Tracer may have been constructed with
# the original ``instrument`` — and patching the class now has no effect
# on the already-wrapped ThreadPoolExecutor. Fail loudly at import rather
# than silently recursing at request time.
#
# NOTE: these guards are implemented as ``if not ...: raise RuntimeError``
# rather than ``assert`` on purpose. ``assert`` statements are stripped
# when Python runs with ``-O`` (some Docker base images and optimized
# CPython builds do this), which would silently re-expose the recursion
# bug. Using an explicit raise keeps the guard active under ``-O``.
def _assert_strands_not_preimported() -> None:
"""Raise RuntimeError if ``strands`` was imported before this patch ran.
Extracted to a named function so tests can monkey-patch it cleanly
(rather than having to regex-neutralize an inline assert in the source).
"""
if "strands" in sys.modules:
raise RuntimeError(
"strands imported before OTel patch applied — "
"remove any strands / ag_ui_strands import that precedes this line in agent_server.py"
)
_assert_strands_not_preimported()
def _disabled_instrument(self, *args, **kwargs):
"""No-op replacement for ``ThreadingInstrumentor.instrument``.
Returns ``self`` so fluent callers (``ThreadingInstrumentor().instrument().uninstrument()``)
don't raise ``AttributeError: 'NoneType' object has no attribute ...``.
"""
return self
_ThreadingInstrumentor.instrument = _disabled_instrument # type: ignore[method-assign]
def _assert_instrumentor_patched() -> None:
"""Raise RuntimeError if the ThreadingInstrumentor patch is not in effect.
Extracted to a named function for the same reason as
``_assert_strands_not_preimported`` — survives ``python -O`` and is
cleanly monkey-patchable from tests.
"""
if _ThreadingInstrumentor.instrument is not _disabled_instrument:
raise RuntimeError(
"ThreadingInstrumentor.instrument patch was not applied — "
"check import order in agent_server.py"
)
_assert_instrumentor_patched()
# ORDER-CRITICAL: install the global httpx hook BEFORE any agent module
# imports. Strands' ``OpenAIModel`` constructs its httpx client at
# ``build_showcase_agent()`` time below (run at module-import scope), so
# the patch must be in place before the agent imports resolve.
from agents._cvdiag_backend import CvdiagBackendMiddleware # noqa: E402
from agents._header_forwarding import ( # noqa: E402
HeaderForwardingHTTPMiddleware,
install_executor_contextvar_propagation,
install_global_httpx_hook,
)
install_global_httpx_hook()
# Strands 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 uvicorn # noqa: E402 (kept after patch for consistent import-ordering policy)
from dotenv import load_dotenv # noqa: E402
from starlette.middleware.base import BaseHTTPMiddleware # noqa: E402
from starlette.responses import JSONResponse # noqa: E402
from ag_ui_strands import create_strands_app # noqa: E402 (must follow instrumentor patch)
from agents.agent import build_showcase_agent # noqa: E402 (must follow instrumentor patch)
from agents.byoc_hashbrown import build_byoc_hashbrown_agent # noqa: E402 (must follow instrumentor patch)
from agents.byoc_json_render import build_byoc_json_render_agent # noqa: E402 (must follow instrumentor patch)
from agents.voice_agent import build_voice_agent # noqa: E402 (must follow instrumentor patch)
from agents.a2ui_fixed import build_a2ui_fixed_schema_agent # noqa: E402 (must follow instrumentor patch)
from agents.a2ui_dynamic import build_a2ui_dynamic_agent # noqa: E402 (must follow instrumentor patch)
from agents.recovery_agent import build_a2ui_recovery_agent # noqa: E402 (must follow instrumentor patch)
load_dotenv()
# Build the agent via factory so import-time failures are localized and
# testable. Any env-var / model-init / hook-patching errors surface here,
# not at arbitrary module-import time.
agui_agent = build_showcase_agent()
# Voice agent: tool-free, for voice demos that only need transcription + chat.
voice_agui_agent = build_voice_agent()
voice_app = create_strands_app(voice_agui_agent, "/")
# Declarative-hashbrown agent: tool-free, emits a strict hashbrown `{ "ui": [...] }`
# JSON envelope (see agents/byoc_hashbrown.py) consumed by `@hashbrownai/react`'s
# useJsonParser + useUiKit. The shared showcase agent at "/" cannot emit this
# envelope, so the declarative-hashbrown demo gets a dedicated specialized agent.
byoc_hashbrown_agui_agent = build_byoc_hashbrown_agent()
byoc_hashbrown_app = create_strands_app(byoc_hashbrown_agui_agent, "/")
# Declarative-json-render agent: tool-free, emits a `@json-render/react` flat-spec
# JSON object (`{ root, elements }`, see agents/byoc_json_render.py). Mounted as a
# dedicated specialized agent so the demo no longer relies on the generic "/" agent.
byoc_json_render_agui_agent = build_byoc_json_render_agent()
byoc_json_render_app = create_strands_app(byoc_json_render_agui_agent, "/")
# A2UI fixed-schema agent: owns the `display_flight` backend tool which emits
# an `a2ui_operations` envelope (createSurface/updateComponents/
# updateDataModel) targeting the showcase frontend's fixed catalog
# (`copilotkit://flight-fixed-catalog`). The runtime A2UIMiddleware paints the
# envelope directly — no generate_a2ui injection. Mounted as a dedicated agent
# so the demo no longer relies on the generic "/" agent's search_flights tool.
a2ui_fixed_schema_agui_agent = build_a2ui_fixed_schema_agent()
a2ui_fixed_schema_app = create_strands_app(a2ui_fixed_schema_agui_agent, "/")
# A2UI dynamic-schema agent (declarative-gen-ui demo): a plain agent with no
# generate_a2ui tool wired. When the runtime forwards `injectA2UITool: true`,
# the adapter auto-injects `generate_a2ui` and drives a secondary render
# planner to GENERATE the surface layout, stamped with the catalog id the page
# registers (`declarative-gen-ui-catalog`). Mounted as a dedicated agent so the
# demo no longer relies on the generic "/" agent.
a2ui_dynamic_agui_agent = build_a2ui_dynamic_agent()
a2ui_dynamic_app = create_strands_app(a2ui_dynamic_agui_agent, "/")
# A2UI error-recovery agent: same auto-inject dynamic-schema setup, but the
# aimock fixtures force the inner render_a2ui to emit free-form/sloppy args (heal
# pill) or a structurally-invalid surface on every attempt (exhaust pill); the
# Strands adapter runs the toolkit validate->retry recovery loop on the
# auto-inject path. Mounted as a dedicated agent so the Next.js route can proxy
# to AGENT_URL/a2ui-recovery/.
a2ui_recovery_agui_agent = build_a2ui_recovery_agent()
a2ui_recovery_app = create_strands_app(a2ui_recovery_agui_agent, "/")
# Create the FastAPI app from the AG-UI Strands integration
agent_path = os.getenv("AGENT_PATH", "/")
app = create_strands_app(agui_agent, agent_path)
# Mount the voice agent as a sub-application at /voice so the Next.js
# voice runtime can point HttpAgent at AGENT_URL/voice/ for tool-free chat.
app.mount("/voice", voice_app)
# Mount the specialized declarative-demo agents as sub-applications so each
# Next.js route can point HttpAgent at a dedicated, prompt-tuned endpoint
# (mirrors agno's /byoc-hashbrown and /byoc-json-render mounts). The Next.js
# routes proxy to AGENT_URL/byoc-hashbrown/ and AGENT_URL/byoc-json-render/
# (trailing slash) so the sub-application's root route resolves.
app.mount("/byoc-hashbrown", byoc_hashbrown_app)
app.mount("/byoc-json-render", byoc_json_render_app)
# A2UI fixed-schema: the Next.js route proxies to AGENT_URL/a2ui-fixed-schema/
# (trailing slash) so the sub-application's root route resolves.
app.mount("/a2ui-fixed-schema", a2ui_fixed_schema_app)
# A2UI dynamic-schema: the Next.js route proxies to AGENT_URL/declarative-gen-ui/
# (trailing slash) so the sub-application's root route resolves.
app.mount("/declarative-gen-ui", a2ui_dynamic_app)
# A2UI error-recovery: the Next.js route proxies to AGENT_URL/a2ui-recovery/
# (trailing slash) so the sub-application's root route resolves.
app.mount("/a2ui-recovery", a2ui_recovery_app)
# Serve /health via middleware so it short-circuits BEFORE route resolution.
# `create_strands_app(..., agent_path="/")` installs a catch-all at the root
# that shadows any later `@app.get("/health")` decorator. Middleware runs
# above the routing layer, so /health stays reachable.
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``)
# into a per-request ContextVar so any outbound LLM/provider httpx call
# made inside the request scope copies them onto its outbound request.
# Paired with ``install_global_httpx_hook`` above.
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."""
port = int(os.getenv("PORT", "8000"))
reload = os.getenv("UVICORN_RELOAD", "").lower() == "true"
uvicorn.run(
"agent_server:app",
host="0.0.0.0",
port=port,
reload=reload,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,812 @@
"""_cvdiag_backend.py — backend-layer CVDIAG boundary instrumentation.
This module wires the spec §3 / §5 **11 backend boundaries** into a Python
showcase integration, emitting schema-v1 CVDIAG envelopes through the shared
``_shared.cvdiag_bootstrap.emit_cvdiag`` sink. It is the per-integration
companion to the header-forwarding shim (``_header_forwarding.py``): that file
forwards correlation headers onto outbound LLM calls and logs lightweight
``CVDIAG component=backend-<fw> boundary=...`` breadcrumbs; THIS file emits the
full structured ``CVDIAG {<json>}`` envelopes the harness/classifier consume.
The 11 backend boundaries (spec §5 / §6 tier matrix):
1. ``backend.request.ingress`` — HTTP request received (default)
2. ``backend.agent.enter`` — agent loop entered (default)
3. ``backend.llm.call.start`` — outbound LLM call dispatched (verbose)
4. ``backend.llm.call.heartbeat`` — fires ~10s while an LLM call is
outstanding (verbose)
5. ``backend.llm.call.response`` — LLM response received (verbose)
6. ``backend.sse.first_byte`` — first SSE byte written (verbose)
7. ``backend.sse.event`` — every SSE event written (debug)
8. ``backend.sse.aborted`` — stream terminated abnormally (default)
9. ``backend.agent.exit`` — agent loop exited (default)
10. ``backend.response.complete`` — HTTP response stream closed (default)
11. ``backend.error.caught`` — exception caught in the agent loop
(default)
Guarding
--------
ALL emission is gated behind the ``CVDIAG_BACKEND_EMITTER`` env flag, default
OFF. With the flag off this module is byte-for-byte inert — no envelope is
built, no stdout line is written, the middleware passes the request straight
through. This is the canary-safe default: the flag is flipped ON only after a
deploy is confirmed healthy.
Tier gating
-----------
Each boundary carries a tier per the §6 matrix. ``_shared.cvdiag_bootstrap``
resolves the active tier (default | verbose | debug) once at import; this
module suppresses a boundary whose tier exceeds the active tier so the
default-tier production emit stays within the §7 event-count budget.
Pure instrumentation
--------------------
Nothing here may throw into the request path. ``emit_cvdiag`` already swallows
its own errors; the helpers below additionally guard envelope construction so a
malformed metadata bag degrades to a dropped emit, never a 500.
Plan unit: L1-C.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import secrets
import time
import uuid
from typing import Any, Dict, Optional
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from _shared.cvdiag_bootstrap import _resolve_tier, current_tier, emit_cvdiag
logger = logging.getLogger(__name__)
# Framework tag — mirrors ``_header_forwarding._CVDIAG_FRAMEWORK`` so the
# structured envelopes and the breadcrumb log lines agree on the integration
# identity. (L1-D: change this single constant when copying to a sibling.)
_CVDIAG_FRAMEWORK = "strands"
# ── 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 = "strands"
# 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. strands 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,117 @@
"""Dedicated Strands agent for the Declarative Generative UI (A2UI — Dynamic
Schema) demo.
Strands port of the canonical langgraph-python ``a2ui_dynamic`` demo
(``../../../langgraph-python/src/agents/a2ui_dynamic.py``). Unlike the
fixed-schema demo (which wires a single ``display_flight`` tool returning a
pre-authored ``a2ui_operations`` envelope), the dynamic demo lets the agent
*generate* the surface layout on the fly.
How the generation is wired (no manual tool):
- The Next.js runtime route (``app/api/copilotkit-declarative-gen-ui/
route.ts``) sets ``a2ui: { injectA2UITool: true, defaultCatalogId:
"declarative-gen-ui-catalog" }``.
- The CopilotKit runtime forwards that ``injectA2UITool`` flag to this
agent, and the Strands adapter auto-injects a ``generate_a2ui`` tool +
drives a secondary ``render_a2ui`` planner LLM to emit the surface ops.
- The ``StrandsAgentConfig.a2ui`` block below supplies the
``default_catalog_id`` stamped into generated surfaces and the
``composition_guide`` that teaches the planner which components the page's
catalog registers. Mirrors the ag-ui dynamic-schema reference example.
The ``composition_guide`` MUST describe the exact catalog the showcase page
registers at ``src/app/demos/declarative-gen-ui/a2ui/{definitions,renderers,
catalog}.ts`` (catalog id ``declarative-gen-ui-catalog``): the custom
components Card / StatusBadge / Metric / InfoRow / PrimaryButton / PieChart /
BarChart / DataTable, composed inside the basic catalog's Row / Column / Text
containers (``includeBasicCatalog: true``).
"""
from __future__ import annotations
from strands import Agent
from ag_ui_strands import StrandsAgent, StrandsAgentConfig
from agents.agent import _build_model
# Must match the catalog id the page registers via
# `createCatalog(..., { catalogId: "declarative-gen-ui-catalog" })`. The
# render planner omits `catalogId` unless told, and the middleware then falls
# back to the unregistered spec basic catalog ("Catalog not found"). Stamping
# it here (and in the route's `defaultCatalogId`) pins the right catalog.
CATALOG_ID = "declarative-gen-ui-catalog"
# Grounding dataset + composition rules for the sales-analyst persona, kept
# byte-for-byte in spirit with the frontend `sales-context.ts`
# (SALES_DATASET + COMPOSITION_RULES) that the page registers via
# `useAgentContext`. The frontend context steers the PRIMARY agent; this
# `composition_guide` is the channel the Strands adapter feeds to the secondary
# `render_a2ui` planner (it gets `guidelines`, not the frontend App Context),
# so the planner is self-contained — it knows both the numbers to ground in and
# which catalog components to compose.
SALES_DATASET = """Vantage Threads (fictional B2B apparel company) — Q2 sales data. Ground every visual in these numbers; invent only plausible details consistent with them.
- Quarterly revenue: $4.2M (up 12% QoQ). New customers: 186 (up 8%). Win rate: 31% (down 2pts). Avg deal size: $22.6k (up 5%).
- Revenue by region: North America $1.9M, EMEA $1.3M, APAC $720k, LATAM $280k.
- Monthly revenue: Jan $1.21M, Feb $1.34M, Mar $1.65M, Apr $1.38M, May $1.42M, Jun $1.40M.
- Reps (vs quota): Dana Whitfield 124%, Marcus Lee 108%, Priya Sharma 97%, Tom Okafor 88%, Elena Vasquez 71%.
- At-risk: total $615k ARR across 3 accounts — Northwind Retail ($340k renewal, no contact 6 weeks; severity high), Cascadia Outfitters ($180k, champion left; severity medium), Atlas Goods ($95k, stalled legal review; severity medium).
- Biggest account: Meridian Apparel Group — owner Dana Whitfield, region North America, ARR $612k, renewal Sep 30, last contact 3 days ago, health green, 4 open opportunities worth $210k.
- Meridian revenue by product line: Outerwear $260k, Footwear $180k, Accessories $112k, Custom $60k."""
COMPOSITION_RULES = """Use ONLY these exact component names (the registered catalog — any other name fails to render): Card, Column, Row, Text, Metric, PieChart, BarChart, DataTable, StatusBadge, InfoRow, PrimaryButton. The single-value KPI tile component is named exactly "Metric" (NOT "MetricTile" or "MetricCard").
Pick A2UI components by the shape of the question — never ask which chart the user wants:
1. Overall snapshot / "sales dashboard" → a Column (gap 16) whose first child is a Row (gap 16) of 4 Metric components (each with trend + trendValue), followed by a Row with a PieChart (revenue by region) next to a BarChart (monthly revenue, all six months Jan-Jun). Do NOT wrap the dashboard in a surrounding Card — the charts carry their own card chrome. Do NOT use StatusBadge, DataTable, or InfoRow here.
2. Rep / team performance → a Column (gap 16) with a Card containing a DataTable (columns: rep, attainment, pipeline) next to or above a BarChart of quota attainment % per rep — no StatusBadge or InfoRow.
3. Risk / health checks → a Column (gap 16): first a Row (gap 16) of 3 Metric components (ARR at risk $615k trend down, accounts at risk 3, biggest exposure Northwind $340k), then a Row (gap 16) with one compact Card per at-risk account (title = account name, subtitle = ARR at stake) containing a StatusBadge (error for high severity, warning otherwise) above a one-line Text with the reason and the recommended next action — no DataTable or InfoRow.
4. Single account/entity details → a Row (gap 16) with a Card of InfoRow facts (owner, region, ARR, renewal date, last contact) next to a PieChart of that account's revenue by product line — no DataTable or StatusBadge.
5. Part-of-whole follow-ups → PieChart; trends or comparisons over time/categories → BarChart.
Compose generously — a dashboard should feel like a real analytics product, not a single widget."""
COMPOSITION_GUIDE = SALES_DATASET + "\n\n" + COMPOSITION_RULES
# Mirrors the langgraph-python demo's a2ui_dynamic.py SYSTEM_PROMPT. The
# dataset + composition rules also reach the primary agent via the frontend
# `sales-context.ts` App Context.
SYSTEM_PROMPT = (
"You are the embedded sales analyst for Vantage Threads, the fictional "
"B2B apparel company described in your App Context. Answer every "
"business question by calling `generate_a2ui` to draw a rich visual "
"surface, and keep the chat reply to one short sentence.\n"
"\n"
"Ground every number in the sales dataset from App Context — never "
"invent figures that contradict it. Follow the dashboard composition "
"rules from App Context when choosing components: pick the component "
"by the shape of the question (snapshot → composed KPI dashboard with "
"charts; team performance → table; risk → status badges; single "
"account → info rows; part-of-whole → pie; trend/comparison → bar). "
"Never ask the user which chart they want. `generate_a2ui` takes no "
"arguments and handles the rendering automatically. Compose "
"generously — a dashboard should feel like a real analytics product, "
"not a single widget."
)
def build_a2ui_dynamic_agent() -> StrandsAgent:
"""Construct the dedicated A2UI dynamic-schema StrandsAgent.
The ``generate_a2ui`` tool is auto-injected by the adapter when the runtime
forwards ``injectA2UITool: true`` — nothing is wired into the Strands
agent's ``tools`` list here.
"""
strands_agent = Agent(
model=_build_model(),
system_prompt=SYSTEM_PROMPT,
)
return StrandsAgent(
agent=strands_agent,
name="a2ui_dynamic_schema",
description="Dynamic A2UI surfaces generated on the fly (auto-injected tool)",
config=StrandsAgentConfig(
a2ui={
"default_catalog_id": CATALOG_ID,
"guidelines": {"composition_guide": COMPOSITION_GUIDE},
}
),
)
@@ -0,0 +1,147 @@
"""Dedicated Strands agent for the A2UI Fixed Schema demo.
Strands port of the canonical langgraph-python ``a2ui_fixed`` demo
(``../../../langgraph-python/src/agents/a2ui_fixed.py``). Unlike the dynamic
A2UI demo (which relies on the adapter auto-injecting ``generate_a2ui`` to
*generate* a surface), the fixed-schema demo wires a single plain backend
``@tool`` — ``display_flight`` — that returns the ``a2ui_operations`` envelope
(create_surface -> update_components -> update_data_model). The component tree
is fixed and authored ahead of time (``a2ui_schemas/flight_schema.json``); only
the *data* changes per call. The runtime's A2UIMiddleware detects the envelope
in the tool result and paints it. No sub-agent, no generation, no recovery
loop, no ``generate_a2ui`` injection.
The schema's component names + data paths match the showcase frontend catalog
at ``src/app/demos/a2ui-fixed-schema/a2ui/{definitions,renderers,catalog}.ts``
(catalog id ``copilotkit://flight-fixed-catalog``).
The tool returns the envelope as a JSON **string** (not a dict): the Strands
adapter reads the ``toolResult`` ``text`` block, ``json.loads`` it, then
``json.dumps`` it back into the ToolCallResult content the client's
A2UIMiddleware scans for ``a2ui_operations``. Returning a string is what lands
the payload in a ``text`` block (a bare dict may land in a ``json`` block the
adapter skips).
Envelope shape note: the ops use the A2UI v0.9 nested form
(``{"version": "v0.9", "createSurface": {...}}``) — identical to what the
``copilotkit`` Python SDK's ``a2ui.render(...)`` emits in the proven
langgraph-python showcase path. We build the dict by hand here because the
``ag_ui_a2ui_toolkit`` package is not a showcase dependency (it is only
importable from a local ag-ui checkout, not from requirements.txt).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from strands import Agent, tool
from ag_ui_strands import StrandsAgent
from agents.agent import _build_model
CATALOG_ID = "copilotkit://flight-fixed-catalog"
SURFACE_ID = "flight-fixed-schema"
_SCHEMAS_DIR = Path(__file__).parent / "a2ui_schemas"
# The schema is JSON so it can be authored and reviewed independently of the
# Python code.
with open(_SCHEMAS_DIR / "flight_schema.json") as _f:
FLIGHT_SCHEMA: list[dict[str, Any]] = json.load(_f)
def _create_surface(surface_id: str, catalog_id: str) -> dict[str, Any]:
return {
"version": "v0.9",
"createSurface": {"surfaceId": surface_id, "catalogId": catalog_id},
}
def _update_components(
surface_id: str, components: list[dict[str, Any]]
) -> dict[str, Any]:
return {
"version": "v0.9",
"updateComponents": {"surfaceId": surface_id, "components": components},
}
def _update_data_model(surface_id: str, data: dict[str, Any]) -> dict[str, Any]:
return {
"version": "v0.9",
"updateDataModel": {"surfaceId": surface_id, "path": "/", "value": data},
}
def _envelope(data: dict[str, Any]) -> str:
"""Build the A2UI operations envelope as a JSON string.
Returned as a string so the Strands adapter emits it in a ``text`` block
the client A2UIMiddleware can detect.
"""
return json.dumps(
{
"a2ui_operations": [
_create_surface(SURFACE_ID, CATALOG_ID),
_update_components(SURFACE_ID, FLIGHT_SCHEMA),
_update_data_model(SURFACE_ID, data),
]
}
)
@tool
def display_flight(origin: str, destination: str, airline: str, price: str) -> str:
"""Show a flight card for the given trip.
Use short airport codes (e.g. "SFO", "JFK") for origin/destination and a
price string like "$289".
After this tool returns, the flight card is already rendered to the user
via the A2UI surface — the JSON returned here is the surface descriptor
the renderer consumes, NOT a status code. Do NOT call this tool again for
the same flight (the user already sees the card). Reply with one short
confirmation sentence and stop.
Args:
origin: Origin airport code, e.g. "SFO".
destination: Destination airport code, e.g. "JFK".
airline: Airline name, e.g. "United".
price: Price string, e.g. "$289".
"""
return _envelope(
{
"origin": origin,
"destination": destination,
"airline": airline,
"price": price,
}
)
SYSTEM_PROMPT = (
"You help users find flights. When asked about a flight, call "
"`display_flight` exactly ONCE with origin, destination, airline, and "
"price. The tool's JSON return value is an A2UI surface descriptor — the "
"flight card is already rendered to the user; do NOT call `display_flight` "
"again for the same trip and do NOT repeat the flight details in text. "
"After the tool returns, reply with one short confirmation sentence and "
"stop."
)
def build_a2ui_fixed_schema_agent() -> StrandsAgent:
"""Construct the dedicated A2UI fixed-schema StrandsAgent."""
strands_agent = Agent(
model=_build_model(),
system_prompt=SYSTEM_PROMPT,
tools=[display_flight],
)
return StrandsAgent(
agent=strands_agent,
name="a2ui_fixed_schema",
description="A2UI surface from a fixed, pre-authored schema (direct backend tool)",
)
@@ -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"
}
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
"""Strands agent specialization for the byoc-hashbrown demo (Wave 2).
The Strands showcase (as documented in `PARITY_NOTES.md`) historically ships
a single shared Strands agent (`src/agents/agent.py`) registered under many
AG-UI agent names. The byoc-hashbrown demo additionally requires the LLM to
emit a strict **hashbrown JSON envelope** (NOT XML) that
`@hashbrownai/react`'s `useJsonParser` + `useUiKit` can progressively parse.
Wire format
-----------
The renderer (`src/app/demos/byoc-hashbrown/hashbrown-renderer.tsx`) consumes
a JSON object shaped like the hashbrown schema itself — not the XML example
DSL used inside `useUiKit({ examples })`. Because this demo drives the LLM
via a Strands Agent (not via hashbrown's own `useUiChat`), we must emit the
raw schema wire format:
{
"ui": [
{ "metric": { "props": { "label": "...", "value": "..." } } },
{ "pieChart": { "props": { "title": "...", "data": "[{...}]" } } },
{ "barChart": { "props": { "title": "...", "data": "[{...}]" } } },
{ "dealCard": { "props": { "title": "...", "stage": "prospect", "value": 100000 } } },
{ "Markdown": { "props": { "children": "## heading\\nbody" } } }
]
}
Every node is a single-key object `{tagName: {props: {...}}}`. The tag names
and prop schemas match `useSalesDashboardKit()` in `hashbrown-renderer.tsx`.
`pieChart` / `barChart` receive `data` as a JSON-encoded string (kept as a
string so the schema is stable under partial streaming).
"""
BYOC_HASHBROWN_SYSTEM_PROMPT = """\
You are a sales analytics assistant that replies by emitting a single JSON
object consumed by a streaming JSON parser on the frontend.
ALWAYS respond with a single JSON object of the form:
{
"ui": [
{ <componentName>: { "props": { ... } } },
...
]
}
Do NOT wrap the response in code fences. Do NOT include any preface or
explanation outside the JSON object. The response MUST be valid JSON.
Available components and their prop schemas:
- "metric": { "props": { "label": string, "value": string } }
A KPI card. `value` is a pre-formatted string like "$1.2M" or "248".
- "pieChart": { "props": { "title": string, "data": string } }
A donut chart. `data` is a JSON-encoded STRING (embedded JSON) of an
array of {label, value} objects with at least 3 segments.
- "barChart": { "props": { "title": string, "data": string } }
A vertical bar chart. `data` is a JSON-encoded STRING of an array of
{label, value} objects with at least 3 bars, typically time-ordered.
- "dealCard": { "props": { "title": string, "stage": string, "value": number } }
A single sales deal. `stage` MUST be one of: "prospect", "qualified",
"proposal", "negotiation", "closed-won", "closed-lost". `value` is a
raw number (no currency symbol or comma).
- "Markdown": { "props": { "children": string } }
Short explanatory text. Use for section headings and brief summaries.
Rules:
- Always produce plausible sample data when the user asks for a dashboard or
chart -- do not refuse for lack of data.
- Prefer 3-6 rows of data in charts; keep labels short.
- Do not emit components that are not listed above.
- `data` props on charts MUST be a JSON STRING -- escape inner quotes.
"""
def build_byoc_hashbrown_agent():
"""Build a StrandsAgent configured with the byoc-hashbrown system prompt.
Returns an ``ag_ui_strands.StrandsAgent`` wrapper (mirrors
``build_voice_agent``) so it can be mounted by ``create_strands_app`` and
exposed as a dedicated AG-UI endpoint. agent_server.py mounts it at
``/byoc-hashbrown`` and the declarative-hashbrown route proxies there.
The agent takes no tools; it is a pure structured-output generator.
"""
# Deferred imports so this module remains importable even when the
# agent_server import-order patches (see agent_server.py) haven't been
# applied yet. Mirrors build_voice_agent: ``from strands import Agent``
# plus the ag_ui_strands wrapper. The OpenAI model is built via the
# shared agents.agent._build_model factory so we don't re-resolve the
# ``strands.models.openai`` submodule independently.
from strands import Agent
from ag_ui_strands import StrandsAgent
from agents.agent import _build_model
strands_agent = Agent(
model=_build_model(),
system_prompt=BYOC_HASHBROWN_SYSTEM_PROMPT,
tools=[],
)
return StrandsAgent(
agent=strands_agent,
name="byoc_hashbrown",
description="Hashbrown UI-kit envelope generator for the declarative-hashbrown demo.",
)
@@ -0,0 +1,92 @@
"""Strands agent specialization for the byoc-json-render demo (Wave 2).
Emits a single JSON object shaped like `@json-render/react`'s flat spec
format (`{ root, elements }`). The frontend validates against a
Zod-validated catalog of MetricCard, BarChart, PieChart.
This module defines the system prompt as the canonical source of truth.
The prompt is mirrored on the frontend (via `useAgentContext`) so the
shared Strands agent emits the right shape even without a per-demo Agent
instance on the backend.
"""
BYOC_JSON_RENDER_SYSTEM_PROMPT = """\
You are a sales-dashboard UI generator for a BYOC json-render demo.
When the user asks for a UI, respond with **exactly one JSON object** and
nothing else — no prose, no markdown fences, no leading explanation. The
object must match this schema (the "flat element map" format consumed by
@json-render/react):
{
"root": "<id of the root element>",
"elements": {
"<id>": {
"type": "<component name>",
"props": { ... component-specific props ... },
"children": [ "<id>", ... ]
},
...
}
}
Available components (use each name verbatim as "type"):
- MetricCard
props: { "label": string, "value": string, "trend": string | null }
- BarChart
props: {
"title": string,
"description": string | null,
"data": [ { "label": string, "value": number }, ... ]
}
- PieChart
props: {
"title": string,
"description": string | null,
"data": [ { "label": string, "value": number }, ... ]
}
Rules:
1. Output only valid JSON. No markdown code fences. No text outside the
object.
2. Every id referenced in root or any children array must be a key in
elements.
3. For a multi-component dashboard, use a root MetricCard and list the
charts in its children array.
4. Use realistic sales-domain values.
5. Never invent component types outside the three listed above.
"""
def build_byoc_json_render_agent():
"""Build a dedicated StrandsAgent for the byoc-json-render demo.
Returns an ``ag_ui_strands.StrandsAgent`` wrapper (mirrors
``build_voice_agent`` / ``build_byoc_hashbrown_agent``) so it can be
mounted by ``create_strands_app`` and exposed as a dedicated AG-UI
endpoint. agent_server.py mounts it at ``/byoc-json-render`` and the
declarative-json-render route proxies there.
"""
# Deferred imports so this module remains importable before the
# agent_server import-order patches run. Mirrors build_voice_agent /
# build_byoc_hashbrown_agent: the OpenAI model is built via the shared
# agents.agent._build_model factory.
from strands import Agent
from ag_ui_strands import StrandsAgent
from agents.agent import _build_model
strands_agent = Agent(
model=_build_model(),
system_prompt=BYOC_JSON_RENDER_SYSTEM_PROMPT,
tools=[],
)
return StrandsAgent(
agent=strands_agent,
name="byoc_json_render",
description="json-render flat-spec generator for the declarative-json-render demo.",
)
@@ -0,0 +1,159 @@
"""gen-ui-agent — Strands `set_steps` planner backend.
Mirrors the per-demo specialization pattern used by `byoc_hashbrown.py` and
`byoc_json_render.py`: this module owns the tool definition, the state hook
that turns the tool's args into a ``StateSnapshotEvent``, and the
prompt addendum. ``agent.py`` imports these and wires them into the shared
``StrandsAgent`` instance (Strands runs one shared backend agent for all
demos — see PARITY_NOTES.md).
Contract (see harness probe `d5-gen-ui-agent.ts` + langgraph-python /
ms-agent-python siblings):
* Tool name: ``set_steps``
* Tool args: ``{"steps": [{"id", "title", "status"}, ...]}``
* Status values: ``"pending" | "in_progress" | "completed"``
* Every ``set_steps`` call must trigger a ``StateSnapshotEvent`` carrying
``{"steps": [...]}`` so the frontend's ``useAgent`` subscription
re-renders ``[data-testid="agent-state-card"]`` and the per-step
``[data-testid="agent-step"][data-status=...]`` markers.
The planner walks each step pending → in_progress → completed by calling
``set_steps`` on every transition, so a normal 3-step plan emits 7 tool
calls (1 initial enumeration + 2 transitions × 3 steps).
Single-shared-agent caveat: this prompt addendum lives at the top-level
SYSTEM_PROMPT so it's seen by every demo's chat, but the instructions are
gated on "when the user asks you to plan / orchestrate a multi-step task"
so other demos (weather lookup, sales pipeline, etc.) are not regressed
into emitting set_steps for unrelated requests.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from strands import tool
logger = logging.getLogger(__name__)
# ---- Tool ---------------------------------------------------------------
@tool
def set_steps(steps: list[dict]) -> str:
"""Publish the current plan and step statuses.
Call this every time a step transitions (including the first
enumeration of steps). ALWAYS pass the COMPLETE list of steps on each
call — the frontend treats this as the source of truth for the live
progress card.
Each step is an object with:
* ``id``: stable string id (e.g. ``"step-1"``)
* ``title``: short human-readable description
* ``status``: one of ``"pending"``, ``"in_progress"``, ``"completed"``
Args:
steps: The complete list of steps with current statuses.
Returns:
Confirmation string for the LLM to summarise back to the user.
"""
return f"Published {len(steps)} step(s)."
# ---- State hook ---------------------------------------------------------
async def steps_state_from_args(context: Any) -> dict | None:
"""Emit a StateSnapshotEvent for the ``steps`` slot on every ``set_steps``.
Mirrors ``notes_state_from_args`` / ``sales_state_from_args``: accept
str-or-dict tool input, validate, return a snapshot dict for
ag_ui_strands to publish to the frontend's ``useAgent`` subscription.
Returns ``None`` (no snapshot) when the input shape is unrecognized,
matching the error-degradation policy of the sibling hooks in
agent.py.
"""
raw_input = getattr(context, "tool_input", None)
if raw_input is None:
logger.warning("steps_state_from_args: context has no tool_input")
return None
tool_input = raw_input
if isinstance(tool_input, str):
try:
tool_input = json.loads(tool_input)
except json.JSONDecodeError as exc:
logger.warning(
"steps_state_from_args: malformed JSON tool input (%s); input excerpt: %s",
exc,
repr(raw_input)[:200],
)
return None
if isinstance(tool_input, dict):
steps_data = tool_input.get("steps")
elif isinstance(tool_input, list):
steps_data = tool_input
else:
logger.warning(
"steps_state_from_args: unsupported tool_input type %s",
type(tool_input).__name__,
)
return None
if not isinstance(steps_data, list):
return None
# Defensive normalization — preserve only the keys the frontend reads
# (id/title/status). Coerce non-dict entries to empty dicts so
# downstream serialization never crashes; the frontend will render
# such entries as "pending" placeholders.
cleaned: list[dict] = []
for s in steps_data:
if not isinstance(s, dict):
continue
cleaned.append(
{
"id": str(s.get("id", "")),
"title": str(s.get("title", "")),
"status": str(s.get("status", "pending")),
}
)
return {"steps": cleaned}
# ---- Prompt addendum ----------------------------------------------------
GEN_UI_AGENT_PROMPT = (
"When the user asks you to plan, organize, research, or otherwise "
'orchestrate a multi-step task (e.g. "plan a product launch", '
'"organize a team offsite", "research a competitor"), enter '
"planner mode and follow this exact sequence:\n"
"1. Plan exactly 3 concrete steps and call `set_steps` ONCE with all "
'three steps at status="pending".\n'
'2. Step 1: call `set_steps` with step 1 at status="in_progress", '
'then call `set_steps` again with step 1 at status="completed".\n'
'3. Step 2: call `set_steps` with step 2 at status="in_progress", '
'then call `set_steps` again with step 2 at status="completed".\n'
'4. Step 3: call `set_steps` with step 3 at status="in_progress", '
'then call `set_steps` again with step 3 at status="completed".\n'
"5. Send ONE final conversational assistant message summarising the "
"plan, then stop. Do not call any more tools after step 3 is "
"completed.\n"
"Rules: ALWAYS pass the full list of 3 steps on every set_steps call "
"(not a diff). Never call set_steps in parallel — wait for one call "
'to return before the next. Use stable string ids like "step-1", '
'"step-2", "step-3". Planner mode does NOT apply to weather / '
"sales / shared-state / sub-agent demos — only enter it when the "
"user explicitly asks you to plan or orchestrate."
)
@@ -0,0 +1,63 @@
"""Strands agent specialization for the open-gen-ui demos (Wave 2).
Minimal and Advanced variants share the same core behavior: on every user
turn, the agent calls the frontend-registered `generateSandboxedUi` tool
exactly once, producing a self-contained sandboxed UI cell.
The runtime's `OpenGenerativeUIMiddleware` (enabled via the
`openGenerativeUI` option on the CopilotRuntime in
`src/app/api/copilotkit-ogui/route.ts`) converts that streaming tool call
into `open-generative-ui` activity events.
Since the shared Strands agent dispatches the frontend-registered tool via
the ag_ui_strands proxy, no dedicated Python Agent instance is required.
This module documents the system prompt that specializes the shared agent
via `useAgentContext` on the frontend.
"""
OPEN_GEN_UI_SYSTEM_PROMPT = """\
You are a UI-generating assistant for an Open Generative UI demo. On every
user turn you MUST call the `generateSandboxedUi` frontend tool exactly once.
Design a visually polished, self-contained HTML + CSS + SVG widget that
teaches the requested concept.
The frontend injects a detailed "design skill" as agent context describing
the palette, typography, labelling, and motion conventions expected —
follow it closely. Key invariants:
- Output ONE call to `generateSandboxedUi` per user turn.
- Respect the `initialHeight`, `placeholderMessages`, `css`, `html`
contract defined by the design skill.
- For the advanced variant, the frontend may register sandbox functions
the iframe can call via `Websandbox.connection.remote.<name>(...)`. Use
these where it makes the demo visibly exercise the iframe <-> host
bridge.
"""
def build_open_gen_ui_agent():
"""Build a Strands Agent for the open-gen-ui demos.
Not currently wired into agent_server.py; the shared agent handles the
`generateSandboxedUi` tool call via ag_ui_strands' frontend-tool proxy.
This module's prompt is mirrored by the frontend design skill passed to
CopilotKit's `openGenerativeUI.designSkill`.
"""
from strands import Agent
from strands.models.openai import OpenAIModel
import os
api_key = os.getenv("OPENAI_API_KEY", "")
if not api_key:
raise RuntimeError(
"OPENAI_API_KEY must be set for the open-gen-ui Strands agent"
)
model = OpenAIModel(
client_args={"api_key": api_key},
model_id="gpt-4o",
)
return Agent(
model=model,
system_prompt=OPEN_GEN_UI_SYSTEM_PROMPT,
tools=[],
)
@@ -0,0 +1,63 @@
"""Dedicated Strands agent for the A2UI Error Recovery demo (OSS-158 / OSS-375).
Same auto-injected dynamic-schema A2UI setup as `a2ui_dynamic.py`
(declarative-gen-ui), but with the toolkit's validate->retry recovery loop made
*visible*. The two aimock pills drive the inner `render_a2ui` sub-agent two
ways:
- HEAL pill: the model emits FREE-FORM / sloppy A2UI args (components and data
as JSON strings rather than structured arrays) — the toolkit heals them via
`parse_and_fix` into a valid surface in a single pass, which paints.
- EXHAUST pill: every attempt is structurally invalid (the root references a
missing child), so the validate->retry loop hits the cap and the tool
returns the `a2ui_recovery_exhausted` hard-fail envelope, which the renderer
surfaces as a tasteful `failed` state (no broken surface).
Wiring: unlike the langgraph/ADK siblings (which own `generate_a2ui` explicitly
via `get_a2ui_tools` + `injectA2UITool: false`), the Strands adapter runs the
recovery loop on its AUTO-INJECT path — when the runtime forwards
`injectA2UITool: true` (the page's provider catalog defaults it on), the adapter
auto-injects `generate_a2ui`, drives the `render_a2ui` sub-agent, and runs the
toolkit recovery loop + recovery-exhausted hard-fail itself. So this agent wires
NO tool; it is a clone of `build_a2ui_dynamic_agent` under a dedicated name.
Mirrors the ag-ui dojo `aws-strands` recovery example.
Catalog is reused from declarative-gen-ui ("declarative-gen-ui-catalog"); the
Vantage Threads sales dataset + composition rules arrive both from the frontend
App Context (the primary agent) and the `composition_guide` below (the inner
render planner).
"""
from __future__ import annotations
from strands import Agent
from ag_ui_strands import StrandsAgent, StrandsAgentConfig
from agents.agent import _build_model
from agents.a2ui_dynamic import CATALOG_ID, COMPOSITION_GUIDE, SYSTEM_PROMPT
def build_a2ui_recovery_agent() -> StrandsAgent:
"""Construct the dedicated A2UI recovery StrandsAgent.
The `generate_a2ui` tool is auto-injected by the adapter when the runtime
forwards `injectA2UITool: true`; the adapter also runs the toolkit
validate->retry recovery loop (default 3 attempts) on that path. Nothing is
wired into the Strands agent's `tools` list here.
"""
strands_agent = Agent(
model=_build_model(),
system_prompt=SYSTEM_PROMPT,
)
return StrandsAgent(
agent=strands_agent,
name="a2ui_recovery",
description="Dynamic A2UI with automatic error recovery (auto-injected tool)",
config=StrandsAgentConfig(
a2ui={
"default_catalog_id": CATALOG_ID,
"guidelines": {"composition_guide": COMPOSITION_GUIDE},
}
),
)
@@ -0,0 +1,30 @@
"""
Simple voice agent for Strands — no tools.
The voice demo tests transcription and basic chat, not tool execution.
Using a tool-free agent avoids the tool-call loop problem where the backend
agent executes a tool but doesn't loop back to the LLM for a text summary,
resulting in empty assistant responses in the AG-UI stream.
"""
from __future__ import annotations
from strands import Agent
from ag_ui_strands import StrandsAgent
from agents.agent import _build_model
def build_voice_agent() -> StrandsAgent:
"""Construct a simple StrandsAgent with no tools for voice demos."""
strands_agent = Agent(
model=_build_model(),
system_prompt="You are a helpful, concise assistant.",
tools=[],
)
return StrandsAgent(
agent=strands_agent,
name="voice_agent",
description="Simple assistant for voice demo — no tools.",
)
@@ -0,0 +1,58 @@
// Dedicated runtime for the Declarative Generative UI (A2UI fixed-schema) demo.
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";
function createAgent() {
// Dedicated backend agent mounted at /a2ui-fixed-schema (see
// src/agent_server.py). It owns the `display_flight` tool which emits its
// own a2ui_operations envelope; the runtime A2UIMiddleware paints it. No
// `a2ui: { injectA2UITool }` flag is needed — the envelope mechanism does
// not require runtime tool injection.
return new HttpAgent({ url: `${AGENT_URL}/a2ui-fixed-schema/` });
}
const a2uiFixedAgent = createAgent();
const agents = {
"a2ui-fixed-schema": a2uiFixedAgent,
default: a2uiFixedAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-a2ui-fixed-schema",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
// Enable the A2UIMiddleware so it detects the `a2ui_operations`
// envelope the dedicated `a2ui_fixed_schema` backend agent's
// `display_flight` tool returns and converts it into A2UI activity
// events the page's catalog renders. `injectA2UITool: false` because
// the agent emits the envelope itself (no generate_a2ui injection).
// Mirrors the beautiful-chat route. Pin the catalog the page registers
// so the middleware doesn't fall back to the unregistered basic catalog.
a2ui: {
injectA2UITool: false,
defaultCatalogId: "copilotkit://flight-fixed-catalog",
},
}),
});
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,54 @@
// Dedicated runtime for the A2UI Error Recovery demo.
// Scoped so a2ui options for this cell stay isolated from the shared route.
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";
function createAgent() {
// Dedicated backend agent mounted at /a2ui-recovery (see src/agent_server.py).
// It wires NO generate_a2ui tool — the catalog the page passes to the provider
// auto-enables A2UI tool injection, so the Strands adapter auto-injects it,
// drives the render_a2ui planner, and runs the toolkit validate->retry
// recovery loop on that auto-inject path. Trailing slash so the
// sub-application's root route resolves.
return new HttpAgent({ url: `${AGENT_URL}/a2ui-recovery/` });
}
const a2uiAgent = createAgent();
const agents = {
"a2ui-recovery": a2uiAgent,
default: a2uiAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-a2ui-recovery",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
// No runtime `a2ui` config: the page passes a catalog to the provider
// (`<CopilotKit a2ui={{ catalog }}>`), which auto-enables A2UI and
// defaults tool injection on (CopilotKit >= 1.61.2, PR #5611). The
// Strands adapter then auto-injects `generate_a2ui` and runs the
// recovery loop the A2UIMiddleware renders.
}),
});
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,46 @@
// Dedicated runtime for the Agent Config Object demo.
//
// The page uses <CopilotKitProvider properties={...}> to forward a typed
// config object (tone / expertise / responseLength) to the agent. Scoped to
// its own endpoint so the Playwright spec can assert propagation against a
// single URL.
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";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
const agentConfigAgent = createAgent();
const agents = {
"agent-config-demo": agentConfigAgent,
default: agentConfigAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-agent-config",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
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,58 @@
// Dedicated runtime for the /demos/auth cell.
//
// Demonstrates framework-native request authentication via the V2 runtime's
// `onRequest` hook. A static `Authorization: Bearer <DEMO_TOKEN>` header is
// required; mismatch throws a 401 before the request reaches the agent.
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";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
const authDemoAgent = createAgent();
const runtime = new CopilotRuntime({
agents: {
// @ts-ignore -- HttpAgent is structurally compatible with AbstractAgent but misses the private `_debug*` fields in the published .d.ts.
"auth-demo": authDemoAgent,
// @ts-ignore
default: authDemoAgent,
},
});
const BASE_PATH = "/api/copilotkit-auth";
const handler = createCopilotRuntimeHandler({
runtime,
basePath: BASE_PATH,
hooks: {
onRequest: ({ request }) => {
const authHeader = request.headers.get("authorization");
if (authHeader !== DEMO_AUTH_HEADER) {
throw new Response(
JSON.stringify({
error: "unauthorized",
message:
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
}),
{
status: 401,
headers: { "content-type": "application/json" },
},
);
}
},
},
});
export const POST = (req: NextRequest) => handler(req);
export const GET = (req: NextRequest) => handler(req);
@@ -0,0 +1,78 @@
// Dedicated runtime for the Beautiful Chat flagship showcase cell (Strands).
//
// Beautiful Chat exercises A2UI (dynamic + fixed schema) and Open
// Generative UI. The shared Strands backend (agent_server.py) hosts a
// single Strands Agent instance on "/", so the cell routes there; 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 { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
// 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 beautifulChatAgent = createAgent();
const agents = {
"beautiful-chat": beautifulChatAgent,
default: beautifulChatAgent,
};
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
openGenerativeUI: true,
a2ui: {
// The targeted Strands agent ("/") already registers the `generate_a2ui`
// tool itself (build_showcase_agent in src/agents/agent.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).
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,53 @@
// Dedicated runtime for the BYOC Hashbrown demo.
//
// The shared Strands backend (agent_server.py) hosts a single Strands Agent
// instance. For the byoc-hashbrown demo we need the LLM to emit a strict
// hashbrown JSON envelope (see src/agents/byoc_hashbrown.py for the canonical
// prompt). Since the shared backend's system prompt is configured at agent
// construction time, prompt specialization per demo is accomplished via the
// `forwardedProps.additional_instructions` field that the Strands agent reads
// from the run config.
//
// If/when the Strands backend is extended to host multiple Agent instances on
// sub-paths (e.g. /byoc_hashbrown/), this route will swap to an HttpAgent
// pointing at that sub-path.
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";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
const byocHashbrownAgent = createAgent();
const agents = {
"byoc-hashbrown-demo": byocHashbrownAgent,
default: byocHashbrownAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-byoc-hashbrown",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
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,41 @@
// Dedicated runtime for the BYOC json-render demo.
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";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
const byocJsonRenderAgent = createAgent();
const agents = {
byoc_json_render: byocJsonRenderAgent,
default: byocJsonRenderAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-byoc-json-render",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
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,53 @@
// Dedicated runtime for the Declarative Generative UI (A2UI dynamic) demo.
// Scoped so a2ui options for this cell stay isolated from the shared route.
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";
function createAgent() {
// Dedicated backend agent mounted at /declarative-gen-ui (see
// src/agent_server.py). It wires NO generate_a2ui tool — the catalog the page
// passes to the provider auto-enables A2UI tool injection, so the Strands
// adapter auto-injects it and GENERATEs the surface layout. Trailing slash so
// the sub-application's root route resolves.
return new HttpAgent({ url: `${AGENT_URL}/declarative-gen-ui/` });
}
const a2uiAgent = createAgent();
const agents = {
"declarative-gen-ui": a2uiAgent,
default: a2uiAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-gen-ui",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
// No runtime `a2ui` config: the page passes a catalog to the provider
// (`<CopilotKit a2ui={{ catalog }}>`), which auto-enables A2UI and
// defaults tool injection on (CopilotKit >= 1.61.2, PR #5611). The
// Strands adapter then auto-injects `generate_a2ui` from the forwarded
// flag and drives the render planner the A2UIMiddleware paints.
}),
});
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,55 @@
// Dedicated runtime for the declarative-hashbrown demo (Strands).
//
// The declarative-hashbrown demo needs the LLM to emit a strict hashbrown
// JSON envelope (see src/agents/byoc_hashbrown.py for the canonical prompt).
// The shared Strands agent at "/" cannot produce that envelope, so the
// backend mounts a dedicated, prompt-specialized agent at `/byoc-hashbrown/`
// (see agent_server.py) and this route proxies to it.
//
// The demo folder + route + agent slug were renamed from `byoc-hashbrown` to
// the canonical `declarative-hashbrown` surface; the page mounts
// <CopilotKit agent="declarative-hashbrown-demo">.
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";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/byoc-hashbrown/` });
}
const declarativeHashbrownAgent = createAgent();
const agents = {
"declarative-hashbrown-demo": declarativeHashbrownAgent,
default: declarativeHashbrownAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-hashbrown",
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,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
console.error(
`[copilotkit-declarative-hashbrown/route] ERROR: ${e.message}`,
e.stack,
);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,55 @@
// Dedicated runtime for the declarative-json-render demo (Strands).
//
// The demo page renders the agent's JSON output into a frontend-owned
// component catalog via @json-render/react. The backend mounts a dedicated,
// prompt-specialized agent at `/byoc-json-render/` whose system prompt
// (src/agents/byoc_json_render.py) instructs the LLM to emit the
// `@json-render/react` flat-spec envelope (`{ root, elements }`); this route
// proxies to that endpoint rather than the generic "/" agent. 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 { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/byoc-json-render/` });
}
const byocJsonRenderAgent = createAgent();
const agents = {
byoc_json_render: byocJsonRenderAgent,
default: byocJsonRenderAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-json-render",
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,
}),
});
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,84 @@
// Dedicated runtime for the MCP Apps demo.
//
// 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.
//
// The Strands shared backend exposes a single Strands Agent. The MCP Apps
// middleware works at the runtime layer: it injects MCP-server-discovered
// tools into the request, so the agent does not need any bespoke tool
// registrations to drive the demo. The agent simply calls whatever MCP tool
// the runtime advertises.
//
// Reference (langgraph-python sibling):
// showcase/integrations/langgraph-python/src/app/api/copilotkit-mcp-apps/route.ts
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";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
const mcpAppsAgent = createAgent();
const agents = {
// headless-complete shares this runtime (its page wires
// runtimeUrl="/api/copilotkit-mcp-apps") but is backed by the single
// shared Strands Agent at "/" — the same backend the main route
// registers it against.
"headless-complete": createAgent(),
"mcp-apps": mcpAppsAgent,
default: mcpAppsAgent,
};
// @region[runtime-mcpapps-config]
// The `mcpApps.servers` config is all you need server-side. The runtime
// auto-applies the MCP Apps middleware to every registered agent: on each
// MCP tool call it fetches the associated UI resource and emits an
// `activity` event that the built-in `MCPAppsActivityRenderer` renders
// inline in the chat.
const runtime = new CopilotRuntime({
// @ts-expect-error -- see main route.ts; published CopilotRuntime's `agents`
// type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects
// plain Records. Fixed in source, pending release.
agents,
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,47 @@
// Dedicated runtime for the Multimodal Attachments demo.
//
// Mirrors the LangGraph-Python shape: the multimodal cell gets its own
// runtime so vision-capable model settings stay scoped to that one cell.
// In the Strands showcase the backend is a single shared Strands agent
// (served by agent_server.py on port 8000); this route just registers the
// demo's slug against the same HttpAgent proxy as the main route.
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";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
const multimodalAgent = createAgent();
const agents = {
"multimodal-demo": multimodalAgent,
default: multimodalAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-multimodal",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
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,56 @@
// Dedicated runtime for the Open Generative UI demos.
//
// Isolated here because the `openGenerativeUI` runtime flag sets
// `openGenerativeUIEnabled: true` globally on the probe response, which
// causes the CopilotKit provider's setTools effect to wipe per-demo
// `useFrontendTool`/`useComponent` registrations in the default runtime.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
const openGenUiAgent = createAgent();
const openGenUiAdvancedAgent = createAgent();
const agents = {
"open-gen-ui": openGenUiAgent,
"open-gen-ui-advanced": openGenUiAdvancedAgent,
default: openGenUiAgent,
};
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]
runtime: new CopilotRuntime({
// @ts-ignore -- 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,83 @@
// Dedicated runtime for the /demos/voice cell (Strands).
//
// Goals
// -----
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
// composer renders the mic button.
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`).
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured.
//
// Wires the V2 `CopilotRuntime` directly because the V1 wrapper drops the
// `transcriptionService` option. V2 URL-routes on `/info`, `/agent/:id/run`,
// `/transcribe`, etc., so the route lives at `[[...slug]]/route.ts`.
// @region[voice-runtime]
// @region[transcription-service-guard]
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
TranscriptionService,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
import OpenAI from "openai";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Point at the tool-free /voice endpoint so aimock returns a direct text
// response instead of a tool call that the agent can't summarize.
const voiceDemoAgent = new HttpAgent({ url: `${AGENT_URL}/voice/` });
class GuardedOpenAITranscriptionService extends TranscriptionService {
private delegate: TranscriptionServiceOpenAI | null;
constructor() {
super();
const apiKey = process.env.OPENAI_API_KEY;
this.delegate = apiKey
? new TranscriptionServiceOpenAI({ openai: new OpenAI({ apiKey }) })
: null;
}
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
if (!this.delegate) {
throw new Error(
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
"Set OPENAI_API_KEY to enable voice transcription.",
);
}
return this.delegate.transcribeFile(options);
}
}
// @endregion[transcription-service-guard]
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
function getHandler(): (req: Request) => Promise<Response> {
if (cachedHandler) return cachedHandler;
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
// source, pending release.
agents: {
"voice-demo": voiceDemoAgent,
default: voiceDemoAgent,
},
transcriptionService: new GuardedOpenAITranscriptionService(),
});
cachedHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-voice",
});
return cachedHandler;
}
export const POST = (req: NextRequest) => getHandler()(req);
export const GET = (req: NextRequest) => getHandler()(req);
export const PUT = (req: NextRequest) => getHandler()(req);
export const DELETE = (req: NextRequest) => getHandler()(req);
// @endregion[voice-runtime]
@@ -0,0 +1,160 @@
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";
// 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";
console.log("[copilotkit/route] Initializing CopilotKit runtime");
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/` });
}
// Register the same agent under all names used by demo pages.
// Strands runs a single shared backend agent; per-demo differentiation
// happens on the frontend (useFrontendTool / useRenderTool / useHumanInTheLoop
// / useAgentContext / A2UI catalogs). Every demo page's `agent=` prop must
// resolve to a name in this list.
const agentNames = [
// Original blitz set
"agentic_chat",
"human_in_the_loop",
"tool-rendering",
"gen-ui-tool-based",
"gen-ui-agent",
"shared-state-read",
"shared-state-write",
"shared-state-streaming",
"subagents",
// Chat UI / chrome demos
"chat-customization-css",
"prebuilt-sidebar",
"prebuilt-popup",
"chat-slots",
"headless-simple",
"headless-complete",
// Reasoning
"agentic-chat-reasoning",
"reasoning-default-render",
// Frontend tools
"frontend_tools",
"frontend-tools-async",
// HITL
"hitl-in-chat",
"hitl-in-app",
// Tool rendering variants
"tool-rendering-default-catchall",
"tool-rendering-custom-catchall",
"tool-rendering-reasoning-chain",
// State / context
"readonly-state-agent-context",
"shared-state-read-write",
// A2UI
"declarative-gen-ui",
"a2ui-fixed-schema",
// Modalities
"multimodal",
"voice",
// Misc
"auth",
"agent-config",
// BYOC renderers (wave 2)
"byoc-hashbrown-demo",
"byoc_json_render",
// Open Generative UI (wave 2)
"open-gen-ui",
"open-gen-ui-advanced",
// Polished chat shell (simplified port — wave 2 follow-up)
"beautiful-chat",
// Interrupt demos (Strategy B — frontend-tool async handler)
"gen-ui-interrupt",
"interrupt-headless",
];
const agents: Record<string, AbstractAgent> = {};
for (const name of agentNames) {
agents[name] = createAgent();
}
agents["default"] = createAgent();
console.log(
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
);
export const POST = async (req: NextRequest) => {
const url = req.url;
const contentType = req.headers.get("content-type");
if (ROUTE_DEBUG) {
console.log(
`[copilotkit/route] POST ${url} (content-type: ${contentType})`,
);
}
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
}),
});
const response = await handleRequest(req);
if (!response.ok) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
} else if (ROUTE_DEBUG) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
}
return response;
} catch (error: unknown) {
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: "strands",
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: "strands",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "strands";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function GET() {
const start = Date.now();
// Hit our own /api/copilotkit endpoint — tests the full deployed stack
const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL ||
`http://localhost:${process.env.PORT || 3000}`;
try {
const res = await fetch(`${baseUrl}/api/copilotkit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: "agent/run",
params: { agentId: "agentic_chat" },
body: {
threadId: `smoke-${Date.now()}`,
runId: `smoke-run-${Date.now()}`,
state: {},
messages: [
{
id: `smoke-msg-${Date.now()}`,
role: "user",
content: "Respond with exactly: OK",
},
],
tools: [],
context: [],
forwardedProps: {},
},
}),
signal: AbortSignal.timeout(45000),
});
const latency = Date.now() - start;
if (!res.ok) {
const errBody = await res.text().catch(() => "");
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "runtime_response",
error: `Runtime returned ${res.status}: ${errBody.slice(0, 200)}`,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
// TTFB: read first chunk only to confirm SSE stream started, then cancel
const reader = res.body?.getReader();
if (!reader) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned no readable body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
const { value, done } = await reader.read();
reader.cancel();
if (done || !value || value.length === 0) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned empty response body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
return NextResponse.json({
status: "ok",
integration: INTEGRATION_SLUG,
latency_ms: latency,
timestamp: new Date().toISOString(),
});
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
const latency = Date.now() - start;
let stage = "unknown";
if (err.name === "AbortError" || err.message.includes("timeout"))
stage = "timeout";
else if (
err.message.includes("fetch") ||
err.message.includes("ECONNREFUSED")
)
stage = "agent_unreachable";
else stage = "pipeline_error";
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage,
error: err.message,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
}
@@ -0,0 +1,55 @@
// Shared fallback time-slot generator for the interrupt demos
// (`gen-ui-interrupt`, `interrupt-headless`). The interrupt backend
// (`src/agents/interrupt_agent.py`) supplies its own candidate slots
// inside the interrupt payload — these fallbacks only run if the
// payload arrives without them. Generating relative to `Date.now()`
// keeps the fallback from rotting, which previously had hardcoded
// dates that decayed within a week of being authored.
export interface TimeSlot {
label: string;
iso: string;
}
function atLocal(date: Date, hour: number, minute = 0): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
hour,
minute,
0,
0,
);
}
function nextMonday(from: Date): Date {
// `getDay()` is 0=Sun, 1=Mon, ..., 6=Sat. We want the next Monday
// that's at LEAST 2 days away — otherwise "Monday" would collide
// with "Tomorrow" on Sunday (offset would be 1) or with itself on
// Monday (offset would be 0). Mirrors interrupt_agent.py.
const day = from.getDay();
let offset = (1 - day + 7) % 7;
if (offset <= 1) offset += 7;
const next = new Date(from);
next.setDate(from.getDate() + offset);
return next;
}
export function generateFallbackSlots(now: Date = new Date()): TimeSlot[] {
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const monday = nextMonday(now);
const candidates: Array<[string, Date]> = [
["Tomorrow 10:00 AM", atLocal(tomorrow, 10)],
["Tomorrow 2:00 PM", atLocal(tomorrow, 14)],
["Monday 9:00 AM", atLocal(monday, 9)],
["Monday 3:30 PM", atLocal(monday, 15, 30)],
];
return candidates.map(([label, date]) => ({
label,
iso: date.toISOString(),
}));
}
@@ -0,0 +1,12 @@
// Coerces a tool-call result into a typed object. Tool results arrive
// as strings when the agent emits JSON or as already-parsed objects
// when the runtime decoded them upstream — this helper handles both
// shapes and returns `{}` if the result is missing or unparseable.
export function parseJsonResult<T>(result: unknown): T {
if (!result) return {} as T;
try {
return (typeof result === "string" ? JSON.parse(result) : result) as T;
} catch {
return {} as T;
}
}
@@ -0,0 +1,21 @@
// Helper for the CopilotChat slot overrides. The slot prop types in
// `@copilotkit/react-core` are nominally typed against the *exact*
// default component identity, but a custom wrapper that returns a
// structurally compatible ReactElement is functionally a drop-in. This
// helper names that fact and centralizes the type assertion in one
// place — readers see `makeSlotOverride` and know it's about the slot
// contract, not arbitrary type-system gymnastics. Once the slot prop
// types accept structural compatibility, this helper can be deleted
// and the casts will resolve automatically.
import type { ComponentType } from "react";
// `any` on the input is intentional: the helper's purpose is to accept
// any component shape and assert it as the slot's expected type. A
// stricter constraint would defeat the whole point.
export function makeSlotOverride<TDefault>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>,
): TDefault {
return component as unknown as TDefault;
}
@@ -0,0 +1,31 @@
import * as React from "react";
/**
* ShadCN-style Badge primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "secondary" | "outline" | "success";
const variantClasses: Record<Variant, string> = {
default: "border-transparent bg-neutral-900 text-neutral-50",
secondary: "border-transparent bg-neutral-100 text-neutral-900",
outline: "border-neutral-200 text-neutral-700 bg-white",
success: "border-transparent bg-emerald-100 text-emerald-700",
};
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: Variant;
}
export function Badge({
className = "",
variant = "default",
...props
}: BadgeProps) {
return (
<div
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium tracking-wide ${variantClasses[variant]} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,48 @@
import * as React from "react";
/**
* ShadCN-style Button primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "outline" | "secondary" | "ghost" | "success";
type Size = "default" | "sm" | "lg";
const baseClasses =
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60";
const variantClasses: Record<Variant, string> = {
default: "bg-neutral-900 text-neutral-50 shadow-sm hover:bg-neutral-800",
outline:
"border border-neutral-200 bg-white text-neutral-900 shadow-sm hover:bg-neutral-100",
secondary: "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-200",
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
success:
"bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm hover:bg-emerald-50",
};
const sizeClasses: Record<Size, string> = {
default: "h-10 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-6",
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className = "", variant = "default", size = "default", ...props },
ref,
) => {
return (
<button
ref={ref}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
/>
);
},
);
Button.displayName = "Button";
@@ -0,0 +1,61 @@
import * as React from "react";
/**
* ShadCN-style Card primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
export const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm ${className}`}
{...props}
/>
));
Card.displayName = "Card";
export const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex flex-col space-y-1.5 p-5 pb-3 ${className}`}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
export const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className = "", ...props }, ref) => (
<h3
ref={ref}
className={`text-base font-semibold leading-none tracking-tight text-neutral-900 ${className}`}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
export const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div ref={ref} className={`p-5 pt-0 ${className}`} {...props} />
));
CardContent.displayName = "CardContent";
export const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex items-center p-5 pt-0 ${className}`}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
@@ -0,0 +1,26 @@
import * as React from "react";
/**
* ShadCN-style Separator primitive (inline-cloned for this demo).
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
*/
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical";
}
export function Separator({
className = "",
orientation = "horizontal",
...props
}: SeparatorProps) {
const orientationClasses =
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
return (
<div
role="separator"
aria-orientation={orientation}
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,23 @@
"use client";
/**
* Fixed A2UI catalog — wires definitions to renderers.
*
* `includeBasicCatalog: true` merges CopilotKit's built-in components
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
* compose custom and basic components interchangeably.
*/
// @region[catalog-creation]
import { createCatalog } from "@copilotkit/a2ui-renderer";
import { definitions } from "./definitions";
import { renderers } from "./renderers";
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
export const catalog = createCatalog(definitions, renderers, {
catalogId: CATALOG_ID,
includeBasicCatalog: true,
});
// @endregion[catalog-creation]
@@ -0,0 +1,107 @@
/**
* A2UI catalog DEFINITIONS — platform-agnostic.
*
* Each entry declares a component name + its Zod props schema. The basic
* catalog (Card, Column, Row, Text, Button, …) ships with CopilotKit and
* is mixed in via `createCatalog(..., { includeBasicCatalog: true })`, so
* we only declare the project-specific additions and the visual overrides
* here. (Custom entries with the same name as a basic component override
* the basic one — Catalog dedupes by `comp.name`, last-write-wins.)
*
* IMPORTANT — path bindings: fields that can be bound to a data-model path
* (e.g. `{ path: "/origin" }` in the fixed schema JSON) must declare their
* Zod type as a union of `z.string()` and `z.object({ path: z.string() })`.
* The A2UI `GenericBinder` uses this union to detect the field as dynamic
* and resolve the path against the current data model at render time. Using
* plain `z.string()` causes the raw `{ path }` object to reach the
* renderer, which React then throws on (error #31 "object with keys {path}").
* This matches the canonical catalog's `DynString` helper:
* examples/integrations/langgraph-python/src/app/declarative-generative-ui/definitions.ts
*/
// @region[definitions-types]
import { z } from "zod";
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
/**
* Dynamic string: literal OR a data-model path binding. The GenericBinder
* resolves path bindings to the actual value at render time.
*/
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
export const definitions = {
/**
* Card override: gives the outer flight-card container a ShadCN look
* (rounded-xl, neutral-200 border, soft shadow). The basic catalog's
* Card uses inline styles; overriding here lets the demo's renderer
* adopt the demo's Tailwind aesthetic without touching the schema JSON.
*/
Card: {
description: "A container card with a single child.",
props: z.object({
child: z.string(),
}),
},
Title: {
description: "A prominent heading for the flight card.",
props: z.object({
text: DynString,
}),
},
Airport: {
description: "A 3-letter airport code, displayed large.",
props: z.object({
code: DynString,
}),
},
Arrow: {
description: "A right-pointing arrow used between airports.",
props: z.object({}),
},
AirlineBadge: {
description: "A pill-styled airline name tag.",
props: z.object({
name: DynString,
}),
},
PriceTag: {
description: "A stylized price display (e.g. '$289').",
props: z.object({
amount: DynString,
}),
},
/**
* Button override: swaps in an ActionButton renderer that tracks
* its own `done` state so clicking "Book flight" visually updates to
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
* so without this override the click fires the action but the button
* looks unchanged. Mirrors the pattern in beautiful-chat
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
*/
Button: {
description:
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
props: z.object({
child: z
.string()
.describe(
"The ID of the child component (e.g. a Text component for the label).",
),
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
} satisfies CatalogDefinitions;
// @endregion[definitions-types]
export type Definitions = typeof definitions;

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