chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+108
@@ -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
|
||||
@@ -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;
|
||||
+13919
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.
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -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";
|
||||
+26
@@ -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
Reference in New Issue
Block a user