chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
**/node_modules
|
||||
.next
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
**/vitest.config.ts
|
||||
**/test-setup.ts
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
@@ -0,0 +1,10 @@
|
||||
# API Keys (shared across integrations)
|
||||
OPENAI_API_KEY=replace-with-your-key
|
||||
ANTHROPIC_API_KEY=replace-with-your-key
|
||||
ANTHROPIC_MODEL=claude-sonnet-4.6
|
||||
|
||||
# Agent backend URL (for the CopilotKit runtime proxy)
|
||||
AGENT_URL=http://localhost:8000
|
||||
|
||||
# Showcase
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
@@ -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,81 @@
|
||||
# Stage 1: Build Next.js frontend
|
||||
FROM node:22-slim AS frontend
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Python venv with agent deps
|
||||
#
|
||||
# True multi-stage split — the builder carries full `python:3.12` (compilers,
|
||||
# build-essential, dev headers needed to compile wheels that don't ship
|
||||
# pre-built for ``-slim``) and produces a self-contained ``/opt/venv`` that
|
||||
# the runner COPYs in as a single opaque tree. The runner never runs
|
||||
# ``pip install`` itself, so no compiler toolchain bloats the final image.
|
||||
FROM python:3.12.13 AS agent-builder
|
||||
WORKDIR /agent
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
COPY requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Stage 3: Production image with Node.js + Python (runtime only — no pip,
|
||||
# no build tools). Node.js is installed via NodeSource because the package
|
||||
# runs BOTH Next.js (frontend) and the Python agent inside this single image;
|
||||
# entrypoint.sh orchestrates both processes.
|
||||
FROM python:3.12.13-slim AS runner
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create unprivileged runtime user BEFORE any COPY so --chown resolves
|
||||
# by name and so recursive chown over /app is never needed (fast builds).
|
||||
# Mirrors the starter Dockerfile pattern for parity — Railway / any
|
||||
# platform that enforces non-root by policy needs this from the package
|
||||
# image too, not just the generated starter.
|
||||
RUN (groupadd --system --gid 1001 app 2>/dev/null || true) \
|
||||
&& (useradd --system --uid 1001 --gid 1001 --no-create-home app 2>/dev/null || true) \
|
||||
&& mkdir -p /home/app && chown app:app /home/app
|
||||
|
||||
# Python venv (prebuilt in agent-builder stage — no pip in the runner).
|
||||
COPY --chown=app:app --from=agent-builder /opt/venv /opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
|
||||
# Next.js build artifacts
|
||||
COPY --chown=app:app --from=frontend /app/.next ./.next
|
||||
COPY --chown=app:app --from=frontend /app/node_modules ./node_modules
|
||||
COPY --chown=app:app --from=frontend /app/package.json ./
|
||||
COPY --chown=app:app --from=frontend /app/public ./public
|
||||
|
||||
# Agent code
|
||||
COPY --chown=app:app src/agent_server.py ./
|
||||
COPY --chown=app:app src/agents/ ./agents/
|
||||
|
||||
# Shared Python tools (symlinked at tools -> ../../shared/python/tools)
|
||||
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,122 @@
|
||||
# Parity Notes
|
||||
|
||||
Baseline: `showcase/integrations/langgraph-python/`.
|
||||
|
||||
This package ports the frontend-composition demos satisfied by the shared
|
||||
Claude backend in `src/agents/agent.py`, plus dedicated ogui / headless
|
||||
surfaces. The demos below are deliberately out of scope for this pass and
|
||||
left for a follow-up.
|
||||
|
||||
## New demos (post-PR #4271)
|
||||
|
||||
- `byoc-json-render` — dedicated `/byoc-json-render` agent endpoint emits
|
||||
a JSON spec; frontend renders via `<JSONUIProvider>` + `<Renderer />`
|
||||
against a Zod catalog (MetricCard, BarChart, PieChart). Includes the
|
||||
`<JSONUIProvider>` fix from PR #4271 and the `defineRegistry`
|
||||
children-forwarding fix for MetricCard.
|
||||
- `byoc-hashbrown` — dedicated `/byoc-hashbrown` agent endpoint emits
|
||||
the hashbrown JSON envelope `{ui: [{metric: {props: {...}}}, ...]}`
|
||||
(NOT XML — the #4271 fix). Frontend parses via `useJsonParser` +
|
||||
`useUiKit` with MetricCard / PieChart / BarChart / DealCard / Markdown.
|
||||
- `multimodal` — dedicated `/multimodal` agent endpoint with
|
||||
`convert_part_for_claude` that translates AG-UI `image` / `document`
|
||||
parts into Claude's native Messages API shape. PDFs flatten to text
|
||||
via `pypdf`. **No legacy-binary shim required** — that shim is
|
||||
specific to the `@ag-ui/langgraph@0.0.27` converter and is not needed
|
||||
when HttpAgent forwards modern parts to the Claude Agent SDK backend.
|
||||
- `voice` — dedicated `/api/copilotkit-voice` runtime with a
|
||||
`GuardedOpenAITranscriptionService` that reports a typed 401 when
|
||||
`OPENAI_API_KEY` is missing (vs a silent 503). Transcription service
|
||||
is written onto the V2 runtime instance directly to work around the
|
||||
V1 wrapper silently dropping the service. Backend reuses the shared
|
||||
Claude agent.
|
||||
- `agent-config` — dedicated `/agent-config` agent endpoint that reads
|
||||
`tone` / `expertise` / `responseLength` off
|
||||
`forwarded_props.config.configurable.properties` and builds the
|
||||
system prompt per turn. Frontend route subclasses `HttpAgent` to
|
||||
repack provider `properties` into the nested configurable shape so
|
||||
the wire format matches the langgraph-python reference exactly.
|
||||
- `auth` — dedicated `/api/copilotkit-auth` route uses
|
||||
`createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` directly
|
||||
so the `onRequest` hook actually fires (V1 adapter drops hooks).
|
||||
Bearer-token gate returns 401 on mismatch. Includes the #4271
|
||||
defaults fix: `useDemoAuth` defaults to signed-in, plus
|
||||
`ChatErrorBoundary` so the page never white-screens on auth
|
||||
transitions.
|
||||
|
||||
## Ported
|
||||
|
||||
### Initial pass (B2)
|
||||
|
||||
- `cli-start` — manifest-only entry with the framework-slug init command.
|
||||
- `prebuilt-sidebar` — default `<CopilotSidebar />` against the shared agent.
|
||||
- `prebuilt-popup` — default `<CopilotPopup />` against the shared agent.
|
||||
- `chat-slots` — custom `welcomeScreen`, `input.disclaimer`, `messageView.assistantMessage` slots.
|
||||
- `chat-customization-css` — scoped CSS variable and class overrides.
|
||||
- `headless-simple` — `useAgent` + `useComponent` minimal custom chat surface.
|
||||
|
||||
### Follow-up pass (B2')
|
||||
|
||||
- `frontend-tools` — `useFrontendTool` with sync handler (change_background).
|
||||
- `frontend-tools-async` — `useFrontendTool` with async handler (query_notes).
|
||||
- `hitl-in-app` — async `useFrontendTool` HITL; `fixed inset-0` overlay
|
||||
(not `createPortal`) to avoid pulling in `@types/react-dom`.
|
||||
- `readonly-state-agent-context` — `useAgentContext` read-only context.
|
||||
- `tool-rendering-default-catchall` — zero-renderer built-in default.
|
||||
- `tool-rendering-custom-catchall` — `useDefaultRenderTool` wildcard.
|
||||
- `open-gen-ui` — minimal open-ended generative UI (dedicated
|
||||
`/api/copilotkit-ogui` route with `openGenerativeUI` flag).
|
||||
- `open-gen-ui-advanced` — OGUI with frontend sandbox functions
|
||||
(evaluateExpression, notifyHost).
|
||||
- `headless-complete` — full custom chat surface built on `useAgent`,
|
||||
with per-tool renderers, frontend component, and wildcard catch-all.
|
||||
Points to the shared `/api/copilotkit` route (the reference points at
|
||||
`/api/copilotkit-mcp-apps`; this package doesn't port mcp-apps — the
|
||||
Excalidraw-MCP suggestion will surface as a catch-all tool card).
|
||||
|
||||
## Skipped
|
||||
|
||||
### Require langgraph-specific primitives (no Claude Agent SDK equivalent)
|
||||
|
||||
- `gen-ui-interrupt` — relies on langgraph's `interrupt()` primitive that
|
||||
pauses the graph and resumes on a client-side response. Claude Agent SDK
|
||||
does not expose an equivalent graph-interrupt API.
|
||||
- `interrupt-headless` — same reason; this is a headless surface for
|
||||
resolving a langgraph interrupt.
|
||||
|
||||
### Require streaming Claude extended-thinking plumbing
|
||||
|
||||
- `reasoning-custom`, `reasoning-default`,
|
||||
`tool-rendering-reasoning-chain` — require streaming Claude extended-
|
||||
thinking (reasoning) blocks as distinct AG-UI message parts. The current
|
||||
`src/agents/agent.py` AG-UI bridge does not translate Anthropic
|
||||
`thinking` content blocks; adding it correctly requires new event types
|
||||
and a thinking-aware message buffer. Follow-up.
|
||||
|
||||
### Deferred — larger-scope multi-file surfaces
|
||||
|
||||
These are feasible on this package but each pulls in substantial
|
||||
multi-file frontend infrastructure (catalogs, renderers, MCP client
|
||||
glue, theme pipelines) that did not fit this pass. Left for dedicated
|
||||
follow-up commits.
|
||||
|
||||
- `declarative-gen-ui` — A2UI BYOC catalog (Card/StatusBadge/Metric/
|
||||
InfoRow/PrimaryButton) wired via `a2ui.catalog` on the provider.
|
||||
- `a2ui-fixed-schema` — fixed-schema A2UI with two JSON schemas
|
||||
(flights + booked) and a per-demo catalog.
|
||||
- `mcp-apps` — MCP server-driven UI via activity renderers. Claude Agent
|
||||
SDK supports MCP clients, but the langgraph-python reference relies on
|
||||
CopilotKit runtime wiring through a dedicated
|
||||
`/api/copilotkit-mcp-apps/route.ts` plus agent-side MCP client glue.
|
||||
- `beautiful-chat` — 28+ supporting files (layout, canvas, generative UI
|
||||
charts, hooks, theme CSS, showcase config, A2UI catalog). Porting
|
||||
requires significant surface-area review that did not fit this pass.
|
||||
|
||||
## Follow-up buckets
|
||||
|
||||
- Reasoning / extended-thinking plumbing in `agent.py` (unlocks
|
||||
`reasoning-custom`, `reasoning-default`,
|
||||
`tool-rendering-reasoning-chain`).
|
||||
- A2UI catalog demos (unlocks `declarative-gen-ui`, `a2ui-fixed-schema`).
|
||||
- MCP client integration (unlocks `mcp-apps`).
|
||||
- Beautiful-chat infrastructure port.
|
||||
@@ -0,0 +1 @@
|
||||
../_shared
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"framework": "claude-sdk-python",
|
||||
"features": {
|
||||
"cli-start": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/quickstart",
|
||||
"shell_docs_path": "/quickstart"
|
||||
},
|
||||
"agentic-chat": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/prebuilt-components",
|
||||
"shell_docs_path": "/prebuilt-components"
|
||||
},
|
||||
"frontend-tools": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/frontend-tools",
|
||||
"shell_docs_path": "/frontend-tools"
|
||||
},
|
||||
"frontend-tools-async": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/frontend-tools",
|
||||
"shell_docs_path": "/frontend-tools"
|
||||
},
|
||||
"hitl-in-chat": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/human-in-the-loop",
|
||||
"shell_docs_path": "/human-in-the-loop"
|
||||
},
|
||||
"hitl-in-app": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/human-in-the-loop",
|
||||
"shell_docs_path": "/human-in-the-loop"
|
||||
},
|
||||
"tool-rendering": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/generative-ui/tool-rendering",
|
||||
"shell_docs_path": "/generative-ui/tool-rendering"
|
||||
},
|
||||
"gen-ui-tool-based": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/generative-ui/tool-based",
|
||||
"shell_docs_path": "/generative-ui/tool-based"
|
||||
},
|
||||
"gen-ui-agent": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/generative-ui/display",
|
||||
"shell_docs_path": "/generative-ui/display"
|
||||
},
|
||||
"shared-state-read": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/shared-state/agent-readonly",
|
||||
"shell_docs_path": "/shared-state/agent-readonly"
|
||||
},
|
||||
"shared-state-read-write": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/shared-state",
|
||||
"shell_docs_path": "/shared-state"
|
||||
},
|
||||
"shared-state-streaming": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/shared-state/streaming",
|
||||
"shell_docs_path": "/shared-state/streaming"
|
||||
},
|
||||
"readonly-state-agent-context": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/shared-state/agent-readonly",
|
||||
"shell_docs_path": "/shared-state/agent-readonly"
|
||||
},
|
||||
"agent-config": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/agent-config",
|
||||
"shell_docs_path": "/agent-config"
|
||||
},
|
||||
"subagents": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/claude-sdk-python/multi-agent/subagents",
|
||||
"shell_docs_path": "/multi-agent/subagents"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Steps>
|
||||
<Step>
|
||||
### Make runtime configuration explicit
|
||||
|
||||
The Claude Agent SDK demo reads configuration from shared state and folds it
|
||||
into the system prompt. This keeps the agent behavior visible to the UI and
|
||||
lets users tune model behavior without rebuilding the backend.
|
||||
|
||||
<DemoCode file="src/agents/agent_config_agent.py" region="agent-config-setup" />
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -0,0 +1,11 @@
|
||||
<Steps>
|
||||
<Step>
|
||||
### Add CopilotKit context to the Claude prompt
|
||||
|
||||
CopilotKit forwards readable app context in the AG-UI run input. Append that
|
||||
context to the Claude system prompt before starting the run so the agent can
|
||||
answer with the current UI state in mind.
|
||||
|
||||
<DemoCode file="src/agents/agent.py" region="agent-context-setup" />
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -0,0 +1,19 @@
|
||||
<Steps>
|
||||
<Step>
|
||||
### Install the Claude Agent SDK packages
|
||||
|
||||
```bash
|
||||
uv add claude-agent-sdk ag-ui-claude-sdk ag-ui-protocol anthropic fastapi uvicorn python-dotenv
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Bridge Claude Agent SDK to AG-UI
|
||||
|
||||
Use `ClaudeAgentAdapter` from `ag_ui_claude_sdk`. The adapter receives the
|
||||
AG-UI run input, emits AG-UI events back to CopilotKit, and can expose
|
||||
backend tools through an in-process Claude SDK MCP server.
|
||||
|
||||
<DemoCode file="src/agents/claude_agent_sdk_adapter.py" region="claude-agent-sdk-agent-setup" />
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -0,0 +1,16 @@
|
||||
<Steps>
|
||||
<Step>
|
||||
### Forward browser tools to Claude
|
||||
|
||||
Frontend tools registered with `useFrontendTool` arrive in the AG-UI run
|
||||
input. Convert each AG-UI tool definition into an Anthropic Messages API
|
||||
tool schema before calling the model. Runs that carry frontend tools use
|
||||
the direct Messages API path rather than the Claude Agent SDK.
|
||||
|
||||
<DemoCode
|
||||
file="src/agents/agent.py"
|
||||
region="frontend-tools-setup"
|
||||
highlight="23-29"
|
||||
/>
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -0,0 +1,12 @@
|
||||
<Steps>
|
||||
<Step>
|
||||
### Model approvals as async frontend tools
|
||||
|
||||
Claude Agent SDK human-in-the-loop demos use CopilotKit's promise-based
|
||||
frontend tool flow. The agent calls an approval tool, the UI resolves the
|
||||
tool result after the user decides, and the same Claude run continues with
|
||||
that result. Register the approval UI from the page component.
|
||||
|
||||
<DemoCode file="src/app/demos/hitl-in-chat/page.tsx" region="hitl-frontend-tool" title="page.tsx - useHumanInTheLoop" />
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -0,0 +1,12 @@
|
||||
<Steps>
|
||||
<Step>
|
||||
### Run Claude through an AG-UI endpoint
|
||||
|
||||
Programmatic control starts from the same AG-UI run boundary as the chat UI.
|
||||
Wrap Claude Agent SDK once, then trigger runs from a custom UI with
|
||||
`useAgent` or the AG-UI client. Inside your component, add the user
|
||||
message to the agent and dispatch the run.
|
||||
|
||||
<DemoCode file="src/app/demos/headless-simple/chat.tsx" region="use-agent-simple" title="chat.tsx - useAgent run control" />
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -0,0 +1,11 @@
|
||||
<Steps>
|
||||
<Step>
|
||||
### Put shared state in the system prompt and tools
|
||||
|
||||
The demo exposes frontend preferences to Claude and gives the agent a tool
|
||||
for writing notes back into CopilotKit state. Keep the state contract small,
|
||||
typed, and mirrored by the UI components that read the same values.
|
||||
|
||||
<DemoCode file="src/agents/shared_state_read_write_agent.py" region="shared-state-setup" />
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -0,0 +1,11 @@
|
||||
<Steps>
|
||||
<Step>
|
||||
### Stream partial state updates while Claude responds
|
||||
|
||||
For streaming state, parse the agent's structured deltas as they arrive and
|
||||
emit CopilotKit state updates before the final message is complete. This
|
||||
branch runs inside the streamed tool-argument handler.
|
||||
|
||||
<DemoCode file="src/agents/agent.py" region="state-streaming-delta-emission" />
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -0,0 +1,12 @@
|
||||
<Steps>
|
||||
<Step>
|
||||
### Represent subagents as delegation tools
|
||||
|
||||
The Claude Agent SDK demo keeps a supervisor prompt and exposes delegation
|
||||
tools for specialist agents. CopilotKit can then render delegation progress
|
||||
while the supervisor coordinates the run. Start by giving the supervisor
|
||||
one tool schema per specialist.
|
||||
|
||||
<DemoCode file="src/agents/subagents_agent.py" region="supervisor-delegation-tools" title="subagents_agent.py - supervisor tool schemas" />
|
||||
</Step>
|
||||
</Steps>
|
||||
+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: claude-sdk-python"
|
||||
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,526 @@
|
||||
name: Claude Agent SDK (Python)
|
||||
slug: claude-sdk-python
|
||||
category: agent-framework
|
||||
language: python
|
||||
logo: /logos/claude-sdk-python.svg
|
||||
description: >-
|
||||
CopilotKit integration with Claude Agent SDK (Python). Supports the full range of
|
||||
CopilotKit features including generative UI, shared state, human-in-the-loop,
|
||||
sub-agents, and streaming.
|
||||
partner_docs: null
|
||||
repo: https://github.com/CopilotKit/CopilotKit/tree/main/showcase/integrations/claude-sdk-python
|
||||
copilotkit_version: 2.0.0
|
||||
deployed: true
|
||||
docs_mode: generated
|
||||
sort_order: 70
|
||||
generative_ui:
|
||||
- constrained-explicit
|
||||
- a2ui-fixed-schema
|
||||
- a2ui-dynamic-schema
|
||||
interaction_modalities:
|
||||
- sidebar
|
||||
- embedded
|
||||
- chat
|
||||
not_supported_features:
|
||||
# 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
|
||||
features:
|
||||
- beautiful-chat
|
||||
- cli-start
|
||||
- agentic-chat
|
||||
- prebuilt-sidebar
|
||||
- prebuilt-popup
|
||||
- chat-slots
|
||||
- chat-customization-css
|
||||
- headless-simple
|
||||
- headless-complete
|
||||
- reasoning-custom
|
||||
- reasoning-default
|
||||
- frontend-tools
|
||||
- frontend-tools-async
|
||||
- gen-ui-tool-based
|
||||
- hitl-in-chat
|
||||
- hitl-in-app
|
||||
- declarative-gen-ui
|
||||
- a2ui-fixed-schema
|
||||
- mcp-apps
|
||||
- gen-ui-agent
|
||||
- tool-rendering-default-catchall
|
||||
- tool-rendering-custom-catchall
|
||||
- tool-rendering
|
||||
- tool-rendering-reasoning-chain
|
||||
- shared-state-read
|
||||
- shared-state-read-write
|
||||
- shared-state-streaming
|
||||
- readonly-state-agent-context
|
||||
- subagents
|
||||
- multimodal
|
||||
- auth
|
||||
- declarative-hashbrown
|
||||
- declarative-json-render
|
||||
- open-gen-ui
|
||||
- open-gen-ui-advanced
|
||||
- voice
|
||||
- agent-config
|
||||
interrupt_pattern: promise-based
|
||||
agent_config_pattern: shared-state
|
||||
|
||||
a2ui_pattern: schema-loading
|
||||
auth_pattern: runtime-onrequest
|
||||
demos:
|
||||
- id: cli-start
|
||||
name: CLI Start Command
|
||||
description: Copy-paste command to clone the canonical starter
|
||||
tags:
|
||||
- chat-ui
|
||||
command: "npx copilotkit@latest init --framework claude-sdk-python"
|
||||
- id: agentic-chat
|
||||
name: Agentic Chat
|
||||
description: Natural conversation with frontend tool execution
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/agentic-chat
|
||||
animated_preview_url:
|
||||
- id: tool-rendering
|
||||
name: Tool Rendering
|
||||
description: Backend agent tools rendered as UI components
|
||||
tags:
|
||||
- agent-capabilities
|
||||
route: /demos/tool-rendering
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/tool-rendering/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: gen-ui-agent
|
||||
name: Agentic Generative UI
|
||||
description: Long-running agent tasks with generated UI
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/gen-ui-agent
|
||||
animated_preview_url:
|
||||
- id: shared-state-streaming
|
||||
name: State Streaming
|
||||
description: Per-token state delta streaming from agent to UI
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/shared-state-streaming
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/shared-state-streaming/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: shared-state-read-write
|
||||
name: Shared State (Read + Write)
|
||||
description: Bidirectional agent state — UI writes preferences, agent writes notes back
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/shared-state-read-write
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/shared_state_read_write_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)
|
||||
description: Agent reads frontend-provided state as context
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/shared-state-read
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/shared-state-read/page.tsx
|
||||
- src/app/demos/shared-state-read/recipe-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: subagents
|
||||
name: Sub-Agents
|
||||
description: Multiple agents with visible task delegation
|
||||
tags:
|
||||
- multi-agent
|
||||
route: /demos/subagents
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/subagents_agent.py
|
||||
- src/app/demos/subagents/page.tsx
|
||||
- src/app/demos/subagents/delegation-log.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: prebuilt-sidebar
|
||||
name: "Pre-Built: Sidebar"
|
||||
description: Docked sidebar chat via <CopilotSidebar />
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/prebuilt-sidebar
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/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/demos/chat-slots/slot-wrappers.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: chat-customization-css
|
||||
name: Chat Customization (CSS)
|
||||
description: Default CopilotChat re-themed via CopilotKitCSSProperties
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/chat-customization-css
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/chat-customization-css/page.tsx
|
||||
- src/app/demos/chat-customization-css/theme.css
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: headless-simple
|
||||
name: Headless Chat (Simple)
|
||||
description: Minimal custom chat surface built on useAgent
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/headless-simple
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/headless-simple/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: headless-complete
|
||||
name: Headless Chat (Complete)
|
||||
description: Full chat implementation built from scratch on useAgent
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/headless-complete
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/headless-complete/page.tsx
|
||||
- src/app/demos/headless-complete/chat/message-list.tsx
|
||||
- src/app/demos/headless-complete/hooks/use-tool-renderers.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: frontend-tools
|
||||
name: Frontend Tools (In-App Actions)
|
||||
description: Agent invokes client-side handlers registered with useFrontendTool
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/frontend-tools
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/frontend_tools.py
|
||||
- src/app/demos/frontend-tools/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: frontend-tools-async
|
||||
name: Frontend Tools (Async)
|
||||
description: useFrontendTool with an async handler — agent awaits a client-side async operation (simulated notes DB query) and uses the returned result
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/frontend-tools-async
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/frontend_tools_async.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-app
|
||||
name: In-App Human in the Loop (Frontend Tools + async HITL)
|
||||
description: Agent requests approval via useFrontendTool with an async handler; the approval UI pops up as an app-level modal OUTSIDE the chat, and a completion callback resolves the pending tool Promise with the user's decision
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-app
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/hitl_in_app.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: 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/readonly_state_agent_context.py
|
||||
- src/app/demos/readonly-state-agent-context/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering-default-catchall
|
||||
name: Tool Rendering (Default Catch-all)
|
||||
description: Out-of-the-box 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/app/demos/tool-rendering-default-catchall/page.tsx
|
||||
- src/agents/agent.py
|
||||
- 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/app/demos/tool-rendering-custom-catchall/page.tsx
|
||||
- src/app/demos/tool-rendering-custom-catchall/custom-catchall-renderer.tsx
|
||||
- src/agents/agent.py
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: open-gen-ui
|
||||
name: Fully Open-Ended Generative UI
|
||||
description: Agent generates UI from an arbitrary component library
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/open-gen-ui
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/open_gen_ui_agent.py
|
||||
- src/app/demos/open-gen-ui/page.tsx
|
||||
- src/app/api/copilotkit-ogui/route.ts
|
||||
- id: open-gen-ui-advanced
|
||||
name: "Open-Ended Gen UI (Advanced: with frontend function calling)"
|
||||
description: Agent-authored UI that can invoke frontend sandbox functions from inside the iframe
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/open-gen-ui-advanced
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/open_gen_ui_advanced_agent.py
|
||||
- src/app/demos/open-gen-ui-advanced/page.tsx
|
||||
- src/app/demos/open-gen-ui-advanced/sandbox-functions.ts
|
||||
- src/app/demos/open-gen-ui-advanced/suggestions.ts
|
||||
- src/app/api/copilotkit-ogui/route.ts
|
||||
- id: declarative-json-render
|
||||
name: "Declarative UI: json-render"
|
||||
description: Declarative generative UI via @json-render/react — Claude emits a flat element-tree spec the renderer consumes
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/declarative-json-render
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/byoc_json_render_agent.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/api/copilotkit-declarative-json-render/route.ts
|
||||
- id: declarative-hashbrown
|
||||
name: "Declarative UI: Hashbrown"
|
||||
description: Declarative streaming UI via @hashbrownai/react useJsonParser — Claude emits a catalog-constrained UI envelope
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/declarative-hashbrown
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/byoc_hashbrown_agent.py
|
||||
- src/app/demos/declarative-hashbrown/page.tsx
|
||||
- src/app/demos/declarative-hashbrown/hashbrown-renderer.tsx
|
||||
- src/app/api/copilotkit-declarative-hashbrown/route.ts
|
||||
- id: multimodal
|
||||
name: Multimodal Attachments
|
||||
description: Image + PDF attachments routed through Claude's native vision + pypdf text flattening
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/multimodal
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/multimodal_agent.py
|
||||
- src/app/demos/multimodal/page.tsx
|
||||
- src/app/demos/multimodal/sample-attachment-buttons.tsx
|
||||
- src/app/api/copilotkit-multimodal/route.ts
|
||||
- id: voice
|
||||
name: Voice Input
|
||||
description: Audio transcription wired on a per-demo runtime with a guarded OpenAI Whisper service that maps missing keys to 401
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/voice
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/voice/page.tsx
|
||||
- src/app/demos/voice/sample-audio-button.tsx
|
||||
- src/app/api/copilotkit-voice/[[...slug]]/route.ts
|
||||
- id: agent-config
|
||||
name: Agent Config Object
|
||||
description: Forwarded props (tone / expertise / responseLength) flow from the provider into the agent's system prompt via `forwardedProps.config.configurable.properties`
|
||||
tags:
|
||||
- agent-capabilities
|
||||
route: /demos/agent-config
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent_config_agent.py
|
||||
- src/app/demos/agent-config/page.tsx
|
||||
- src/app/demos/agent-config/config-card.tsx
|
||||
- src/app/demos/agent-config/use-agent-config.ts
|
||||
- src/app/api/copilotkit-agent-config/route.ts
|
||||
- id: reasoning-custom
|
||||
name: Agentic Chat (Reasoning)
|
||||
description: Visible reasoning / thinking chain rendered via a custom messageView slot
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/reasoning-custom
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/reasoning_agent.py
|
||||
- src/app/demos/reasoning-custom/page.tsx
|
||||
- src/app/demos/reasoning-custom/reasoning-block.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: reasoning-default
|
||||
name: Reasoning (Default Render)
|
||||
description: Reasoning rendered via CopilotKit's built-in collapsible card with zero custom config
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/reasoning-default
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/reasoning_agent.py
|
||||
- src/app/demos/reasoning-default/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering-reasoning-chain
|
||||
name: Tool Rendering (Reasoning Chain)
|
||||
description: Sequential tool calls combined with visible reasoning steps
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/tool-rendering-reasoning-chain
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/tool_rendering_reasoning_chain_agent.py
|
||||
- src/app/demos/tool-rendering-reasoning-chain/page.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/weather-card.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/flight-list-card.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/custom-catchall-renderer.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/reasoning-block.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: auth
|
||||
name: Authentication
|
||||
description: Framework-native request auth via the V2 runtime's `onRequest` hook; missing bearer token returns 401
|
||||
tags:
|
||||
- agent-capabilities
|
||||
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: mcp-apps
|
||||
name: MCP Apps
|
||||
description: MCP server-driven UI via activity renderers — the agent calls a tool on a remote MCP server (Excalidraw) and the runtime middleware renders the returned UI resource as a sandboxed iframe inline in the chat
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/mcp-apps
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/mcp_apps_agent.py
|
||||
- src/app/demos/mcp-apps/page.tsx
|
||||
- src/app/api/copilotkit-mcp-apps/route.ts
|
||||
- id: beautiful-chat
|
||||
name: Beautiful Chat
|
||||
description: Polished brand-themed chat surface over the Claude Agent SDK backend with seeded suggestion pills and a glanceable decorative side panel — pure frontend dressing, no extra runtime config
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/beautiful-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/beautiful-chat/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: gen-ui-tool-based
|
||||
name: Tool-Based Generative UI
|
||||
description: Agent uses tools to trigger UI generation (haiku generator via useFrontendTool + useRenderTool)
|
||||
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: hitl-in-chat
|
||||
name: In-Chat HITL (useHumanInTheLoop — ergonomic API)
|
||||
description: Interactive approval/decision surface rendered inline in the chat via the high-level `useHumanInTheLoop` hook
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/hitl_in_chat_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: gen-ui-interrupt
|
||||
name: In-Chat HITL (useInterrupt — low-level primitive)
|
||||
description: Interactive time-picker component rendered inline in the chat via useFrontendTool with an async handler — the Promise resolves only when the user picks a slot or cancels
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/gen-ui-interrupt
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/interrupt_agent.py
|
||||
- src/app/demos/gen-ui-interrupt/page.tsx
|
||||
- src/app/demos/gen-ui-interrupt/_components/time-picker-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: interrupt-headless
|
||||
name: Headless Interrupt (testing)
|
||||
description: Resolve interrupts from a plain button grid — no chat, no useInterrupt render prop; the time-picker popup appears in the app surface outside the chat
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/interrupt-headless
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/interrupt_agent.py
|
||||
- src/app/demos/interrupt-headless/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: declarative-gen-ui
|
||||
name: Declarative Generative UI (A2UI)
|
||||
description: Canonical A2UI BYOC — custom catalog (Card/StatusBadge/Metric/InfoRow/PrimaryButton/PieChart/BarChart) wired via a2ui.catalog on the provider; backend agent owns the `generate_a2ui` tool with injectA2UITool false
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/declarative-gen-ui
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/a2ui_dynamic.py
|
||||
- src/app/demos/declarative-gen-ui/page.tsx
|
||||
- src/app/demos/declarative-gen-ui/a2ui/catalog.ts
|
||||
- src/app/demos/declarative-gen-ui/a2ui/definitions.ts
|
||||
- src/app/demos/declarative-gen-ui/a2ui/renderers.tsx
|
||||
- src/app/api/copilotkit-declarative-gen-ui/route.ts
|
||||
- id: a2ui-fixed-schema
|
||||
name: Declarative Generative UI (A2UI — Fixed Schema)
|
||||
description: A2UI rendering against a known client-side schema; the backend ships flight_schema.json and only streams data via display_flight
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/a2ui-fixed-schema
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/a2ui_fixed.py
|
||||
- src/agents/a2ui_schemas/flight_schema.json
|
||||
- src/app/demos/a2ui-fixed-schema/page.tsx
|
||||
- src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts
|
||||
- src/app/api/copilotkit-a2ui-fixed-schema/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;
|
||||
+12676
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@copilotkit/showcase-claude-sdk-python",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"next dev --turbopack\" \"PYTHONPATH=src python -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 --reload\"",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-ui/client": "0.0.57",
|
||||
"@ag-ui/core": "0.0.57",
|
||||
"@copilotkit/a2ui-renderer": "1.61.2",
|
||||
"@copilotkit/react-core": "1.61.2",
|
||||
"@copilotkit/runtime": "1.61.2",
|
||||
"@copilotkit/shared": "1.61.2",
|
||||
"@copilotkit/voice": "1.61.2",
|
||||
"@hashbrownai/core": "0.5.0-beta.4",
|
||||
"@hashbrownai/react": "0.5.0-beta.4",
|
||||
"@json-render/core": "0.18.0",
|
||||
"@json-render/react": "0.18.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"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.9.0",
|
||||
"zod": "^3.25.76",
|
||||
"zod4": "npm:zod@^4.3.6"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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": "claude-sdk-python",
|
||||
},
|
||||
},
|
||||
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,30 @@
|
||||
<svg viewBox="0 0 224.43 237.166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M76.4556 75.7547C97.0552 48.81 114.152 22.1656 120.731 0.648813C120.908 0.0633572 121.594 -0.185355 122.103 0.152636C144.979 15.3013 186.646 25.2726 223.5 25.5066C224.134 25.5107 224.571 26.1359 224.342 26.7272C212.088 57.8146 197.122 113.518 196.54 177.128C196.54 178.073 195.21 178.412 194.742 177.59C173.768 140.886 106.586 89.3107 76.7986 77.138C76.2477 76.9114 76.0919 76.2307 76.4556 75.7547Z" fill="url(#paint0_linear)"/>
|
||||
<path d="M145.956 59.273C113.757 69.4674 84.3336 75.1349 77.3077 76.4226C76.8608 76.5047 76.7673 77.1231 77.1934 77.2977C107.209 89.777 174.059 141.202 194.835 177.757C194.877 177.837 194.981 177.867 195.064 177.83C195.147 177.791 195.189 177.687 195.158 177.597L145.956 59.273Z" fill="url(#paint1_linear)"/>
|
||||
<path d="M122.197 0.0860308C149.76 15.1211 181.615 21.8738 223.875 25.4319C224.135 25.4546 224.228 25.8103 223.989 25.9339C218.585 28.7116 187.623 44.4667 164.633 52.905C158.47 55.1657 152.275 57.263 146.174 59.1979C146.039 59.2402 145.894 59.1736 145.842 59.0446L121.563 0.655481C121.397 0.262302 121.823 -0.117886 122.197 0.0860308Z" fill="url(#paint2_linear)"/>
|
||||
<path d="M121.361 0.145972C121.761 -0.0218955 122.214 0.121754 122.45 0.467128L122.536 0.6264L196.496 177.058L196.548 177.233C196.628 177.642 196.415 178.065 196.015 178.233C195.615 178.401 195.162 178.257 194.926 177.912L194.838 177.753L120.881 1.32093L120.826 1.14599C120.745 0.736568 120.961 0.313703 121.361 0.145972Z" fill="#513C9F"/>
|
||||
<path d="M223.089 25.5869C223.52 25.3424 224.069 25.4925 224.313 25.9238C224.558 26.3552 224.406 26.9035 223.974 27.1483V27.1509H223.969C223.965 27.153 223.96 27.1575 223.953 27.1614C223.939 27.1695 223.916 27.1821 223.888 27.1979C223.832 27.2294 223.749 27.2755 223.64 27.3363C223.419 27.4593 223.091 27.6413 222.661 27.8768C221.8 28.3482 220.529 29.0371 218.885 29.9029C215.597 31.6348 210.813 34.0821 204.83 36.9423C192.865 42.6619 176.091 50.0388 156.86 56.6737C137.624 63.3089 117.782 68.4614 102.755 71.9534C95.2398 73.6998 88.9245 75.0317 84.4882 75.9274C82.2701 76.3752 80.5211 76.7135 79.3262 76.9405C78.7293 77.0538 78.2706 77.1391 77.9606 77.1963C77.8058 77.2249 77.6873 77.2472 77.6081 77.2616C77.5693 77.2687 77.5395 77.2737 77.5194 77.2773C77.5095 77.2791 77.501 77.2816 77.4959 77.2825H77.488L77.3052 77.2982C76.8881 77.2877 76.5205 76.9859 76.4436 76.5593C76.356 76.0711 76.6815 75.6027 77.1695 75.5149H77.1773C77.182 75.514 77.1888 75.5113 77.1982 75.5096C77.2176 75.5061 77.2481 75.501 77.287 75.494C77.3647 75.4798 77.4812 75.4595 77.6342 75.4313C77.9413 75.3746 78.3978 75.2882 78.992 75.1754C80.1806 74.9497 81.9227 74.6138 84.1331 74.1676C88.5551 73.2748 94.8521 71.9459 102.348 70.204C117.342 66.7197 137.119 61.5841 156.276 54.9766C175.426 48.3694 192.134 41.0193 204.055 35.3208C210.015 32.4718 214.777 30.0352 218.047 28.3128C219.682 27.4518 220.944 26.7694 221.796 26.3024C222.222 26.0693 222.546 25.8906 222.763 25.7697C222.871 25.7092 222.954 25.6619 223.008 25.6313C223.035 25.6163 223.055 25.6049 223.068 25.5974C223.075 25.5937 223.08 25.5914 223.084 25.5895L223.086 25.5869H223.089Z" fill="#513C9F"/>
|
||||
<path d="M2.77027 236.611C2.20838 237.273 1.21525 237.353 0.553517 236.792C-0.107154 236.23 -0.18789 235.239 0.373357 234.577L2.77027 236.611ZM132.306 21.7584C133.136 22.0085 133.607 22.8858 133.358 23.7167L106.569 112.86H169.57L169.886 112.891C170.602 113.037 171.142 113.672 171.142 114.431C171.142 115.191 170.602 115.825 169.886 115.972L169.57 116.003H105.182L2.77027 236.611L1.57181 235.593L0.373357 234.577L103.044 113.664L130.347 22.8133C130.597 21.9819 131.474 21.5086 132.306 21.7584Z" fill="#ABABAB"/>
|
||||
<path d="M72.9636 210.65L60.8346 212.356C67.1226 228.985 80.0206 236.249 95.4131 236.249C133.141 236.249 121.625 193.585 143.482 193.585C159.342 193.585 152.899 228.165 187.02 228.165C207.848 228.165 209.927 207.183 206.372 198.156C206.351 198.101 206.331 198.051 206.299 198.002L200.718 189.455C200.355 188.887 199.471 189.101 199.409 189.776L198.369 200.135C198.297 200.856 198.317 201.574 198.401 202.293C199.253 209.45 199.804 226.818 187.02 226.818C173.54 226.818 170.297 192.686 143.482 192.686C112.032 192.686 116.075 234.902 96.7643 234.902C84.0221 234.902 74.3043 220.531 72.9636 210.65Z" fill="url(#paint3_linear)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="171.825" y1="13.8344" x2="135.895" y2="112.635" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6430AB"/>
|
||||
<stop offset="1" stop-color="#AA89D8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear" x1="143.981" y1="69.5214" x2="97.7306" y2="158.891" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#005DBB"/>
|
||||
<stop offset="1" stop-color="#3D92E8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear" x1="164.633" y1="13.8337" x2="150.706" y2="57.3959" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1B70C4"/>
|
||||
<stop offset="1" stop-color="#54A4F2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear" x1="60.8346" y1="213.57" x2="207.775" y2="213.57" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4497EA"/>
|
||||
<stop offset="0.254755" stop-color="#1463B2"/>
|
||||
<stop offset="0.498725" stop-color="#0A437D"/>
|
||||
<stop offset="0.666667" stop-color="#2476C8"/>
|
||||
<stop offset="0.972542" stop-color="#0C549A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,46 @@
|
||||
<svg viewBox="0 0 1044.21 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(7.74 31.74)">
|
||||
<g id="CopilotKit">
|
||||
<path d="M111.689 53.5173H78.8802C78.647 50.8028 78.0267 48.3403 77.0185 46.1299C76.0487 43.9195 74.6918 42.0192 72.9466 40.4291C71.2403 38.8006 69.1654 37.5591 66.7223 36.7063C64.2791 35.8143 61.5062 35.3683 58.4038 35.3683C52.9745 35.3683 48.379 36.6867 44.6173 39.3235C40.8943 41.9611 38.0633 45.742 36.1243 50.6669C34.224 55.5919 33.2739 61.5063 33.2739 68.4092C33.2739 75.7001 34.2434 81.8084 36.1825 86.7334C38.1602 91.6199 41.0106 95.3042 44.7336 97.7855C48.4566 100.229 52.9358 101.45 58.1711 101.45C61.1573 101.45 63.8331 101.082 66.1988 100.345C68.5644 99.5695 70.6198 98.4647 72.3649 97.0293C74.11 95.5947 75.5258 93.8687 76.6117 91.8523C77.7362 89.7965 78.4923 87.4895 78.8802 84.9297L111.689 85.1629C111.301 90.2041 109.885 95.3426 107.442 100.578C104.999 105.774 101.528 110.583 97.0293 115.004C92.57 119.386 87.0435 122.916 80.4507 125.591C73.8579 128.267 66.1988 129.605 57.4731 129.605C46.5369 129.605 36.7254 127.259 28.0384 122.567C19.3904 117.874 12.5456 110.971 7.50404 101.858C2.50134 92.7443 0 81.5948 0 68.4092C0 55.1467 2.55952 43.9776 7.6786 34.9026C12.7977 25.7893 19.7006 18.9057 28.3875 14.252C37.0744 9.55957 46.7696 7.2133 57.4731 7.2133C64.9965 7.2133 71.9189 8.24101 78.2403 10.2964C84.5614 12.3518 90.1067 15.3572 94.877 19.3129C99.6472 23.2297 103.486 28.0578 106.395 33.7978C109.303 39.5371 111.068 46.1102 111.689 53.5173Z" fill="#010507"/>
|
||||
<path d="M169.874 129.606C160.179 129.606 151.88 127.686 144.977 123.847C138.074 119.968 132.781 114.578 129.096 107.675C125.412 100.733 123.57 92.6864 123.57 83.5337C123.57 74.3818 125.412 66.3544 129.096 59.4515C132.781 52.5092 138.074 47.1186 144.977 43.2796C151.88 39.4014 160.179 37.4626 169.874 37.4626C179.569 37.4626 187.868 39.4014 194.771 43.2796C201.674 47.1186 206.968 52.5092 210.652 59.4515C214.336 66.3544 216.178 74.3818 216.178 83.5337C216.178 92.6864 214.336 100.733 210.652 107.675C206.968 114.578 201.674 119.968 194.771 123.847C187.868 127.686 179.569 129.606 169.874 129.606ZM170.107 105.872C172.822 105.872 175.168 104.96 177.145 103.138C179.123 101.315 180.655 98.7169 181.741 95.3428C182.827 91.9686 183.37 87.9553 183.37 83.3013C183.37 78.6087 182.827 74.5946 181.741 71.2597C180.655 67.8856 179.123 65.2873 177.145 63.4648C175.168 61.6422 172.822 60.7306 170.107 60.7306C167.237 60.7306 164.775 61.6422 162.719 63.4648C160.664 65.2873 159.093 67.8856 158.007 71.2597C156.922 74.5946 156.378 78.6087 156.378 83.3013C156.378 87.9553 156.922 91.9686 158.007 95.3428C159.093 98.7169 160.664 101.315 162.719 103.138C164.775 104.96 167.237 105.872 170.107 105.872Z" fill="#010507"/>
|
||||
<path d="M229.208 161.484V38.6256H261.086V54.2155H261.784C262.948 51.1908 264.655 48.4182 266.903 45.8976C269.153 43.3377 271.945 41.3016 275.28 39.7893C278.615 38.2376 282.493 37.4626 286.914 37.4626C292.809 37.4626 298.413 39.0331 303.726 42.174C309.077 45.3157 313.421 50.2603 316.756 57.0078C320.13 63.7561 321.817 72.52 321.817 83.3013C321.817 93.6168 320.208 102.168 316.989 108.955C313.809 115.741 309.543 120.802 304.191 124.137C298.878 127.473 293.041 129.14 286.682 129.14C282.493 129.14 278.751 128.461 275.455 127.104C272.197 125.708 269.405 123.827 267.078 121.462C264.79 119.057 263.025 116.342 261.784 113.317H261.319V161.484H229.208ZM260.621 83.3013C260.621 87.6444 261.183 91.4064 262.308 94.5866C263.471 97.7275 265.101 100.171 267.195 101.916C269.327 103.622 271.868 104.476 274.815 104.476C277.762 104.476 280.263 103.642 282.319 101.974C284.413 100.268 286.003 97.8437 287.089 94.7028C288.214 91.5226 288.776 87.7221 288.776 83.3013C288.776 78.8804 288.214 75.0995 287.089 71.9578C286.003 68.7776 284.413 66.3544 282.319 64.6866C280.263 62.9803 277.762 62.1267 274.815 62.1267C271.868 62.1267 269.327 62.9803 267.195 64.6866C265.101 66.3544 263.471 68.7776 262.308 71.9578C261.183 75.0995 260.621 78.8804 260.621 83.3013Z" fill="#010507"/>
|
||||
<path d="M335.269 127.977V38.6253H367.38V127.977H335.269ZM351.324 29.318C346.981 29.318 343.258 27.8834 340.156 25.0136C337.054 22.1438 335.502 18.6923 335.502 14.6592C335.502 10.6259 337.054 7.17442 340.156 4.30463C343.258 1.43485 346.981 0 351.324 0C355.707 0 359.429 1.43485 362.493 4.30463C365.596 7.17442 367.147 10.6259 367.147 14.6592C367.147 18.6923 365.596 22.1438 362.493 25.0136C359.429 27.8834 355.707 29.318 351.324 29.318Z" fill="#010507"/>
|
||||
<path d="M415.851 8.84214V127.977H383.74V8.84214H415.851Z" fill="#010507"/>
|
||||
<path d="M475.258 129.606C465.563 129.606 457.264 127.686 450.361 123.847C443.458 119.968 438.164 114.578 434.48 107.675C430.796 100.733 428.954 92.6864 428.954 83.5337C428.954 74.3818 430.796 66.3544 434.48 59.4515C438.164 52.5092 443.458 47.1186 450.361 43.2796C457.264 39.4014 465.563 37.4626 475.258 37.4626C484.954 37.4626 493.253 39.4014 500.156 43.2796C507.059 47.1186 512.352 52.5092 516.036 59.4515C519.72 66.3544 521.563 74.3818 521.563 83.5337C521.563 92.6864 519.72 100.733 516.036 107.675C512.352 114.578 507.059 119.968 500.156 123.847C493.253 127.686 484.954 129.606 475.258 129.606ZM475.491 105.872C478.205 105.872 480.552 104.96 482.53 103.138C484.508 101.315 486.039 98.7169 487.125 95.3428C488.211 91.9686 488.754 87.9553 488.754 83.3013C488.754 78.6087 488.211 74.5946 487.125 71.2597C486.039 67.8856 484.508 65.2873 482.53 63.4648C480.552 61.6422 478.205 60.7306 475.491 60.7306C472.621 60.7306 470.158 61.6422 468.103 63.4648C466.048 65.2873 464.477 67.8856 463.391 71.2597C462.305 74.5946 461.763 78.6087 461.763 83.3013C461.763 87.9553 462.305 91.9686 463.391 95.3428C464.477 98.7169 466.048 101.315 468.103 103.138C470.158 104.96 472.621 105.872 475.491 105.872Z" fill="#010507"/>
|
||||
<path d="M587.877 38.6253V61.8941H529.008V38.6253H587.877ZM540.41 17.2186H572.52V99.2396C572.52 100.481 572.734 101.528 573.16 102.381C573.587 103.196 574.246 103.816 575.138 104.242C576.03 104.63 577.174 104.824 578.57 104.824C579.539 104.824 580.664 104.708 581.944 104.476C583.262 104.242 584.232 104.048 584.852 103.894L589.506 126.464C588.072 126.891 586.016 127.415 583.34 128.035C580.703 128.655 577.561 129.063 573.916 129.256C566.548 129.644 560.363 128.888 555.36 126.987C550.357 125.049 546.595 122.004 544.075 117.854C541.554 113.705 540.332 108.508 540.41 102.265V17.2186Z" fill="#010507"/>
|
||||
<path d="M602.435 127.977V8.84214H634.778V57.0077H636.406L672.24 8.84214H709.935L669.681 61.8942L710.866 127.977H672.24L645.481 83.3012L634.778 97.2626V127.977H602.435Z" fill="#010507"/>
|
||||
<path d="M725.597 127.977V38.6253H757.708V127.977H725.597ZM741.653 29.318C737.309 29.318 733.586 27.8834 730.484 25.0136C727.381 22.1438 725.831 18.6923 725.831 14.6592C725.831 10.6259 727.381 7.17442 730.484 4.30463C733.586 1.43485 737.309 0 741.653 0C746.035 0 749.758 1.43485 752.821 4.30463C755.924 7.17442 757.475 10.6259 757.475 14.6592C757.475 18.6923 755.924 22.1438 752.821 25.0136C749.758 27.8834 746.035 29.318 741.653 29.318Z" fill="#010507"/>
|
||||
<path d="M827.354 38.6253V61.8941H768.484V38.6253H827.354ZM779.886 17.2186H811.994V99.2396C811.994 100.481 812.206 101.528 812.632 102.381C813.066 103.196 813.72 103.816 814.612 104.242C815.504 104.63 816.65 104.824 818.05 104.824C819.015 104.824 820.136 104.708 821.421 104.476C822.739 104.242 823.704 104.048 824.326 103.894L828.983 126.464C827.551 126.891 825.489 127.415 822.812 128.035C820.177 128.655 817.035 129.063 813.393 129.256C806.024 129.644 799.838 128.888 794.836 126.987C789.833 125.049 786.071 122.004 783.55 117.854C781.03 113.705 779.808 108.508 779.886 102.265V17.2186Z" fill="#010507"/>
|
||||
</g>
|
||||
</g>
|
||||
<svg x="859.58" y="7.1" width="176.717" height="186.745" viewBox="0 0 176.717 186.745" fill="none">
|
||||
<path d="M60.2013 59.6493C76.4214 38.4331 89.8836 17.4532 95.0639 0.510877C95.203 0.0498883 95.7431 -0.145948 96.1441 0.120187C114.156 12.0483 146.965 19.8996 175.984 20.0839C176.484 20.0871 176.827 20.5795 176.647 21.045C166.999 45.5233 155.214 89.384 154.756 139.471C154.756 140.215 153.708 140.482 153.34 139.835C136.825 110.934 83.9258 70.3234 60.4713 60.7386C60.0376 60.5602 59.9148 60.0242 60.2013 59.6493Z" fill="url(#paint0_linear)"/>
|
||||
<path d="M114.926 46.6717C89.5724 54.6988 66.4044 59.1614 60.8722 60.1754C60.5203 60.24 60.4466 60.7269 60.7822 60.8644C84.4167 70.6906 137.054 111.183 153.414 139.967C153.446 140.03 153.528 140.052 153.594 140.024C153.659 139.993 153.692 139.911 153.667 139.841L114.926 46.6717Z" fill="url(#paint1_linear)"/>
|
||||
<path d="M96.218 0.0677872C117.921 11.9064 143.004 17.2235 176.279 20.0252C176.484 20.043 176.558 20.3231 176.369 20.4204C172.114 22.6076 147.734 35.0132 129.632 41.6575C124.779 43.4376 119.902 45.089 115.098 46.6126C114.991 46.6459 114.877 46.5934 114.836 46.4919L95.7188 0.516173C95.5878 0.206583 95.9234 -0.0927773 96.218 0.0677872Z" fill="url(#paint2_linear)"/>
|
||||
<path d="M95.5597 0.114978C95.875 -0.0172013 96.2317 0.0959082 96.417 0.367856L96.4849 0.493267L154.721 139.416L154.762 139.554C154.825 139.876 154.657 140.209 154.343 140.341C154.027 140.473 153.671 140.36 153.485 140.088L153.415 139.963L95.1814 1.04014L95.1383 0.902396C95.075 0.580014 95.2449 0.247049 95.5597 0.114978Z" fill="#513C9F"/>
|
||||
<path d="M175.661 20.1472C176 19.9546 176.432 20.0729 176.625 20.4124C176.818 20.7522 176.697 21.1839 176.357 21.3767V21.3787H176.353C176.351 21.3803 176.346 21.3839 176.341 21.3869C176.33 21.3933 176.312 21.4033 176.29 21.4157C176.246 21.4405 176.18 21.4768 176.094 21.5247C175.921 21.6215 175.662 21.7648 175.323 21.9503C174.646 22.3215 173.645 22.8639 172.351 23.5456C169.761 24.9093 165.995 26.8363 161.283 29.0884C151.862 33.592 138.655 39.4006 123.512 44.625C108.365 49.8495 92.7416 53.9066 80.9093 56.6563C74.9919 58.0314 70.0192 59.0801 66.5261 59.7854C64.7796 60.138 63.4024 60.4044 62.4615 60.5831C61.9916 60.6723 61.6304 60.7395 61.3863 60.7845C61.2644 60.807 61.1711 60.8246 61.1087 60.8359C61.0782 60.8415 61.0547 60.8454 61.0388 60.8483C61.0311 60.8497 61.0244 60.8517 61.0203 60.8524H61.0142L60.8702 60.8647C60.5418 60.8565 60.2523 60.6188 60.1918 60.2829C60.1228 59.8986 60.3791 59.5297 60.7633 59.4605H60.7695C60.7732 59.4599 60.7786 59.4578 60.786 59.4564C60.8013 59.4537 60.8252 59.4497 60.8559 59.4441C60.9171 59.4329 61.0088 59.417 61.1293 59.3947C61.3711 59.3501 61.7305 59.2821 62.1984 59.1933C63.1343 59.0155 64.506 58.7511 66.2465 58.3997C69.7284 57.6967 74.6866 56.6503 80.5886 55.2788C92.3949 52.5352 107.968 48.4914 123.052 43.2887C138.13 38.0862 151.286 32.2987 160.673 27.8117C165.366 25.5684 169.116 23.6497 171.691 22.2936C172.978 21.6156 173.972 21.0783 174.643 20.7105C174.978 20.527 175.233 20.3863 175.404 20.2911C175.489 20.2435 175.554 20.2062 175.597 20.1822C175.618 20.1704 175.634 20.1614 175.644 20.1554C175.649 20.1525 175.654 20.1507 175.656 20.1493L175.658 20.1472H175.661Z" fill="#513C9F"/>
|
||||
<path d="M2.18131 186.308C1.73888 186.829 0.956885 186.893 0.435837 186.45C-0.0843773 186.008 -0.147949 185.227 0.293978 184.707L2.18131 186.308ZM104.178 17.1327C104.832 17.3295 105.202 18.0203 105.006 18.6746L83.9124 88.8658H133.52L133.768 88.8905C134.333 89.0058 134.757 89.5053 134.757 90.1035C134.757 90.7016 134.333 91.2011 133.768 91.3164L133.52 91.3411H82.8207L2.18131 186.308L1.23765 185.506L0.293978 184.707L81.1369 89.499L102.636 17.9632C102.832 17.3086 103.523 16.9359 104.178 17.1327Z" fill="#ABABAB"/>
|
||||
<path d="M57.4516 165.866L47.9012 167.209C52.8524 180.303 63.0083 186.023 75.1284 186.023C104.835 186.023 95.7677 152.43 112.978 152.43C125.466 152.43 120.393 179.658 147.26 179.658C163.66 179.658 165.297 163.136 162.498 156.029C162.481 155.985 162.465 155.946 162.44 155.907L158.046 149.177C157.759 148.73 157.064 148.898 157.015 149.43L156.196 157.587C156.139 158.154 156.155 158.72 156.221 159.286C156.892 164.921 157.326 178.597 147.26 178.597C136.645 178.597 134.092 151.722 112.978 151.722C88.2142 151.722 91.3977 184.962 76.1923 184.962C66.1591 184.962 58.5073 173.647 57.4516 165.866Z" fill="url(#paint3_linear)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="135.295" y1="10.8932" x2="107.004" y2="88.6892" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6430AB"/>
|
||||
<stop offset="1" stop-color="#AA89D8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear" x1="113.371" y1="54.7414" x2="76.9532" y2="125.111" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#005DBB"/>
|
||||
<stop offset="1" stop-color="#3D92E8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear" x1="129.632" y1="10.8927" x2="118.666" y2="45.1937" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1B70C4"/>
|
||||
<stop offset="1" stop-color="#54A4F2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear" x1="47.9012" y1="168.165" x2="163.603" y2="168.165" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4497EA"/>
|
||||
<stop offset="0.254755" stop-color="#1463B2"/>
|
||||
<stop offset="0.498725" stop-color="#0A437D"/>
|
||||
<stop offset="0.666667" stop-color="#2476C8"/>
|
||||
<stop offset="0.972542" stop-color="#0C549A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,18 @@
|
||||
# Voice demo audio
|
||||
|
||||
Drop a small (<100KB) WAV file named `sample.wav` in this directory. The voice
|
||||
demo (`/demos/voice`) fetches it client-side and POSTs to the transcription
|
||||
endpoint so the flow works without mic permissions.
|
||||
|
||||
Expected content: an audio clip speaking the phrase
|
||||
**"What is the weather in Tokyo?"** — the demo caption advertises that phrase
|
||||
to the user, and the bundled QA checklist + E2E spec assert the transcribed
|
||||
text contains "weather" and/or "Tokyo".
|
||||
|
||||
Generate locally, for example:
|
||||
|
||||
- macOS: `say -o sample.aiff "What is the weather in Tokyo?" && ffmpeg -i sample.aiff -ar 16000 -ac 1 sample.wav`
|
||||
- Linux: `espeak-ng -w sample.wav "What is the weather in Tokyo?"`
|
||||
- Windows: PowerShell `System.Speech.Synthesis.SpeechSynthesizer` → `SetOutputToWaveFile`
|
||||
|
||||
Target: 16kHz mono, 3-5s duration, <100KB.
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bd4aa7b049f1c3e324dfd15af4068d7f8fbf2eae1dd044df270dddc5f38a5c57
|
||||
size 87078
|
||||
@@ -0,0 +1,22 @@
|
||||
# Demo Files — Multimodal Demo
|
||||
|
||||
This directory bundles sample files referenced by the `/demos/multimodal` page.
|
||||
|
||||
Required files (must be committed as binaries; see `.gitattributes` at repo root):
|
||||
|
||||
- `sample.png` — a small (< 50 KB) PNG that the "Try with sample image" button
|
||||
injects into the chat. A CopilotKit logo or other recognizable brand mark
|
||||
works well so the vision-capable agent has something to describe.
|
||||
- `sample.pdf` — a small (< 50 KB) one-page PDF that the "Try with sample PDF"
|
||||
button injects. The content must mention "CopilotKit" so E2E soft
|
||||
assertions hold (e.g. a one-page export of the CopilotKit quickstart).
|
||||
|
||||
The page at `src/app/demos/multimodal/page.tsx` fetches these via the public
|
||||
path (`/demo-files/sample.png`, `/demo-files/sample.pdf`), wraps the fetched
|
||||
blob in a `File`, and routes it through the same `AttachmentsConfig.onUpload`
|
||||
callback the paperclip button uses — so the sample path exercises the exact
|
||||
same queueing code as a real user upload.
|
||||
|
||||
If these files are missing, the demo page still renders but the sample
|
||||
buttons will surface a fetch error. The paperclip / drag-and-drop paths
|
||||
continue to work without them.
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3da2afae36a1a81fd2c02f15e54bfc38b6c22e41655c31a5b54ff1e0e3daab41
|
||||
size 2486
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:01aa5681de99461247543e9215c1e4da3242e26b2bee11593fcdbe209672d973
|
||||
size 10083
|
||||
@@ -0,0 +1,52 @@
|
||||
# QA: Agent Config Object — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
- `ANTHROPIC_API_KEY` is set on the deployment
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/agent-config`
|
||||
- [ ] Verify the config card renders with three select inputs:
|
||||
`agent-config-tone-select`, `agent-config-expertise-select`,
|
||||
`agent-config-length-select`
|
||||
- [ ] Verify the chat surface is visible below the config card
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Tone
|
||||
|
||||
- [ ] Set tone to `professional`, send "Tell me about Paris"
|
||||
- [ ] Verify Claude's reply avoids emoji and exclamation marks
|
||||
- [ ] Change tone to `enthusiastic`, send the same prompt
|
||||
- [ ] Verify the next reply is noticeably more upbeat
|
||||
|
||||
#### Expertise
|
||||
|
||||
- [ ] Set expertise to `beginner`, ask "Explain quantum entanglement"
|
||||
- [ ] Verify the reply defines jargon and uses an analogy
|
||||
- [ ] Switch to `expert` and ask again
|
||||
- [ ] Verify the reply uses precise terminology and skips basics
|
||||
|
||||
#### Response length
|
||||
|
||||
- [ ] Set responseLength to `concise` and ask anything open-ended
|
||||
- [ ] Verify the reply is at most 3 sentences
|
||||
- [ ] Switch to `detailed`
|
||||
- [ ] Verify the reply spans multiple paragraphs
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send a message without changing any config first — verify the
|
||||
default (professional / intermediate / concise) behaviour holds
|
||||
- [ ] No console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Config changes take effect on the NEXT message (not retroactively).
|
||||
- `forwardedProps` reaches the backend via
|
||||
`forwarded_props.config.configurable.properties`.
|
||||
@@ -0,0 +1,62 @@
|
||||
# QA: Agentic Chat — Claude Agent SDK (Python)
|
||||
|
||||
## 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,48 @@
|
||||
# QA: Authentication — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
- `ANTHROPIC_API_KEY` is set on the deployment
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality (happy path — start signed in)
|
||||
|
||||
- [ ] Navigate to `/demos/auth`
|
||||
- [ ] Verify the auth banner renders with
|
||||
`data-authenticated="true"`
|
||||
- [ ] Verify the status reads "✓ Signed in as demo user"
|
||||
- [ ] Verify the "Sign out" button is visible
|
||||
- [ ] Send "Hello" and verify Claude responds
|
||||
|
||||
### 2. Sign-out path
|
||||
|
||||
- [ ] Click "Sign out"
|
||||
- [ ] Verify the banner flips to amber with
|
||||
`data-authenticated="false"`
|
||||
- [ ] Verify the button now reads "Sign in"
|
||||
- [ ] Attempt to send a message
|
||||
- [ ] Verify the error banner (`data-testid="auth-demo-error"`)
|
||||
renders with "401 Unauthorized" text
|
||||
- [ ] Verify no white-screen and no uncaught React errors (the
|
||||
ChatErrorBoundary catches any render-time crash from chat
|
||||
internals in the unauth state and renders
|
||||
`data-testid="auth-demo-chat-boundary"`)
|
||||
|
||||
### 3. Sign-in recovery
|
||||
|
||||
- [ ] Click "Sign in"
|
||||
- [ ] Verify the banner flips back to green
|
||||
- [ ] Verify the error banner clears
|
||||
- [ ] Send a message and verify Claude replies again
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- [ ] No console errors during normal usage.
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Runtime rejects un-bearer'd requests with HTTP 401.
|
||||
- Page never white-screens, even during auth transitions.
|
||||
@@ -0,0 +1,49 @@
|
||||
# QA: BYOC hashbrown — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
- `ANTHROPIC_API_KEY` is set on the deployment
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/byoc-hashbrown`
|
||||
- [ ] Verify the header "BYOC: Hashbrown" renders
|
||||
- [ ] Verify the description paragraph mentions `@hashbrownai/react`
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Q4 Sales Summary (mixed catalog)
|
||||
|
||||
- [ ] Send "Show me a Q4 sales summary" (or click a suggestion)
|
||||
- [ ] Verify a `data-testid="metric-card"` renders with a formatted value
|
||||
- [ ] Verify a `data-testid="pie-chart"` renders with at least three
|
||||
legend rows
|
||||
- [ ] Verify a `data-testid="bar-chart"` renders with at least three
|
||||
columns
|
||||
- [ ] Verify at least one Markdown heading renders inline
|
||||
|
||||
#### Deal card
|
||||
|
||||
- [ ] Ask "Show me a sample deal in the negotiation stage"
|
||||
- [ ] Verify a `data-testid="hashbrown-deal-card"` renders with a stage
|
||||
badge
|
||||
|
||||
### 3. Streaming behaviour
|
||||
|
||||
- [ ] Observe components progressively appear as Claude streams the
|
||||
JSON envelope — no full-refresh flash at the end of streaming
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- [ ] No console errors during normal usage.
|
||||
- [ ] No hashbrown schema-validation errors logged.
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 15 seconds
|
||||
- Backend emits the JSON envelope (`{ui: [...]}`), NEVER XML
|
||||
@@ -0,0 +1,59 @@
|
||||
# QA: BYOC json-render — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
- `ANTHROPIC_API_KEY` is set on the deployment
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/byoc-json-render`
|
||||
- [ ] Verify the chat surface loads inside the centered 4xl container
|
||||
- [ ] Verify the three suggestion pills are visible:
|
||||
"Sales dashboard", "Revenue by category", "Expense trend"
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Sales dashboard (MetricCard + BarChart)
|
||||
|
||||
- [ ] Click the "Sales dashboard" suggestion pill
|
||||
- [ ] Verify a `data-testid="metric-card"` element renders with a label
|
||||
and a dollar-formatted value
|
||||
- [ ] Verify a `data-testid="bar-chart"` element renders inside the same
|
||||
`data-testid="json-render-root"` wrapper
|
||||
|
||||
#### Revenue by category (PieChart)
|
||||
|
||||
- [ ] Click "Revenue by category"
|
||||
- [ ] Verify a `data-testid="pie-chart"` element renders with at least
|
||||
three legend rows
|
||||
|
||||
#### Expense trend (BarChart)
|
||||
|
||||
- [ ] Click "Expense trend"
|
||||
- [ ] Verify `data-testid="bar-chart"` renders with three months of data
|
||||
|
||||
### 3. Streaming behaviour
|
||||
|
||||
- [ ] Observe the raw JSON streaming into the chat bubble briefly while
|
||||
the model emits the spec
|
||||
- [ ] Verify the catalog components swap in cleanly once the JSON
|
||||
becomes valid — no flicker, no duplicate render
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- [ ] Ask a free-form question that has nothing to do with dashboards
|
||||
(e.g. "What is 2+2?"). The agent should still reply with a JSON
|
||||
spec — it may emit a single MetricCard — and the page must NOT
|
||||
white-screen.
|
||||
- [ ] No console errors during normal usage.
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 15 seconds (Claude opus)
|
||||
- Components render from the json-render catalog wrapped in a single
|
||||
`<JSONUIProvider>` (no missing-provider crashes)
|
||||
@@ -0,0 +1,39 @@
|
||||
# QA: Chat Customization (CSS) — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/chat-customization-css`
|
||||
- [ ] Verify the chat loads inside the `.chat-css-demo-scope` wrapper
|
||||
- [ ] Send a basic message
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Theme Application
|
||||
|
||||
- [ ] Verify user message bubbles render with the hot-pink gradient
|
||||
- [ ] Verify assistant message bubbles render with the amber background and monospace font
|
||||
- [ ] Verify the input area shows a dashed pink border
|
||||
- [ ] Verify the input placeholder color is pink italic
|
||||
|
||||
#### Variable Overrides
|
||||
|
||||
- [ ] Inspect the `.chat-css-demo-scope` element and verify CopilotKit CSS variables are overridden (e.g. `--copilot-kit-primary-color: #ff006e`)
|
||||
- [ ] Verify the customization does not leak outside the scoped container
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads within 3 seconds
|
||||
- Theme is applied only inside `.chat-css-demo-scope`
|
||||
- Agent responds within 10 seconds
|
||||
@@ -0,0 +1,44 @@
|
||||
# QA: Chat Slots — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/chat-slots`
|
||||
- [ ] Verify the custom welcome screen (`data-testid="custom-welcome-screen"`) is visible
|
||||
- [ ] Verify the welcome card shows "Welcome to the Slots demo"
|
||||
- [ ] Verify the suggestion pills ("Write a sonnet", "Tell me a joke") are visible
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Welcome Slot
|
||||
|
||||
- [ ] Confirm the gradient welcome card wraps the default input + suggestions
|
||||
- [ ] Verify the "Custom Slot" pill is visible above the headline
|
||||
|
||||
#### Disclaimer Slot
|
||||
|
||||
- [ ] After sending a message, verify the custom disclaimer (`data-testid="custom-disclaimer"`) is visible
|
||||
- [ ] Verify the disclaimer text contains "Custom disclaimer injected via"
|
||||
|
||||
#### Assistant Message Slot
|
||||
|
||||
- [ ] Send a basic message
|
||||
- [ ] Wait for the agent response
|
||||
- [ ] Verify the assistant message is wrapped by the custom slot (`data-testid="custom-assistant-message"`)
|
||||
- [ ] Verify the "slot" badge is visible on the assistant message
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads within 3 seconds
|
||||
- All three slot overrides (welcome, disclaimer, assistantMessage) render as custom components
|
||||
- Agent responds within 10 seconds
|
||||
@@ -0,0 +1,22 @@
|
||||
# QA: CLI Start Command — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- None (manifest-only demo — no hosted route)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Manifest
|
||||
|
||||
- [ ] Verify the `cli-start` entry exists in `manifest.yaml` under `demos:`
|
||||
- [ ] Verify the `command` field reads `npx copilotkit@latest init --framework claude-sdk-python`
|
||||
- [ ] Verify `cli-start` is listed in the `features:` array
|
||||
|
||||
### 2. Expected Behavior
|
||||
|
||||
- [ ] Running the command in a fresh directory scaffolds the Claude Agent SDK (Python) starter
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Manifest entry validates against the showcase schema
|
||||
- Command matches the canonical framework slug
|
||||
@@ -0,0 +1,33 @@
|
||||
# QA: Frontend Tools (Async) — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy
|
||||
- ANTHROPIC_API_KEY is set
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/frontend-tools-async`
|
||||
- [ ] Verify the chat renders
|
||||
- [ ] Click the "Find project-planning notes" suggestion
|
||||
- [ ] Verify a NotesCard appears briefly in loading state ("Querying local notes DB...")
|
||||
- [ ] After ~500ms, verify the card shows matching notes (Q2 kickoff, migrate auth to passkeys, retrospective notes, career planning)
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Search for "auth" — verify the "migrate auth to passkeys" note is returned
|
||||
- [ ] Ask "Do I have notes tagged reading?" — verify the Book recommendations note appears
|
||||
- [ ] Search for "nonsense-keyword-no-match" — verify "No notes matched." empty state
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Verify no console errors during search
|
||||
- [ ] Verify the loading state transitions to complete within a few seconds
|
||||
|
||||
## Expected Results
|
||||
|
||||
- NotesCard with `data-testid="notes-card"` displays matching notes
|
||||
- Each note rendered with `data-testid="note-<id>"` and tags
|
||||
@@ -0,0 +1,32 @@
|
||||
# QA: Frontend Tools — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
- ANTHROPIC_API_KEY is set
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/frontend-tools`
|
||||
- [ ] Verify a chat surface renders in the centered card
|
||||
- [ ] Send a message: "Change the background to a blue-to-purple gradient."
|
||||
- [ ] Verify the agent calls the `change_background` frontend tool
|
||||
- [ ] Verify the page background updates to the requested gradient
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Click the "Sunset theme" suggestion — background changes to a sunset gradient
|
||||
- [ ] Ask "Change the background back to default." — background resets
|
||||
- [ ] Verify no errors in the console
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Verify the chat remains usable after multiple background changes
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Background element with `data-testid="background-container"` reflects the CSS background value returned by the tool handler
|
||||
- Agent calls the tool and produces a short confirmation message
|
||||
@@ -0,0 +1,62 @@
|
||||
# QA: Agentic Generative UI — Claude Agent SDK (Python)
|
||||
|
||||
## 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 — Claude Agent SDK (Python)
|
||||
|
||||
## 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,33 @@
|
||||
# QA: Headless Chat (Complete) — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy
|
||||
- ANTHROPIC_API_KEY is set
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/headless-complete`
|
||||
- [ ] Verify the custom chat surface renders (header, scrollable list, input bar)
|
||||
- [ ] Verify NO `<CopilotChat />` imports are visible (all chrome is custom)
|
||||
- [ ] Send "Hello" — verify the user bubble + assistant bubble render
|
||||
|
||||
### 2. Generative UI Weave
|
||||
|
||||
- [ ] Click the "Weather in Tokyo" suggestion — verify the WeatherCard renders inline
|
||||
- [ ] Click the "Highlight a note" suggestion — verify the HighlightNote component renders
|
||||
- [ ] Try a message that triggers other tools — verify the catch-all tool card renders
|
||||
|
||||
### 3. Lifecycle
|
||||
|
||||
- [ ] During agent response, verify the Send button swaps to Stop
|
||||
- [ ] Click Stop — verify the agent cancels cleanly
|
||||
- [ ] Verify no console errors after stop
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Messages area with `data-testid="headless-complete-messages"` scrolls automatically
|
||||
- Input disabled while running; Send button disabled when empty
|
||||
@@ -0,0 +1,41 @@
|
||||
# QA: Headless Chat (Simple) — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/headless-simple`
|
||||
- [ ] Verify the heading "Headless Chat (Simple)" is visible
|
||||
- [ ] Verify the placeholder text "No messages yet. Say hi!" is displayed
|
||||
- [ ] Type a message into the textarea
|
||||
- [ ] Click Send (or press Enter)
|
||||
- [ ] Verify the user bubble appears immediately on the right
|
||||
- [ ] Verify the agent responds with a text bubble on the left
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### useAgent / useComponent
|
||||
|
||||
- [ ] Ask "Show me a card about cats"
|
||||
- [ ] Verify the ShowCard component renders with a title and body
|
||||
|
||||
#### Running state
|
||||
|
||||
- [ ] While the agent is replying, verify "Agent is thinking..." appears below the message list
|
||||
- [ ] Verify the Send button is disabled while the agent is running
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Verify sending an empty message does nothing
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- ShowCard renders when the agent calls `show_card`
|
||||
@@ -0,0 +1,38 @@
|
||||
# QA: In-App HITL — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
- ANTHROPIC_API_KEY is set
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/hitl-in-app`
|
||||
- [ ] Verify the Support Inbox renders three tickets (#12345, #12346, #12347)
|
||||
- [ ] Verify the chat surface renders on the right
|
||||
|
||||
### 2. Approval Flow
|
||||
|
||||
- [ ] Click the "Approve refund for #12345" suggestion
|
||||
- [ ] Verify a modal dialog appears with the action summary
|
||||
- [ ] Click "Approve" — verify the dialog closes and the agent confirms
|
||||
|
||||
### 3. Rejection Flow
|
||||
|
||||
- [ ] Click the "Downgrade plan for #12346" suggestion
|
||||
- [ ] Verify the modal appears
|
||||
- [ ] Add a note "Customer needs to stay on current plan"
|
||||
- [ ] Click "Reject" — verify the agent acknowledges the rejection
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- [ ] Verify no console errors during the approval/rejection flows
|
||||
- [ ] Verify the modal blocks interaction with the chat until resolved
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Modal uses `fixed inset-0` overlay rather than a portal (to avoid @types/react-dom)
|
||||
- Agent accurately reflects the approve/reject outcome
|
||||
@@ -0,0 +1,57 @@
|
||||
# QA: Human in the Loop — Claude Agent SDK (Python)
|
||||
|
||||
## 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,57 @@
|
||||
# QA: Multimodal Attachments — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
- `ANTHROPIC_API_KEY` is set on the deployment
|
||||
- `pypdf` is installed in the backend environment (listed in
|
||||
`requirements.txt`)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/multimodal`
|
||||
- [ ] Verify the "Multimodal attachments" header renders
|
||||
- [ ] Verify the bundled-samples row shows "Try with sample image" and
|
||||
"Try with sample PDF" buttons
|
||||
- [ ] Verify the paperclip button is visible on the chat composer
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Sample image
|
||||
|
||||
- [ ] Click "Try with sample image" — the button should flip to
|
||||
"Loading…" briefly
|
||||
- [ ] Verify the CopilotChat attachment strip shows a pending
|
||||
`sample.png` chip
|
||||
- [ ] Type "What's in this image?" and send
|
||||
- [ ] Verify Claude's reply references concrete visual details (e.g.
|
||||
colors, objects) from the bundled PNG
|
||||
|
||||
#### Sample PDF
|
||||
|
||||
- [ ] Click "Try with sample PDF"
|
||||
- [ ] Verify the `sample.pdf` chip appears
|
||||
- [ ] Type "Summarise this document" and send
|
||||
- [ ] Verify Claude replies with a short summary of the PDF's actual
|
||||
content (not a generic "I cannot read PDFs" reply)
|
||||
|
||||
### 3. LFS-pointer guard
|
||||
|
||||
- [ ] If the deployment accidentally ships a Git LFS pointer instead of
|
||||
the real binary, clicking a sample button should surface
|
||||
`data-testid="multimodal-sample-error"` with an actionable message
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- [ ] No console errors during normal usage.
|
||||
- [ ] Upload a file over 10 MB and verify the attachment is rejected
|
||||
with a visible toast.
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Image replies reference real visual details (not generic).
|
||||
- PDF replies reference document-specific content.
|
||||
- No silent dropping of attachments.
|
||||
@@ -0,0 +1,38 @@
|
||||
# QA: Open-Ended Generative UI (Advanced) — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy
|
||||
- ANTHROPIC_API_KEY is set
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/open-gen-ui-advanced`
|
||||
- [ ] Click the "Calculator (calls evaluateExpression)" suggestion
|
||||
- [ ] Verify an iframe renders with a calculator UI (no <form>, only <button type='button'>)
|
||||
|
||||
### 2. Sandbox-Function Round-Trip
|
||||
|
||||
- [ ] In the calculator, enter "12 \* (3 + 4.5)" and press "="
|
||||
- [ ] Verify the display updates with the host-evaluated result
|
||||
- [ ] Open devtools console — verify `[open-gen-ui/advanced] evaluateExpression` log appears
|
||||
|
||||
### 3. notifyHost
|
||||
|
||||
- [ ] Try the "Ping the host (calls notifyHost)" suggestion
|
||||
- [ ] Click the button in the rendered card
|
||||
- [ ] Verify `[open-gen-ui/advanced] notifyHost:` log in the host console
|
||||
- [ ] Verify the card updates with a confirmation showing `receivedAt`
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- [ ] Verify no CORS/CSP errors from the sandbox
|
||||
- [ ] Verify evaluateExpression rejects non-arithmetic input safely
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Sandbox -> host calls via Websandbox.connection.remote work end-to-end
|
||||
- Handler return values are visible inside the iframe UI
|
||||
@@ -0,0 +1,33 @@
|
||||
# QA: Open-Ended Generative UI — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy
|
||||
- ANTHROPIC_API_KEY is set
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/open-gen-ui`
|
||||
- [ ] Verify the chat renders with minimal suggestions visible
|
||||
- [ ] Click the "3D axis visualization (model airplane)" suggestion
|
||||
- [ ] Verify a sandboxed iframe appears with the generated visualization
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Verify the iframe contains SVG-based content (not div-stacks)
|
||||
- [ ] Try the "Quicksort visualization" suggestion — verify an animated bar sort renders
|
||||
- [ ] Verify the visualization is self-running (no user interaction needed)
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Verify no CORS/CSP errors in the console
|
||||
- [ ] Verify the sandbox iframe renders within the chat turn
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Agent calls `generateSandboxedUi` once per turn
|
||||
- The runtime's OpenGenerativeUIMiddleware converts the call into activity events
|
||||
- The built-in renderer mounts the agent-authored HTML inside a sandbox
|
||||
@@ -0,0 +1,41 @@
|
||||
# QA: Pre-Built Popup — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/prebuilt-popup`
|
||||
- [ ] Verify the main content heading "Popup demo — look for the floating launcher" is visible
|
||||
- [ ] Verify `<CopilotPopup />` opens by default (popup overlay is visible)
|
||||
- [ ] Verify the chat input placeholder reads "Ask the popup anything..."
|
||||
- [ ] Send a basic message (e.g. "Say hi from the popup!")
|
||||
- [ ] Verify the agent responds in the popup
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify the "Say hi" suggestion button is visible inside the popup
|
||||
- [ ] Click the suggestion and verify it sends the message
|
||||
|
||||
#### Popup Toggle
|
||||
|
||||
- [ ] Close the popup via its toggle/launcher
|
||||
- [ ] Verify the floating launcher bubble remains on the page
|
||||
- [ ] Reopen the popup and verify the conversation is preserved
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Verify no console errors during normal usage
|
||||
- [ ] Verify closing/opening the popup does not break the chat state
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads within 3 seconds
|
||||
- Popup opens with floating launcher
|
||||
- Agent responds within 10 seconds
|
||||
@@ -0,0 +1,40 @@
|
||||
# QA: Pre-Built Sidebar — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/prebuilt-sidebar`
|
||||
- [ ] Verify the main content heading "Sidebar demo — click the launcher" is visible
|
||||
- [ ] Verify `<CopilotSidebar />` opens by default (sidebar is visible)
|
||||
- [ ] Verify the sidebar has a chat input
|
||||
- [ ] Send a basic message (e.g. "Say hi")
|
||||
- [ ] Verify the agent responds in the sidebar
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify the "Say hi" suggestion button is visible inside the sidebar
|
||||
- [ ] Click the suggestion and verify it sends the message
|
||||
|
||||
#### Sidebar Toggle
|
||||
|
||||
- [ ] Close the sidebar via its toggle/launcher
|
||||
- [ ] Reopen the sidebar and verify the conversation is preserved
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Verify no console errors during normal usage
|
||||
- [ ] Verify closing/opening the sidebar does not break the chat state
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads within 3 seconds
|
||||
- Sidebar renders with launcher
|
||||
- Agent responds within 10 seconds
|
||||
@@ -0,0 +1,33 @@
|
||||
# QA: Readonly State (Agent Context) — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy
|
||||
- ANTHROPIC_API_KEY is set
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/readonly-state-agent-context`
|
||||
- [ ] Verify the Agent Context card renders with Name, Timezone, Recent Activity controls
|
||||
- [ ] Verify the published context JSON pre is visible
|
||||
|
||||
### 2. Context Wiring
|
||||
|
||||
- [ ] Edit the Name input to "Dana"
|
||||
- [ ] Click the "Who am I?" suggestion
|
||||
- [ ] Verify the agent greets "Dana" in its reply
|
||||
- [ ] Change the timezone to "Asia/Tokyo"
|
||||
- [ ] Click "Plan my morning" — verify the agent references Tokyo time
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Verify no console errors on context updates
|
||||
- [ ] Verify the chat remains usable after multiple context changes
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Agent references the current Name, Timezone, and Recent Activity
|
||||
- The context card cannot be modified by the agent (agent has no write tools)
|
||||
@@ -0,0 +1,61 @@
|
||||
# QA: Shared State (Read + Write) — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible at `/demos/shared-state-read-write` on the dashboard host
|
||||
- Agent backend is healthy (`/api/health`); `ANTHROPIC_API_KEY` is set on Railway; the FastAPI backend exposes `POST /shared-state-read-write`
|
||||
|
||||
## 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 15s the assistant reply references the name "Atai", a formal tone, Spanish, and the Cooking/Travel interests (the Python backend's `build_preferences_block` injects these into the system prompt 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 20s 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 20s the notes list grows (previous notes preserved, new note added) — confirms the agent passes the FULL updated list per `set_notes` contract
|
||||
|
||||
#### 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
|
||||
- [ ] 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)
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Attempt to send an empty message; verify it is a no-op (no user bubble, no assistant response)
|
||||
- [ ] Deselect all interests and clear the name; send "Who am I?"; verify the agent answers without crashing (the preferences block is skipped when nothing recognised is set)
|
||||
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads within 3 seconds; assistant text response within 10 seconds for plain chat
|
||||
- Preferences writes are reflected in `pref-state-json` synchronously on change
|
||||
- Agent-authored notes appear in `notes-card` within 20 seconds of a "remember" prompt, and the full prior list is preserved on subsequent `set_notes` calls
|
||||
- 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) — Claude Agent SDK (Python)
|
||||
|
||||
## 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 — Claude Agent SDK (Python)
|
||||
|
||||
## 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) — Claude Agent SDK (Python)
|
||||
|
||||
## 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,60 @@
|
||||
# QA: Sub-Agents — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible at `/demos/subagents` on the dashboard host
|
||||
- Agent backend is healthy (`/api/health`); `ANTHROPIC_API_KEY` is set on Railway; the FastAPI backend exposes `POST /subagents`
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/subagents`; verify the page renders within 3s with a left-side delegation log and a right-side `CopilotChat` pane
|
||||
- [ ] Verify `data-testid="delegation-log"` is visible with heading "Sub-agent delegations"
|
||||
- [ ] Verify `data-testid="delegation-count"` reads "0 calls" on first load
|
||||
- [ ] Verify the empty-state placeholder reads "Ask the supervisor to complete a task. Every sub-agent it calls will appear here."
|
||||
- [ ] Verify the chat input placeholder is "Give the supervisor a task..."
|
||||
- [ ] Verify all 3 suggestion pills are visible with verbatim titles: "Write a blog post", "Explain a topic", "Summarize a topic"
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Multi-stage Delegation Flow
|
||||
|
||||
- [ ] Click the "Write a blog post" suggestion (cold-exposure training prompt)
|
||||
- [ ] Within 5s verify `data-testid="supervisor-running"` appears with the "Supervisor running" pulse indicator
|
||||
- [ ] Within 30s verify at least one `data-testid="delegation-entry"` appears with badge "Research" and `data-testid="delegation-status"` initially reading `running`
|
||||
- [ ] Verify the entry's status flips to `completed` once the sub-agent returns and the `result` text is visible inside the entry's white inner panel
|
||||
- [ ] Within 60s total verify additional delegation entries appear in order: Research -> Writing -> Critique (3 entries total in most cases)
|
||||
- [ ] Verify `data-testid="delegation-count"` updates to match the number of entries (e.g. "3 calls")
|
||||
- [ ] Verify the supervisor's final chat reply includes a brief summary of the produced deliverable
|
||||
|
||||
#### Delegation Entry Layout
|
||||
|
||||
- [ ] Each `data-testid="delegation-entry"` shows: a `#N` index, a sub-agent badge with the correct emoji (🔎 Research / ✍️ Writing / 🧐 Critique), a status chip, the task text after "Task:", and the sub-agent's result rendered with whitespace preserved
|
||||
- [ ] Hover the supervisor running chip while a delegation is in flight — verify the pulse animation is present (no static-only state)
|
||||
|
||||
#### Sub-Agent Independence
|
||||
|
||||
- [ ] Click "Explain a topic" (LLM tool calling prompt) and wait for completion
|
||||
- [ ] Verify the writing entry's `result` is a single polished paragraph (the writing sub-agent's signature)
|
||||
- [ ] Verify the research entry's `result` is a bulleted list of 3-5 facts (the research sub-agent's signature)
|
||||
- [ ] Verify the critique entry's `result` contains 2-3 actionable critiques
|
||||
|
||||
#### Supervisor State Reset Across Tasks
|
||||
|
||||
- [ ] After the first run completes, click "Summarize a topic" (reusable rockets)
|
||||
- [ ] Verify NEW delegation entries are appended to the existing list (count keeps growing) — confirms `state["delegations"]` accumulates across turns within the same thread
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message; verify it is a no-op
|
||||
- [ ] If a sub-agent call fails (e.g. due to upstream rate limit), verify the failing entry is rendered with status `failed` and a result line starting with "sub-agent call failed:" — confirms the fail-loud path
|
||||
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads within 3 seconds
|
||||
- First delegation entry appears within 30 seconds of submitting a non-trivial task
|
||||
- Each delegation entry transitions from `running` -> `completed` (or `failed`) and the count badge stays in sync with `state["delegations"].length`
|
||||
- Supervisor's final chat reply summarises the work and arrives within 90 seconds of submission
|
||||
- No UI layout breaks, no uncaught console errors
|
||||
@@ -0,0 +1,30 @@
|
||||
# QA: Tool Rendering (Custom Catch-all) — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy
|
||||
- ANTHROPIC_API_KEY is set
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/tool-rendering-custom-catchall`
|
||||
- [ ] Verify the chat renders
|
||||
- [ ] Click "Weather in SF" — verify the agent calls `get_weather`
|
||||
- [ ] Verify the BRANDED `CustomCatchallRenderer` card appears (not the default)
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Verify `data-testid="custom-catchall-card"` renders with the tool name
|
||||
- [ ] Verify status pill transitions: streaming → running → done
|
||||
- [ ] Try "Find flights" — verify the same branded card renders
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Verify no console errors
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Every tool call renders via the custom `CustomCatchallRenderer`, including tool name, arguments pre, and result pre
|
||||
@@ -0,0 +1,29 @@
|
||||
# QA: Tool Rendering (Default Catch-all) — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy
|
||||
- ANTHROPIC_API_KEY is set
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/tool-rendering-default-catchall`
|
||||
- [ ] Verify the chat renders
|
||||
- [ ] Click "Weather in SF" — verify the agent calls `get_weather`
|
||||
- [ ] Verify the BUILT-IN `DefaultToolCallRenderer` card appears inline
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Verify the tool-call card shows the tool name, status pill (Running → Done), and Arguments/Result sections
|
||||
- [ ] Try "Find flights" — verify multiple tool calls render
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Verify no console errors during tool calls
|
||||
|
||||
## Expected Results
|
||||
|
||||
- CopilotKit's package-provided default tool card renders every tool call (no custom renderers)
|
||||
@@ -0,0 +1,61 @@
|
||||
# QA: Tool Rendering — Claude Agent SDK (Python)
|
||||
|
||||
## 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,53 @@
|
||||
# QA: Voice input — Claude Agent SDK (Python)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
- `ANTHROPIC_API_KEY` is set on the deployment (for chat replies)
|
||||
- `OPENAI_API_KEY` is set on the Next.js runtime (for Whisper
|
||||
transcription). If it is not, the mic button still renders but
|
||||
clicking it (or clicking "Play sample") will return a clean 401.
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/voice`
|
||||
- [ ] Verify the "Voice input" header renders
|
||||
- [ ] Verify the mic button is visible on the chat composer
|
||||
- [ ] Verify the "Play sample" button renders next to a caption
|
||||
matching `SAMPLE_LABEL` (e.g. "What is the weather in Tokyo?")
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Play sample
|
||||
|
||||
- [ ] Click "Play sample"
|
||||
- [ ] Verify the button flips to "Transcribing…" briefly
|
||||
- [ ] Verify the composer's textarea is populated with the transcribed
|
||||
text
|
||||
- [ ] Press send and verify Claude replies to the transcribed prompt
|
||||
|
||||
#### Mic button (manual)
|
||||
|
||||
- [ ] Click the mic button, allow microphone access, speak a short
|
||||
sentence, click again
|
||||
- [ ] Verify the textarea is populated with the transcription
|
||||
- [ ] Press send and verify Claude replies
|
||||
|
||||
### 3. Misconfigured deployment
|
||||
|
||||
- [ ] With `OPENAI_API_KEY` unset, click "Play sample"
|
||||
- [ ] Verify the error slot renders "Error — see console"
|
||||
- [ ] Verify the network response is HTTP 401, not 500/503
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- [ ] No console errors during normal usage (other than the expected
|
||||
401 when `OPENAI_API_KEY` is missing).
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Mic button always visible (transcription service is always mounted).
|
||||
- Missing `OPENAI_API_KEY` manifests as 401, not silent failure.
|
||||
@@ -0,0 +1,10 @@
|
||||
anthropic==0.111.0
|
||||
claude-agent-sdk==0.2.104
|
||||
ag-ui-claude-sdk==0.1.5
|
||||
ag-ui-protocol==0.1.19
|
||||
fastapi>=0.115.0
|
||||
uvicorn>=0.34.0
|
||||
python-dotenv>=1.0.1
|
||||
# Used by the multimodal demo to flatten PDFs to text so the model can
|
||||
# read them without depending on Claude's PDF beta.
|
||||
pypdf>=4.0.0
|
||||
@@ -0,0 +1,516 @@
|
||||
"""
|
||||
Agent Server for Claude Agent SDK (Python)
|
||||
|
||||
FastAPI server that hosts the Claude agent backend via AG-UI protocol.
|
||||
The Next.js CopilotKit runtime proxies requests here.
|
||||
|
||||
Endpoints:
|
||||
POST / — default shared Claude agent (sales
|
||||
assistant). Used by most demos.
|
||||
POST /byoc-json-render — BYOC json-render demo: emits a
|
||||
single JSON spec.
|
||||
POST /byoc-hashbrown — BYOC hashbrown demo: emits
|
||||
hashbrown UI envelope JSON.
|
||||
POST /multimodal — vision + PDF support for the
|
||||
multimodal demo.
|
||||
POST /agent-config — reads tone/expertise/responseLength
|
||||
from ``forwarded_props`` for the
|
||||
agent-config demo.
|
||||
POST /shared-state-read-write — bidirectional shared state: UI
|
||||
writes preferences, agent writes
|
||||
notes back via ``set_notes`` tool.
|
||||
POST /subagents — supervisor delegates to research /
|
||||
writing / critique sub-agents,
|
||||
each its own Anthropic SDK call.
|
||||
POST /mcp-apps — MCP Apps demo: no bespoke tools,
|
||||
forwards MCP middleware-injected
|
||||
tools straight to Claude.
|
||||
|
||||
Each dedicated endpoint reuses the shared AG-UI <-> Anthropic streaming
|
||||
plumbing in ``agents.agent`` but swaps the system prompt and/or tool set
|
||||
as the demo requires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# 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 anthropic / claude-agent-sdk), so it is safe to run before the agent
|
||||
# imports below.
|
||||
import _shared.cvdiag_bootstrap # noqa: F401,E402 (first non-stdlib import — bootstrap side effects)
|
||||
|
||||
# ORDER-CRITICAL: install the global httpx hook BEFORE any agent module
|
||||
# (and before the ``anthropic`` SDK) imports. The Claude SDK Python
|
||||
# integration constructs ``anthropic.AsyncAnthropic`` inside ``run_agent``
|
||||
# per request, but installing the hook at module-import time guarantees
|
||||
# every future httpx client (including sub-agent calls) auto-attaches the
|
||||
# forwarded-header hook.
|
||||
from agents._cvdiag_backend import CvdiagBackendMiddleware
|
||||
from agents._header_forwarding import (
|
||||
HeaderForwardingHTTPMiddleware,
|
||||
install_global_httpx_hook,
|
||||
)
|
||||
|
||||
install_global_httpx_hook()
|
||||
|
||||
import uvicorn
|
||||
from ag_ui.core import RunAgentInput
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from agents.a2ui_dynamic import run_a2ui_dynamic_agent
|
||||
from agents.a2ui_fixed import run_a2ui_fixed_agent
|
||||
from agents.agent import (
|
||||
BEAUTIFUL_CHAT_SYSTEM_PROMPT,
|
||||
BEAUTIFUL_CHAT_TOOLS,
|
||||
GEN_UI_AGENT_SYSTEM_PROMPT,
|
||||
GEN_UI_AGENT_TOOLS,
|
||||
HEADLESS_COMPLETE_SYSTEM_PROMPT,
|
||||
HEADLESS_COMPLETE_TOOLS,
|
||||
SHARED_STATE_STREAMING_SYSTEM_PROMPT,
|
||||
SHARED_STATE_STREAMING_TOOLS,
|
||||
TOOL_RENDERING_SYSTEM_PROMPT,
|
||||
TOOL_RENDERING_TOOLS,
|
||||
create_app,
|
||||
run_agent,
|
||||
)
|
||||
from agents.agent_config_agent import build_system_prompt, read_properties
|
||||
from agents.byoc_hashbrown_agent import BYOC_HASHBROWN_SYSTEM_PROMPT
|
||||
from agents.byoc_json_render_agent import BYOC_JSON_RENDER_SYSTEM_PROMPT
|
||||
from agents.hitl_in_chat_agent import run_hitl_in_chat_agent
|
||||
from agents.interrupt_agent import run_interrupt_agent
|
||||
from agents.mcp_apps_agent import run_mcp_apps_agent
|
||||
from agents.multimodal_agent import SYSTEM_PROMPT as MULTIMODAL_SYSTEM_PROMPT
|
||||
from agents.multimodal_agent import convert_part_for_claude
|
||||
from agents.reasoning_agent import run_reasoning_agent
|
||||
from agents.shared_state_read_write_agent import (
|
||||
run_shared_state_read_write_agent,
|
||||
)
|
||||
from agents.subagents_agent import run_subagents_agent
|
||||
from agents.tool_rendering_reasoning_chain_agent import (
|
||||
run_tool_rendering_reasoning_chain_agent,
|
||||
)
|
||||
|
||||
INTEGRATION_ROOT = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(dotenv_path=INTEGRATION_ROOT / ".env")
|
||||
|
||||
|
||||
def _stream_agent_response(
|
||||
input_data: RunAgentInput,
|
||||
*,
|
||||
system_prompt_override: str | None = None,
|
||||
disable_tools: bool = False,
|
||||
preprocess_user_parts: Callable[..., Any] | None = None,
|
||||
tools_override: list[dict[str, Any]] | None = None,
|
||||
frontend_tool_names_allowlist: set[str] | None = None,
|
||||
latest_user_message_only: bool = False,
|
||||
) -> StreamingResponse:
|
||||
"""Wrap ``run_agent`` in a StreamingResponse with demo-specific overrides.
|
||||
|
||||
The shared ``run_agent`` in ``agents.agent`` accepts keyword overrides
|
||||
that let per-demo endpoints swap the system prompt or tool registry
|
||||
without duplicating the streaming loop.
|
||||
"""
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
async for chunk in run_agent(
|
||||
input_data,
|
||||
system_prompt_override=system_prompt_override,
|
||||
disable_tools=disable_tools,
|
||||
preprocess_user_parts=preprocess_user_parts,
|
||||
tools_override=tools_override,
|
||||
frontend_tool_names_allowlist=frontend_tool_names_allowlist,
|
||||
latest_user_message_only=latest_user_message_only,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
# 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 right
|
||||
# after ``create_app`` so it is the OUTERMOST layer over the routes + CORS
|
||||
# ``create_app`` installs: 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)
|
||||
|
||||
# Tighten CORS: the dedicated endpoints share the same CORS policy as the
|
||||
# default route, which `create_app` already opens up with `*`. No extra
|
||||
# middleware needed here.
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
"""Liveness probe.
|
||||
|
||||
The Next.js runtime route at ``src/app/api/copilotkit/route.ts`` polls
|
||||
``GET ${AGENT_URL}/health`` to surface backend reachability in its own
|
||||
health response. Without this endpoint the probe always reports
|
||||
``unreachable`` even when FastAPI is healthy.
|
||||
"""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/byoc-json-render")
|
||||
async def byoc_json_render_endpoint(request: Request) -> StreamingResponse:
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
return _stream_agent_response(
|
||||
input_data,
|
||||
system_prompt_override=BYOC_JSON_RENDER_SYSTEM_PROMPT,
|
||||
disable_tools=True,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/declarative-json-render")
|
||||
async def declarative_json_render_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Canonical route alias for the declarative JSON renderer demo."""
|
||||
return await byoc_json_render_endpoint(request)
|
||||
|
||||
|
||||
@app.post("/byoc-hashbrown")
|
||||
async def byoc_hashbrown_endpoint(request: Request) -> StreamingResponse:
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
return _stream_agent_response(
|
||||
input_data,
|
||||
system_prompt_override=BYOC_HASHBROWN_SYSTEM_PROMPT,
|
||||
disable_tools=True,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/declarative-hashbrown")
|
||||
async def declarative_hashbrown_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Canonical route alias for the declarative Hashbrown demo."""
|
||||
return await byoc_hashbrown_endpoint(request)
|
||||
|
||||
|
||||
@app.post("/multimodal")
|
||||
async def multimodal_endpoint(request: Request) -> StreamingResponse:
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
return _stream_agent_response(
|
||||
input_data,
|
||||
system_prompt_override=MULTIMODAL_SYSTEM_PROMPT,
|
||||
disable_tools=True,
|
||||
preprocess_user_parts=convert_part_for_claude,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/beautiful-chat")
|
||||
async def beautiful_chat_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Beautiful Chat -- backend tools plus frontend/runtime tools."""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
return _stream_agent_response(
|
||||
input_data,
|
||||
system_prompt_override=BEAUTIFUL_CHAT_SYSTEM_PROMPT,
|
||||
tools_override=BEAUTIFUL_CHAT_TOOLS,
|
||||
)
|
||||
|
||||
|
||||
# @region[backend-demo-endpoints]
|
||||
@app.post("/headless-complete")
|
||||
async def headless_complete_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Headless Complete — backend weather/stock tools plus highlight_note."""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
return _stream_agent_response(
|
||||
input_data,
|
||||
system_prompt_override=HEADLESS_COMPLETE_SYSTEM_PROMPT,
|
||||
tools_override=HEADLESS_COMPLETE_TOOLS,
|
||||
frontend_tool_names_allowlist={"highlight_note"},
|
||||
preprocess_user_parts=convert_part_for_claude,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/tool-rendering")
|
||||
async def tool_rendering_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Tool Rendering family — render-only UI, backend-owned tool execution."""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
return _stream_agent_response(
|
||||
input_data,
|
||||
system_prompt_override=TOOL_RENDERING_SYSTEM_PROMPT,
|
||||
tools_override=TOOL_RENDERING_TOOLS,
|
||||
frontend_tool_names_allowlist=set(),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/gen-ui-agent")
|
||||
async def gen_ui_agent_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Agentic Generative UI — backend-owned set_steps state updates."""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
return _stream_agent_response(
|
||||
input_data,
|
||||
system_prompt_override=GEN_UI_AGENT_SYSTEM_PROMPT,
|
||||
tools_override=GEN_UI_AGENT_TOOLS,
|
||||
frontend_tool_names_allowlist=set(),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/shared-state-streaming")
|
||||
async def shared_state_streaming_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Shared State Streaming — writes the final document into shared state."""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
return _stream_agent_response(
|
||||
input_data,
|
||||
system_prompt_override=SHARED_STATE_STREAMING_SYSTEM_PROMPT,
|
||||
tools_override=SHARED_STATE_STREAMING_TOOLS,
|
||||
frontend_tool_names_allowlist=set(),
|
||||
)
|
||||
|
||||
|
||||
# @endregion[backend-demo-endpoints]
|
||||
|
||||
|
||||
@app.post("/agent-config")
|
||||
async def agent_config_endpoint(request: Request) -> StreamingResponse:
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
props = read_properties(input_data.forwarded_props)
|
||||
system_prompt = build_system_prompt(
|
||||
props["tone"], props["expertise"], props["response_length"]
|
||||
)
|
||||
return _stream_agent_response(
|
||||
input_data,
|
||||
system_prompt_override=system_prompt,
|
||||
disable_tools=True,
|
||||
latest_user_message_only=True,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/shared-state-read-write")
|
||||
async def shared_state_read_write_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Bidirectional shared state demo — UI writes preferences, agent writes notes.
|
||||
|
||||
Uses its own streaming loop (not the shared sales-assistant
|
||||
``run_agent``) because the state schema, tools, and prompt-injection
|
||||
middleware are all demo-specific.
|
||||
"""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
async for chunk in run_shared_state_read_write_agent(input_data):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/reasoning")
|
||||
async def reasoning_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Reasoning demo backend — emits AG-UI REASONING_MESSAGE_* events.
|
||||
|
||||
Shared by the reasoning-custom and reasoning-default
|
||||
demos. Both demos hit the same backend; the difference is purely
|
||||
on the frontend slot configuration.
|
||||
"""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
async for chunk in run_reasoning_agent(input_data):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/tool-rendering-reasoning-chain")
|
||||
async def tool_rendering_reasoning_chain_endpoint(
|
||||
request: Request,
|
||||
) -> StreamingResponse:
|
||||
"""Sequential tool calls + visible reasoning chain."""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
async for chunk in run_tool_rendering_reasoning_chain_agent(input_data):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/mcp-apps")
|
||||
async def mcp_apps_endpoint(request: Request) -> StreamingResponse:
|
||||
"""MCP Apps demo — pass-through tools forwarded by the runtime middleware.
|
||||
|
||||
The dedicated runtime at ``/api/copilotkit-mcp-apps`` configures
|
||||
``mcpApps: { servers: [...] }``, which auto-applies the MCP Apps
|
||||
middleware to the agent. The middleware appends the remote MCP
|
||||
server's tools to the AG-UI request's ``tools`` array; this endpoint
|
||||
forwards them straight to Claude.
|
||||
"""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
async for chunk in run_mcp_apps_agent(input_data):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/subagents")
|
||||
async def subagents_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Sub-agents demo — supervisor delegates to research/writing/critique."""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
async for chunk in run_subagents_agent(input_data):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/hitl-in-chat")
|
||||
async def hitl_in_chat_endpoint(request: Request) -> StreamingResponse:
|
||||
"""In-Chat HITL demo — frontend `book_call` tool via `useHumanInTheLoop`."""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
async for chunk in run_hitl_in_chat_agent(input_data):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/interrupt-adapted")
|
||||
async def interrupt_adapted_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Interrupt-adapted scheduling agent — shared by gen-ui-interrupt and
|
||||
interrupt-headless. The ``schedule_meeting`` tool is registered on the
|
||||
frontend via ``useFrontendTool``; the backend only provides the system
|
||||
prompt and forwards frontend tools to Claude."""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
async for chunk in run_interrupt_agent(input_data):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/declarative-gen-ui")
|
||||
async def declarative_gen_ui_endpoint(request: Request) -> StreamingResponse:
|
||||
"""Declarative Generative UI (A2UI Dynamic Schema) demo."""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
async for chunk in run_a2ui_dynamic_agent(input_data):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/a2ui-fixed-schema")
|
||||
async def a2ui_fixed_schema_endpoint(request: Request) -> StreamingResponse:
|
||||
"""A2UI Fixed Schema demo — backend ships flight_schema.json."""
|
||||
body = await request.json()
|
||||
input_data = RunAgentInput(**body)
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
async for chunk in run_a2ui_fixed_agent(input_data):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the uvicorn server."""
|
||||
port = int(os.getenv("AGENT_PORT", "8000"))
|
||||
uvicorn.run("agent_server:app", host="0.0.0.0", port=port, reload=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def sanitize_unresolved_tool_uses(
|
||||
messages: list[dict[str, Any]],
|
||||
tool_names: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop unresolved assistant tool_use blocks before nested Claude calls."""
|
||||
|
||||
resolved_tool_ids: set[str] = set()
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") == "tool_result":
|
||||
tool_use_id = block.get("tool_use_id")
|
||||
if isinstance(tool_use_id, str) and tool_use_id:
|
||||
resolved_tool_ids.add(tool_use_id)
|
||||
|
||||
sanitized: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
if msg.get("role") != "assistant" or not isinstance(content, list):
|
||||
sanitized.append(msg)
|
||||
continue
|
||||
|
||||
filtered_content: list[Any] = []
|
||||
for block in content:
|
||||
if (
|
||||
isinstance(block, dict)
|
||||
and block.get("type") == "tool_use"
|
||||
and (tool_names is None or block.get("name") in tool_names)
|
||||
and block.get("id") not in resolved_tool_ids
|
||||
):
|
||||
continue
|
||||
filtered_content.append(block)
|
||||
|
||||
if filtered_content:
|
||||
sanitized.append({**msg, "content": filtered_content})
|
||||
|
||||
return sanitized
|
||||
@@ -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 = "claude-sdk-python"
|
||||
|
||||
# ── 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,403 @@
|
||||
"""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 = "claude-sdk-python"
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Claude Agent SDK backend for the Declarative Generative UI (A2UI Dynamic) demo.
|
||||
|
||||
The agent exposes a single `generate_a2ui(context: str)` tool. When called,
|
||||
it invokes a secondary Claude call bound to the `render_a2ui` tool schema
|
||||
(forced via `tool_choice`) and returns an `a2ui_operations` container which
|
||||
the runtime's A2UI middleware detects and forwards to the frontend renderer.
|
||||
|
||||
The dedicated runtime route (`api/copilotkit-declarative-gen-ui/route.ts`)
|
||||
sets `injectA2UITool: false` so the runtime does not double-bind a second
|
||||
A2UI tool on top of this one — the registered client catalog is still
|
||||
serialised into `copilotkit.context` so the secondary LLM knows what's
|
||||
available.
|
||||
|
||||
Mirrors the langgraph-python and ag2 references.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from collections.abc import AsyncIterator
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
RunAgentInput,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from ag_ui.encoder import EventEncoder
|
||||
|
||||
from tools import (
|
||||
RENDER_A2UI_TOOL_SCHEMA,
|
||||
build_a2ui_operations_from_tool_call,
|
||||
)
|
||||
from agents._anthropic_message_safety import sanitize_unresolved_tool_uses
|
||||
from agents.claude_agent_sdk_adapter import normalize_claude_model
|
||||
|
||||
|
||||
SYSTEM_PROMPT = dedent("""
|
||||
You are a demo assistant for Declarative Generative UI (A2UI — Dynamic
|
||||
Schema). Whenever a response would benefit from a rich visual — a
|
||||
dashboard, status report, KPI summary, card layout, info grid, a
|
||||
pie/donut chart of part-of-whole breakdowns, a bar chart comparing
|
||||
values across categories, or anything more structured than plain text —
|
||||
call `generate_a2ui` to draw it. The registered catalog includes
|
||||
`Card`, `StatusBadge`, `Metric`, `InfoRow`, `PrimaryButton`, `PieChart`,
|
||||
and `BarChart` (in addition to the basic A2UI primitives). Prefer
|
||||
`PieChart` for part-of-whole breakdowns and `BarChart` for comparisons
|
||||
across categories. `generate_a2ui` takes a single `context` argument
|
||||
summarising the conversation. Keep chat replies to one short sentence;
|
||||
let the UI do the talking.
|
||||
""").strip()
|
||||
|
||||
|
||||
# @region[a2ui-backend-tool]
|
||||
GENERATE_A2UI_TOOL = {
|
||||
"name": "generate_a2ui",
|
||||
"description": (
|
||||
"Generate dynamic A2UI components based on the conversation. "
|
||||
"A secondary LLM designs the UI schema and data using the registered catalog."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "Conversation context summary the secondary LLM should design UI from.",
|
||||
},
|
||||
},
|
||||
"required": ["context"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _generate_a2ui(
|
||||
context: str, conversation_messages: list[dict[str, Any]] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Invoke a secondary LLM bound to render_a2ui and return an operations container."""
|
||||
client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
|
||||
render_tool_schema = {
|
||||
"name": RENDER_A2UI_TOOL_SCHEMA["name"],
|
||||
"description": RENDER_A2UI_TOOL_SCHEMA["description"],
|
||||
"input_schema": RENDER_A2UI_TOOL_SCHEMA["parameters"],
|
||||
}
|
||||
llm_messages = (
|
||||
sanitize_unresolved_tool_uses(conversation_messages)
|
||||
if conversation_messages
|
||||
else [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Generate a dynamic A2UI dashboard based on the conversation.",
|
||||
}
|
||||
]
|
||||
)
|
||||
response = client.messages.create(
|
||||
model=normalize_claude_model(os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4.6")),
|
||||
max_tokens=4096,
|
||||
system=context or "Generate a useful dashboard UI.",
|
||||
messages=llm_messages,
|
||||
tools=[render_tool_schema],
|
||||
tool_choice={"type": "tool", "name": "render_a2ui"},
|
||||
)
|
||||
for block in response.content:
|
||||
if getattr(block, "type", None) == "tool_use" and block.name == "render_a2ui":
|
||||
return build_a2ui_operations_from_tool_call(dict(block.input))
|
||||
return {"error": "LLM did not call render_a2ui"}
|
||||
|
||||
|
||||
# @endregion[a2ui-backend-tool]
|
||||
|
||||
|
||||
async def run_a2ui_dynamic_agent(input_data: RunAgentInput) -> AsyncIterator[str]:
|
||||
"""Stream a Claude conversation that may call `generate_a2ui`."""
|
||||
encoder = EventEncoder()
|
||||
client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
|
||||
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in input_data.messages or []:
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
raw = getattr(msg, "content", None)
|
||||
content = ""
|
||||
if isinstance(raw, str):
|
||||
content = raw
|
||||
elif isinstance(raw, list):
|
||||
parts = []
|
||||
for part in raw:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
content = "".join(parts)
|
||||
if content:
|
||||
messages.append({"role": role, "content": content})
|
||||
|
||||
thread_id = input_data.thread_id or "default"
|
||||
run_id = input_data.run_id or "run-1"
|
||||
|
||||
yield encoder.encode(
|
||||
RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id)
|
||||
)
|
||||
|
||||
while True:
|
||||
msg_id = f"msg-{run_id}-{len(messages)}"
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
|
||||
response_text = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
try:
|
||||
async with client.messages.stream(
|
||||
model=normalize_claude_model(
|
||||
os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4.6")
|
||||
),
|
||||
max_tokens=2048,
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=messages,
|
||||
tools=[GENERATE_A2UI_TOOL],
|
||||
) as stream:
|
||||
current_tool_id: str | None = None
|
||||
current_tool_name: str | None = None
|
||||
current_tool_args = ""
|
||||
|
||||
async for event in stream:
|
||||
etype = type(event).__name__
|
||||
if etype == "RawContentBlockStartEvent":
|
||||
block = event.content_block # type: ignore[attr-defined]
|
||||
if block.type == "tool_use":
|
||||
current_tool_id = block.id
|
||||
current_tool_name = block.name
|
||||
current_tool_args = ""
|
||||
yield encoder.encode(
|
||||
ToolCallStartEvent(
|
||||
type=EventType.TOOL_CALL_START,
|
||||
tool_call_id=current_tool_id,
|
||||
tool_call_name=current_tool_name,
|
||||
parent_message_id=msg_id,
|
||||
)
|
||||
)
|
||||
elif etype == "RawContentBlockDeltaEvent":
|
||||
delta = event.delta # type: ignore[attr-defined]
|
||||
if delta.type == "text_delta":
|
||||
response_text += delta.text
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=delta.text,
|
||||
)
|
||||
)
|
||||
elif delta.type == "input_json_delta":
|
||||
current_tool_args += delta.partial_json
|
||||
yield encoder.encode(
|
||||
ToolCallArgsEvent(
|
||||
type=EventType.TOOL_CALL_ARGS,
|
||||
tool_call_id=current_tool_id or "",
|
||||
delta=delta.partial_json,
|
||||
)
|
||||
)
|
||||
elif etype in (
|
||||
"RawContentBlockStopEvent",
|
||||
"ParsedContentBlockStopEvent",
|
||||
):
|
||||
if current_tool_id and current_tool_name:
|
||||
yield encoder.encode(
|
||||
ToolCallEndEvent(
|
||||
type=EventType.TOOL_CALL_END,
|
||||
tool_call_id=current_tool_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
parsed = (
|
||||
json.loads(current_tool_args)
|
||||
if current_tool_args
|
||||
else {}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
parsed = {}
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": current_tool_id,
|
||||
"name": current_tool_name,
|
||||
"input": parsed,
|
||||
}
|
||||
)
|
||||
current_tool_id = None
|
||||
current_tool_name = None
|
||||
current_tool_args = ""
|
||||
except Exception:
|
||||
err_text = f"Agent error: {traceback.format_exc()}"
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=err_text,
|
||||
)
|
||||
)
|
||||
|
||||
yield encoder.encode(
|
||||
TextMessageEndEvent(
|
||||
type=EventType.TEXT_MESSAGE_END,
|
||||
message_id=msg_id,
|
||||
)
|
||||
)
|
||||
|
||||
if not tool_calls:
|
||||
break
|
||||
|
||||
# Build assistant turn with tool_use blocks.
|
||||
assistant_content: list[dict[str, Any]] = []
|
||||
if response_text:
|
||||
assistant_content.append({"type": "text", "text": response_text})
|
||||
for tc in tool_calls:
|
||||
assistant_content.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"input": tc["input"],
|
||||
}
|
||||
)
|
||||
messages.append({"role": "assistant", "content": assistant_content})
|
||||
|
||||
# Execute generate_a2ui and emit tool_result.
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
for tc in tool_calls:
|
||||
if tc["name"] == "generate_a2ui":
|
||||
ctx = tc["input"].get("context", "")
|
||||
try:
|
||||
result_obj = _generate_a2ui(ctx, conversation_messages=messages)
|
||||
result_text = json.dumps(result_obj)
|
||||
except Exception as exc: # noqa: BLE001 - surface as tool result
|
||||
result_text = json.dumps(
|
||||
{
|
||||
"error": "generate_a2ui failed",
|
||||
"detail": exc.__class__.__name__,
|
||||
}
|
||||
)
|
||||
else:
|
||||
result_text = json.dumps({"error": f"unknown tool {tc['name']}"})
|
||||
yield encoder.encode(
|
||||
ToolCallResultEvent(
|
||||
type=EventType.TOOL_CALL_RESULT,
|
||||
tool_call_id=tc["id"],
|
||||
message_id=f"{msg_id}-tool-result-{tc['id']}",
|
||||
content=result_text,
|
||||
)
|
||||
)
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tc["id"],
|
||||
"content": result_text,
|
||||
}
|
||||
)
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
|
||||
yield encoder.encode(
|
||||
RunFinishedEvent(
|
||||
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Claude Agent SDK backend for the A2UI Fixed Schema demo.
|
||||
|
||||
The component tree (schema) is authored ahead of time as JSON and shipped
|
||||
with the backend. The agent only streams *data* into the data model at
|
||||
runtime via the `display_flight` tool, which emits an `a2ui_operations`
|
||||
container the runtime A2UI middleware detects in tool results and forwards
|
||||
to the frontend renderer.
|
||||
|
||||
Mirrors the langgraph-python and ag2 references. The dedicated runtime
|
||||
route at `api/copilotkit-a2ui-fixed-schema/route.ts` runs with
|
||||
`injectA2UITool: false` because the backend owns the rendering tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
RunAgentInput,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from ag_ui.encoder import EventEncoder
|
||||
from agents.claude_agent_sdk_adapter import normalize_claude_model
|
||||
|
||||
|
||||
CATALOG_ID = "copilotkit://flight-fixed-catalog"
|
||||
SURFACE_ID = "flight-fixed-schema"
|
||||
|
||||
# @region[backend-render-operations]
|
||||
# @region[backend-schema-json-load]
|
||||
_SCHEMAS_DIR = Path(__file__).parent / "a2ui_schemas"
|
||||
|
||||
|
||||
def _load_schema(filename: str) -> list[dict]:
|
||||
with open(_SCHEMAS_DIR / filename, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
FLIGHT_SCHEMA = _load_schema("flight_schema.json")
|
||||
# @endregion[backend-schema-json-load]
|
||||
|
||||
|
||||
SYSTEM_PROMPT = dedent("""
|
||||
You help users find flights. When asked about a flight, call
|
||||
`display_flight` with origin (3-letter code), destination (3-letter
|
||||
code), airline, and price (e.g. '$289'). Keep any chat reply to one
|
||||
short sentence.
|
||||
""").strip()
|
||||
|
||||
|
||||
DISPLAY_FLIGHT_TOOL = {
|
||||
"name": "display_flight",
|
||||
"description": (
|
||||
"Show a flight card for the given trip. Emits an a2ui_operations "
|
||||
"container the runtime A2UI middleware detects and forwards to the "
|
||||
"frontend renderer."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"origin": {
|
||||
"type": "string",
|
||||
"description": "Origin airport code, e.g. 'SFO'",
|
||||
},
|
||||
"destination": {
|
||||
"type": "string",
|
||||
"description": "Destination airport code, e.g. 'JFK'",
|
||||
},
|
||||
"airline": {"type": "string", "description": "Airline name, e.g. 'United'"},
|
||||
"price": {"type": "string", "description": "Price string, e.g. '$289'"},
|
||||
},
|
||||
"required": ["origin", "destination", "airline", "price"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _display_flight_operations(
|
||||
origin: str, destination: str, airline: str, price: str
|
||||
) -> dict[str, Any]:
|
||||
# A2UI v0.9 message shape — each operation is wrapped in a versioned
|
||||
# container keyed by the operation name (createSurface, updateComponents,
|
||||
# updateDataModel). The runtime A2UI middleware + react-core renderer
|
||||
# (packages/react-core/src/v2/a2ui/A2UIMessageRenderer.tsx) read these
|
||||
# keys directly; the legacy snake_case `{type: "create_surface", ...}`
|
||||
# shape is silently dropped, leaving the flight card unrendered.
|
||||
# Mirrors `copilotkit.a2ui.render(...)` used by langgraph-python's
|
||||
# display_flight tool (sdk-python/copilotkit/a2ui.py).
|
||||
return {
|
||||
"a2ui_operations": [
|
||||
{
|
||||
"version": "v0.9",
|
||||
"createSurface": {
|
||||
"surfaceId": SURFACE_ID,
|
||||
"catalogId": CATALOG_ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
"version": "v0.9",
|
||||
"updateComponents": {
|
||||
"surfaceId": SURFACE_ID,
|
||||
"components": FLIGHT_SCHEMA,
|
||||
},
|
||||
},
|
||||
{
|
||||
"version": "v0.9",
|
||||
"updateDataModel": {
|
||||
"surfaceId": SURFACE_ID,
|
||||
"path": "/",
|
||||
"value": {
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"airline": airline,
|
||||
"price": price,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# @endregion[backend-render-operations]
|
||||
|
||||
|
||||
async def run_a2ui_fixed_agent(input_data: RunAgentInput) -> AsyncIterator[str]:
|
||||
"""Stream a Claude conversation that may call `display_flight`."""
|
||||
encoder = EventEncoder()
|
||||
client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
|
||||
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in input_data.messages or []:
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
raw = getattr(msg, "content", None)
|
||||
content = ""
|
||||
if isinstance(raw, str):
|
||||
content = raw
|
||||
elif isinstance(raw, list):
|
||||
parts = []
|
||||
for part in raw:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
content = "".join(parts)
|
||||
if content:
|
||||
messages.append({"role": role, "content": content})
|
||||
|
||||
thread_id = input_data.thread_id or "default"
|
||||
run_id = input_data.run_id or "run-1"
|
||||
|
||||
yield encoder.encode(
|
||||
RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id)
|
||||
)
|
||||
|
||||
while True:
|
||||
msg_id = f"msg-{run_id}-{len(messages)}"
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
|
||||
response_text = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
try:
|
||||
async with client.messages.stream(
|
||||
model=normalize_claude_model(
|
||||
os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4.6")
|
||||
),
|
||||
max_tokens=2048,
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=messages,
|
||||
tools=[DISPLAY_FLIGHT_TOOL],
|
||||
) as stream:
|
||||
current_tool_id: str | None = None
|
||||
current_tool_name: str | None = None
|
||||
current_tool_args = ""
|
||||
|
||||
async for event in stream:
|
||||
etype = type(event).__name__
|
||||
if etype == "RawContentBlockStartEvent":
|
||||
block = event.content_block # type: ignore[attr-defined]
|
||||
if block.type == "tool_use":
|
||||
current_tool_id = block.id
|
||||
current_tool_name = block.name
|
||||
current_tool_args = ""
|
||||
yield encoder.encode(
|
||||
ToolCallStartEvent(
|
||||
type=EventType.TOOL_CALL_START,
|
||||
tool_call_id=current_tool_id,
|
||||
tool_call_name=current_tool_name,
|
||||
parent_message_id=msg_id,
|
||||
)
|
||||
)
|
||||
elif etype == "RawContentBlockDeltaEvent":
|
||||
delta = event.delta # type: ignore[attr-defined]
|
||||
if delta.type == "text_delta":
|
||||
response_text += delta.text
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=delta.text,
|
||||
)
|
||||
)
|
||||
elif delta.type == "input_json_delta":
|
||||
current_tool_args += delta.partial_json
|
||||
yield encoder.encode(
|
||||
ToolCallArgsEvent(
|
||||
type=EventType.TOOL_CALL_ARGS,
|
||||
tool_call_id=current_tool_id or "",
|
||||
delta=delta.partial_json,
|
||||
)
|
||||
)
|
||||
elif etype in (
|
||||
"RawContentBlockStopEvent",
|
||||
"ParsedContentBlockStopEvent",
|
||||
):
|
||||
if current_tool_id and current_tool_name:
|
||||
yield encoder.encode(
|
||||
ToolCallEndEvent(
|
||||
type=EventType.TOOL_CALL_END,
|
||||
tool_call_id=current_tool_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
parsed = (
|
||||
json.loads(current_tool_args)
|
||||
if current_tool_args
|
||||
else {}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
parsed = {}
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": current_tool_id,
|
||||
"name": current_tool_name,
|
||||
"input": parsed,
|
||||
}
|
||||
)
|
||||
current_tool_id = None
|
||||
current_tool_name = None
|
||||
current_tool_args = ""
|
||||
except Exception:
|
||||
err_text = f"Agent error: {traceback.format_exc()}"
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=err_text,
|
||||
)
|
||||
)
|
||||
|
||||
yield encoder.encode(
|
||||
TextMessageEndEvent(
|
||||
type=EventType.TEXT_MESSAGE_END,
|
||||
message_id=msg_id,
|
||||
)
|
||||
)
|
||||
|
||||
if not tool_calls:
|
||||
break
|
||||
|
||||
assistant_content: list[dict[str, Any]] = []
|
||||
if response_text:
|
||||
assistant_content.append({"type": "text", "text": response_text})
|
||||
for tc in tool_calls:
|
||||
assistant_content.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"input": tc["input"],
|
||||
}
|
||||
)
|
||||
messages.append({"role": "assistant", "content": assistant_content})
|
||||
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
for tc in tool_calls:
|
||||
if tc["name"] == "display_flight":
|
||||
args = tc["input"]
|
||||
result_obj = _display_flight_operations(
|
||||
origin=args.get("origin", ""),
|
||||
destination=args.get("destination", ""),
|
||||
airline=args.get("airline", ""),
|
||||
price=args.get("price", ""),
|
||||
)
|
||||
result_text = json.dumps(result_obj)
|
||||
else:
|
||||
result_text = json.dumps({"error": f"unknown tool {tc['name']}"})
|
||||
yield encoder.encode(
|
||||
ToolCallResultEvent(
|
||||
type=EventType.TOOL_CALL_RESULT,
|
||||
tool_call_id=tc["id"],
|
||||
message_id=f"{msg_id}-tool-result-{tc['id']}",
|
||||
content=result_text,
|
||||
)
|
||||
)
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tc["id"],
|
||||
"content": result_text,
|
||||
}
|
||||
)
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
|
||||
yield encoder.encode(
|
||||
RunFinishedEvent(
|
||||
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"id": "root",
|
||||
"component": "Column",
|
||||
"gap": 8,
|
||||
"children": ["title", "detail"]
|
||||
},
|
||||
{
|
||||
"id": "title",
|
||||
"component": "Text",
|
||||
"text": { "path": "/title" },
|
||||
"variant": "h2"
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"component": "Text",
|
||||
"text": { "path": "/detail" },
|
||||
"variant": "body"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
[
|
||||
{
|
||||
"id": "root",
|
||||
"component": "Card",
|
||||
"child": "content"
|
||||
},
|
||||
{
|
||||
"id": "content",
|
||||
"component": "Column",
|
||||
"children": ["title", "route", "meta", "bookButton"]
|
||||
},
|
||||
{
|
||||
"id": "title",
|
||||
"component": "Title",
|
||||
"text": "Flight Details"
|
||||
},
|
||||
{
|
||||
"id": "route",
|
||||
"component": "Row",
|
||||
"justify": "spaceBetween",
|
||||
"align": "center",
|
||||
"children": ["from", "arrow", "to"]
|
||||
},
|
||||
{
|
||||
"id": "from",
|
||||
"component": "Airport",
|
||||
"code": { "path": "/origin" }
|
||||
},
|
||||
{
|
||||
"id": "arrow",
|
||||
"component": "Arrow"
|
||||
},
|
||||
{
|
||||
"id": "to",
|
||||
"component": "Airport",
|
||||
"code": { "path": "/destination" }
|
||||
},
|
||||
{
|
||||
"id": "meta",
|
||||
"component": "Row",
|
||||
"justify": "spaceBetween",
|
||||
"align": "center",
|
||||
"children": ["airline", "price"]
|
||||
},
|
||||
{
|
||||
"id": "airline",
|
||||
"component": "AirlineBadge",
|
||||
"name": { "path": "/airline" }
|
||||
},
|
||||
{
|
||||
"id": "price",
|
||||
"component": "PriceTag",
|
||||
"amount": { "path": "/price" }
|
||||
},
|
||||
{
|
||||
"id": "bookButton",
|
||||
"component": "Button",
|
||||
"variant": "primary",
|
||||
"child": "bookButtonLabel",
|
||||
"action": {
|
||||
"event": {
|
||||
"name": "book_flight",
|
||||
"context": {
|
||||
"origin": { "path": "/origin" },
|
||||
"destination": { "path": "/destination" },
|
||||
"airline": { "path": "/airline" },
|
||||
"price": { "path": "/price" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bookButtonLabel",
|
||||
"component": "Text",
|
||||
"text": "Book flight"
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
"""Claude Agent SDK backing the Agent Config Object demo.
|
||||
|
||||
Reads three forwarded properties — tone, expertise, responseLength — from
|
||||
the AG-UI run's ``forwarded_props`` (which the dedicated runtime route at
|
||||
``/api/copilotkit-agent-config`` repacks from the CopilotKit provider's
|
||||
``properties`` prop) and builds the system prompt dynamically per turn.
|
||||
|
||||
The companion Next.js route
|
||||
(``src/app/api/copilotkit-agent-config/route.ts``) ensures the frontend's
|
||||
``<CopilotKitProvider properties={{tone, expertise, responseLength}}>``
|
||||
values reach the AG-UI ``forwardedProps`` field on the request body, from
|
||||
which this backend reads them.
|
||||
|
||||
Unlike the langgraph-python reference — which routes through
|
||||
``RunnableConfig["configurable"]["properties"]`` inside a LangGraph node
|
||||
— Claude Agent SDK here reads the values directly off the AG-UI run
|
||||
input's ``forwardedProps`` / ``forwarded_props`` envelope before
|
||||
constructing the system prompt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
# @region[agent-config-setup]
|
||||
Tone = Literal["professional", "casual", "enthusiastic"]
|
||||
Expertise = Literal["beginner", "intermediate", "expert"]
|
||||
ResponseLength = Literal["concise", "detailed"]
|
||||
|
||||
DEFAULT_TONE: Tone = "professional"
|
||||
DEFAULT_EXPERTISE: Expertise = "intermediate"
|
||||
DEFAULT_RESPONSE_LENGTH: ResponseLength = "concise"
|
||||
|
||||
VALID_TONES: set[str] = {"professional", "casual", "enthusiastic"}
|
||||
VALID_EXPERTISE: set[str] = {"beginner", "intermediate", "expert"}
|
||||
VALID_RESPONSE_LENGTHS: set[str] = {"concise", "detailed"}
|
||||
|
||||
|
||||
def read_properties(forwarded_props: Any) -> dict[str, str]:
|
||||
"""Read the three config axes with defensive defaults.
|
||||
|
||||
``forwarded_props`` may arrive as either the raw top-level dict (when
|
||||
the Next.js route forwards provider ``properties`` straight through)
|
||||
or nested under ``config.configurable.properties`` (the LangGraph
|
||||
convention the shared runtime route adopts for compatibility). We
|
||||
accept both shapes — unknown values fall back to the defaults; the
|
||||
function never raises.
|
||||
"""
|
||||
if not isinstance(forwarded_props, dict):
|
||||
forwarded_props = {}
|
||||
|
||||
# Prefer the nested shape (mirrors the langgraph-python convention
|
||||
# the dedicated route repacks into) but fall back to top-level keys
|
||||
# so the demo still works if a caller forwards properties directly.
|
||||
nested = ((forwarded_props.get("config") or {}).get("configurable") or {}).get(
|
||||
"properties"
|
||||
) or {}
|
||||
props = nested if isinstance(nested, dict) and nested else forwarded_props
|
||||
|
||||
tone = props.get("tone", DEFAULT_TONE)
|
||||
expertise = props.get("expertise", DEFAULT_EXPERTISE)
|
||||
response_length = props.get("responseLength", DEFAULT_RESPONSE_LENGTH)
|
||||
|
||||
if tone not in VALID_TONES:
|
||||
tone = DEFAULT_TONE
|
||||
if expertise not in VALID_EXPERTISE:
|
||||
expertise = DEFAULT_EXPERTISE
|
||||
if response_length not in VALID_RESPONSE_LENGTHS:
|
||||
response_length = DEFAULT_RESPONSE_LENGTH
|
||||
|
||||
return {
|
||||
"tone": tone,
|
||||
"expertise": expertise,
|
||||
"response_length": response_length,
|
||||
}
|
||||
|
||||
|
||||
def build_system_prompt(tone: str, expertise: str, response_length: str) -> str:
|
||||
"""Compose the system prompt from the three axes."""
|
||||
tone_rules = {
|
||||
"professional": ("Use neutral, precise language. No emoji. Short sentences."),
|
||||
"casual": (
|
||||
"Use friendly, conversational language. Contractions OK. "
|
||||
"Light humor welcome."
|
||||
),
|
||||
"enthusiastic": (
|
||||
"Use upbeat, energetic language. Exclamation points OK. Emoji OK."
|
||||
),
|
||||
}
|
||||
expertise_rules = {
|
||||
"beginner": "Assume no prior knowledge. Define jargon. Use analogies.",
|
||||
"intermediate": (
|
||||
"Assume common terms are understood; explain specialized terms."
|
||||
),
|
||||
"expert": ("Assume technical fluency. Use precise terminology. Skip basics."),
|
||||
}
|
||||
length_rules = {
|
||||
"concise": "Respond in 1-3 sentences.",
|
||||
"detailed": ("Respond in multiple paragraphs with examples where relevant."),
|
||||
}
|
||||
return (
|
||||
"You are a helpful assistant.\n\n"
|
||||
f"Tone: {tone_rules[tone]}\n"
|
||||
f"Expertise level: {expertise_rules[expertise]}\n"
|
||||
f"Response length: {length_rules[response_length]}"
|
||||
)
|
||||
|
||||
|
||||
# @endregion[agent-config-setup]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_TONE",
|
||||
"DEFAULT_EXPERTISE",
|
||||
"DEFAULT_RESPONSE_LENGTH",
|
||||
"VALID_TONES",
|
||||
"VALID_EXPERTISE",
|
||||
"VALID_RESPONSE_LENGTHS",
|
||||
"read_properties",
|
||||
"build_system_prompt",
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Claude Agent SDK backing the byoc-hashbrown demo.
|
||||
|
||||
Emits hashbrown-shaped structured output consumed by
|
||||
``@hashbrownai/react``'s ``useJsonParser`` + ``useUiKit`` on the frontend.
|
||||
|
||||
Wire format
|
||||
-----------
|
||||
``useJsonParser(content, kit.schema)`` expects a streaming JSON object
|
||||
literal matching ``kit.schema`` — NOT the ``<ui>...</ui>`` XML-style
|
||||
examples visible inside ``useUiKit({ examples })``. Those XML examples
|
||||
are hashbrown's prompt DSL used when hashbrown drives the LLM directly
|
||||
(e.g. ``useUiChat``). Because this demo drives the LLM via Claude
|
||||
instead, the backend must mirror hashbrown's 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 the ported
|
||||
``hashbrown-renderer.tsx``. ``pieChart`` and ``barChart`` receive ``data``
|
||||
as a JSON-encoded string to keep the schema stable under partial
|
||||
streaming (PR #4252 rationale).
|
||||
"""
|
||||
|
||||
BYOC_HASHBROWN_SYSTEM_PROMPT = """\
|
||||
You are a sales analytics assistant that replies by emitting a single JSON
|
||||
object consumed by a streaming JSON parser on the frontend.
|
||||
|
||||
ALWAYS respond with a single JSON object of the form:
|
||||
|
||||
{
|
||||
"ui": [
|
||||
{ <componentName>: { "props": { ... } } },
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Do NOT wrap the response in code fences. Do NOT include any preface or
|
||||
explanation outside the JSON object. The response MUST be valid JSON.
|
||||
|
||||
Available components and their prop schemas:
|
||||
|
||||
- "metric": { "props": { "label": string, "value": string } }
|
||||
A KPI card. `value` is a pre-formatted string like "$1.2M" or "248".
|
||||
|
||||
- "pieChart": { "props": { "title": string, "data": string } }
|
||||
A donut chart. `data` is a JSON-encoded STRING (embedded JSON) of an
|
||||
array of {label, value} objects with at least 3 segments, e.g.
|
||||
"data": "[{\\"label\\":\\"Enterprise\\",\\"value\\":600000}]".
|
||||
|
||||
- "barChart": { "props": { "title": string, "data": string } }
|
||||
A vertical bar chart. `data` is a JSON-encoded STRING of an array of
|
||||
{label, value} objects with at least 3 bars, typically time-ordered.
|
||||
|
||||
- "dealCard": { "props": { "title": string, "stage": string, "value": number } }
|
||||
A single sales deal. `stage` MUST be one of: "prospect", "qualified",
|
||||
"proposal", "negotiation", "closed-won", "closed-lost". `value` is a
|
||||
raw number (no currency symbol or comma).
|
||||
|
||||
- "Markdown": { "props": { "children": string } }
|
||||
Short explanatory text. Use for section headings and brief summaries.
|
||||
Standard markdown is supported in `children`.
|
||||
|
||||
Rules:
|
||||
- Always produce plausible sample data when the user asks for a dashboard or
|
||||
chart — do not refuse for lack of data.
|
||||
- Prefer 3-6 rows of data in charts; keep labels short.
|
||||
- Use "Markdown" for short headings or linking sentences between visual
|
||||
components. Do not emit long prose.
|
||||
- Do not emit components that are not listed above.
|
||||
- `data` props on charts MUST be a JSON STRING — escape inner quotes.
|
||||
|
||||
Example response (sales dashboard):
|
||||
{"ui":[{"Markdown":{"props":{"children":"## Q4 Sales Summary"}}},{"metric":{"props":{"label":"Total Revenue","value":"$1.2M"}}},{"metric":{"props":{"label":"New Customers","value":"248"}}},{"pieChart":{"props":{"title":"Revenue by Segment","data":"[{\\"label\\":\\"Enterprise\\",\\"value\\":600000},{\\"label\\":\\"SMB\\",\\"value\\":400000},{\\"label\\":\\"Startup\\",\\"value\\":200000}]"}}},{"barChart":{"props":{"title":"Monthly Revenue","data":"[{\\"label\\":\\"Oct\\",\\"value\\":350000},{\\"label\\":\\"Nov\\",\\"value\\":400000},{\\"label\\":\\"Dec\\",\\"value\\":450000}]"}}}]}
|
||||
"""
|
||||
|
||||
|
||||
__all__ = ["BYOC_HASHBROWN_SYSTEM_PROMPT"]
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Claude Agent SDK backing the BYOC json-render demo.
|
||||
|
||||
Emits a single JSON object shaped like ``@json-render/react``'s flat spec
|
||||
format (``{ root, elements }``) so the frontend can feed it directly into
|
||||
``<Renderer />`` against a Zod-validated catalog of three components —
|
||||
MetricCard, BarChart, PieChart.
|
||||
|
||||
Wire format
|
||||
-----------
|
||||
The agent streams a plain JSON text block (no tool calls, no XML). The
|
||||
frontend's `JsonRenderAssistantMessage` slot parses the streaming content
|
||||
and renders the catalog components when the JSON becomes valid.
|
||||
|
||||
Scenario mirrors the langgraph-python reference exactly — same catalog
|
||||
shapes, same example responses — so the two BYOC rows are directly
|
||||
comparable across frameworks. The only substantive difference is the
|
||||
underlying LLM provider.
|
||||
"""
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
# System prompt pulled into the dedicated endpoint in agent_server.py.
|
||||
# Kept verbatim from the langgraph-python reference so behaviour matches
|
||||
# across frameworks.
|
||||
BYOC_JSON_RENDER_SYSTEM_PROMPT = dedent(
|
||||
"""
|
||||
You are a sales-dashboard UI generator for a BYOC json-render demo.
|
||||
|
||||
When the user asks for a UI, respond with **exactly one JSON object** and
|
||||
nothing else — no prose, no markdown fences, no leading explanation. The
|
||||
object must match this schema (the "flat element map" format consumed by
|
||||
`@json-render/react`):
|
||||
|
||||
{
|
||||
"root": "<id of the root element>",
|
||||
"elements": {
|
||||
"<id>": {
|
||||
"type": "<component name>",
|
||||
"props": { ... component-specific props ... },
|
||||
"children": [ "<id>", ... ]
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
Available components (use each name verbatim as "type"):
|
||||
|
||||
- MetricCard
|
||||
props: { "label": string, "value": string, "trend": string | null }
|
||||
Example trend strings: "+12% vs last quarter", "-3% vs last month", null.
|
||||
|
||||
- BarChart
|
||||
props: {
|
||||
"title": string,
|
||||
"description": string | null,
|
||||
"data": [ { "label": string, "value": number }, ... ]
|
||||
}
|
||||
|
||||
- PieChart
|
||||
props: {
|
||||
"title": string,
|
||||
"description": string | null,
|
||||
"data": [ { "label": string, "value": number }, ... ]
|
||||
}
|
||||
|
||||
Rules:
|
||||
|
||||
1. Output **only** valid JSON. No markdown code fences. No text outside
|
||||
the object.
|
||||
2. Every id referenced in `root` or any `children` array must be a key
|
||||
in `elements`.
|
||||
3. For a multi-component dashboard, use a root MetricCard and list the
|
||||
charts in its `children` array, OR pick any element as root and list
|
||||
the others as its children. Do not emit orphan elements.
|
||||
4. Use realistic sales-domain values (revenue, pipeline, conversion,
|
||||
categories, months) — the demo is a sales dashboard.
|
||||
5. `children` is optional but when present must be an array of strings.
|
||||
6. Never invent component types outside the three listed above.
|
||||
|
||||
### Worked example — "Show me the sales dashboard with metrics and a revenue chart"
|
||||
|
||||
{
|
||||
"root": "revenue-metric",
|
||||
"elements": {
|
||||
"revenue-metric": {
|
||||
"type": "MetricCard",
|
||||
"props": {
|
||||
"label": "Revenue (Q3)",
|
||||
"value": "$1.24M",
|
||||
"trend": "+18% vs Q2"
|
||||
},
|
||||
"children": ["revenue-bar"]
|
||||
},
|
||||
"revenue-bar": {
|
||||
"type": "BarChart",
|
||||
"props": {
|
||||
"title": "Monthly revenue",
|
||||
"description": "Revenue by month across Q3",
|
||||
"data": [
|
||||
{ "label": "Jul", "value": 380000 },
|
||||
{ "label": "Aug", "value": 410000 },
|
||||
{ "label": "Sep", "value": 450000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Worked example — "Break down revenue by category as a pie chart"
|
||||
|
||||
{
|
||||
"root": "category-pie",
|
||||
"elements": {
|
||||
"category-pie": {
|
||||
"type": "PieChart",
|
||||
"props": {
|
||||
"title": "Revenue by category",
|
||||
"description": "Share of total revenue by product category",
|
||||
"data": [
|
||||
{ "label": "Enterprise", "value": 540000 },
|
||||
{ "label": "SMB", "value": 310000 },
|
||||
{ "label": "Self-serve", "value": 220000 },
|
||||
{ "label": "Partner", "value": 170000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Worked example — "Show me monthly expenses as a bar chart"
|
||||
|
||||
{
|
||||
"root": "expense-bar",
|
||||
"elements": {
|
||||
"expense-bar": {
|
||||
"type": "BarChart",
|
||||
"props": {
|
||||
"title": "Monthly expenses",
|
||||
"description": "Operating expenses by month",
|
||||
"data": [
|
||||
{ "label": "Jul", "value": 210000 },
|
||||
{ "label": "Aug", "value": 225000 },
|
||||
{ "label": "Sep", "value": 240000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Respond with the JSON object only.
|
||||
"""
|
||||
).strip()
|
||||
|
||||
|
||||
__all__ = ["BYOC_JSON_RENDER_SYSTEM_PROMPT"]
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Official Claude Agent SDK adapter wiring for the showcase agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from typing import Any
|
||||
|
||||
from ag_ui.core import EventType, RunAgentInput, StateSnapshotEvent
|
||||
from ag_ui.encoder import EventEncoder
|
||||
from ag_ui_claude_sdk import ClaudeAgentAdapter
|
||||
from claude_agent_sdk import create_sdk_mcp_server, tool as sdk_tool
|
||||
|
||||
COPILOTKIT_MCP_SERVER_NAME = "copilotkit"
|
||||
COPILOTKIT_TOOL_PREFIX = f"mcp__{COPILOTKIT_MCP_SERVER_NAME}__"
|
||||
|
||||
ExecuteTool = Callable[
|
||||
[str, dict[str, Any], Any, list[dict[str, Any]] | None],
|
||||
tuple[str, Any | None],
|
||||
]
|
||||
|
||||
|
||||
def should_use_claude_agent_sdk(
|
||||
*,
|
||||
input_data: RunAgentInput,
|
||||
backend_tools: list[dict[str, Any]],
|
||||
frontend_tool_names: set[str],
|
||||
preprocess_user_parts: Any = None,
|
||||
) -> bool:
|
||||
"""Return whether this request can safely run through the official adapter."""
|
||||
|
||||
base_url = os.getenv("ANTHROPIC_BASE_URL", "")
|
||||
if "aimock" in base_url:
|
||||
return False
|
||||
|
||||
# The official adapter uses the Claude Agent SDK process transport, which
|
||||
# has no supported per-request HTTP header hook for x-aimock-context today.
|
||||
if preprocess_user_parts is not None:
|
||||
return False
|
||||
|
||||
# The official Claude Agent SDK path can execute backend MCP tools, but it
|
||||
# does not yet bridge CopilotKit frontend/runtime tools back through AG-UI.
|
||||
if frontend_tool_names:
|
||||
return False
|
||||
|
||||
if _has_structured_user_content(input_data):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# @region[claude-agent-sdk-python-adapter]
|
||||
# @region[claude-agent-sdk-agent-setup]
|
||||
async def run_with_claude_agent_sdk(
|
||||
input_data: RunAgentInput,
|
||||
*,
|
||||
system_prompt: str,
|
||||
tools: list[dict[str, Any]],
|
||||
state: Any,
|
||||
model: str,
|
||||
execute_tool: ExecuteTool,
|
||||
max_turns: int = 10,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Run through the official AG-UI Claude adapter and emit SSE chunks."""
|
||||
|
||||
encoder = EventEncoder()
|
||||
state_box = {"state": state}
|
||||
pending_state_snapshots: list[Any] = []
|
||||
sdk_tools = _build_sdk_tools(
|
||||
tools,
|
||||
execute_tool=execute_tool,
|
||||
get_state=lambda: state_box["state"],
|
||||
set_state=lambda next_state: _set_state(
|
||||
next_state,
|
||||
state_box,
|
||||
pending_state_snapshots,
|
||||
),
|
||||
)
|
||||
|
||||
options: dict[str, Any] = {
|
||||
"model": _normalize_claude_agent_sdk_model(model),
|
||||
"system_prompt": system_prompt,
|
||||
"tools": [],
|
||||
"permission_mode": "dontAsk",
|
||||
"max_turns": max_turns,
|
||||
}
|
||||
|
||||
if sdk_tools:
|
||||
options["mcp_servers"] = {
|
||||
COPILOTKIT_MCP_SERVER_NAME: create_sdk_mcp_server(
|
||||
COPILOTKIT_MCP_SERVER_NAME,
|
||||
"1.0.0",
|
||||
tools=sdk_tools,
|
||||
)
|
||||
}
|
||||
options["allowed_tools"] = [
|
||||
f"{COPILOTKIT_TOOL_PREFIX}{schema['name']}" for schema in tools
|
||||
]
|
||||
|
||||
adapter = ClaudeAgentAdapter(
|
||||
name="claude-sdk-python",
|
||||
options=options,
|
||||
)
|
||||
run_input = _with_initial_state(input_data, state)
|
||||
|
||||
async for event in adapter.run(run_input):
|
||||
if event.type == EventType.TOOL_CALL_RESULT and pending_state_snapshots:
|
||||
yield encoder.encode(
|
||||
StateSnapshotEvent(
|
||||
type=EventType.STATE_SNAPSHOT,
|
||||
snapshot=pending_state_snapshots.pop(0),
|
||||
)
|
||||
)
|
||||
yield encoder.encode(event)
|
||||
|
||||
|
||||
# @endregion[claude-agent-sdk-agent-setup]
|
||||
|
||||
|
||||
def _build_sdk_tools(
|
||||
tool_schemas: list[dict[str, Any]],
|
||||
*,
|
||||
execute_tool: ExecuteTool,
|
||||
get_state: Callable[[], Any],
|
||||
set_state: Callable[[Any], None],
|
||||
) -> list[Any]:
|
||||
return [
|
||||
_make_sdk_tool(
|
||||
schema,
|
||||
execute_tool=execute_tool,
|
||||
get_state=get_state,
|
||||
set_state=set_state,
|
||||
)
|
||||
for schema in tool_schemas
|
||||
]
|
||||
|
||||
|
||||
def _make_sdk_tool(
|
||||
schema: dict[str, Any],
|
||||
*,
|
||||
execute_tool: ExecuteTool,
|
||||
get_state: Callable[[], Any],
|
||||
set_state: Callable[[Any], None],
|
||||
) -> Any:
|
||||
name = schema["name"]
|
||||
description = schema.get("description", "")
|
||||
input_schema = schema.get("input_schema", {"type": "object", "properties": {}})
|
||||
|
||||
@sdk_tool(name, description, input_schema)
|
||||
async def sdk_tool_handler(args: dict[str, Any]):
|
||||
try:
|
||||
result_text, next_state = execute_tool(
|
||||
name,
|
||||
dict(args or {}),
|
||||
get_state(),
|
||||
None,
|
||||
)
|
||||
if next_state is not None:
|
||||
set_state(next_state)
|
||||
return {"content": [{"type": "text", "text": result_text}]}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"content": [{"type": "text", "text": str(exc)}],
|
||||
"is_error": True,
|
||||
}
|
||||
|
||||
return sdk_tool_handler
|
||||
|
||||
|
||||
def _set_state(
|
||||
next_state: Any,
|
||||
state_box: dict[str, Any],
|
||||
pending_state_snapshots: list[Any],
|
||||
) -> None:
|
||||
state_box["state"] = next_state
|
||||
pending_state_snapshots.append(_snapshot_from_state(next_state))
|
||||
|
||||
|
||||
def _snapshot_from_state(state: Any) -> Any:
|
||||
if hasattr(state, "model_dump"):
|
||||
return state.model_dump()
|
||||
return state
|
||||
|
||||
|
||||
def _with_initial_state(input_data: RunAgentInput, state: Any) -> RunAgentInput:
|
||||
if getattr(input_data, "state", None) is not None or state is None:
|
||||
return input_data
|
||||
if hasattr(input_data, "model_copy"):
|
||||
return input_data.model_copy(update={"state": state})
|
||||
if hasattr(input_data, "copy"):
|
||||
return input_data.copy(update={"state": state})
|
||||
return input_data
|
||||
|
||||
|
||||
def _has_structured_user_content(input_data: RunAgentInput) -> bool:
|
||||
for msg in input_data.messages or []:
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
raw_content = getattr(msg, "content", None)
|
||||
if role == "user" and isinstance(raw_content, list):
|
||||
for part in raw_content:
|
||||
part_type = getattr(part, "type", None) or (
|
||||
part.get("type") if isinstance(part, dict) else None
|
||||
)
|
||||
if part_type and part_type != "text":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def normalize_claude_model(model: str) -> str:
|
||||
return "claude-sonnet-4-6" if model == "claude-sonnet-4.6" else model
|
||||
|
||||
|
||||
def _normalize_claude_agent_sdk_model(model: str) -> str:
|
||||
return normalize_claude_model(model)
|
||||
|
||||
|
||||
# @endregion[claude-agent-sdk-python-adapter]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Claude Agent SDK backing the Frontend Tools demo.
|
||||
|
||||
The demo illustrates `useFrontendTool` with a sync handler. The frontend
|
||||
registers a `change_background` tool; CopilotKit forwards its schema to
|
||||
the agent at runtime and the handler executes in the browser.
|
||||
|
||||
The shared Claude backend in `src/agents/agent.py` already accepts
|
||||
frontend-registered tool schemas via AG-UI message forwarding, so this
|
||||
module is documentation-only — there is no separate Python graph to
|
||||
mount for this cell. The agent instance served by `agent_server.py`
|
||||
handles the `frontend-tools` agent name via the route.ts registration.
|
||||
"""
|
||||
|
||||
# The demo shares the default Claude agent (see src/agents/agent.py).
|
||||
# This module exists so the manifest's `highlight` paths can point to a
|
||||
# per-demo Python reference, mirroring the langgraph-python layout.
|
||||
|
||||
SYSTEM_PROMPT_HINT = (
|
||||
"You are a helpful assistant. The frontend has registered a "
|
||||
"`change_background` tool via `useFrontendTool`. When the user asks "
|
||||
"to change the background, call that tool with a CSS-valid background "
|
||||
"value (prefer gradients)."
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Claude Agent SDK backing the Frontend Tools (Async) demo.
|
||||
|
||||
This cell demonstrates `useFrontendTool` with an ASYNC handler. The
|
||||
frontend registers a `query_notes` tool whose handler awaits a simulated
|
||||
client-side DB query (500ms latency) and returns matching notes. The
|
||||
agent uses the returned result to summarize what it found.
|
||||
|
||||
Like the sibling `frontend_tools` cell, the backend registers no tools
|
||||
of its own — CopilotKit forwards the frontend tool schema(s) to the
|
||||
agent at runtime, and the handler executes in the browser. The shared
|
||||
Claude backend in `src/agents/agent.py` handles all demo routes.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT_HINT = (
|
||||
"You are a helpful assistant that can search the user's personal notes. "
|
||||
"When the user asks about their notes, call the `query_notes` tool with "
|
||||
"a concise keyword extracted from their request. The tool is provided "
|
||||
"by the frontend at runtime and runs entirely in the user's browser — "
|
||||
"you do not need to implement it yourself. After the tool returns, "
|
||||
"summarize the matching notes clearly and concisely. If no notes match, "
|
||||
"say so plainly and offer to try a different keyword."
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Claude Agent SDK backing the In-App HITL (frontend-tool + popup) demo.
|
||||
|
||||
The agent is a support assistant that processes customer-care requests
|
||||
(refunds, account changes, escalations). Any action that materially
|
||||
affects a customer MUST be confirmed by the human operator via the
|
||||
frontend-provided `request_user_approval` tool.
|
||||
|
||||
The tool is defined on the frontend via `useFrontendTool` with an async
|
||||
handler that opens a modal dialog OUTSIDE the chat surface. The handler
|
||||
awaits the user's decision and resolves with
|
||||
`{"approved": bool, "reason": str}`. The agent treats that result as
|
||||
authoritative: if `approved` is `True`, continue; otherwise, stop and
|
||||
explain the decision back to the user.
|
||||
|
||||
The shared Claude backend in `src/agents/agent.py` handles this demo
|
||||
via the `hitl-in-app` agent name registered in the copilotkit route.
|
||||
This module exists so the manifest's `highlight` path references a
|
||||
per-demo Python reference, mirroring the langgraph-python layout.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT_HINT = (
|
||||
"You are a support operations copilot working alongside a human operator "
|
||||
"inside an internal support console. Whenever the operator asks you to "
|
||||
"take an action that affects a customer — for example: issuing a refund, "
|
||||
"updating a customer's plan, cancelling a subscription, escalating a "
|
||||
"ticket, or sending an apology credit — you MUST first call the "
|
||||
"frontend-provided `request_user_approval` tool to obtain the operator's "
|
||||
"explicit consent. The tool returns an object of the shape "
|
||||
"{'approved': bool, 'reason': str | null}. If approved, confirm in one "
|
||||
"short sentence; if rejected, acknowledge in one short sentence and "
|
||||
"reflect the operator's reason back to them. Do NOT retry."
|
||||
)
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Claude Agent SDK backend for the In-Chat HITL (useHumanInTheLoop) demo.
|
||||
|
||||
The `book_call` tool is defined on the FRONTEND via `useHumanInTheLoop`,
|
||||
so there is no backend tool here. The agent simply responds in chat and
|
||||
relies on the standard frontend-tool / tool-call lifecycle to invoke
|
||||
`book_call` when the user asks to book.
|
||||
|
||||
Mirrors the langgraph-python `hitl_in_chat_agent.py` reference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from collections.abc import AsyncIterator
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
RunAgentInput,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from ag_ui.encoder import EventEncoder
|
||||
|
||||
from agents.claude_agent_sdk_adapter import normalize_claude_model
|
||||
|
||||
|
||||
SYSTEM_PROMPT = dedent("""
|
||||
You help users book an onboarding call with the sales team. When they
|
||||
ask to book a call, call the frontend-provided `book_call` tool with a
|
||||
short topic and the user's name (use a sensible placeholder like
|
||||
'Alice from Sales' if no attendee was specified). Keep any chat reply
|
||||
to one short sentence.
|
||||
""").strip()
|
||||
|
||||
|
||||
async def run_hitl_in_chat_agent(input_data: RunAgentInput) -> AsyncIterator[str]:
|
||||
"""Stream a Claude response that may call the frontend `book_call` tool.
|
||||
|
||||
`book_call` is defined on the frontend via `useHumanInTheLoop`. AG-UI
|
||||
forwards frontend tool definitions in `input_data.tools`, so we just
|
||||
pass them straight to Claude and let the standard tool-call lifecycle
|
||||
resolve the user's choice back through CopilotKit.
|
||||
"""
|
||||
encoder = EventEncoder()
|
||||
client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
|
||||
|
||||
# Convert AG-UI messages to Anthropic format.
|
||||
#
|
||||
# AG-UI delivers three message roles:
|
||||
# - "user" → plain user text
|
||||
# - "assistant" → assistant text + optional tool_use blocks
|
||||
# - "tool" → tool result from a resolved frontend tool
|
||||
#
|
||||
# When the CopilotKit runtime re-invokes this agent after the user
|
||||
# resolves a frontend tool (e.g. picks a time slot in the book_call
|
||||
# HITL UI), the messages array includes:
|
||||
# 1. assistant message with tool_use content (the original tool call)
|
||||
# 2. tool message with the resolved result
|
||||
#
|
||||
# Anthropic's Messages API represents tool results as a "user" role
|
||||
# message with content blocks of type "tool_result". We must convert
|
||||
# AG-UI "tool" messages into that shape, and assistant messages with
|
||||
# tool_use content into Anthropic's structured format, so the LLM
|
||||
# sees the full conversation and aimock's ``hasToolResult`` matcher
|
||||
# fires correctly.
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in input_data.messages or []:
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
|
||||
# Handle tool result messages from AG-UI (resolved frontend tools).
|
||||
if role == "tool":
|
||||
tool_call_id = getattr(msg, "tool_call_id", None) or (
|
||||
getattr(msg, "toolCallId", None)
|
||||
)
|
||||
raw = getattr(msg, "content", None)
|
||||
result_text = ""
|
||||
if isinstance(raw, str):
|
||||
result_text = raw
|
||||
elif isinstance(raw, list):
|
||||
parts = []
|
||||
for part in raw:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
parts_text = "".join(parts)
|
||||
if parts_text:
|
||||
result_text = parts_text
|
||||
else:
|
||||
result_text = json.dumps(raw)
|
||||
if tool_call_id:
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_call_id,
|
||||
"content": result_text,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
|
||||
raw = getattr(msg, "content", None)
|
||||
|
||||
# For assistant messages, check for tool calls (AG-UI's
|
||||
# AssistantMessage stores them in `tool_calls`, not in `content`).
|
||||
# Anthropic requires tool_use blocks in the assistant content so
|
||||
# the subsequent tool_result can pair with them.
|
||||
if role == "assistant":
|
||||
msg_tool_calls = getattr(msg, "tool_calls", None)
|
||||
text_content = ""
|
||||
if isinstance(raw, str):
|
||||
text_content = raw
|
||||
elif isinstance(raw, list):
|
||||
for part in raw:
|
||||
if hasattr(part, "text"):
|
||||
text_content += part.text
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
text_content += part["text"]
|
||||
|
||||
if msg_tool_calls:
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
if text_content:
|
||||
content_blocks.append({"type": "text", "text": text_content})
|
||||
for tc in msg_tool_calls:
|
||||
tc_id = getattr(tc, "id", None) or (
|
||||
tc.get("id") if isinstance(tc, dict) else None
|
||||
)
|
||||
func = getattr(tc, "function", None) or (
|
||||
tc.get("function") if isinstance(tc, dict) else None
|
||||
)
|
||||
if func:
|
||||
tc_name = getattr(func, "name", None) or (
|
||||
func.get("name") if isinstance(func, dict) else "unknown"
|
||||
)
|
||||
tc_args_str = getattr(func, "arguments", None) or (
|
||||
func.get("arguments", "{}")
|
||||
if isinstance(func, dict)
|
||||
else "{}"
|
||||
)
|
||||
else:
|
||||
tc_name = "unknown"
|
||||
tc_args_str = "{}"
|
||||
try:
|
||||
tc_args = (
|
||||
json.loads(tc_args_str)
|
||||
if isinstance(tc_args_str, str)
|
||||
else tc_args_str
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
tc_args = {}
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tc_id or "unknown",
|
||||
"name": tc_name,
|
||||
"input": tc_args,
|
||||
}
|
||||
)
|
||||
messages.append({"role": "assistant", "content": content_blocks})
|
||||
continue
|
||||
elif text_content:
|
||||
messages.append({"role": "assistant", "content": text_content})
|
||||
continue
|
||||
|
||||
content = ""
|
||||
if isinstance(raw, str):
|
||||
content = raw
|
||||
elif isinstance(raw, list):
|
||||
parts = []
|
||||
for part in raw:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
content = "".join(parts)
|
||||
if content:
|
||||
messages.append({"role": role, "content": content})
|
||||
|
||||
# Forward frontend-defined tools (including useHumanInTheLoop's `book_call`)
|
||||
# to Claude. AG-UI sends them in `input_data.tools` with JSON-Schema
|
||||
# parameters; Claude expects `input_schema` of the same shape.
|
||||
tools: list[dict[str, Any]] = []
|
||||
for t in input_data.tools or []:
|
||||
# AG-UI Tool schema: { name, description, parameters }
|
||||
name = getattr(t, "name", None) or (
|
||||
t.get("name") if isinstance(t, dict) else None
|
||||
)
|
||||
description = getattr(t, "description", None) or (
|
||||
t.get("description", "") if isinstance(t, dict) else ""
|
||||
)
|
||||
parameters = getattr(t, "parameters", None) or (
|
||||
t.get("parameters", {}) if isinstance(t, dict) else {}
|
||||
)
|
||||
if not name:
|
||||
continue
|
||||
tools.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": description or "",
|
||||
"input_schema": parameters or {"type": "object", "properties": {}},
|
||||
}
|
||||
)
|
||||
|
||||
thread_id = input_data.thread_id or "default"
|
||||
run_id = input_data.run_id or "run-1"
|
||||
|
||||
yield encoder.encode(
|
||||
RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id)
|
||||
)
|
||||
|
||||
msg_id = f"msg-{run_id}-0"
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
|
||||
stream_kwargs: dict[str, Any] = {
|
||||
"model": normalize_claude_model(
|
||||
os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4.6")
|
||||
),
|
||||
"max_tokens": 1024,
|
||||
"system": SYSTEM_PROMPT,
|
||||
"messages": messages,
|
||||
}
|
||||
if tools:
|
||||
stream_kwargs["tools"] = tools # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
async with client.messages.stream(**stream_kwargs) as stream:
|
||||
current_tool_id: str | None = None
|
||||
current_tool_name: str | None = None
|
||||
|
||||
async for event in stream:
|
||||
etype = type(event).__name__
|
||||
|
||||
if etype == "RawContentBlockStartEvent":
|
||||
block = event.content_block # type: ignore[attr-defined]
|
||||
if block.type == "tool_use":
|
||||
current_tool_id = block.id
|
||||
current_tool_name = block.name
|
||||
yield encoder.encode(
|
||||
ToolCallStartEvent(
|
||||
type=EventType.TOOL_CALL_START,
|
||||
tool_call_id=current_tool_id,
|
||||
tool_call_name=current_tool_name,
|
||||
parent_message_id=msg_id,
|
||||
)
|
||||
)
|
||||
|
||||
elif etype == "RawContentBlockDeltaEvent":
|
||||
delta = event.delta # type: ignore[attr-defined]
|
||||
if delta.type == "text_delta":
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=delta.text,
|
||||
)
|
||||
)
|
||||
elif delta.type == "input_json_delta":
|
||||
yield encoder.encode(
|
||||
ToolCallArgsEvent(
|
||||
type=EventType.TOOL_CALL_ARGS,
|
||||
tool_call_id=current_tool_id or "",
|
||||
delta=delta.partial_json,
|
||||
)
|
||||
)
|
||||
|
||||
elif etype in (
|
||||
"RawContentBlockStopEvent",
|
||||
"ParsedContentBlockStopEvent",
|
||||
):
|
||||
if current_tool_id:
|
||||
yield encoder.encode(
|
||||
ToolCallEndEvent(
|
||||
type=EventType.TOOL_CALL_END,
|
||||
tool_call_id=current_tool_id,
|
||||
)
|
||||
)
|
||||
current_tool_id = None
|
||||
current_tool_name = None
|
||||
except Exception:
|
||||
err_text = f"Agent error: {traceback.format_exc()}"
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=err_text,
|
||||
)
|
||||
)
|
||||
|
||||
yield encoder.encode(
|
||||
TextMessageEndEvent(
|
||||
type=EventType.TEXT_MESSAGE_END,
|
||||
message_id=msg_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Frontend (`useHumanInTheLoop`) resolves `book_call` and the runtime
|
||||
# injects the resolution back into a follow-up turn. Each turn is its
|
||||
# own POST so we don't loop here — emitting RUN_FINISHED returns control
|
||||
# to the runtime, which will re-invoke us with the resolved tool result.
|
||||
yield encoder.encode(
|
||||
RunFinishedEvent(
|
||||
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Claude Agent SDK backend for the interrupt-adapted demos.
|
||||
|
||||
This agent powers two demos (gen-ui-interrupt, interrupt-headless) that in the
|
||||
LangGraph showcase rely on the native ``interrupt()`` primitive with
|
||||
checkpoint/resume. The Claude Agent SDK does NOT have that primitive, so we
|
||||
adapt by delegating the time-picker interaction to a **frontend tool** that the
|
||||
agent calls by name (``schedule_meeting``). The frontend registers the tool via
|
||||
``useFrontendTool`` with an async handler; that handler renders the interactive
|
||||
picker, waits for the user to choose a slot (or cancel), and resolves the tool
|
||||
call with the result. The backend only defines the system prompt and advertises
|
||||
no local ``schedule_meeting`` implementation — the agent's tool call is satisfied
|
||||
entirely by the frontend.
|
||||
|
||||
Mirrors the ms-agent-python ``interrupt_agent.py`` reference (Strategy B).
|
||||
"""
|
||||
|
||||
# @region[backend-interrupt-tool]
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from collections.abc import AsyncIterator
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
RunAgentInput,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from ag_ui.encoder import EventEncoder
|
||||
|
||||
from agents.claude_agent_sdk_adapter import normalize_claude_model
|
||||
|
||||
|
||||
# @region[backend-tool-call]
|
||||
SYSTEM_PROMPT = dedent("""
|
||||
You are a scheduling assistant. Whenever the user asks you to book a call
|
||||
or schedule a meeting, you MUST call the `schedule_meeting` tool. Pass a
|
||||
short `topic` describing the purpose of the meeting and, if known, an
|
||||
`attendee` describing who the meeting is with.
|
||||
|
||||
The `schedule_meeting` tool is implemented on the client: it surfaces a
|
||||
time-picker UI to the user and returns the user's selection. After the
|
||||
tool returns, briefly confirm whether the meeting was scheduled and at
|
||||
what time, or note that the user cancelled. Do NOT ask for approval
|
||||
yourself — always call the tool and let the picker handle the decision.
|
||||
|
||||
Keep responses short and friendly. After you finish executing tools,
|
||||
always send a brief final assistant message summarizing what happened so
|
||||
the message persists.
|
||||
""").strip()
|
||||
# @endregion[backend-tool-call]
|
||||
# @endregion[backend-interrupt-tool]
|
||||
|
||||
|
||||
async def run_interrupt_agent(input_data: RunAgentInput) -> AsyncIterator[str]:
|
||||
"""Stream a Claude response that may call the frontend ``schedule_meeting`` tool.
|
||||
|
||||
``schedule_meeting`` is defined on the frontend via ``useFrontendTool``.
|
||||
AG-UI forwards frontend tool definitions in ``input_data.tools``, so we
|
||||
just pass them straight to Claude and let the standard tool-call lifecycle
|
||||
resolve the user's choice back through CopilotKit.
|
||||
"""
|
||||
encoder = EventEncoder()
|
||||
client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
|
||||
|
||||
# Convert AG-UI messages to Anthropic format.
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in input_data.messages or []:
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
|
||||
# Handle tool result messages from AG-UI (resolved frontend tools).
|
||||
if role == "tool":
|
||||
tool_call_id = getattr(msg, "tool_call_id", None) or (
|
||||
getattr(msg, "toolCallId", None)
|
||||
)
|
||||
raw = getattr(msg, "content", None)
|
||||
result_text = ""
|
||||
if isinstance(raw, str):
|
||||
result_text = raw
|
||||
elif isinstance(raw, list):
|
||||
parts = []
|
||||
for part in raw:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
parts_text = "".join(parts)
|
||||
if parts_text:
|
||||
result_text = parts_text
|
||||
else:
|
||||
result_text = json.dumps(raw)
|
||||
if tool_call_id:
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_call_id,
|
||||
"content": result_text,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
|
||||
raw = getattr(msg, "content", None)
|
||||
|
||||
# For assistant messages, check for tool calls.
|
||||
if role == "assistant":
|
||||
msg_tool_calls = getattr(msg, "tool_calls", None)
|
||||
text_content = ""
|
||||
if isinstance(raw, str):
|
||||
text_content = raw
|
||||
elif isinstance(raw, list):
|
||||
for part in raw:
|
||||
if hasattr(part, "text"):
|
||||
text_content += part.text
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
text_content += part["text"]
|
||||
|
||||
if msg_tool_calls:
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
if text_content:
|
||||
content_blocks.append({"type": "text", "text": text_content})
|
||||
for tc in msg_tool_calls:
|
||||
tc_id = getattr(tc, "id", None) or (
|
||||
tc.get("id") if isinstance(tc, dict) else None
|
||||
)
|
||||
func = getattr(tc, "function", None) or (
|
||||
tc.get("function") if isinstance(tc, dict) else None
|
||||
)
|
||||
if func:
|
||||
tc_name = getattr(func, "name", None) or (
|
||||
func.get("name") if isinstance(func, dict) else "unknown"
|
||||
)
|
||||
tc_args_str = getattr(func, "arguments", None) or (
|
||||
func.get("arguments", "{}")
|
||||
if isinstance(func, dict)
|
||||
else "{}"
|
||||
)
|
||||
else:
|
||||
tc_name = "unknown"
|
||||
tc_args_str = "{}"
|
||||
try:
|
||||
tc_args = (
|
||||
json.loads(tc_args_str)
|
||||
if isinstance(tc_args_str, str)
|
||||
else tc_args_str
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
tc_args = {}
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tc_id or "unknown",
|
||||
"name": tc_name,
|
||||
"input": tc_args,
|
||||
}
|
||||
)
|
||||
messages.append({"role": "assistant", "content": content_blocks})
|
||||
continue
|
||||
elif text_content:
|
||||
messages.append({"role": "assistant", "content": text_content})
|
||||
continue
|
||||
|
||||
content = ""
|
||||
if isinstance(raw, str):
|
||||
content = raw
|
||||
elif isinstance(raw, list):
|
||||
parts = []
|
||||
for part in raw:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
content = "".join(parts)
|
||||
if content:
|
||||
messages.append({"role": role, "content": content})
|
||||
|
||||
# Forward frontend-defined tools (including useFrontendTool's `schedule_meeting`)
|
||||
# to Claude. AG-UI sends them in `input_data.tools` with JSON-Schema
|
||||
# parameters; Claude expects `input_schema` of the same shape.
|
||||
tools: list[dict[str, Any]] = []
|
||||
for t in input_data.tools or []:
|
||||
name = getattr(t, "name", None) or (
|
||||
t.get("name") if isinstance(t, dict) else None
|
||||
)
|
||||
description = getattr(t, "description", None) or (
|
||||
t.get("description", "") if isinstance(t, dict) else ""
|
||||
)
|
||||
parameters = getattr(t, "parameters", None) or (
|
||||
t.get("parameters", {}) if isinstance(t, dict) else {}
|
||||
)
|
||||
if not name:
|
||||
continue
|
||||
tools.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": description or "",
|
||||
"input_schema": parameters or {"type": "object", "properties": {}},
|
||||
}
|
||||
)
|
||||
|
||||
thread_id = input_data.thread_id or "default"
|
||||
run_id = input_data.run_id or "run-1"
|
||||
|
||||
yield encoder.encode(
|
||||
RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id)
|
||||
)
|
||||
|
||||
msg_id = f"msg-{run_id}-0"
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
|
||||
stream_kwargs: dict[str, Any] = {
|
||||
"model": normalize_claude_model(
|
||||
os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4.6")
|
||||
),
|
||||
"max_tokens": 1024,
|
||||
"system": SYSTEM_PROMPT,
|
||||
"messages": messages,
|
||||
}
|
||||
if tools:
|
||||
stream_kwargs["tools"] = tools
|
||||
|
||||
try:
|
||||
async with client.messages.stream(**stream_kwargs) as stream:
|
||||
current_tool_id: str | None = None
|
||||
current_tool_name: str | None = None
|
||||
|
||||
async for event in stream:
|
||||
etype = type(event).__name__
|
||||
|
||||
if etype == "RawContentBlockStartEvent":
|
||||
block = event.content_block # type: ignore[attr-defined]
|
||||
if block.type == "tool_use":
|
||||
current_tool_id = block.id
|
||||
current_tool_name = block.name
|
||||
yield encoder.encode(
|
||||
ToolCallStartEvent(
|
||||
type=EventType.TOOL_CALL_START,
|
||||
tool_call_id=current_tool_id,
|
||||
tool_call_name=current_tool_name,
|
||||
parent_message_id=msg_id,
|
||||
)
|
||||
)
|
||||
|
||||
elif etype == "RawContentBlockDeltaEvent":
|
||||
delta = event.delta # type: ignore[attr-defined]
|
||||
if delta.type == "text_delta":
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=delta.text,
|
||||
)
|
||||
)
|
||||
elif delta.type == "input_json_delta":
|
||||
yield encoder.encode(
|
||||
ToolCallArgsEvent(
|
||||
type=EventType.TOOL_CALL_ARGS,
|
||||
tool_call_id=current_tool_id or "",
|
||||
delta=delta.partial_json,
|
||||
)
|
||||
)
|
||||
|
||||
elif etype in (
|
||||
"RawContentBlockStopEvent",
|
||||
"ParsedContentBlockStopEvent",
|
||||
):
|
||||
if current_tool_id:
|
||||
yield encoder.encode(
|
||||
ToolCallEndEvent(
|
||||
type=EventType.TOOL_CALL_END,
|
||||
tool_call_id=current_tool_id,
|
||||
)
|
||||
)
|
||||
current_tool_id = None
|
||||
current_tool_name = None
|
||||
except Exception:
|
||||
err_text = f"Agent error: {traceback.format_exc()}"
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=err_text,
|
||||
)
|
||||
)
|
||||
|
||||
yield encoder.encode(
|
||||
TextMessageEndEvent(
|
||||
type=EventType.TEXT_MESSAGE_END,
|
||||
message_id=msg_id,
|
||||
)
|
||||
)
|
||||
|
||||
yield encoder.encode(
|
||||
RunFinishedEvent(
|
||||
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Claude Agent SDK (Python) backend for the CopilotKit MCP Apps demo.
|
||||
|
||||
This agent has no bespoke tools — the CopilotKit runtime is wired with
|
||||
``mcpApps: { servers: [...] }`` pointing at the public Excalidraw MCP
|
||||
server (see ``src/app/api/copilotkit-mcp-apps/route.ts``). The runtime
|
||||
auto-applies the MCP Apps middleware, which appends the remote MCP
|
||||
server's tools to the AG-UI tool list forwarded to this agent on every
|
||||
request and emits the activity events that CopilotKit's built-in
|
||||
``MCPAppsActivityRenderer`` renders in the chat as a sandboxed iframe.
|
||||
|
||||
Implementation note:
|
||||
The shared ``run_agent`` in ``src/agents/agent.py`` ships a fixed
|
||||
sales-assistant tool registry (``TOOLS``) and ignores
|
||||
``input_data.tools``. For MCP Apps we want the OPPOSITE — no
|
||||
bespoke tools, only the MCP-injected tools forwarded by the
|
||||
runtime. So this module owns its own streaming loop that:
|
||||
|
||||
1. Builds the Anthropic ``tools`` list directly from
|
||||
``input_data.tools`` (the MCP middleware injects them there).
|
||||
2. Streams Anthropic SSE through to AG-UI events.
|
||||
3. Pass-through: when Claude emits ``tool_use``, we emit
|
||||
``TOOL_CALL_*`` events and stop. The MCP Apps middleware on the
|
||||
runtime layer intercepts the call, fetches the UI resource,
|
||||
emits the activity event, and re-invokes us with the tool
|
||||
result. No server-side tool execution loop here.
|
||||
|
||||
Reference:
|
||||
https://docs.copilotkit.ai/integrations/claude-agent-sdk-python/generative-ui/mcp-apps
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from collections.abc import AsyncIterator
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
RunAgentInput,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from ag_ui.encoder import EventEncoder
|
||||
|
||||
from agents.claude_agent_sdk_adapter import normalize_claude_model
|
||||
|
||||
|
||||
SYSTEM_PROMPT = dedent(
|
||||
"""
|
||||
You draw simple diagrams in Excalidraw via the MCP tool.
|
||||
|
||||
SPEED MATTERS. Produce a correct-enough diagram fast; do not optimize
|
||||
for polish. Target: one tool call, done in seconds.
|
||||
|
||||
When the user asks for a diagram:
|
||||
1. Call `create_view` ONCE with 3-5 elements total: shapes + arrows +
|
||||
an optional title text.
|
||||
2. Use straightforward shapes (rectangle, ellipse, diamond) with plain
|
||||
`label` fields (`{"text": "...", "fontSize": 18}`) on them.
|
||||
3. Connect with arrows. Endpoints can be element centers or simple
|
||||
coordinates — you don't need edge anchors / fixedPoint bindings.
|
||||
4. Include ONE `cameraUpdate` at the END of the elements array that
|
||||
frames the whole diagram. Use an approved 4:3 size (600x450 or
|
||||
800x600). No opening camera needed.
|
||||
5. Reply with ONE short sentence describing what you drew.
|
||||
|
||||
Every element needs a unique string `id` (e.g. `"b1"`, `"a1"`,
|
||||
`"title"`). Standard sizes: rectangles 160x70, ellipses/diamonds
|
||||
120x80, 40-80px gap between shapes.
|
||||
|
||||
Do NOT:
|
||||
- Call `read_me`. You already know the basic shape API.
|
||||
- Make multiple `create_view` calls.
|
||||
- Iterate or refine. Ship on the first shot.
|
||||
- Add decorative colors / fills / zone backgrounds unless the user
|
||||
explicitly asks for them.
|
||||
- Add labels on arrows unless crucial.
|
||||
|
||||
If the user asks for something specific (colors, more elements,
|
||||
particular layout), follow their lead — but still in ONE call.
|
||||
"""
|
||||
).strip()
|
||||
|
||||
|
||||
def _build_anthropic_tools(input_tools: list[Any] | None) -> list[dict[str, Any]]:
|
||||
"""Map AG-UI ``input_data.tools`` into Anthropic ``tools`` schemas.
|
||||
|
||||
The MCP Apps middleware appends MCP server tools to ``input_data.tools``
|
||||
on every request. We forward them to Anthropic verbatim so Claude
|
||||
can pick the right MCP tool to call.
|
||||
"""
|
||||
if not input_tools:
|
||||
return []
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for tool in input_tools:
|
||||
name = getattr(tool, "name", None) or (
|
||||
tool.get("name") if isinstance(tool, dict) else None
|
||||
)
|
||||
if not name:
|
||||
continue
|
||||
description = getattr(tool, "description", None) or (
|
||||
tool.get("description") if isinstance(tool, dict) else ""
|
||||
)
|
||||
parameters = getattr(tool, "parameters", None)
|
||||
if parameters is None and isinstance(tool, dict):
|
||||
parameters = tool.get("parameters")
|
||||
|
||||
# ``parameters`` is a JSON schema (or a JSON-encoded string).
|
||||
if isinstance(parameters, str):
|
||||
try:
|
||||
parameters = json.loads(parameters)
|
||||
except json.JSONDecodeError:
|
||||
parameters = {"type": "object", "properties": {}}
|
||||
if not isinstance(parameters, dict):
|
||||
parameters = {"type": "object", "properties": {}}
|
||||
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": description or "",
|
||||
"input_schema": parameters,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _convert_messages(input_data: RunAgentInput) -> list[dict[str, Any]]:
|
||||
"""Flatten AG-UI messages into Anthropic ``messages`` shape.
|
||||
|
||||
Preserve frontend/MCP tool continuations: after the runtime resolves a
|
||||
tool call it re-invokes this agent with the original assistant tool_use
|
||||
plus a tool result message. Anthropic needs those as structured
|
||||
assistant/user blocks, not flattened text, or the model never sees the MCP
|
||||
result and can repeat the same call.
|
||||
"""
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in input_data.messages or []:
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
if role == "tool":
|
||||
tool_call_id = getattr(msg, "tool_call_id", None) or (
|
||||
getattr(msg, "toolCallId", None)
|
||||
)
|
||||
raw_content = getattr(msg, "content", None)
|
||||
result_text = _text_from_content(raw_content)
|
||||
if tool_call_id:
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_call_id,
|
||||
"content": result_text,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
raw_content = getattr(msg, "content", None)
|
||||
content = _text_from_content(raw_content)
|
||||
|
||||
if role == "assistant":
|
||||
tool_calls = getattr(msg, "tool_calls", None) or getattr(
|
||||
msg, "toolCalls", None
|
||||
)
|
||||
if tool_calls:
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
if content:
|
||||
content_blocks.append({"type": "text", "text": content})
|
||||
for tool_call in tool_calls:
|
||||
tool_call_id = getattr(tool_call, "id", None) or (
|
||||
tool_call.get("id") if isinstance(tool_call, dict) else None
|
||||
)
|
||||
function = getattr(tool_call, "function", None) or (
|
||||
tool_call.get("function")
|
||||
if isinstance(tool_call, dict)
|
||||
else None
|
||||
)
|
||||
tool_name = "unknown"
|
||||
args_raw: Any = "{}"
|
||||
if function:
|
||||
tool_name = getattr(function, "name", None) or (
|
||||
function.get("name")
|
||||
if isinstance(function, dict)
|
||||
else "unknown"
|
||||
)
|
||||
args_raw = getattr(function, "arguments", None) or (
|
||||
function.get("arguments", "{}")
|
||||
if isinstance(function, dict)
|
||||
else "{}"
|
||||
)
|
||||
try:
|
||||
tool_args = (
|
||||
json.loads(args_raw)
|
||||
if isinstance(args_raw, str)
|
||||
else args_raw
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
tool_args = {}
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tool_call_id or "unknown",
|
||||
"name": tool_name,
|
||||
"input": tool_args,
|
||||
}
|
||||
)
|
||||
messages.append({"role": "assistant", "content": content_blocks})
|
||||
continue
|
||||
|
||||
if content:
|
||||
messages.append({"role": role, "content": content})
|
||||
return messages
|
||||
|
||||
|
||||
def _text_from_content(raw_content: Any) -> str:
|
||||
if isinstance(raw_content, str):
|
||||
return raw_content
|
||||
if isinstance(raw_content, list):
|
||||
parts: list[str] = []
|
||||
for part in raw_content:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
parts_text = "".join(parts)
|
||||
return parts_text if parts_text else json.dumps(raw_content)
|
||||
return ""
|
||||
|
||||
|
||||
async def run_mcp_apps_agent(input_data: RunAgentInput) -> AsyncIterator[str]:
|
||||
"""Pass-through Claude streaming loop for the MCP Apps demo.
|
||||
|
||||
No bespoke tools. No server-side tool execution. Tools come in via
|
||||
the AG-UI request (injected by the MCP Apps middleware), and tool
|
||||
calls go back out as AG-UI events for the runtime middleware to
|
||||
intercept.
|
||||
"""
|
||||
encoder = EventEncoder()
|
||||
client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
|
||||
|
||||
thread_id = input_data.thread_id or "default"
|
||||
run_id = input_data.run_id or "run-1"
|
||||
msg_id = f"msg-{run_id}"
|
||||
|
||||
yield encoder.encode(
|
||||
RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id)
|
||||
)
|
||||
|
||||
tools = _build_anthropic_tools(input_data.tools)
|
||||
messages = _convert_messages(input_data)
|
||||
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
stream_kwargs: dict[str, Any] = {
|
||||
"model": normalize_claude_model(
|
||||
os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4.6")
|
||||
),
|
||||
"max_tokens": 4096,
|
||||
"system": SYSTEM_PROMPT,
|
||||
"messages": messages,
|
||||
}
|
||||
if tools:
|
||||
stream_kwargs["tools"] = tools
|
||||
|
||||
async with client.messages.stream(**stream_kwargs) as stream:
|
||||
current_tool_id: str | None = None
|
||||
current_tool_name: str | None = None
|
||||
|
||||
async for event in stream:
|
||||
etype = type(event).__name__
|
||||
|
||||
if etype == "RawContentBlockStartEvent":
|
||||
block = event.content_block # type: ignore[attr-defined]
|
||||
if block.type == "tool_use":
|
||||
current_tool_id = block.id
|
||||
current_tool_name = block.name
|
||||
yield encoder.encode(
|
||||
ToolCallStartEvent(
|
||||
type=EventType.TOOL_CALL_START,
|
||||
tool_call_id=current_tool_id,
|
||||
tool_call_name=current_tool_name,
|
||||
parent_message_id=msg_id,
|
||||
)
|
||||
)
|
||||
elif etype == "RawContentBlockDeltaEvent":
|
||||
delta = event.delta # type: ignore[attr-defined]
|
||||
if delta.type == "text_delta":
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=delta.text,
|
||||
)
|
||||
)
|
||||
elif delta.type == "input_json_delta" and current_tool_id:
|
||||
yield encoder.encode(
|
||||
ToolCallArgsEvent(
|
||||
type=EventType.TOOL_CALL_ARGS,
|
||||
tool_call_id=current_tool_id,
|
||||
delta=delta.partial_json,
|
||||
)
|
||||
)
|
||||
elif etype in (
|
||||
"RawContentBlockStopEvent",
|
||||
"ParsedContentBlockStopEvent",
|
||||
):
|
||||
if current_tool_id:
|
||||
yield encoder.encode(
|
||||
ToolCallEndEvent(
|
||||
type=EventType.TOOL_CALL_END,
|
||||
tool_call_id=current_tool_id,
|
||||
)
|
||||
)
|
||||
current_tool_id = None
|
||||
current_tool_name = None
|
||||
except Exception:
|
||||
# Surface error as visible chat text so probes catch it instead
|
||||
# of silently breaking the SSE stream. Mirrors the pattern in
|
||||
# ``agents.agent.run_agent``.
|
||||
err_text = f"Agent error: {traceback.format_exc()}"
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=err_text,
|
||||
)
|
||||
)
|
||||
|
||||
yield encoder.encode(
|
||||
TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=msg_id)
|
||||
)
|
||||
|
||||
yield encoder.encode(
|
||||
RunFinishedEvent(
|
||||
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Multimodal Claude agent — accepts image + document (PDF) attachments.
|
||||
|
||||
Scoped to the ``/demos/multimodal`` cell so other demos keep their cheaper
|
||||
text-only model defaults.
|
||||
|
||||
Wire format the agent sees
|
||||
==========================
|
||||
Attachments arrive here after travelling through:
|
||||
|
||||
CopilotChat → AG-UI message content parts → /api/copilotkit-multimodal
|
||||
→ HttpAgent (this Python backend)
|
||||
|
||||
Claude's Messages API supports image content parts natively (``{ "type":
|
||||
"image", "source": {...} }``). The AG-UI message content parts use the
|
||||
modern ``{ type: "image" | "document", source: { type: "data" | "url",
|
||||
value, mimeType } }`` shape (what CopilotChat emits today). We convert
|
||||
each attachment in-process:
|
||||
|
||||
- ``image/*`` parts become Claude image blocks (base64 inline), forwarded
|
||||
to the vision-capable model unchanged.
|
||||
- ``application/pdf`` parts are flattened to text via ``pypdf`` and sent
|
||||
as a ``text`` block prefixed with ``[Attached document]``. Claude
|
||||
recent models also support PDFs natively as ``document`` blocks, but
|
||||
``pypdf`` keeps the demo provider-agnostic and avoids counting against
|
||||
PDF beta access.
|
||||
- Anything else falls through as-is.
|
||||
|
||||
References:
|
||||
- packages/runtime/src/agent/converters/tanstack.ts (modern AG-UI parts)
|
||||
- langgraph-python multimodal_agent.py (baseline pattern; the legacy
|
||||
``binary`` shim is not needed here because HttpAgent forwards modern
|
||||
parts through without rewriting them).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant. The user may attach images or documents "
|
||||
"(PDFs). When they do, analyze the attachment carefully and answer the "
|
||||
"user's question. If no attachment is present, answer the text question "
|
||||
"normally. Keep responses concise (1-3 sentences) unless asked to go deep."
|
||||
)
|
||||
|
||||
|
||||
def _extract_pdf_text(b64_payload: str) -> str:
|
||||
"""Decode an inline-base64 PDF and extract its text.
|
||||
|
||||
Returns an empty string if decoding or extraction fails — callers must
|
||||
treat the extracted text as best-effort. Any exception here is logged
|
||||
and swallowed so one malformed attachment does not tank the whole
|
||||
user turn.
|
||||
"""
|
||||
try:
|
||||
raw = base64.b64decode(b64_payload, validate=False)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
print(f"[multimodal_agent] base64 decode failed: {exc}")
|
||||
return ""
|
||||
|
||||
try:
|
||||
# Lazy import — keep the module importable even when pypdf is
|
||||
# missing; only needed when a PDF actually arrives.
|
||||
from pypdf import PdfReader # type: ignore[import-not-found]
|
||||
except ImportError as exc: # pragma: no cover - defensive
|
||||
print(
|
||||
"[multimodal_agent] pypdf not installed — PDF text extraction "
|
||||
f"unavailable: {exc}",
|
||||
)
|
||||
return ""
|
||||
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(raw))
|
||||
pages = [page.extract_text() or "" for page in reader.pages]
|
||||
return "\n\n".join(pages).strip()
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
print(f"[multimodal_agent] pypdf extraction failed: {exc}")
|
||||
return ""
|
||||
|
||||
|
||||
def _source_to_base64(source: dict[str, Any]) -> str | None:
|
||||
"""Pull base64 bytes out of an AG-UI ``source`` field.
|
||||
|
||||
Supports both ``{type: "data", value: "<base64>"}`` and
|
||||
``{type: "url", value: "data:...;base64,..."}`` shapes. Returns
|
||||
``None`` for network URLs — Claude's native image block supports
|
||||
remote URLs, but PDFs must be inlined for ``pypdf``.
|
||||
"""
|
||||
src_type = source.get("type")
|
||||
value = source.get("value")
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if src_type == "data":
|
||||
return value
|
||||
if src_type == "url":
|
||||
if value.startswith("data:"):
|
||||
_, _, payload = value.partition(",")
|
||||
return payload
|
||||
return None
|
||||
|
||||
|
||||
def convert_part_for_claude(part: Any) -> Any:
|
||||
"""Translate an AG-UI content part into the Claude Messages API shape.
|
||||
|
||||
Pass-through for parts we don't recognise — Claude will ignore or
|
||||
reject unknown shapes, which is better than silently dropping an
|
||||
attachment the user actually sent.
|
||||
"""
|
||||
if isinstance(part, str):
|
||||
return {"type": "text", "text": part}
|
||||
|
||||
if not isinstance(part, dict):
|
||||
return part
|
||||
|
||||
part_type = part.get("type")
|
||||
|
||||
if part_type == "text":
|
||||
return {"type": "text", "text": part.get("text", "")}
|
||||
|
||||
# Modern AG-UI image part → Claude image block (base64 inline).
|
||||
if part_type == "image":
|
||||
source = part.get("source") or {}
|
||||
mime = source.get("mimeType") or "image/png"
|
||||
b64 = _source_to_base64(source)
|
||||
if b64:
|
||||
return {
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": mime,
|
||||
"data": b64,
|
||||
},
|
||||
}
|
||||
url_value = source.get("value")
|
||||
if isinstance(url_value, str) and url_value.startswith("http"):
|
||||
return {
|
||||
"type": "image",
|
||||
"source": {"type": "url", "url": url_value},
|
||||
}
|
||||
return {"type": "text", "text": "[Attached image could not be decoded.]"}
|
||||
|
||||
# Modern AG-UI document part → flatten PDFs to text for a
|
||||
# provider-agnostic demo. Non-PDF documents become a placeholder so
|
||||
# the model can tell the user the attachment was unsupported.
|
||||
if part_type == "document":
|
||||
source = part.get("source") or {}
|
||||
mime = (source.get("mimeType") or "").lower()
|
||||
b64 = _source_to_base64(source)
|
||||
if "pdf" in mime and b64:
|
||||
text = _extract_pdf_text(b64)
|
||||
if text:
|
||||
return {"type": "text", "text": f"[Attached document]\n{text}"}
|
||||
return {
|
||||
"type": "text",
|
||||
"text": "[Attached document: PDF could not be read.]",
|
||||
}
|
||||
return {
|
||||
"type": "text",
|
||||
"text": f"[Attached document: unsupported type {mime or 'unknown'}.]",
|
||||
}
|
||||
|
||||
# Legacy ``binary`` shape (defensive: if any adapter in the chain
|
||||
# still rewrites to it, handle it here instead of dropping).
|
||||
if part_type == "binary":
|
||||
mime = (part.get("mimeType") or "").lower()
|
||||
data = part.get("data")
|
||||
if isinstance(data, str) and mime.startswith("image/"):
|
||||
return {
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": mime,
|
||||
"data": data,
|
||||
},
|
||||
}
|
||||
if isinstance(data, str) and "pdf" in mime:
|
||||
text = _extract_pdf_text(data)
|
||||
if text:
|
||||
return {"type": "text", "text": f"[Attached document]\n{text}"}
|
||||
return {
|
||||
"type": "text",
|
||||
"text": "[Attached document: PDF could not be read.]",
|
||||
}
|
||||
return part
|
||||
|
||||
return part
|
||||
|
||||
|
||||
__all__ = ["SYSTEM_PROMPT", "convert_part_for_claude"]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Claude Agent SDK backing the Open-Ended Generative UI (Advanced) demo.
|
||||
|
||||
This is the "advanced" variant of the Open Generative UI demo. The key
|
||||
distinguishing feature: the agent-authored, sandboxed UI can invoke
|
||||
frontend-registered **sandbox functions** — functions the app defines
|
||||
on the host page (see `src/app/demos/open-gen-ui-advanced/sandbox-functions.ts`)
|
||||
and makes callable from inside the iframe via
|
||||
`await Websandbox.connection.remote.<name>(args)`.
|
||||
|
||||
The shared Claude backend in `src/agents/agent.py` handles this demo
|
||||
via the `open-gen-ui-advanced` agent name registered in the ogui route.
|
||||
This module exists so the manifest's `highlight` path references a
|
||||
per-demo Python reference, mirroring the langgraph-python layout.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT_HINT = (
|
||||
"You are a UI-generating assistant for the Open Generative UI "
|
||||
"(Advanced) demo. On every user turn you MUST call the "
|
||||
"`generateSandboxedUi` frontend tool exactly once. The generated UI "
|
||||
"must be INTERACTIVE and must invoke the available host-side sandbox "
|
||||
"functions described in your agent context in response to user "
|
||||
"interactions. Call host functions with "
|
||||
"`await Websandbox.connection.remote.<functionName>(args)`. Do NOT "
|
||||
"use <form> or type='submit' — the sandbox blocks form submissions; "
|
||||
"use <button type='button'> wired with addEventListener('click')."
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Claude Agent SDK backing the Open-Ended Generative UI (minimal) demo.
|
||||
|
||||
The simplest possible example that exercises the open-ended generative UI
|
||||
pipeline. All the interesting work happens outside the agent:
|
||||
|
||||
- CopilotKit merges the frontend-registered `generateSandboxedUi` tool
|
||||
(auto-registered by `CopilotKitProvider` when the runtime has
|
||||
`openGenerativeUI` enabled) into the agent's tool list. The LLM then
|
||||
sees the tool via the normal AG-UI flow.
|
||||
- When the LLM calls `generateSandboxedUi`, the runtime's
|
||||
`OpenGenerativeUIMiddleware` (enabled via `openGenerativeUI` on the
|
||||
runtime — see `src/app/api/copilotkit-ogui/route.ts`) converts that
|
||||
streaming tool call into `open-generative-ui` activity events that the
|
||||
built-in renderer mounts inside a sandboxed iframe.
|
||||
|
||||
This is the minimal variant: no sandbox functions, no app-side tools.
|
||||
The shared Claude backend in `src/agents/agent.py` handles this demo
|
||||
via the `open-gen-ui` agent name registered in the ogui route. This
|
||||
module exists so the manifest's `highlight` path references a per-demo
|
||||
Python reference, mirroring the langgraph-python layout.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT_HINT = (
|
||||
"You are a UI-generating assistant for an Open Generative UI demo "
|
||||
"focused on intricate, educational visualisations. 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. Use inline SVG (or "
|
||||
"<canvas>) for geometric content — no stacks of <div>s. Keep your "
|
||||
"own chat message brief (1 sentence); the rendered visualisation is "
|
||||
"the real output."
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Claude Agent SDK backing the Readonly State (Agent Context) demo.
|
||||
|
||||
Demonstrates the `useAgentContext` hook from @copilotkit/react-core/v2:
|
||||
the frontend provides READ-ONLY context *to* the agent. The UI cannot
|
||||
be edited by the agent, but the agent reads this context on every turn
|
||||
via the CopilotKit runtime, which routes the context entries into the
|
||||
model's message history.
|
||||
|
||||
The shared Claude backend in `src/agents/agent.py` handles this demo via
|
||||
the `readonly-state-agent-context` agent name registered in the
|
||||
copilotkit route. This module exists so the manifest's `highlight` path
|
||||
references a per-demo Python reference, mirroring the langgraph-python
|
||||
layout.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT_HINT = (
|
||||
"You are a helpful, concise assistant. The frontend may provide "
|
||||
"read-only context about the user (e.g. name, timezone, recent "
|
||||
"activity) via the `useAgentContext` hook. Always consult that "
|
||||
"context when it is relevant — address the user by name if known, "
|
||||
"respect their timezone when mentioning times, and reference "
|
||||
"recent activity when it helps you answer. Keep responses short."
|
||||
)
|
||||
@@ -0,0 +1,489 @@
|
||||
"""Reasoning agent — emits AG-UI REASONING_MESSAGE_* events.
|
||||
|
||||
Shared by:
|
||||
- reasoning-custom (custom amber ReasoningBlock slot on the frontend)
|
||||
- reasoning-default (CopilotKit's built-in reasoning slot)
|
||||
|
||||
Approach:
|
||||
The Anthropic Python SDK supports Claude's extended-thinking ("thinking
|
||||
budget") parameter on `messages.stream`, which streams `thinking_delta`
|
||||
content blocks separately from text. We map those onto AG-UI's
|
||||
REASONING_MESSAGE_* events. Models without extended-thinking fall back
|
||||
to an inline ``<reasoning>...</reasoning>`` system-prompt convention that
|
||||
this agent parses out of the text stream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import traceback
|
||||
from collections.abc import AsyncIterator
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
RunAgentInput,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
)
|
||||
from ag_ui.encoder import EventEncoder
|
||||
from agents.claude_agent_sdk_adapter import normalize_claude_model
|
||||
|
||||
# Extended-thinking budget for the native reasoning channel. Anthropic
|
||||
# requires max_tokens > budget_tokens when thinking is enabled.
|
||||
_THINKING_BUDGET_TOKENS = 1024
|
||||
|
||||
# System prompt for the NATIVE extended-thinking path (the default).
|
||||
# Extended thinking is always enabled below, so the model already emits its
|
||||
# step-by-step plan on the native `thinking` channel — which we forward as
|
||||
# REASONING_MESSAGE_*. We must NOT also instruct the model to wrap a plan in
|
||||
# `<reasoning>...</reasoning>` text tags: against real Claude that produces a
|
||||
# SECOND reasoning block (native thinking + tag-text), i.e. the double-bubble.
|
||||
# So the native prompt asks only for a clean final answer; the reasoning chain
|
||||
# comes entirely from the native channel.
|
||||
NATIVE_REASONING_SYSTEM_PROMPT = dedent("""
|
||||
You are a helpful assistant. Think through each user question
|
||||
step-by-step, then give a single concise final answer for the user.
|
||||
Do not wrap your answer in any XML or markup tags.
|
||||
""").strip()
|
||||
|
||||
# Fallback system prompt for a NO-native-thinking deployment. If extended
|
||||
# thinking is ever disabled, the inline `<reasoning>...</reasoning>` tag
|
||||
# convention (parsed by the dormant state machine below) is the only way to
|
||||
# surface a reasoning chain, so this prompt re-instates the tag instruction.
|
||||
REASONING_SYSTEM_PROMPT = dedent("""
|
||||
You are a helpful assistant. For each user question, FIRST emit a
|
||||
short step-by-step plan inside `<reasoning>...</reasoning>` tags
|
||||
(one or two short sentences per step, plain text only — no
|
||||
Markdown, no JSON), THEN follow with the final concise answer for
|
||||
the user OUTSIDE the tags.
|
||||
|
||||
The reasoning block is shown to the user as a visible "thinking"
|
||||
chain — keep it brief and readable.
|
||||
|
||||
Example:
|
||||
<reasoning>
|
||||
The user is asking about X. I should consider Y and Z.
|
||||
Combining both gives W.
|
||||
</reasoning>
|
||||
Final answer: W.
|
||||
""").strip()
|
||||
|
||||
|
||||
async def run_reasoning_agent(
|
||||
input_data: RunAgentInput,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream a reasoning-enabled assistant response as AG-UI events.
|
||||
|
||||
Splits Claude's response into REASONING_MESSAGE_* (everything inside
|
||||
`<reasoning>...</reasoning>` tags) and TEXT_MESSAGE_* (everything
|
||||
outside). Tags themselves are stripped before forwarding.
|
||||
"""
|
||||
encoder = EventEncoder()
|
||||
client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
|
||||
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in input_data.messages or []:
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
raw_content = getattr(msg, "content", None)
|
||||
content = ""
|
||||
if isinstance(raw_content, str):
|
||||
content = raw_content
|
||||
elif isinstance(raw_content, list):
|
||||
parts: list[str] = []
|
||||
for part in raw_content:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
content = "".join(parts)
|
||||
if content:
|
||||
messages.append({"role": role, "content": content})
|
||||
|
||||
thread_id = input_data.thread_id or "default"
|
||||
run_id = input_data.run_id or "run-1"
|
||||
|
||||
yield encoder.encode(
|
||||
RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id)
|
||||
)
|
||||
|
||||
# Native extended thinking is always enabled on the stream below, so the
|
||||
# reasoning chain comes from the native `thinking` channel. Pick the system
|
||||
# prompt to MATCH that: the native prompt does NOT instruct the model to
|
||||
# emit `<reasoning>` tags (which would double up as a second reasoning
|
||||
# block against real Claude). The tag-instructing prompt + the inline-tag
|
||||
# parser stay as a dormant fallback for a no-native-thinking deployment.
|
||||
# An explicit caller-supplied `system_prompt` always wins.
|
||||
_native_thinking_enabled = True
|
||||
default_system = (
|
||||
NATIVE_REASONING_SYSTEM_PROMPT
|
||||
if _native_thinking_enabled
|
||||
else REASONING_SYSTEM_PROMPT
|
||||
)
|
||||
system = system_prompt or default_system
|
||||
text_msg_id = f"msg-{run_id}-text"
|
||||
reasoning_msg_id = f"msg-{run_id}-reasoning"
|
||||
|
||||
# State machine for parsing <reasoning>...</reasoning>
|
||||
REASONING_OPEN = "<reasoning>"
|
||||
REASONING_CLOSE = "</reasoning>"
|
||||
|
||||
in_reasoning = False
|
||||
reasoning_started = False
|
||||
# Idempotency guard for the inline-`<reasoning>`-tag REASONING_MESSAGE_END.
|
||||
# Set once END is emitted for `reasoning_msg_id` so the in-stream close, the
|
||||
# post-stream buffer flush, and the error/early-end cleanup never
|
||||
# double-emit an END for the same inline reasoning block.
|
||||
reasoning_ended = False
|
||||
text_started = False
|
||||
buffer = ""
|
||||
|
||||
# Native extended-thinking streaming state. When the model (or aimock
|
||||
# replay) emits Anthropic `thinking`/`thinking_delta` blocks we forward
|
||||
# them as REASONING_MESSAGE_* directly — independent of the inline
|
||||
# `<reasoning>`-tag text parsing below, which stays as the no-thinking
|
||||
# fallback. Mirrors claude-sdk-typescript's /reasoning handler.
|
||||
#
|
||||
# A single turn may contain MORE THAN ONE `thinking` content block. Each
|
||||
# gets its OWN message id (incorporating `id(block)`) assigned on its
|
||||
# content_block_start, and `native_reasoning_started` is reset to False at
|
||||
# BOTH block start and block stop so every thinking block emits its own
|
||||
# balanced START/CONTENT/END lifecycle rather than reopening an already
|
||||
# ENDED message id. `native_reasoning_id is not None` is the "a native
|
||||
# thinking block is currently open" sentinel. Mirrors the sibling
|
||||
# tool_rendering_reasoning_chain_agent.py pattern.
|
||||
native_reasoning_id: str | None = None
|
||||
native_reasoning_started = False
|
||||
|
||||
# Import lazily so missing imports don't crash module import-time
|
||||
# on older ag_ui versions.
|
||||
from ag_ui.core import (
|
||||
ReasoningMessageContentEvent,
|
||||
ReasoningMessageEndEvent,
|
||||
ReasoningMessageStartEvent,
|
||||
)
|
||||
|
||||
# Extended thinking enabled so Claude streams native thinking blocks.
|
||||
# Overridable via ANTHROPIC_REASONING_MODEL (falling back to
|
||||
# ANTHROPIC_MODEL). Under aimock replay the thinking channel comes from
|
||||
# the fixture's `reasoning` field regardless of the model name.
|
||||
reasoning_model = os.getenv(
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4.6"),
|
||||
)
|
||||
reasoning_model = normalize_claude_model(reasoning_model)
|
||||
|
||||
try:
|
||||
async with client.messages.stream(
|
||||
model=reasoning_model,
|
||||
max_tokens=_THINKING_BUDGET_TOKENS + 2048,
|
||||
system=system,
|
||||
messages=messages,
|
||||
thinking={
|
||||
"type": "enabled",
|
||||
"budget_tokens": _THINKING_BUDGET_TOKENS,
|
||||
},
|
||||
) as stream:
|
||||
async for event in stream:
|
||||
etype = type(event).__name__
|
||||
if etype == "RawContentBlockStartEvent":
|
||||
block = event.content_block # type: ignore[attr-defined]
|
||||
if getattr(block, "type", None) == "thinking":
|
||||
# Fresh per-block id so two thinking blocks in one turn
|
||||
# each get their own balanced lifecycle. Reset
|
||||
# `started` so a new START is emitted for THIS block.
|
||||
native_reasoning_id = f"msg-{run_id}-think-{id(block)}"
|
||||
native_reasoning_started = False
|
||||
continue
|
||||
if etype in (
|
||||
"RawContentBlockStopEvent",
|
||||
"ParsedContentBlockStopEvent",
|
||||
):
|
||||
if native_reasoning_id is not None:
|
||||
if native_reasoning_started:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=native_reasoning_id,
|
||||
)
|
||||
)
|
||||
# Reset both so the next thinking block starts clean.
|
||||
native_reasoning_id = None
|
||||
native_reasoning_started = False
|
||||
continue
|
||||
if etype != "RawContentBlockDeltaEvent":
|
||||
continue
|
||||
delta = event.delta # type: ignore[attr-defined]
|
||||
if delta.type == "thinking_delta" and native_reasoning_id:
|
||||
thinking_text = getattr(delta, "thinking", "") or ""
|
||||
if thinking_text:
|
||||
if not native_reasoning_started:
|
||||
native_reasoning_started = True
|
||||
yield encoder.encode(
|
||||
ReasoningMessageStartEvent(
|
||||
type=EventType.REASONING_MESSAGE_START,
|
||||
message_id=native_reasoning_id,
|
||||
role="reasoning",
|
||||
)
|
||||
)
|
||||
yield encoder.encode(
|
||||
ReasoningMessageContentEvent(
|
||||
type=EventType.REASONING_MESSAGE_CONTENT,
|
||||
message_id=native_reasoning_id,
|
||||
delta=thinking_text,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if delta.type != "text_delta":
|
||||
continue
|
||||
buffer += delta.text
|
||||
|
||||
# Drain the buffer, emitting either reasoning or text
|
||||
# whenever we have enough to safely classify.
|
||||
while True:
|
||||
if in_reasoning:
|
||||
close_idx = buffer.find(REASONING_CLOSE)
|
||||
if close_idx == -1:
|
||||
# Hold last few chars in case the close tag is split.
|
||||
keep = max(0, len(buffer) - len(REASONING_CLOSE))
|
||||
chunk = buffer[:keep]
|
||||
buffer = buffer[keep:]
|
||||
if chunk:
|
||||
if not reasoning_started:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageStartEvent(
|
||||
type=EventType.REASONING_MESSAGE_START,
|
||||
message_id=reasoning_msg_id,
|
||||
role="reasoning",
|
||||
)
|
||||
)
|
||||
reasoning_started = True
|
||||
yield encoder.encode(
|
||||
ReasoningMessageContentEvent(
|
||||
type=EventType.REASONING_MESSAGE_CONTENT,
|
||||
message_id=reasoning_msg_id,
|
||||
delta=chunk,
|
||||
)
|
||||
)
|
||||
break
|
||||
# Found close tag — emit remaining reasoning, end it, switch mode.
|
||||
chunk = buffer[:close_idx]
|
||||
if chunk:
|
||||
if not reasoning_started:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageStartEvent(
|
||||
type=EventType.REASONING_MESSAGE_START,
|
||||
message_id=reasoning_msg_id,
|
||||
role="reasoning",
|
||||
)
|
||||
)
|
||||
reasoning_started = True
|
||||
yield encoder.encode(
|
||||
ReasoningMessageContentEvent(
|
||||
type=EventType.REASONING_MESSAGE_CONTENT,
|
||||
message_id=reasoning_msg_id,
|
||||
delta=chunk,
|
||||
)
|
||||
)
|
||||
if reasoning_started and not reasoning_ended:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=reasoning_msg_id,
|
||||
)
|
||||
)
|
||||
reasoning_ended = True
|
||||
buffer = buffer[close_idx + len(REASONING_CLOSE) :]
|
||||
in_reasoning = False
|
||||
continue
|
||||
else:
|
||||
open_idx = buffer.find(REASONING_OPEN)
|
||||
if open_idx == -1:
|
||||
# No open tag in buffer — emit as text but hold tail in case tag is split.
|
||||
keep = max(0, len(buffer) - len(REASONING_OPEN))
|
||||
chunk = buffer[:keep]
|
||||
buffer = buffer[keep:]
|
||||
if chunk:
|
||||
# Skip leading whitespace-only fragments before any reasoning
|
||||
# so the empty assistant message doesn't appear.
|
||||
if not text_started:
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=text_msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
text_started = True
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=text_msg_id,
|
||||
delta=chunk,
|
||||
)
|
||||
)
|
||||
break
|
||||
# Found open tag — flush text up to it, switch to reasoning mode.
|
||||
chunk = buffer[:open_idx]
|
||||
if chunk:
|
||||
if not text_started:
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=text_msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
text_started = True
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=text_msg_id,
|
||||
delta=chunk,
|
||||
)
|
||||
)
|
||||
buffer = buffer[open_idx + len(REASONING_OPEN) :]
|
||||
in_reasoning = True
|
||||
continue
|
||||
|
||||
# Lifecycle guarantee: if a native thinking block streamed content
|
||||
# but its content_block_stop never arrived (e.g. stream ended early),
|
||||
# still close the reasoning message so the UI doesn't render an
|
||||
# in-flight thinking bubble.
|
||||
if native_reasoning_id is not None and native_reasoning_started:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=native_reasoning_id,
|
||||
)
|
||||
)
|
||||
native_reasoning_id = None
|
||||
native_reasoning_started = False
|
||||
|
||||
# Flush any remaining buffered content as text.
|
||||
if buffer:
|
||||
if in_reasoning:
|
||||
if not reasoning_started:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageStartEvent(
|
||||
type=EventType.REASONING_MESSAGE_START,
|
||||
message_id=reasoning_msg_id,
|
||||
role="reasoning",
|
||||
)
|
||||
)
|
||||
reasoning_started = True
|
||||
yield encoder.encode(
|
||||
ReasoningMessageContentEvent(
|
||||
type=EventType.REASONING_MESSAGE_CONTENT,
|
||||
message_id=reasoning_msg_id,
|
||||
delta=buffer,
|
||||
)
|
||||
)
|
||||
if not reasoning_ended:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=reasoning_msg_id,
|
||||
)
|
||||
)
|
||||
reasoning_ended = True
|
||||
else:
|
||||
if not text_started:
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=text_msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
text_started = True
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=text_msg_id,
|
||||
delta=buffer,
|
||||
)
|
||||
)
|
||||
|
||||
# Lifecycle guarantee for the inline-`<reasoning>`-tag block: if the
|
||||
# stream ended mid-inline-block (open `<reasoning>` with started content
|
||||
# but no `</reasoning>`) and the buffer-flush above did not already emit
|
||||
# the END (e.g. the buffer was empty because all content had already
|
||||
# been streamed), close it now so the UI doesn't render a perpetual
|
||||
# in-flight reasoning bubble. Mirrors the native-channel guard above.
|
||||
if reasoning_started and not reasoning_ended:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=reasoning_msg_id,
|
||||
)
|
||||
)
|
||||
reasoning_ended = True
|
||||
except Exception:
|
||||
# Lifecycle guarantee on the error path: if the stream raised while a
|
||||
# native thinking block was still open, close it before emitting the
|
||||
# error text bubble so the UI doesn't render a perpetual in-flight
|
||||
# thinking bubble. Mirrors the normal-completion guard above.
|
||||
if native_reasoning_id is not None and native_reasoning_started:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=native_reasoning_id,
|
||||
)
|
||||
)
|
||||
native_reasoning_id = None
|
||||
native_reasoning_started = False
|
||||
# Same guarantee for the inline-`<reasoning>`-tag block: if the stream
|
||||
# raised while inside an open inline reasoning block whose END had not
|
||||
# yet been emitted, close it before the error text bubble so the UI
|
||||
# doesn't strand a perpetual in-flight reasoning bubble. The
|
||||
# `reasoning_ended` flag keeps this idempotent with the in-stream close
|
||||
# and the normal-path cleanup.
|
||||
if reasoning_started and not reasoning_ended:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=reasoning_msg_id,
|
||||
)
|
||||
)
|
||||
reasoning_ended = True
|
||||
err_text = f"Agent error: {traceback.format_exc()}"
|
||||
if not text_started:
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=text_msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
text_started = True
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=text_msg_id,
|
||||
delta=err_text,
|
||||
)
|
||||
)
|
||||
|
||||
if text_started:
|
||||
yield encoder.encode(
|
||||
TextMessageEndEvent(
|
||||
type=EventType.TEXT_MESSAGE_END,
|
||||
message_id=text_msg_id,
|
||||
)
|
||||
)
|
||||
|
||||
yield encoder.encode(
|
||||
RunFinishedEvent(
|
||||
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Claude Agent SDK backing the Shared State (Read + Write) demo.
|
||||
|
||||
Demonstrates the canonical bidirectional shared-state pattern between
|
||||
the UI and the Claude agent:
|
||||
|
||||
- **UI -> agent (write)**: The frontend owns a ``preferences`` object
|
||||
({name, tone, language, interests}) that is written into agent state
|
||||
via ``agent.setState({preferences: ...})``. Every turn, the backend
|
||||
reads the latest preferences out of ``input_data.state`` and injects
|
||||
them into the system prompt so the LLM adapts.
|
||||
- **agent -> UI (read)**: The agent calls ``set_notes`` to append/replace
|
||||
the ``notes`` slot in shared state. The UI subscribes via ``useAgent``
|
||||
and re-renders every change.
|
||||
|
||||
This is conceptually identical to ``langgraph-python`` /
|
||||
``shared_state_read_write.py`` — we just emit AG-UI ``StateSnapshotEvent``
|
||||
events directly from the streaming loop in ``agent.py``-style fashion
|
||||
instead of relying on a graph framework's middleware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
RunAgentInput,
|
||||
RunErrorEvent,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
StateSnapshotEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from ag_ui.encoder import EventEncoder
|
||||
|
||||
from agents.claude_agent_sdk_adapter import normalize_claude_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default Anthropic model for this showcase. Override with the
|
||||
# ANTHROPIC_MODEL env var.
|
||||
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4.6"
|
||||
|
||||
|
||||
# @region[shared-state-setup]
|
||||
SYSTEM_PROMPT = dedent("""
|
||||
You are a helpful, concise assistant.
|
||||
|
||||
The user's preferences are supplied via shared state and added at the
|
||||
start of every turn — always respect them. Address the user by name
|
||||
when known, match the requested tone, and respond in the requested
|
||||
language.
|
||||
|
||||
When the user asks you to "remember" something, or you observe
|
||||
something worth surfacing in the UI's notes panel, call the
|
||||
``set_notes`` tool with the FULL updated list of short notes
|
||||
(existing notes + new ones, not a diff). Keep each note short
|
||||
(< 120 characters). After updating notes, briefly acknowledge what
|
||||
you remembered.
|
||||
""").strip()
|
||||
|
||||
|
||||
SET_NOTES_TOOL: dict[str, Any] = {
|
||||
"name": "set_notes",
|
||||
"description": (
|
||||
"Replace the notes array in shared state with the FULL updated "
|
||||
"list. Always include every existing note plus any new ones, "
|
||||
"not a diff. Keep each note short (< 120 chars)."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"notes": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Full list of short note strings to persist.",
|
||||
},
|
||||
},
|
||||
"required": ["notes"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_preferences_block(prefs: dict[str, Any] | None) -> str | None:
|
||||
"""Render the user-supplied preferences as an injectable prompt block.
|
||||
|
||||
Returns ``None`` when no recognised keys are present so the system
|
||||
prompt is left untouched.
|
||||
"""
|
||||
if not isinstance(prefs, dict) or not prefs:
|
||||
return None
|
||||
lines = ["The user has shared these preferences with you:"]
|
||||
if prefs.get("name"):
|
||||
lines.append(f"- Name: {prefs['name']}")
|
||||
if prefs.get("tone"):
|
||||
lines.append(f"- Preferred tone: {prefs['tone']}")
|
||||
if prefs.get("language"):
|
||||
lines.append(f"- Preferred language: {prefs['language']}")
|
||||
interests = prefs.get("interests") or []
|
||||
if isinstance(interests, list) and interests:
|
||||
lines.append(f"- Interests: {', '.join(str(i) for i in interests)}")
|
||||
if len(lines) == 1:
|
||||
# No recognised fields — don't emit a header with no content.
|
||||
return None
|
||||
lines.append(
|
||||
"Tailor every response to these preferences. Address the user "
|
||||
"by name when appropriate."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _state_dict(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Coerce the AG-UI raw state envelope into the slots we care about."""
|
||||
return {
|
||||
"preferences": state.get("preferences") or {},
|
||||
"notes": list(state.get("notes") or []),
|
||||
}
|
||||
|
||||
|
||||
# @endregion[shared-state-setup]
|
||||
|
||||
|
||||
def _convert_messages(input_data: RunAgentInput) -> list[dict[str, Any]]:
|
||||
"""Flatten AG-UI messages into Anthropic Messages-API shape (text only)."""
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in input_data.messages or []:
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
raw_content = getattr(msg, "content", None)
|
||||
content = ""
|
||||
if isinstance(raw_content, str):
|
||||
content = raw_content
|
||||
elif isinstance(raw_content, list):
|
||||
parts = []
|
||||
for part in raw_content:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
content = "".join(parts)
|
||||
if content:
|
||||
messages.append({"role": role, "content": content})
|
||||
return messages
|
||||
|
||||
|
||||
async def run_shared_state_read_write_agent(
|
||||
input_data: RunAgentInput,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Run the shared-state-read-write Claude agent and yield AG-UI events."""
|
||||
encoder = EventEncoder()
|
||||
client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
|
||||
|
||||
state = _state_dict(input_data.state if isinstance(input_data.state, dict) else {})
|
||||
|
||||
# @region[shared-state-prefs-injection]
|
||||
# Read UI-supplied preferences out of agent state every turn and
|
||||
# prepend them onto the system prompt. This is the agent-side half of
|
||||
# the bidirectional shared-state pattern: the UI writes via
|
||||
# ``agent.setState({preferences: ...})``, the backend reads here.
|
||||
prefs_block = build_preferences_block(state["preferences"])
|
||||
system = SYSTEM_PROMPT
|
||||
if prefs_block:
|
||||
system = f"{prefs_block}\n\n{SYSTEM_PROMPT}"
|
||||
# @endregion[shared-state-prefs-injection]
|
||||
|
||||
messages = _convert_messages(input_data)
|
||||
|
||||
thread_id = input_data.thread_id or "default"
|
||||
run_id = input_data.run_id or "run-1"
|
||||
|
||||
yield encoder.encode(
|
||||
RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id)
|
||||
)
|
||||
|
||||
# Echo the current state at the start so the UI sees the snapshot we
|
||||
# are operating on (helpful when the agent decides not to call any
|
||||
# tool — the UI still gets a confirmation event).
|
||||
yield encoder.encode(
|
||||
StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=state)
|
||||
)
|
||||
|
||||
while True:
|
||||
response_text = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
msg_id = f"msg-{run_id}-{len(messages)}"
|
||||
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
|
||||
async with client.messages.stream(
|
||||
model=normalize_claude_model(
|
||||
os.getenv("ANTHROPIC_MODEL", DEFAULT_ANTHROPIC_MODEL)
|
||||
),
|
||||
max_tokens=2048,
|
||||
system=system,
|
||||
messages=messages,
|
||||
tools=[SET_NOTES_TOOL],
|
||||
) as stream:
|
||||
current_tool_id: str | None = None
|
||||
current_tool_name: str | None = None
|
||||
current_tool_args = ""
|
||||
|
||||
async for event in stream:
|
||||
etype = type(event).__name__
|
||||
|
||||
if etype == "RawContentBlockStartEvent":
|
||||
block = event.content_block # type: ignore[attr-defined]
|
||||
if block.type == "tool_use":
|
||||
current_tool_id = block.id
|
||||
current_tool_name = block.name
|
||||
current_tool_args = ""
|
||||
yield encoder.encode(
|
||||
ToolCallStartEvent(
|
||||
type=EventType.TOOL_CALL_START,
|
||||
tool_call_id=current_tool_id,
|
||||
tool_call_name=current_tool_name,
|
||||
parent_message_id=msg_id,
|
||||
)
|
||||
)
|
||||
|
||||
elif etype == "RawContentBlockDeltaEvent":
|
||||
delta = event.delta # type: ignore[attr-defined]
|
||||
if delta.type == "text_delta":
|
||||
response_text += delta.text
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=delta.text,
|
||||
)
|
||||
)
|
||||
elif delta.type == "input_json_delta":
|
||||
current_tool_args += delta.partial_json
|
||||
yield encoder.encode(
|
||||
ToolCallArgsEvent(
|
||||
type=EventType.TOOL_CALL_ARGS,
|
||||
tool_call_id=current_tool_id or "",
|
||||
delta=delta.partial_json,
|
||||
)
|
||||
)
|
||||
|
||||
elif etype in (
|
||||
"RawContentBlockStopEvent",
|
||||
"ParsedContentBlockStopEvent",
|
||||
):
|
||||
if current_tool_id and current_tool_name:
|
||||
yield encoder.encode(
|
||||
ToolCallEndEvent(
|
||||
type=EventType.TOOL_CALL_END,
|
||||
tool_call_id=current_tool_id,
|
||||
)
|
||||
)
|
||||
parsed_args: dict[str, Any] | None
|
||||
try:
|
||||
parsed_args = (
|
||||
json.loads(current_tool_args)
|
||||
if current_tool_args
|
||||
else {}
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
# Surface malformed tool args loudly instead of
|
||||
# silently substituting an empty dict — for
|
||||
# set_notes, an empty dict would clear the user's
|
||||
# notes with no error feedback.
|
||||
logger.warning(
|
||||
"shared_state_read_write: failed to parse "
|
||||
"tool args for %s (id=%s): %s; raw=%r",
|
||||
current_tool_name,
|
||||
current_tool_id,
|
||||
exc,
|
||||
current_tool_args,
|
||||
)
|
||||
yield encoder.encode(
|
||||
RunErrorEvent(
|
||||
type=EventType.RUN_ERROR,
|
||||
message=(
|
||||
f"Failed to parse arguments for tool "
|
||||
f"'{current_tool_name}': {exc}"
|
||||
),
|
||||
code="TOOL_ARGS_PARSE_ERROR",
|
||||
)
|
||||
)
|
||||
parsed_args = None
|
||||
|
||||
if parsed_args is not None:
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": current_tool_id,
|
||||
"name": current_tool_name,
|
||||
"input": parsed_args,
|
||||
}
|
||||
)
|
||||
# else: skip this tool call entirely rather than
|
||||
# invoking it with an empty/dropped argument set.
|
||||
current_tool_id = None
|
||||
current_tool_name = None
|
||||
current_tool_args = ""
|
||||
|
||||
yield encoder.encode(
|
||||
TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=msg_id)
|
||||
)
|
||||
|
||||
if not tool_calls:
|
||||
break
|
||||
|
||||
# Append assistant turn to message history for the next iteration.
|
||||
assistant_content: list[dict[str, Any]] = []
|
||||
if response_text:
|
||||
assistant_content.append({"type": "text", "text": response_text})
|
||||
for tc in tool_calls:
|
||||
assistant_content.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"input": tc["input"],
|
||||
}
|
||||
)
|
||||
messages.append({"role": "assistant", "content": assistant_content})
|
||||
|
||||
# @region[shared-state-set-notes]
|
||||
# Execute set_notes by mutating shared state and emitting a
|
||||
# StateSnapshotEvent so the UI re-renders the agent-authored
|
||||
# notes. This is the agent-side half of the WRITE direction.
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
for tc in tool_calls:
|
||||
if tc["name"] == "set_notes":
|
||||
notes = tc["input"].get("notes") or []
|
||||
if isinstance(notes, list):
|
||||
state["notes"] = [str(n) for n in notes]
|
||||
result_text = json.dumps({"status": "ok", "count": len(state["notes"])})
|
||||
yield encoder.encode(
|
||||
StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=state)
|
||||
)
|
||||
else:
|
||||
result_text = json.dumps({"error": f"unknown tool {tc['name']}"})
|
||||
|
||||
yield encoder.encode(
|
||||
ToolCallResultEvent(
|
||||
type=EventType.TOOL_CALL_RESULT,
|
||||
tool_call_id=tc["id"],
|
||||
message_id=f"{msg_id}-tool-result-{tc['id']}",
|
||||
content=result_text,
|
||||
)
|
||||
)
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tc["id"],
|
||||
"content": result_text,
|
||||
}
|
||||
)
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
# @endregion[shared-state-set-notes]
|
||||
|
||||
yield encoder.encode(
|
||||
RunFinishedEvent(
|
||||
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,468 @@
|
||||
"""Claude Agent SDK backing the Sub-Agents demo.
|
||||
|
||||
A supervisor Claude call orchestrates three specialised sub-agents
|
||||
exposed as tools:
|
||||
|
||||
- ``research_agent`` — gathers facts on a topic
|
||||
- ``writing_agent`` — drafts polished prose from a brief + facts
|
||||
- ``critique_agent`` — reviews a draft and suggests improvements
|
||||
|
||||
Each delegation issues its own single-shot Anthropic SDK call with a
|
||||
sub-agent-specific system prompt. This mirrors the ``google-adk`` pattern
|
||||
in ``subagents_agent.py`` (which uses ``client.models.generate_content``
|
||||
under the hood) — a much lighter approach than spinning up a full second
|
||||
agent loop, but conceptually identical.
|
||||
|
||||
Every delegation appends an entry to ``state["delegations"]`` with shape
|
||||
``{id, sub_agent, task, status, result}``. Entries are emitted as
|
||||
``running`` first and flipped to ``completed`` / ``failed`` once the
|
||||
sub-agent returns, so the UI's delegation log animates in real time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
RunAgentInput,
|
||||
RunErrorEvent,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
StateSnapshotEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from ag_ui.encoder import EventEncoder
|
||||
|
||||
from agents.claude_agent_sdk_adapter import normalize_claude_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default Anthropic model for this showcase. Override with the
|
||||
# ANTHROPIC_MODEL or ANTHROPIC_SUBAGENT_MODEL env vars.
|
||||
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4.6"
|
||||
|
||||
|
||||
# @region[subagent-setup]
|
||||
# Each sub-agent is defined by its own system prompt; `_invoke_sub_agent`
|
||||
# (below) issues a single-shot Anthropic call as that sub-agent. They
|
||||
# don't share memory or tools with the supervisor — the supervisor only
|
||||
# ever sees what the sub-agent returns as a tool result.
|
||||
# @region[subagents-system-prompts]
|
||||
SUB_AGENT_PROMPTS: dict[str, str] = {
|
||||
"research_agent": (
|
||||
"You are a research sub-agent. Given a topic, produce a concise "
|
||||
"bulleted list of 3-5 key facts. No preamble, no closing."
|
||||
),
|
||||
"writing_agent": (
|
||||
"You are a writing sub-agent. Given a brief and optional source "
|
||||
"facts, produce a polished 1-paragraph draft. Be clear and "
|
||||
"concrete. No preamble."
|
||||
),
|
||||
"critique_agent": (
|
||||
"You are an editorial critique sub-agent. Given a draft, give "
|
||||
"2-3 crisp, actionable critiques. No preamble."
|
||||
),
|
||||
}
|
||||
# @endregion[subagents-system-prompts]
|
||||
|
||||
|
||||
SUPERVISOR_SYSTEM_PROMPT = (
|
||||
"You are a supervisor agent that coordinates three specialized "
|
||||
"sub-agents to produce high-quality deliverables.\n\n"
|
||||
"Available sub-agents (call them as tools):\n"
|
||||
" - research_agent: gathers facts on a topic.\n"
|
||||
" - writing_agent: turns facts + a brief into a polished draft.\n"
|
||||
" - critique_agent: reviews a draft and suggests improvements.\n\n"
|
||||
"For most non-trivial user requests, delegate in sequence: "
|
||||
"research -> write -> critique. Pass the relevant facts/draft "
|
||||
"through the `task` argument of each tool. Keep your own messages "
|
||||
"short — explain the plan once, delegate, then return a concise "
|
||||
"summary once done. The UI shows the user a live log of every "
|
||||
"sub-agent delegation, including the in-flight 'running' state."
|
||||
)
|
||||
|
||||
|
||||
# The supervisor delegates by calling tools. Each entry in
|
||||
# `SUPERVISOR_TOOLS` is an Anthropic tool schema that the supervisor LLM
|
||||
# "calls" to delegate work; the run loop in `run_subagents_agent` (see
|
||||
# the subagents-delegation-flow region) runs the matching sub-agent
|
||||
# synchronously, records the delegation into shared agent state, and
|
||||
# returns the sub-agent's output as a tool_result the supervisor can
|
||||
# read on its next step.
|
||||
# @region[supervisor-delegation-tools]
|
||||
def _delegation_tool_schema(name: str, description: str) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The full task description to hand to the "
|
||||
"sub-agent. Pass relevant prior facts/drafts "
|
||||
"verbatim — the sub-agent has no shared memory "
|
||||
"with the supervisor."
|
||||
),
|
||||
}
|
||||
},
|
||||
"required": ["task"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
SUPERVISOR_TOOLS: list[dict[str, Any]] = [
|
||||
_delegation_tool_schema(
|
||||
"research_agent",
|
||||
"Delegate a research task. Returns a bulleted list of key facts.",
|
||||
),
|
||||
_delegation_tool_schema(
|
||||
"writing_agent",
|
||||
(
|
||||
"Delegate a drafting task. Pass relevant facts in `task`. "
|
||||
"Returns a polished paragraph."
|
||||
),
|
||||
),
|
||||
_delegation_tool_schema(
|
||||
"critique_agent",
|
||||
"Delegate a critique task. Returns 2-3 actionable critiques.",
|
||||
),
|
||||
]
|
||||
# @endregion[supervisor-delegation-tools]
|
||||
|
||||
|
||||
# @region[subagents-invocation]
|
||||
async def _invoke_sub_agent(
|
||||
client: anthropic.AsyncAnthropic,
|
||||
sub_agent: str,
|
||||
task: str,
|
||||
) -> str:
|
||||
"""Issue a single-shot Anthropic call as the named sub-agent.
|
||||
|
||||
Returns the concatenated text content of the response. Raises any
|
||||
SDK exception so the caller can mark the delegation as ``failed``.
|
||||
"""
|
||||
response = await client.messages.create(
|
||||
model=normalize_claude_model(
|
||||
os.getenv("ANTHROPIC_SUBAGENT_MODEL", DEFAULT_ANTHROPIC_MODEL)
|
||||
),
|
||||
max_tokens=1024,
|
||||
system=SUB_AGENT_PROMPTS[sub_agent],
|
||||
messages=[{"role": "user", "content": task}],
|
||||
)
|
||||
parts: list[str] = []
|
||||
for block in response.content:
|
||||
if getattr(block, "type", None) == "text":
|
||||
parts.append(getattr(block, "text", ""))
|
||||
text = "".join(parts).strip()
|
||||
if not text:
|
||||
raise RuntimeError("sub-agent returned empty response")
|
||||
return text
|
||||
|
||||
|
||||
# @endregion[subagents-invocation]
|
||||
# @endregion[subagent-setup]
|
||||
|
||||
|
||||
def _convert_messages(input_data: RunAgentInput) -> list[dict[str, Any]]:
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in input_data.messages or []:
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
raw_content = getattr(msg, "content", None)
|
||||
content = ""
|
||||
if isinstance(raw_content, str):
|
||||
content = raw_content
|
||||
elif isinstance(raw_content, list):
|
||||
parts = []
|
||||
for part in raw_content:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
content = "".join(parts)
|
||||
if content:
|
||||
messages.append({"role": role, "content": content})
|
||||
return messages
|
||||
|
||||
|
||||
async def run_subagents_agent(
|
||||
input_data: RunAgentInput,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Run the supervisor and yield AG-UI events.
|
||||
|
||||
Each delegation:
|
||||
1. Appends a ``running`` entry to ``state['delegations']`` and
|
||||
emits a StateSnapshotEvent.
|
||||
2. Issues the sub-agent's Anthropic call.
|
||||
3. Mutates the entry in place to ``completed`` / ``failed`` and
|
||||
emits another StateSnapshotEvent.
|
||||
4. Returns the sub-agent's text as a ToolCallResult so the
|
||||
supervisor can use it on its next step.
|
||||
"""
|
||||
encoder = EventEncoder()
|
||||
client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
|
||||
|
||||
state: dict[str, Any] = {
|
||||
"delegations": list((input_data.state or {}).get("delegations") or [])
|
||||
if isinstance(input_data.state, dict)
|
||||
else []
|
||||
}
|
||||
|
||||
messages = _convert_messages(input_data)
|
||||
thread_id = input_data.thread_id or "default"
|
||||
run_id = input_data.run_id or "run-1"
|
||||
|
||||
yield encoder.encode(
|
||||
RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id)
|
||||
)
|
||||
yield encoder.encode(
|
||||
StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=state)
|
||||
)
|
||||
|
||||
while True:
|
||||
response_text = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
msg_id = f"msg-{run_id}-{len(messages)}"
|
||||
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
|
||||
async with client.messages.stream(
|
||||
model=normalize_claude_model(
|
||||
os.getenv("ANTHROPIC_MODEL", DEFAULT_ANTHROPIC_MODEL)
|
||||
),
|
||||
max_tokens=2048,
|
||||
system=SUPERVISOR_SYSTEM_PROMPT,
|
||||
messages=messages,
|
||||
tools=SUPERVISOR_TOOLS,
|
||||
) as stream:
|
||||
current_tool_id: str | None = None
|
||||
current_tool_name: str | None = None
|
||||
current_tool_args = ""
|
||||
|
||||
async for event in stream:
|
||||
etype = type(event).__name__
|
||||
|
||||
if etype == "RawContentBlockStartEvent":
|
||||
block = event.content_block # type: ignore[attr-defined]
|
||||
if block.type == "tool_use":
|
||||
current_tool_id = block.id
|
||||
current_tool_name = block.name
|
||||
current_tool_args = ""
|
||||
yield encoder.encode(
|
||||
ToolCallStartEvent(
|
||||
type=EventType.TOOL_CALL_START,
|
||||
tool_call_id=current_tool_id,
|
||||
tool_call_name=current_tool_name,
|
||||
parent_message_id=msg_id,
|
||||
)
|
||||
)
|
||||
|
||||
elif etype == "RawContentBlockDeltaEvent":
|
||||
delta = event.delta # type: ignore[attr-defined]
|
||||
if delta.type == "text_delta":
|
||||
response_text += delta.text
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=delta.text,
|
||||
)
|
||||
)
|
||||
elif delta.type == "input_json_delta":
|
||||
current_tool_args += delta.partial_json
|
||||
yield encoder.encode(
|
||||
ToolCallArgsEvent(
|
||||
type=EventType.TOOL_CALL_ARGS,
|
||||
tool_call_id=current_tool_id or "",
|
||||
delta=delta.partial_json,
|
||||
)
|
||||
)
|
||||
|
||||
elif etype in (
|
||||
"RawContentBlockStopEvent",
|
||||
"ParsedContentBlockStopEvent",
|
||||
):
|
||||
if current_tool_id and current_tool_name:
|
||||
yield encoder.encode(
|
||||
ToolCallEndEvent(
|
||||
type=EventType.TOOL_CALL_END,
|
||||
tool_call_id=current_tool_id,
|
||||
)
|
||||
)
|
||||
parsed_args: dict[str, Any] | None
|
||||
try:
|
||||
parsed_args = (
|
||||
json.loads(current_tool_args)
|
||||
if current_tool_args
|
||||
else {}
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
# Surface malformed tool args loudly instead of
|
||||
# silently substituting an empty dict — calling
|
||||
# a sub-agent with empty arguments is worse than
|
||||
# skipping the delegation outright.
|
||||
logger.warning(
|
||||
"subagents: failed to parse tool args for "
|
||||
"%s (id=%s): %s; raw=%r",
|
||||
current_tool_name,
|
||||
current_tool_id,
|
||||
exc,
|
||||
current_tool_args,
|
||||
)
|
||||
yield encoder.encode(
|
||||
RunErrorEvent(
|
||||
type=EventType.RUN_ERROR,
|
||||
message=(
|
||||
f"Failed to parse arguments for tool "
|
||||
f"'{current_tool_name}': {exc}"
|
||||
),
|
||||
code="TOOL_ARGS_PARSE_ERROR",
|
||||
)
|
||||
)
|
||||
parsed_args = None
|
||||
|
||||
if parsed_args is not None:
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": current_tool_id,
|
||||
"name": current_tool_name,
|
||||
"input": parsed_args,
|
||||
}
|
||||
)
|
||||
# else: skip this delegation entirely rather than
|
||||
# invoking the sub-agent with an empty task.
|
||||
current_tool_id = None
|
||||
current_tool_name = None
|
||||
current_tool_args = ""
|
||||
|
||||
yield encoder.encode(
|
||||
TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=msg_id)
|
||||
)
|
||||
|
||||
if not tool_calls:
|
||||
break
|
||||
|
||||
# Persist supervisor turn into the message history.
|
||||
assistant_content: list[dict[str, Any]] = []
|
||||
if response_text:
|
||||
assistant_content.append({"type": "text", "text": response_text})
|
||||
for tc in tool_calls:
|
||||
assistant_content.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"input": tc["input"],
|
||||
}
|
||||
)
|
||||
messages.append({"role": "assistant", "content": assistant_content})
|
||||
|
||||
# @region[subagents-delegation-flow]
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
for tc in tool_calls:
|
||||
sub_agent = tc["name"]
|
||||
task = (tc["input"] or {}).get("task", "")
|
||||
|
||||
if sub_agent not in SUB_AGENT_PROMPTS:
|
||||
err = f"unknown sub-agent: {sub_agent}"
|
||||
yield encoder.encode(
|
||||
ToolCallResultEvent(
|
||||
type=EventType.TOOL_CALL_RESULT,
|
||||
tool_call_id=tc["id"],
|
||||
message_id=f"{msg_id}-tool-result-{tc['id']}",
|
||||
content=err,
|
||||
)
|
||||
)
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tc["id"],
|
||||
"content": err,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
entry_id = str(uuid.uuid4())
|
||||
entry: dict[str, Any] = {
|
||||
"id": entry_id,
|
||||
"sub_agent": sub_agent,
|
||||
"task": task,
|
||||
"status": "running",
|
||||
"result": "",
|
||||
}
|
||||
state["delegations"] = [*state["delegations"], entry]
|
||||
yield encoder.encode(
|
||||
StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=state)
|
||||
)
|
||||
|
||||
try:
|
||||
result_text = await _invoke_sub_agent(client, sub_agent, task)
|
||||
final_status = "completed"
|
||||
except Exception as exc: # noqa: BLE001 — surface any failure to UI
|
||||
logger.exception("subagent: %s failed", sub_agent)
|
||||
result_text = (
|
||||
f"sub-agent call failed: {exc.__class__.__name__} "
|
||||
"(see server logs for details)"
|
||||
)
|
||||
final_status = "failed"
|
||||
|
||||
# Mutate the matching entry in place. Using identity over the
|
||||
# entry dict is safe because we control both ends of the list.
|
||||
updated_delegations = []
|
||||
for d in state["delegations"]:
|
||||
if d.get("id") == entry_id:
|
||||
updated_delegations.append(
|
||||
{**d, "status": final_status, "result": result_text}
|
||||
)
|
||||
else:
|
||||
updated_delegations.append(d)
|
||||
state["delegations"] = updated_delegations
|
||||
yield encoder.encode(
|
||||
StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=state)
|
||||
)
|
||||
|
||||
yield encoder.encode(
|
||||
ToolCallResultEvent(
|
||||
type=EventType.TOOL_CALL_RESULT,
|
||||
tool_call_id=tc["id"],
|
||||
message_id=f"{msg_id}-tool-result-{tc['id']}",
|
||||
content=result_text,
|
||||
)
|
||||
)
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tc["id"],
|
||||
"content": result_text,
|
||||
}
|
||||
)
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
# @endregion[subagents-delegation-flow]
|
||||
|
||||
yield encoder.encode(
|
||||
RunFinishedEvent(
|
||||
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
)
|
||||
+696
@@ -0,0 +1,696 @@
|
||||
"""Tool Rendering (Reasoning Chain).
|
||||
|
||||
Combines:
|
||||
- Visible reasoning steps (parsed out of `<reasoning>...</reasoning>`
|
||||
blocks the model emits before each tool call).
|
||||
- Sequential tool calls: get_weather, search_flights, get_stock_price,
|
||||
roll_dice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import traceback
|
||||
from collections.abc import AsyncIterator
|
||||
from textwrap import dedent
|
||||
from typing import Any, Literal, TypedDict, Union, cast
|
||||
|
||||
import anthropic
|
||||
from ag_ui.core import (
|
||||
EventType,
|
||||
ReasoningMessageContentEvent,
|
||||
ReasoningMessageEndEvent,
|
||||
ReasoningMessageStartEvent,
|
||||
RunAgentInput,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from ag_ui.encoder import EventEncoder
|
||||
from agents.claude_agent_sdk_adapter import normalize_claude_model
|
||||
|
||||
|
||||
# Typed shapes for the assistant-history thinking blocks replayed to Anthropic
|
||||
# on tool-loop continuation. Type-only — does NOT change how blocks are built.
|
||||
class ThinkingBlock(TypedDict):
|
||||
type: Literal["thinking"]
|
||||
thinking: str
|
||||
signature: str
|
||||
|
||||
|
||||
class RedactedThinkingBlock(TypedDict):
|
||||
type: Literal["redacted_thinking"]
|
||||
data: str
|
||||
|
||||
|
||||
ThinkingContentBlock = Union[ThinkingBlock, RedactedThinkingBlock]
|
||||
|
||||
|
||||
TOOLS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get the current weather for a given location.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"location": {"type": "string"}},
|
||||
"required": ["location"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "search_flights",
|
||||
"description": "Search mock flights between two airports.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"origin": {"type": "string"},
|
||||
"destination": {"type": "string"},
|
||||
},
|
||||
"required": ["origin", "destination"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_stock_price",
|
||||
"description": "Get a mock current price for a stock ticker.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"ticker": {"type": "string"}},
|
||||
"required": ["ticker"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "roll_dice",
|
||||
"description": "Roll a single die with the given number of sides.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"sides": {"type": "integer"}},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
SYSTEM_PROMPT = dedent("""
|
||||
You are a travel & lifestyle concierge. Think through the user's request
|
||||
before calling any tool, then call 2+ tools in succession when relevant.
|
||||
After the last tool, write a brief final summary. Do not include
|
||||
XML-style reasoning tags in the visible answer.
|
||||
""").strip()
|
||||
|
||||
|
||||
# Extended-thinking budget for the native reasoning channel. Anthropic
|
||||
# requires max_tokens > budget_tokens when thinking is enabled.
|
||||
_THINKING_BUDGET_TOKENS = 2048
|
||||
|
||||
|
||||
def _build_stream_kwargs(messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Build the Anthropic `messages.stream` kwargs with extended thinking
|
||||
enabled so Claude streams native `thinking`/`thinking_delta` blocks.
|
||||
|
||||
Mirrors claude-sdk-typescript's /reasoning handler: a thinking-capable
|
||||
model plus `thinking={"type": "enabled", ...}`. The model is overridable
|
||||
via ANTHROPIC_REASONING_MODEL (falling back to ANTHROPIC_MODEL) so a
|
||||
deployment can pin a different extended-thinking model. Under aimock
|
||||
replay the thinking channel is produced from the fixture's `reasoning`
|
||||
field regardless of the model name.
|
||||
"""
|
||||
model = os.getenv(
|
||||
"ANTHROPIC_REASONING_MODEL",
|
||||
os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4.6"),
|
||||
)
|
||||
model = normalize_claude_model(model)
|
||||
return {
|
||||
"model": model,
|
||||
"max_tokens": _THINKING_BUDGET_TOKENS + 2048,
|
||||
"system": SYSTEM_PROMPT,
|
||||
"messages": messages,
|
||||
"tools": TOOLS,
|
||||
"thinking": {
|
||||
"type": "enabled",
|
||||
"budget_tokens": _THINKING_BUDGET_TOKENS,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _execute_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
if name == "get_weather":
|
||||
return {
|
||||
"city": args.get("location", ""),
|
||||
"temperature": 68,
|
||||
"humidity": 55,
|
||||
"wind_speed": 10,
|
||||
"conditions": "Sunny",
|
||||
}
|
||||
if name == "search_flights":
|
||||
return {
|
||||
"origin": args.get("origin", ""),
|
||||
"destination": args.get("destination", ""),
|
||||
"flights": [
|
||||
{
|
||||
"airline": "United",
|
||||
"flight": "UA231",
|
||||
"depart": "08:15",
|
||||
"arrive": "16:45",
|
||||
"price_usd": 348,
|
||||
},
|
||||
{
|
||||
"airline": "Delta",
|
||||
"flight": "DL412",
|
||||
"depart": "11:20",
|
||||
"arrive": "19:55",
|
||||
"price_usd": 312,
|
||||
},
|
||||
{
|
||||
"airline": "JetBlue",
|
||||
"flight": "B6722",
|
||||
"depart": "17:05",
|
||||
"arrive": "01:30",
|
||||
"price_usd": 289,
|
||||
},
|
||||
],
|
||||
}
|
||||
if name == "get_stock_price":
|
||||
return {
|
||||
"ticker": str(args.get("ticker", "")).upper(),
|
||||
"price_usd": round(
|
||||
100 + random.randint(0, 400) + random.randint(0, 99) / 100, 2
|
||||
),
|
||||
"change_pct": round(
|
||||
random.choice([-1, 1]) * (random.randint(0, 300) / 100), 2
|
||||
),
|
||||
}
|
||||
if name == "roll_dice":
|
||||
sides = int(args.get("sides", 6) or 6)
|
||||
return {"sides": sides, "result": random.randint(1, max(2, sides))}
|
||||
return {"error": f"unknown tool {name}"}
|
||||
|
||||
|
||||
async def run_tool_rendering_reasoning_chain_agent(
|
||||
input_data: RunAgentInput,
|
||||
) -> AsyncIterator[str]:
|
||||
encoder = EventEncoder()
|
||||
client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
|
||||
|
||||
messages: list[dict[str, Any]] = []
|
||||
latest_user_message: dict[str, Any] | None = None
|
||||
for msg in input_data.messages or []:
|
||||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
raw_content = getattr(msg, "content", None)
|
||||
content = ""
|
||||
if isinstance(raw_content, str):
|
||||
content = raw_content
|
||||
elif isinstance(raw_content, list):
|
||||
parts: list[str] = []
|
||||
for part in raw_content:
|
||||
if hasattr(part, "text"):
|
||||
parts.append(part.text)
|
||||
elif isinstance(part, dict) and "text" in part:
|
||||
parts.append(part["text"])
|
||||
content = "".join(parts)
|
||||
if content:
|
||||
message = {"role": role, "content": content}
|
||||
if role == "user":
|
||||
latest_user_message = message
|
||||
messages.append(message)
|
||||
|
||||
# Each suggestion in this demo is an independent tool-routing task.
|
||||
# Keeping prior UI transcript here makes old prompt text eligible for
|
||||
# fixture matching and replays prior assistant tool-use turns without their
|
||||
# native-thinking blocks. The per-request tool loop below still preserves
|
||||
# the assistant/tool history required to complete a single multi-tool turn.
|
||||
if latest_user_message is not None:
|
||||
messages = [latest_user_message]
|
||||
|
||||
thread_id = input_data.thread_id or "default"
|
||||
run_id = input_data.run_id or "run-1"
|
||||
yield encoder.encode(
|
||||
RunStartedEvent(type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id)
|
||||
)
|
||||
|
||||
REASONING_OPEN = "<reasoning>"
|
||||
REASONING_CLOSE = "</reasoning>"
|
||||
|
||||
iteration = 0
|
||||
while True:
|
||||
iteration += 1
|
||||
msg_id = f"msg-{run_id}-{iteration}"
|
||||
reasoning_msg_id = f"reason-{run_id}-{iteration}"
|
||||
|
||||
in_reasoning = False
|
||||
reasoning_started = False
|
||||
text_started = False
|
||||
buffer = ""
|
||||
response_text = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
|
||||
# Native extended-thinking streaming state. Hoisted to the iteration
|
||||
# scope (not the `async with` body) so the `except`/post-stream cleanup
|
||||
# can close an open native thinking block on the error path. Also
|
||||
# accumulates the thinking text + signature so the assistant turn can
|
||||
# be replayed to Anthropic with its original thinking block(s) first
|
||||
# (required when continuing a tool-use conversation with extended
|
||||
# thinking enabled).
|
||||
native_reasoning_id: str | None = None
|
||||
native_reasoning_started = False
|
||||
# Per-block accumulators for the thinking block currently streaming.
|
||||
thinking_text_acc = ""
|
||||
thinking_signature = ""
|
||||
# ALL thinking blocks of this assistant turn, in stream order. Anthropic
|
||||
# requires that when continuing a tool loop with extended thinking, the
|
||||
# assistant turn is replayed with EVERY original thinking block (each
|
||||
# with its signature) — not just the last one — as the leading content
|
||||
# blocks. `redacted_thinking` blocks are captured here too (as
|
||||
# `{"type": "redacted_thinking", "data": ...}`) since they must also be
|
||||
# preserved verbatim or Anthropic 400s on continuation.
|
||||
thinking_blocks: list[ThinkingContentBlock] = []
|
||||
|
||||
async def flush_reasoning(chunk: str) -> AsyncIterator[str]:
|
||||
nonlocal reasoning_started
|
||||
if not chunk:
|
||||
return
|
||||
if not reasoning_started:
|
||||
reasoning_started = True
|
||||
yield encoder.encode(
|
||||
ReasoningMessageStartEvent(
|
||||
type=EventType.REASONING_MESSAGE_START,
|
||||
message_id=reasoning_msg_id,
|
||||
role="reasoning",
|
||||
)
|
||||
)
|
||||
yield encoder.encode(
|
||||
ReasoningMessageContentEvent(
|
||||
type=EventType.REASONING_MESSAGE_CONTENT,
|
||||
message_id=reasoning_msg_id,
|
||||
delta=chunk,
|
||||
)
|
||||
)
|
||||
|
||||
async def emit_text(chunk: str) -> AsyncIterator[str]:
|
||||
nonlocal text_started
|
||||
if not chunk:
|
||||
return
|
||||
if not text_started:
|
||||
text_started = True
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=chunk,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
async with client.messages.stream(
|
||||
**_build_stream_kwargs(messages),
|
||||
) as stream:
|
||||
current_tool_id: str | None = None
|
||||
current_tool_name: str | None = None
|
||||
current_tool_args = ""
|
||||
# Native extended-thinking streaming. When the model (or
|
||||
# aimock replay) emits Anthropic `thinking`/`thinking_delta`
|
||||
# blocks, forward them as REASONING_MESSAGE_* — independent of
|
||||
# the inline `<reasoning>`-tag text path below, which stays as
|
||||
# the no-thinking fallback. Mirrors claude-sdk-typescript's
|
||||
# /reasoning handler. (State declared at iteration scope above
|
||||
# so error-path cleanup can close an open block.)
|
||||
|
||||
async for event in stream:
|
||||
etype = type(event).__name__
|
||||
if etype == "RawContentBlockStartEvent":
|
||||
block = event.content_block # type: ignore[attr-defined]
|
||||
if block.type == "thinking":
|
||||
native_reasoning_id = (
|
||||
f"think-{run_id}-{iteration}-{id(block)}"
|
||||
)
|
||||
native_reasoning_started = False
|
||||
# Reset per-block accumulators for the new thinking
|
||||
# block. Seed the signature from the block's initial
|
||||
# `signature` field if Anthropic provides one up
|
||||
# front. The completed block is appended to
|
||||
# `thinking_blocks` on its content_block_stop.
|
||||
thinking_text_acc = ""
|
||||
thinking_signature = getattr(block, "signature", "") or ""
|
||||
continue
|
||||
if block.type == "redacted_thinking":
|
||||
# Redacted thinking has no deltas — its opaque
|
||||
# `data` blob arrives on the block start. Capture it
|
||||
# immediately so the assistant turn can be replayed
|
||||
# with the redacted block preserved verbatim (else
|
||||
# Anthropic 400s on tool-loop continuation). Not
|
||||
# surfaced to the UI.
|
||||
redacted_data = getattr(block, "data", "") or ""
|
||||
if redacted_data:
|
||||
thinking_blocks.append(
|
||||
{
|
||||
"type": "redacted_thinking",
|
||||
"data": redacted_data,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if block.type == "tool_use":
|
||||
# Flush any pending text/reasoning buffer first.
|
||||
# Text that precedes a tool call is step narration
|
||||
# for the visible chain. Emit it immediately so the
|
||||
# chat surface remains responsive while the tool
|
||||
# loop continues, and keep it in provider history.
|
||||
if buffer:
|
||||
if in_reasoning:
|
||||
async for ev in flush_reasoning(buffer):
|
||||
yield ev
|
||||
else:
|
||||
async for ev in emit_text(buffer):
|
||||
yield ev
|
||||
response_text += buffer
|
||||
buffer = ""
|
||||
current_tool_id = block.id
|
||||
current_tool_name = block.name
|
||||
current_tool_args = ""
|
||||
yield encoder.encode(
|
||||
ToolCallStartEvent(
|
||||
type=EventType.TOOL_CALL_START,
|
||||
tool_call_id=current_tool_id,
|
||||
tool_call_name=current_tool_name,
|
||||
parent_message_id=msg_id,
|
||||
)
|
||||
)
|
||||
elif etype == "RawContentBlockDeltaEvent":
|
||||
delta = event.delta # type: ignore[attr-defined]
|
||||
if delta.type == "thinking_delta" and native_reasoning_id:
|
||||
thinking_text = getattr(delta, "thinking", "") or ""
|
||||
if thinking_text:
|
||||
# Accumulate for history reconstruction.
|
||||
thinking_text_acc += thinking_text
|
||||
if not native_reasoning_started:
|
||||
native_reasoning_started = True
|
||||
yield encoder.encode(
|
||||
ReasoningMessageStartEvent(
|
||||
type=EventType.REASONING_MESSAGE_START,
|
||||
message_id=native_reasoning_id,
|
||||
role="reasoning",
|
||||
)
|
||||
)
|
||||
yield encoder.encode(
|
||||
ReasoningMessageContentEvent(
|
||||
type=EventType.REASONING_MESSAGE_CONTENT,
|
||||
message_id=native_reasoning_id,
|
||||
delta=thinking_text,
|
||||
)
|
||||
)
|
||||
elif delta.type == "signature_delta" and native_reasoning_id:
|
||||
# Anthropic streams the thinking block's
|
||||
# cryptographic signature as a `signature_delta` on
|
||||
# the thinking content block. Accumulate it so the
|
||||
# replayed assistant turn carries the original
|
||||
# signature (required for tool-loop continuation
|
||||
# with extended thinking). Not surfaced to the UI.
|
||||
thinking_signature += getattr(delta, "signature", "") or ""
|
||||
elif delta.type == "text_delta":
|
||||
buffer += delta.text
|
||||
# Drain
|
||||
while True:
|
||||
if in_reasoning:
|
||||
close_idx = buffer.find(REASONING_CLOSE)
|
||||
if close_idx == -1:
|
||||
keep = max(
|
||||
0, len(buffer) - len(REASONING_CLOSE)
|
||||
)
|
||||
chunk = buffer[:keep]
|
||||
buffer = buffer[keep:]
|
||||
async for ev in flush_reasoning(chunk):
|
||||
yield ev
|
||||
break
|
||||
chunk = buffer[:close_idx]
|
||||
async for ev in flush_reasoning(chunk):
|
||||
yield ev
|
||||
if reasoning_started:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=reasoning_msg_id,
|
||||
)
|
||||
)
|
||||
reasoning_started = False
|
||||
buffer = buffer[close_idx + len(REASONING_CLOSE) :]
|
||||
in_reasoning = False
|
||||
continue
|
||||
else:
|
||||
open_idx = buffer.find(REASONING_OPEN)
|
||||
if open_idx == -1:
|
||||
keep = max(0, len(buffer) - len(REASONING_OPEN))
|
||||
chunk = buffer[:keep]
|
||||
buffer = buffer[keep:]
|
||||
if chunk:
|
||||
async for ev in emit_text(chunk):
|
||||
yield ev
|
||||
response_text += chunk
|
||||
break
|
||||
chunk = buffer[:open_idx]
|
||||
if chunk:
|
||||
async for ev in emit_text(chunk):
|
||||
yield ev
|
||||
response_text += chunk
|
||||
# New reasoning message id per block.
|
||||
reasoning_msg_id = (
|
||||
f"reason-{run_id}-{iteration}-{len(buffer)}"
|
||||
)
|
||||
buffer = buffer[open_idx + len(REASONING_OPEN) :]
|
||||
in_reasoning = True
|
||||
continue
|
||||
elif delta.type == "input_json_delta":
|
||||
current_tool_args += delta.partial_json
|
||||
yield encoder.encode(
|
||||
ToolCallArgsEvent(
|
||||
type=EventType.TOOL_CALL_ARGS,
|
||||
tool_call_id=current_tool_id or "",
|
||||
delta=delta.partial_json,
|
||||
)
|
||||
)
|
||||
elif etype in (
|
||||
"RawContentBlockStopEvent",
|
||||
"ParsedContentBlockStopEvent",
|
||||
):
|
||||
if native_reasoning_id is not None:
|
||||
if native_reasoning_started:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=native_reasoning_id,
|
||||
)
|
||||
)
|
||||
# Finalize THIS thinking block into the per-turn
|
||||
# list (in stream order) so the assistant turn is
|
||||
# replayed with every thinking block, each carrying
|
||||
# its own signature — not just the last one.
|
||||
if thinking_text_acc:
|
||||
thinking_blocks.append(
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": thinking_text_acc,
|
||||
"signature": thinking_signature,
|
||||
}
|
||||
)
|
||||
thinking_text_acc = ""
|
||||
thinking_signature = ""
|
||||
native_reasoning_id = None
|
||||
native_reasoning_started = False
|
||||
elif current_tool_id and current_tool_name:
|
||||
yield encoder.encode(
|
||||
ToolCallEndEvent(
|
||||
type=EventType.TOOL_CALL_END,
|
||||
tool_call_id=current_tool_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
parsed_args = (
|
||||
json.loads(current_tool_args)
|
||||
if current_tool_args
|
||||
else {}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
parsed_args = {}
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": current_tool_id,
|
||||
"name": current_tool_name,
|
||||
"input": parsed_args,
|
||||
}
|
||||
)
|
||||
current_tool_id = None
|
||||
current_tool_name = None
|
||||
current_tool_args = ""
|
||||
except Exception:
|
||||
# Lifecycle guarantee on the error path: if the stream raised while
|
||||
# a native thinking block was still open, close it before emitting
|
||||
# the error text bubble so the UI doesn't render a perpetual
|
||||
# in-flight thinking bubble. (The post-stream cleanup below closes
|
||||
# the `<reasoning>`-tag id; this closes the native one.)
|
||||
if native_reasoning_id is not None and native_reasoning_started:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=native_reasoning_id,
|
||||
)
|
||||
)
|
||||
native_reasoning_id = None
|
||||
native_reasoning_started = False
|
||||
err_text = f"Agent error: {traceback.format_exc()}"
|
||||
if not text_started:
|
||||
text_started = True
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=err_text,
|
||||
)
|
||||
)
|
||||
|
||||
# Lifecycle guarantee on the normal path: if a native thinking block
|
||||
# streamed content but its content_block_stop never arrived (e.g. the
|
||||
# stream ended early without raising), still close the reasoning
|
||||
# message so the UI doesn't render an in-flight thinking bubble.
|
||||
if native_reasoning_id is not None and native_reasoning_started:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=native_reasoning_id,
|
||||
)
|
||||
)
|
||||
native_reasoning_id = None
|
||||
native_reasoning_started = False
|
||||
|
||||
# Flush remaining buffer.
|
||||
if buffer:
|
||||
if in_reasoning:
|
||||
async for ev in flush_reasoning(buffer):
|
||||
yield ev
|
||||
if reasoning_started:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=reasoning_msg_id,
|
||||
)
|
||||
)
|
||||
reasoning_started = False
|
||||
else:
|
||||
async for ev in emit_text(buffer):
|
||||
yield ev
|
||||
response_text += buffer
|
||||
buffer = ""
|
||||
|
||||
if reasoning_started:
|
||||
yield encoder.encode(
|
||||
ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=reasoning_msg_id,
|
||||
)
|
||||
)
|
||||
reasoning_started = False
|
||||
|
||||
if not tool_calls and response_text and not text_started:
|
||||
text_started = True
|
||||
yield encoder.encode(
|
||||
TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
)
|
||||
yield encoder.encode(
|
||||
TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=msg_id,
|
||||
delta=response_text,
|
||||
)
|
||||
)
|
||||
|
||||
if text_started:
|
||||
yield encoder.encode(
|
||||
TextMessageEndEvent(
|
||||
type=EventType.TEXT_MESSAGE_END,
|
||||
message_id=msg_id,
|
||||
)
|
||||
)
|
||||
|
||||
if not tool_calls:
|
||||
break
|
||||
|
||||
# Append assistant + tool_use blocks to history.
|
||||
#
|
||||
# Anthropic requires that when continuing a tool-use conversation with
|
||||
# extended thinking enabled, the assistant turn that produced the
|
||||
# tool_use is re-sent with ALL of its ORIGINAL thinking blocks — each
|
||||
# including its `signature` — as the LEADING content blocks, in their
|
||||
# original stream order. Without them, iteration 2+ of the tool loop
|
||||
# fails with a 400 ("expected `thinking` ... as the first block /
|
||||
# signature required"). A turn may contain MORE THAN ONE thinking block
|
||||
# (and/or `redacted_thinking` blocks), so we replay the full
|
||||
# `thinking_blocks` list rather than only the last one. Each thinking
|
||||
# block's signature is accumulated from its `signature_delta` events
|
||||
# during streaming; redacted blocks carry their opaque `data`. (Under
|
||||
# aimock replay a signature is an empty string, preserved verbatim.)
|
||||
assistant_content: list[dict[str, Any]] = []
|
||||
# Type-only widening: TypedDict is invariant, so extending the
|
||||
# `dict[str, Any]` list with `list[ThinkingContentBlock]` needs an
|
||||
# explicit widen. No runtime/behavior change — the dicts are appended
|
||||
# verbatim in stream order, exactly as before.
|
||||
assistant_content.extend(cast("list[dict[str, Any]]", thinking_blocks))
|
||||
if response_text:
|
||||
assistant_content.append({"type": "text", "text": response_text})
|
||||
for tc in tool_calls:
|
||||
assistant_content.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"input": tc["input"],
|
||||
}
|
||||
)
|
||||
messages.append({"role": "assistant", "content": assistant_content})
|
||||
|
||||
tool_results: list[dict[str, Any]] = []
|
||||
for tc in tool_calls:
|
||||
result = _execute_tool(tc["name"], tc["input"])
|
||||
yield encoder.encode(
|
||||
ToolCallResultEvent(
|
||||
type=EventType.TOOL_CALL_RESULT,
|
||||
tool_call_id=tc["id"],
|
||||
message_id=f"{msg_id}-tr-{tc['id']}",
|
||||
content=json.dumps(result),
|
||||
)
|
||||
)
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tc["id"],
|
||||
"content": json.dumps(result),
|
||||
}
|
||||
)
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
|
||||
yield encoder.encode(
|
||||
RunFinishedEvent(
|
||||
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AIMOCK_CONTEXT = "claude-sdk-python";
|
||||
|
||||
function shouldAttachAimockContext(): boolean {
|
||||
return [process.env.ANTHROPIC_BASE_URL, process.env.AIMOCK_URL].some(
|
||||
(value) => value?.includes("aimock"),
|
||||
);
|
||||
}
|
||||
|
||||
export function claudeHttpAgentConfig(url: string): {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
} {
|
||||
return {
|
||||
url,
|
||||
...(shouldAttachAimockContext()
|
||||
? { headers: { "x-aimock-context": AIMOCK_CONTEXT } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createClaudeHttpAgent(url: string): HttpAgent {
|
||||
return new HttpAgent(claudeHttpAgentConfig(url));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function internalRuntimeErrorResponse(route: string, error: unknown) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const errorId = randomUUID();
|
||||
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
at: new Date().toISOString(),
|
||||
level: "error",
|
||||
route,
|
||||
errorId,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "internal runtime error", errorId },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// Dedicated runtime for the A2UI — Fixed Schema cell. Splitting into its
|
||||
// own endpoint lets us set `a2ui.injectA2UITool: false` — the backend
|
||||
// Claude Agent SDK agent owns the `display_flight` tool which emits its
|
||||
// own `a2ui_operations` container directly in the tool result.
|
||||
//
|
||||
// Reference:
|
||||
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-a2ui-fixed-schema/route.ts
|
||||
// - src/agents/a2ui_fixed.py (the Claude Agent SDK backend)
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { createClaudeHttpAgent } from "@/app/api/_shared/claude-http-agent";
|
||||
import { internalRuntimeErrorResponse } from "@/app/api/_shared/route-error";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const a2uiFixedSchemaAgent = createClaudeHttpAgent(
|
||||
`${AGENT_URL}/a2ui-fixed-schema`,
|
||||
);
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: { "a2ui-fixed-schema": a2uiFixedSchemaAgent },
|
||||
a2ui: {
|
||||
// The backend agent emits its own `a2ui_operations` container inside
|
||||
// `display_flight` (see src/agents/a2ui_fixed.py). We still run the
|
||||
// A2UI middleware so it detects the container in tool results and
|
||||
// forwards surfaces to the frontend — but we do NOT inject a runtime
|
||||
// `render_a2ui` tool on top of the agent's existing tools.
|
||||
injectA2UITool: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-a2ui-fixed-schema",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
return internalRuntimeErrorResponse(
|
||||
"/api/copilotkit-a2ui-fixed-schema",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
// Dedicated runtime for the Agent Config Object demo.
|
||||
//
|
||||
// The CopilotKit provider on the frontend (src/app/demos/agent-config/page.tsx)
|
||||
// forwards `{tone, expertise, responseLength}` via <CopilotKit properties={...}>.
|
||||
// The runtime takes those provider `properties` and attaches them as
|
||||
// top-level keys on the AG-UI run's `forwardedProps` envelope.
|
||||
//
|
||||
// This route subclasses the `HttpAgent` so it can intercept each run and
|
||||
// repack the non-structural forwardedProps keys into
|
||||
// `forwardedProps.config.configurable.properties` — the compatibility shape
|
||||
// accepted by the agent-config Python backend (see
|
||||
// src/agents/agent_config_agent.py :: `read_properties`). Repacking keeps the
|
||||
// wire format consistent across framework implementations.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import type { AbstractAgent } from "@ag-ui/client";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import { claudeHttpAgentConfig } from "@/app/api/_shared/claude-http-agent";
|
||||
import { internalRuntimeErrorResponse } from "@/app/api/_shared/route-error";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
// Shape of the AG-UI run input we care about. We avoid a direct import
|
||||
// of `RunAgentInput` from `@ag-ui/client` so this route has no
|
||||
// additional peer-dep on internal AG-UI packages — the field we touch
|
||||
// (`forwardedProps`) is part of the stable AG-UI protocol contract.
|
||||
type RunInputWithForwardedProps = {
|
||||
forwardedProps?: Record<string, unknown> | undefined;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
// Keys on `forwardedProps` that should NOT be repacked into
|
||||
// `configurable.properties`. These mirror the reserved list from
|
||||
// `@ag-ui/langgraph` so a future refactor that aliases this route to
|
||||
// the langgraph shape remains drop-in compatible.
|
||||
const RESERVED_FORWARDED_PROPS_KEYS = new Set<string>([
|
||||
"config",
|
||||
"command",
|
||||
"streamMode",
|
||||
"streamSubgraphs",
|
||||
"nodeName",
|
||||
"threadMetadata",
|
||||
"checkpointId",
|
||||
"checkpointDuring",
|
||||
"interruptBefore",
|
||||
"interruptAfter",
|
||||
"multitaskStrategy",
|
||||
"ifNotExists",
|
||||
"afterSeconds",
|
||||
"onCompletion",
|
||||
"onDisconnect",
|
||||
"webhook",
|
||||
"feedbackKeys",
|
||||
"metadata",
|
||||
]);
|
||||
|
||||
/**
|
||||
* HttpAgent subclass that repacks the CopilotKit provider's `properties`
|
||||
* (which arrive as top-level keys on `forwardedProps`) into
|
||||
* `forwardedProps.config.configurable.properties` so the Python backend
|
||||
* can read them from a stable location regardless of which framework
|
||||
* (Claude Agent SDK, LangGraph, etc.) drives the agent.
|
||||
*
|
||||
* The backend's `read_properties` (src/agents/agent_config_agent.py)
|
||||
* accepts both the nested shape and the flat top-level shape for
|
||||
* resilience — but we standardise on the nested shape at this boundary
|
||||
* so the wire format matches the langgraph-python reference and
|
||||
* Playwright specs can assert against a consistent payload shape.
|
||||
*/
|
||||
class AgentConfigHttpAgent extends HttpAgent {
|
||||
run(input: Parameters<HttpAgent["run"]>[0]): ReturnType<HttpAgent["run"]> {
|
||||
const repacked = repackForwardedPropsIntoConfigurable(
|
||||
input as unknown as RunInputWithForwardedProps,
|
||||
);
|
||||
return super.run(repacked as Parameters<HttpAgent["run"]>[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function repackForwardedPropsIntoConfigurable<
|
||||
T extends RunInputWithForwardedProps,
|
||||
>(input: T): T {
|
||||
const fp = (input.forwardedProps ?? {}) as Record<string, unknown>;
|
||||
if (!fp || typeof fp !== "object") return input;
|
||||
|
||||
const userProps: Record<string, unknown> = {};
|
||||
const structural: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(fp)) {
|
||||
if (RESERVED_FORWARDED_PROPS_KEYS.has(key)) {
|
||||
structural[key] = value;
|
||||
} else {
|
||||
userProps[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(userProps).length === 0) return input;
|
||||
|
||||
const existingConfig = (structural.config ?? {}) as {
|
||||
configurable?: Record<string, unknown>;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
const existingConfigurable =
|
||||
(existingConfig.configurable as Record<string, unknown> | undefined) ?? {};
|
||||
const existingProperties =
|
||||
(existingConfigurable.properties as Record<string, unknown> | undefined) ??
|
||||
{};
|
||||
|
||||
const mergedConfig = {
|
||||
...existingConfig,
|
||||
configurable: {
|
||||
...existingConfigurable,
|
||||
properties: {
|
||||
...existingProperties,
|
||||
...userProps,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...input,
|
||||
forwardedProps: {
|
||||
...structural,
|
||||
config: mergedConfig,
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
function createAgent(): AbstractAgent {
|
||||
// @ts-ignore -- dangling @ag-ui/client symlink in some install
|
||||
// topologies causes tsc to lose the HttpAgent constructor signature.
|
||||
// At runtime the constructor takes `{ url: string }` and works fine;
|
||||
// the other routes in this package silence the same symptom with
|
||||
// `@ts-ignore` on the CopilotRuntime `agents` property (see
|
||||
// src/app/api/copilotkit/route.ts).
|
||||
return new AgentConfigHttpAgent(
|
||||
claudeHttpAgentConfig(`${AGENT_URL}/agent-config`),
|
||||
);
|
||||
}
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"agent-config-demo": createAgent(),
|
||||
default: createAgent(),
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-agent-config",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
return internalRuntimeErrorResponse("/api/copilotkit-agent-config", error);
|
||||
}
|
||||
};
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
// Dedicated runtime for the /demos/auth cell.
|
||||
//
|
||||
// Demonstrates framework-native request authentication via the V2
|
||||
// runtime's `onRequest` hook, which runs before routing and can
|
||||
// short-circuit the request by throwing a Response. We validate a
|
||||
// static `Authorization: Bearer <DEMO_TOKEN>` header; mismatch throws
|
||||
// 401 before the request reaches the agent.
|
||||
//
|
||||
// Implementation note: the V1 Next.js adapter
|
||||
// (`copilotRuntimeNextJSAppRouterEndpoint`) does NOT forward the
|
||||
// `hooks` option to the V2 fetch handler. To get `onRequest` wired,
|
||||
// this route uses `createCopilotRuntimeHandler` from
|
||||
// `@copilotkit/runtime/v2` directly — the framework-agnostic fetch
|
||||
// handler that returns a plain `(Request) => Promise<Response>`, which
|
||||
// composes cleanly with a Next.js App Router route export.
|
||||
//
|
||||
// References:
|
||||
// - packages/runtime/src/v2/runtime/core/hooks.ts (onRequest semantics)
|
||||
// - packages/runtime/src/v2/runtime/__tests__/hooks.test.ts (throw
|
||||
// Response pattern)
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import type { CopilotRuntimeOptions } from "@copilotkit/runtime/v2";
|
||||
import { createClaudeHttpAgent } from "@/app/api/_shared/claude-http-agent";
|
||||
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const authDemoAgent = createClaudeHttpAgent(`${AGENT_URL}/`);
|
||||
type StaticRuntimeAgents = Awaited<
|
||||
Exclude<CopilotRuntimeOptions["agents"], (...args: never[]) => unknown>
|
||||
>;
|
||||
type RuntimeAgent = StaticRuntimeAgents[keyof StaticRuntimeAgents];
|
||||
|
||||
const agents: Record<string, RuntimeAgent> = {
|
||||
"auth-demo": authDemoAgent as unknown as RuntimeAgent,
|
||||
// Fallback: useAgent() with no args resolves "default" — alias to
|
||||
// the same agent so hooks in the demo page resolve cleanly.
|
||||
default: authDemoAgent as unknown as RuntimeAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({ agents });
|
||||
|
||||
const BASE_PATH = "/api/copilotkit-auth";
|
||||
|
||||
// Framework-agnostic fetch handler with the auth gate wired up.
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: BASE_PATH,
|
||||
hooks: {
|
||||
onRequest: ({ request }) => {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (authHeader !== DEMO_AUTH_HEADER) {
|
||||
// Throwing a Response short-circuits the pipeline. The runtime
|
||||
// maps thrown Responses to the HTTP response verbatim.
|
||||
throw new Response(
|
||||
JSON.stringify({
|
||||
error: "unauthorized",
|
||||
message:
|
||||
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Next.js App Router bindings.
|
||||
export const POST = (req: NextRequest) => handler(req);
|
||||
export const GET = (req: NextRequest) => handler(req);
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// Dedicated runtime for the Beautiful Chat flagship showcase cell.
|
||||
//
|
||||
// Beautiful Chat exercises A2UI, Open Generative UI, and MCP Apps in one
|
||||
// runtime, matching the canonical langgraph-python topology.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import type { AbstractAgent } from "@ag-ui/client";
|
||||
import { createClaudeHttpAgent } from "@/app/api/_shared/claude-http-agent";
|
||||
import { internalRuntimeErrorResponse } from "@/app/api/_shared/route-error";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"beautiful-chat": createClaudeHttpAgent(`${AGENT_URL}/beautiful-chat`),
|
||||
default: createClaudeHttpAgent(`${AGENT_URL}/beautiful-chat`),
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
openGenerativeUI: true,
|
||||
a2ui: {
|
||||
injectA2UITool: false,
|
||||
defaultCatalogId: "copilotkit://app-dashboard-catalog",
|
||||
},
|
||||
mcpApps: {
|
||||
servers: [
|
||||
{
|
||||
type: "http",
|
||||
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
|
||||
serverId: "excalidraw",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
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) {
|
||||
return internalRuntimeErrorResponse(
|
||||
"/api/copilotkit-beautiful-chat",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
// Dedicated runtime for the byoc-hashbrown demo.
|
||||
//
|
||||
// The backing Python agent (see src/agent_server.py `/byoc-hashbrown`)
|
||||
// has a system prompt tuned to emit the hashbrown JSON envelope
|
||||
// `{ui: [{tagName: {props: {...}}}, ...]}` — see
|
||||
// `src/agents/byoc_hashbrown_agent.py` for the full schema. Keeping
|
||||
// that prompt off the shared `/api/copilotkit` runtime is load-bearing
|
||||
// because the other demos share the sales-assistant prompt.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import type { AbstractAgent } from "@ag-ui/client";
|
||||
import { createClaudeHttpAgent } from "@/app/api/_shared/claude-http-agent";
|
||||
import { internalRuntimeErrorResponse } from "@/app/api/_shared/route-error";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent() {
|
||||
return createClaudeHttpAgent(`${AGENT_URL}/byoc-hashbrown`);
|
||||
}
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"byoc-hashbrown-demo": createAgent(),
|
||||
default: createAgent(),
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-byoc-hashbrown",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
return internalRuntimeErrorResponse(
|
||||
"/api/copilotkit-byoc-hashbrown",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// Dedicated runtime for the BYOC json-render demo.
|
||||
//
|
||||
// Isolated from `/api/copilotkit` because the backing agent has a very
|
||||
// different system prompt (single JSON object, no tools) and bleeding
|
||||
// that prompt into the shared runtime would break every other demo that
|
||||
// shares the default Claude backend.
|
||||
//
|
||||
// The Python agent server (see src/agent_server.py) exposes a dedicated
|
||||
// `/byoc-json-render` endpoint that reuses the shared AG-UI streaming
|
||||
// loop but swaps in the json-render system prompt and disables tools.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import type { AbstractAgent } from "@ag-ui/client";
|
||||
import { createClaudeHttpAgent } from "@/app/api/_shared/claude-http-agent";
|
||||
import { internalRuntimeErrorResponse } from "@/app/api/_shared/route-error";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
function createAgent() {
|
||||
return createClaudeHttpAgent(`${AGENT_URL}/byoc-json-render`);
|
||||
}
|
||||
|
||||
// The demo page mounts <CopilotKit agent="byoc_json_render">; resolve
|
||||
// that to this dedicated agent + expose a `default` alias in case any
|
||||
// internal `useAgent()` call falls back to the default slug.
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
byoc_json_render: createAgent(),
|
||||
default: createAgent(),
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-byoc-json-render",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts: CopilotRuntime agents type is
|
||||
// stricter than a plain Record but fixed in source, pending
|
||||
// release.
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
return internalRuntimeErrorResponse(
|
||||
"/api/copilotkit-byoc-json-render",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user