chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,11 @@
**/node_modules
.next
__pycache__
*.pyc
.git
**/vitest.config.ts
**/test-setup.ts
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
@@ -0,0 +1,19 @@
# API Keys (shared across integrations)
OPENAI_API_KEY=replace-with-your-key
ANTHROPIC_API_KEY=replace-with-your-key
# Agent backend URL (for the CopilotKit runtime proxy)
AGENT_URL=http://localhost:8000
# Langroid model (defaults to the bare OpenAI name `gpt-4.1`).
# NOTE: langroid does NOT strip the `openai/` prefix before passing the
# model string to the OpenAI SDK — using `openai/gpt-4.1` here will produce
# a "model not found" error at request time. Use bare names like `gpt-4.1`
# for OpenAI; use provider prefixes (`litellm/anthropic/...`, `gemini/...`,
# `openrouter/...`, `ollama/...`, etc.) for non-OpenAI providers.
# LANGROID_MODEL=gpt-4.1
# LANGROID_MODEL=litellm/anthropic/claude-opus-4
# LANGROID_MODEL=gemini/gemini-2.5-flash
# Showcase
NEXT_PUBLIC_BASE_URL=http://localhost:3000
+13
View File
@@ -0,0 +1,13 @@
node_modules/
.next/
.env.local
.env
*.pyc
__pycache__/
.venv/
dist/
playwright-report/
test-results/
# Shared modules (copied by CI)
shared_frontend/
+83
View File
@@ -0,0 +1,83 @@
# 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 in repo, resolved by Docker build context)
COPY --chown=app:app tools/ /app/tools/
# Shared CVDIAG bootstrap module (single source: showcase/integrations/_shared;
# symlinked in source, dereferenced into this build context by the harness
# stage_shared step). Lands at /app/_shared; /app is already on PYTHONPATH.
COPY --chown=app:app _shared/ ./_shared/
ENV PYTHONPATH=/app
# Entrypoint
COPY --chown=app:app entrypoint.sh ./
RUN chmod +x entrypoint.sh
USER app
EXPOSE 10000
# Intentionally NOT setting `ENV NODE_ENV=production` at the image level.
# NODE_ENV=production at the image level would leak into every child process
# (Python agent, shell scripts, healthchecks) — most of which don't use it
# the way Next.js does. entrypoint.sh scopes NODE_ENV=production to the
# Next.js invocation only so non-Next children see the host's environment.
ENV PORT=10000
ENV HOSTNAME=0.0.0.0
CMD ["./entrypoint.sh"]
@@ -0,0 +1,113 @@
# Langroid showcase — parity notes
Tracks demos from the canonical `showcase/integrations/langgraph-python/manifest.yaml`
that are either deliberately skipped or deferred for the Langroid integration.
Canonical list: 36 demos (excluding `cli-start`).
## Ported
### Wave 1 (initial scaffold)
- agentic-chat
- hitl-in-chat (route: `/demos/hitl`)
- tool-rendering
- gen-ui-tool-based
- gen-ui-agent
- shared-state-read-write
- shared-state-streaming
- subagents
### Wave 2 (chat chrome + reasoning)
- chat-customization-css
- prebuilt-sidebar
- prebuilt-popup
- chat-slots
- headless-simple
- frontend-tools
- frontend-tools-async
- hitl-in-app
- agentic-chat-reasoning
- reasoning-default-render
- readonly-state-agent-context
### Wave 3 (batch B4 second pass)
- tool-rendering-default-catchall
- tool-rendering-custom-catchall
- declarative-gen-ui (A2UI dynamic schema — reuses agent's `generate_a2ui` tool)
- auth (dedicated `/api/copilotkit-auth` route with `onRequest` hook)
- headless-complete (frontend-only, reuses unified runtime)
- agent-config (frontend + dedicated route + backend wired end-to-end;
`forwardedProps.config.configurable.properties` steers the agent's
system prompt per run)
### Wave 4 (showcase-fill-186)
- cli-start (informational manifest entry — `npx copilotkit@latest init
--framework langroid`)
- gen-ui-tool-based (manifest entry only — frontend already shipped in
Wave 1; the agent's `generate_haiku` frontend tool was the missing
manifest link)
- hitl-in-chat (frontend-only — `useHumanInTheLoop` with a `book_call`
tool defined client-side; the Langroid agent calls it via the same
AG-UI runtime path as any other tool)
- hitl-in-chat-booking (alias of hitl-in-chat, identical route — mirrors
the langgraph-python manifest where both demos point at the same
page)
## Skipped — Langroid lacks the framework primitive
- **gen-ui-interrupt** — uses `useLangGraphInterrupt` + LangGraph's
`interrupt()` node + `Command(resume=...)` lifecycle. Langroid has no
equivalent interrupt primitive and the current AG-UI adapter emits no
interrupt events.
- **interrupt-headless** — same reason as `gen-ui-interrupt`.
- **mcp-apps** — the LangGraph showcase relies on the runtime
`mcpApps: { servers: [...] }` middleware to inject MCP-server-backed
tools into the agent at request time. Langroid's custom AG-UI adapter
goes directly to the OpenAI Chat Completions API with a static tool
list built from `ALL_TOOLS`, so the runtime-level tool injection
never reaches the LLM. Porting requires a Langroid-aware AG-UI
adapter that consumes the runtime-injected tool descriptors per turn.
## Deferred — portable in principle, requires additional agent or runtime work
Each of these is achievable but needs a dedicated Langroid agent tool / module
that we did not take on in this pass. They are tracked so a follow-up can
pick them up without re-litigating scope.
- **tool-rendering-reasoning-chain** — needs the Langroid AG-UI adapter to
emit reasoning events; the current adapter only emits text + tool deltas.
- **a2ui-fixed-schema** — needs a dedicated agent that loads JSON schemas
at startup and exposes a `display_flight`-shaped tool backed by the same
A2UI middleware path.
- **byoc-json-render** — needs a Langroid agent that streams structured
JSON matching the `@json-render/react` Zod catalog, plus a dedicated BYOC
runtime route.
- **byoc-hashbrown** — needs a Langroid agent that streams structured
output matching the hashbrown schema, plus a dedicated BYOC runtime route.
- **beautiful-chat** — polished starter chat. Large frontend (example-layout,
example-canvas, generative-ui charts, hooks). Requires combining
openGenerativeUI + a2ui on one dedicated runtime route.
- **multimodal** — needs a Langroid agent configured with a vision-capable
LLM and a dedicated runtime that accepts CopilotChat attachments
(images + PDFs).
- **voice** — needs the `@copilotkit/voice` plumbing plus a dedicated voice
runtime route and sample audio assets.
- **open-gen-ui** — needs a Langroid agent that emits a `generateSandboxedUi`
tool call; the runtime `openGenerativeUI` middleware converts that stream
into activity events.
- **open-gen-ui-advanced** — same agent surface as `open-gen-ui` plus
host-side `sandboxFunctions` wiring on the frontend.
- **mcp-apps** — Langroid does ship an MCP client surface, but the canonical
demo is tightly coupled to the LangGraph activity-message emission path.
A Langroid port needs a custom AG-UI adapter path that emits the
MCP-apps activity events.
## Known limitations
(none currently tracked — previous agent-config backend gap was closed by
propagating upstream PR #4271's forwardedProps repack + backend system-
prompt wiring.)
+1
View File
@@ -0,0 +1 @@
../_shared
@@ -0,0 +1,10 @@
{
"framework": "langroid",
"features": {},
"missing": [
{
"feature": "all",
"reason": "No langroid-scoped docs tree exists on docs.copilotkit.ai (returns SPA fallback). Most demos in langroid's manifest fall through to the framework-agnostic feature-level og_docs_url + shell_docs_path in showcase/shared/feature-registry.json, which renders green. The `auth` and `agent-config` demos have no registry-level entry and render red — a deliberate honest signal that no documentation page exists yet. This file is kept as a placeholder so contributors know the choice to defer to the registry was deliberate, not an oversight."
}
]
}
+425
View File
@@ -0,0 +1,425 @@
#!/bin/bash
set -e
# Initialize PIDs up front so the cleanup trap below does not emit bare
# ``kill`` usage errors when the script aborts before either child starts
# (e.g. FATAL in ``_check_key``).
AGENT_PID=""
NEXT_PID=""
WATCHDOG_PID=""
# 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. Paired with `python
# -u` on the uvicorn invocation below and `awk ... fflush()` on the log
# prefixer — all three are belt-and-suspenders measures against pipe-
# buffered log loss observed across Railway deploys.
export PYTHONUNBUFFERED=1
cleanup() {
# Trap may fire from a FATAL ``exit 1`` path where ``set -e`` is still
# active. Any non-zero return from ``kill`` (e.g. process already gone)
# in a ``&&`` chain whose final command is ``kill`` is subject to
# errexit and would abort cleanup before the grace loop runs. Disable
# errexit for the duration of the trap — every kill/wait below
# explicitly expects and tolerates non-zero returns.
set +e
# Guard each pid: empty var -> skip (no operand), set var -> best-effort
# SIGTERM. ``2>/dev/null`` swallows normal "no such process" races after
# wait has already reaped the child.
#
# After SIGTERM, give each child up to 5s to exit cleanly before
# escalating to SIGKILL. Matches the survivor-termination grace window
# further down and the starter entrypoint's cleanup pattern — a
# runaway uvicorn / next.js process should not get wedged on trap-exit
# waiting for the container runtime to SIGKILL it.
[ -n "$AGENT_PID" ] && kill "$AGENT_PID" 2>/dev/null
[ -n "$NEXT_PID" ] && kill "$NEXT_PID" 2>/dev/null
[ -n "$WATCHDOG_PID" ] && kill "$WATCHDOG_PID" 2>/dev/null
for _ in 1 2 3 4 5; do
local any_alive=0
[ -n "$AGENT_PID" ] && kill -0 "$AGENT_PID" 2>/dev/null && any_alive=1
[ -n "$NEXT_PID" ] && kill -0 "$NEXT_PID" 2>/dev/null && any_alive=1
[ "$any_alive" = "0" ] && break
sleep 1
done
[ -n "$AGENT_PID" ] && kill -0 "$AGENT_PID" 2>/dev/null && kill -9 "$AGENT_PID" 2>/dev/null
[ -n "$NEXT_PID" ] && kill -0 "$NEXT_PID" 2>/dev/null && kill -9 "$NEXT_PID" 2>/dev/null
[ -n "$WATCHDOG_PID" ] && kill -0 "$WATCHDOG_PID" 2>/dev/null && kill -9 "$WATCHDOG_PID" 2>/dev/null
return 0
}
trap cleanup EXIT
# Provider-agnostic startup diagnostic. langroid is multi-provider — the chat
# model is selected via ``LANGROID_MODEL`` (e.g. ``gpt-4.1``,
# ``litellm/anthropic/claude-opus-4``, ``gemini/gemini-2.5-flash``). Whichever
# provider is picked, only THAT provider's API key is required.
#
# This block inspects ``LANGROID_MODEL`` (and the planner-only override
# ``A2UI_MODEL`` if distinct) and warns when the expected credential env
# var is missing. Default behavior is warn-and-continue so operators can
# bring the container up for local dev; set ``REQUIRE_LANGROID_API_KEY=1``
# in production to fail-fast.
# Map a langroid model string like ``gpt-4.1`` (bare OpenAI name) or
# ``gemini/gemini-2.5-flash`` to the env var that langroid's ``OpenAIGPT``
# client actually reads at request time. Mappings verified against
# langroid's installed ``language_models/openai_gpt.py`` — in particular:
# * Bare OpenAI names (``gpt-*``, ``o1*``, ``o3*``, ``o4*``, anything with
# NO ``/`` separator)
# -> ``OPENAI_API_KEY``. langroid strips no prefix from
# ``openai/<model>`` — it passes the model string
# LITERALLY to the OpenAI SDK, which then rejects
# ``openai/gpt-4.1`` as "model not found". Use bare
# OpenAI names.
# * ``openai/*`` -> WARN (fatal under REQUIRE_LANGROID_API_KEY=1):
# ``openai/`` is NOT a langroid-native prefix;
# langroid passes it literally to the OpenAI SDK
# which will reject the model id.
# * ``gemini/*`` -> ``GEMINI_API_KEY`` (NOT ``GOOGLE_API_KEY``; that is
# google-genai / google-adk's convention, not langroid's).
# * ``openrouter/*`` -> ``OPENROUTER_API_KEY``.
# * ``groq/*`` -> ``GROQ_API_KEY`` (native langroid prefix).
# * ``cerebras/*`` -> ``CEREBRAS_API_KEY`` (native langroid prefix).
# * ``glhf/*`` -> ``GLHF_API_KEY`` (native langroid prefix).
# * ``minimax/*`` -> ``MINIMAX_API_KEY`` (native langroid prefix).
# * ``portkey/*`` -> ``PORTKEY_API_KEY`` (native langroid prefix; note
# langroid ALSO reads portkey provider-specific keys
# at request time — a plain ``PORTKEY_API_KEY`` probe
# is the best we can do at boot).
# * ``deepseek/*`` -> ``DEEPSEEK_API_KEY`` (native langroid prefix).
# * ``litellm/anthropic/*`` -> ``ANTHROPIC_API_KEY`` (langroid strips the
# ``litellm/`` prefix and delegates to litellm, which
# reads ``ANTHROPIC_API_KEY`` for the Anthropic provider).
# * Bare ``anthropic/*`` is NOT a langroid-native prefix — langroid has no
# handling for it and falls through to the default
# OpenAI client, which rejects the request. We still
# map it to ``ANTHROPIC_API_KEY`` so the env-guard
# doesn't falsely succeed in warn-mode, but _check_key
# FATALs under ``REQUIRE_LANGROID_API_KEY=1`` so fail-
# fast operators see this misconfig at boot rather than
# at first request.
# * ``ollama/*``, ``local/*``, ``vllm/*``, ``llamacpp/*`` -> no API key
# required (local-inference); ``_check_key`` returns
# the ``NO_KEY_REQUIRED`` sentinel and logs INFO.
_expected_key_for_model() {
local model="${1:-gpt-4.1}"
# ORDER MATTERS: ``litellm/anthropic/*`` must precede the bare
# ``anthropic/*`` arm below. Otherwise ``litellm/anthropic/...`` would
# never match — bash ``case`` uses first-match-wins, and an earlier bare
# ``anthropic/*`` arm would never fire for a ``litellm/`` prefix anyway,
# but keeping litellm first makes the routing intent explicit and is
# robust to future reorderings.
case "$model" in
# Local-inference prefixes: no API key required. Sentinel distinct
# from the empty string so _check_key can log an INFO and return 0
# even under REQUIRE_LANGROID_API_KEY=1 (fail-fast), rather than
# FATALing with "Cannot infer required credential".
ollama/*|local/*|vllm/*|llamacpp/*) echo "NO_KEY_REQUIRED" ;;
litellm/anthropic/*) echo "ANTHROPIC_API_KEY" ;;
anthropic/*) echo "ANTHROPIC_API_KEY" ;;
openai/*) echo "OPENAI_API_KEY" ;;
openrouter/*) echo "OPENROUTER_API_KEY" ;;
gemini/*) echo "GEMINI_API_KEY" ;;
# ``google/`` is intentionally NOT mapped here. langroid has no
# native ``google/`` prefix handling — treating it as a gemini
# alias would let fail-fast mode "succeed" at boot (because
# GEMINI_API_KEY is set) only to blow up at request time when
# langroid falls through to the default OpenAI client. The
# dedicated ``google/*`` arm inside ``_check_key`` FATALs under
# REQUIRE_LANGROID_API_KEY=1 and WARNs otherwise, which is the
# correct signal.
groq/*) echo "GROQ_API_KEY" ;;
cerebras/*) echo "CEREBRAS_API_KEY" ;;
glhf/*) echo "GLHF_API_KEY" ;;
minimax/*) echo "MINIMAX_API_KEY" ;;
portkey/*) echo "PORTKEY_API_KEY" ;;
deepseek/*) echo "DEEPSEEK_API_KEY" ;;
# langdb/*: langroid's ``OpenAIGPT`` natively handles this prefix
# (sets ``is_langdb``) and resolves credentials via ``langdb_params``
# (a config object) rather than a single env var. There is no env var
# for us to probe at startup — emit a distinct NO_KEY_REQUIRED_*
# sentinel so ``_check_key`` logs INFO and returns 0 even under
# REQUIRE_LANGROID_API_KEY=1.
langdb/*) echo "NO_KEY_REQUIRED_LANGDB" ;;
# litellm-proxy/*: langroid's ``OpenAIGPT`` natively handles this
# prefix (sets ``is_litellm_proxy``) and resolves credentials via
# ``LiteLLMProxyConfig`` (a config object) rather than a single env
# var. Same NO_KEY_REQUIRED_* treatment as langdb/.
litellm-proxy/*) echo "NO_KEY_REQUIRED_LITELLM_PROXY" ;;
# Non-anthropic litellm variants (``litellm/openai/*``,
# ``litellm/azure/*``, ``litellm/bedrock/*``, etc.) — litellm resolves
# per-provider env vars internally (AZURE_API_KEY, AZURE_API_BASE,
# AWS_ACCESS_KEY_ID, ...) and we don't know which to probe at boot.
# Note: ``litellm/anthropic/*`` is handled by the SPECIFIC earlier
# arm (returns ANTHROPIC_API_KEY) and matches first by bash
# first-match-wins ordering — this catch-all only sees the non-
# anthropic variants.
litellm/*) echo "NO_KEY_REQUIRED_LITELLM" ;;
# Bare model names with no ``/`` separator are treated as OpenAI
# (gpt-*, o1*, o3*, o4*, chatgpt-*, etc.). This matches langroid's
# canonical convention (``OpenAIChatModel.GPT4_1.value == "gpt-4.1"``)
# — and the OpenAI SDK accepts them directly.
*/*) echo "" ;;
*) echo "OPENAI_API_KEY" ;;
esac
}
# Log when we're falling back to the default so operators understand why
# the OpenAI-shaped env guard fires even though they "didn't pick OpenAI".
if [ -z "${LANGROID_MODEL:-}" ]; then
echo "[entrypoint] INFO: LANGROID_MODEL not set — defaulting to 'gpt-4.1' (OPENAI_API_KEY will be required)" >&2
fi
LANGROID_MODEL_EFFECTIVE="${LANGROID_MODEL:-gpt-4.1}"
A2UI_MODEL_EFFECTIVE="${A2UI_MODEL:-$LANGROID_MODEL_EFFECTIVE}"
_check_key() {
local model="$1"; local role="$2"
# ``google/`` is a common typo for ``gemini/`` — handle it BEFORE we
# call ``_expected_key_for_model`` so a GEMINI_API_KEY that happens to
# be set can't silently pass the fail-fast guard for a prefix that has
# no langroid-native routing.
case "$model" in
google/*)
if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then
echo "[entrypoint] FATAL: $role model '$model' uses 'google/' prefix which is not a langroid-native prefix. Use 'gemini/<model>' instead (with GEMINI_API_KEY set); refusing to start under REQUIRE_LANGROID_API_KEY=1" >&2
exit 1
fi
echo "[entrypoint] WARN: $role model '$model' uses 'google/' prefix — langroid has no native google/ routing; use 'gemini/<model>' instead. Request-time calls will fail." >&2
return 0
;;
esac
# ``openai/*`` is NOT langroid-native either: langroid passes the full
# string LITERALLY to the OpenAI SDK (verified empirically — the
# ``openai/`` prefix is not stripped inside ``lm.OpenAIGPT``), and the
# SDK rejects ``openai/gpt-4.1`` as "model not found". Emit a warning so
# operators see the boot-time remediation rather than a cryptic
# request-time failure.
case "$model" in
openai/*)
if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then
echo "[entrypoint] FATAL: $role model '$model' uses 'openai/' prefix which is not a langroid-native prefix — langroid passes it literally to the OpenAI SDK which will reject it. Use the bare model name (e.g. 'gpt-4.1') instead; refusing to start under REQUIRE_LANGROID_API_KEY=1" >&2
exit 1
fi
echo "[entrypoint] WARN: $role model '$model' uses 'openai/' prefix — langroid passes it LITERALLY to the OpenAI SDK (the prefix is NOT stripped) and the SDK will reject it as 'model not found'. Use the bare model name (e.g. 'gpt-4.1') instead. Falling through to OPENAI_API_KEY check so the operator sees both issues at boot." >&2
;;
esac
local var
var=$(_expected_key_for_model "$model")
# NO_KEY_REQUIRED sentinels — two families:
# * Plain ``NO_KEY_REQUIRED``: local-inference models (ollama/, local/,
# vllm/, llamacpp/) — no credential at all.
# * ``NO_KEY_REQUIRED_*`` variants: langroid-native prefixes where
# credentials ARE required but resolved via a config object
# (langdb_params, LiteLLMProxyConfig) or via per-provider env vars
# internal to litellm (AZURE_*, AWS_*, etc.). We cannot name a
# single env var to probe at startup — skip the env-key check and
# let request-time surface any missing config.
# Both skip the env check and return 0 even under REQUIRE_LANGROID_API_KEY=1
# so the fail-fast contract doesn't reject a legitimately-configured
# langroid-native prefix.
case "$var" in
NO_KEY_REQUIRED)
echo "[entrypoint] INFO: local-inference model '$model' — no API key required for $role" >&2
return 0
;;
NO_KEY_REQUIRED_*)
echo "[entrypoint] INFO: $role model '$model' uses a langroid-native prefix that resolves credentials via config (no single env var to probe) — skipping env-key check" >&2
return 0
;;
esac
if [ -z "$var" ]; then
if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then
echo "[entrypoint] FATAL: Cannot infer required credential for $role model '$model'. Set a langroid-native prefix (bare OpenAI name e.g. 'gpt-4.1', litellm/anthropic/, gemini/, openrouter/, groq/, cerebras/, glhf/, minimax/, portkey/, deepseek/, ollama/, local/, vllm/, llamacpp/) or set REQUIRE_LANGROID_API_KEY=0 to downgrade to warn-mode." >&2
exit 1
fi
echo "[entrypoint] INFO: $role model '$model' does not match a known provider prefix — skipping env-key check (request-time calls will surface credentials)" >&2
return 0
fi
# Bash indirect expansion with default: ``${!var:-}`` resolves to the
# value of the env var NAMED by ``$var``, or "" if unset. The ``:-``
# default guarantees we evaluate to the empty string when the caller has
# not exported the credential, which is what the empty-check below
# expects. Note: this script runs under ``set -e`` but NOT ``set -u`` —
# every ``${FOO:-default}`` site in the file is load-bearing as-written
# because several env vars (REQUIRE_LANGROID_API_KEY, LANGROID_MODEL,
# A2UI_MODEL, PORT) are commonly unset in dev.
local val="${!var:-}"
if [ -z "$val" ]; then
if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then
echo "[entrypoint] FATAL: $var not set (required by $role model '$model') and REQUIRE_LANGROID_API_KEY=1 — refusing to start" >&2
exit 1
fi
echo "[entrypoint] WARN: $var not set — $role ('$model') calls will fail at request time (structured error returned to client)" >&2
fi
# Bare ``anthropic/<model>`` is not a langroid-native prefix; langroid
# only routes Anthropic via ``litellm/anthropic/...`` or
# ``openrouter/anthropic/...``. If an operator sets
# ``LANGROID_MODEL=anthropic/claude-opus-4`` the env-key check passes
# but the request will fail downstream because langroid falls back to
# the default OpenAI client and the OpenAI SDK rejects the model id.
#
# Under ``REQUIRE_LANGROID_API_KEY=1`` we FATAL (fail-fast contract) —
# silently booting and failing at first request contradicts the whole
# point of the guard. Under warn-mode we surface a WARN so local-dev
# operators can still bring the container up. The outer ``case``
# pattern already matched ``anthropic/*`` — no inner guard is needed
# (a string cannot simultaneously start with ``anthropic/`` and
# ``litellm/anthropic/``; the latter is handled by the earlier
# ``litellm/anthropic/*`` arm in ``_expected_key_for_model``).
# NOTE: this case intentionally tests only the bare ``anthropic/*``
# pattern. ``litellm/anthropic/...`` strings already matched the earlier
# ``litellm/anthropic/*`` arm in ``_expected_key_for_model`` (which runs
# first by design — see the ORDER MATTERS comment there) and are routed
# correctly via litellm; we must NOT warn on them here.
case "$model" in
anthropic/*)
if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then
echo "[entrypoint] FATAL: $role model '$model' uses bare 'anthropic/' prefix which is not routable through langroid (native langroid Anthropic support goes via 'litellm/anthropic/<model>' with ANTHROPIC_API_KEY set); refusing to start under REQUIRE_LANGROID_API_KEY=1" >&2
exit 1
fi
echo "[entrypoint] WARN: $role model '$model' uses bare 'anthropic/' prefix — langroid has no native Anthropic routing; requests will fail. Use 'litellm/anthropic/<model>' instead (drop-in replacement that reads ANTHROPIC_API_KEY)." >&2
;;
esac
}
_check_key "$LANGROID_MODEL_EFFECTIVE" "primary agent"
if [ "$A2UI_MODEL_EFFECTIVE" != "$LANGROID_MODEL_EFFECTIVE" ]; then
_check_key "$A2UI_MODEL_EFFECTIVE" "A2UI planner"
fi
# Start agent backend.
# NOTE: `set -e` does not fire on backgrounded processes — if uvicorn crashes
# immediately, the shell still proceeds to start Next.js. We capture PIDs and
# probe them explicitly after `wait -n` so operators can tell which process
# died with which exit code.
#
# `python -u` + `awk ... fflush()` below: unbuffered stdout at the interpreter
# level + line-flushed awk prefixer so uvicorn request lines and tracebacks
# reach Railway's log stream immediately rather than block-buffered in pipe
# buffers.
python -u -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 &> >(awk '{print "[agent] " $0; fflush()}') &
AGENT_PID=$!
# Start Next.js frontend (PORT defaults to 10000 — Railway / local compose
# override as needed).
npx next start --port ${PORT:-10000} &> >(awk '{print "[nextjs] " $0; fflush()}') &
NEXT_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. 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
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)" >&2
if [ $FAILS -ge 3 ]; then
echo "[watchdog] Agent unresponsive for ~90s — killing PID $AGENT_PID to trigger container restart" >&2
kill -9 "$AGENT_PID" 2>/dev/null || true
break
fi
fi
done
) &
WATCHDOG_PID=$!
echo "[entrypoint] Watchdog started (PID: $WATCHDOG_PID)" >&2
# Wait for either process to exit; then figure out which one.
# set +e for wait -n; exit code captured explicitly into EXIT_CODE. The
# subsequent `kill -0` / `echo` calls run without errexit — that is fine
# because the final `exit "$EXIT_CODE"` uses the captured value, so the
# container exits with the dying child's status regardless.
#
# errexit (set -e) is INTENTIONALLY left off for the remainder of the
# script: the diagnostic and cleanup blocks below use `kill`, `kill -0`,
# and `wait` calls whose non-zero returns are expected (dead process,
# already-reaped child, EPERM). Re-enabling errexit would cause the shell
# to abort before the survivor-termination grace window runs.
set +e
# ``wait -n "$AGENT_PID" "$NEXT_PID"`` (positional pid list) narrows the wait
# to just the two children we explicitly spawned, so an unrelated reaped
# subshell (e.g. process-substitution helper) cannot spuriously satisfy
# ``wait -n`` with its exit code. Requires bash 5.1+ — the base image ships
# bash 5.2. For symmetry with the starter entrypoint.
wait -n "$AGENT_PID" "$NEXT_PID"
EXIT_CODE=$?
# Interpret common POSIX / shell exit codes for operators reading the log
# stream. These are the codes likely to show up from uvicorn/next.js/Node
# under typical container-orchestration conditions (OOM kill, SIGTERM,
# missing binary, uncaught-fatal, Ctrl-C during `docker run -it`, etc.).
case "$EXIT_CODE" in
0) EXIT_MEANING="clean exit (unexpected for a long-running server)" ;;
1) EXIT_MEANING="generic error (uncaught exception / non-zero program exit)" ;;
2) EXIT_MEANING="misuse of shell builtin / bad CLI args" ;;
126) EXIT_MEANING="command invoked but not executable (permission denied)" ;;
127) EXIT_MEANING="command not found (missing binary / bad PATH)" ;;
130) EXIT_MEANING="SIGINT (Ctrl-C / interactive interrupt)" ;;
137) EXIT_MEANING="SIGKILL (likely OOM-killed or force-stopped)" ;;
139) EXIT_MEANING="SIGSEGV (segmentation fault — native crash)" ;;
143) EXIT_MEANING="SIGTERM (orderly shutdown from platform)" ;;
255) EXIT_MEANING="exit -1 / catastrophic program failure" ;;
*) EXIT_MEANING="(no common interpretation)" ;;
esac
SURVIVOR_PID=""
if ! kill -0 "$AGENT_PID" 2>/dev/null; then
echo "[entrypoint] agent backend (uvicorn, pid=$AGENT_PID) exited with code $EXIT_CODE$EXIT_MEANING" >&2
if kill -0 "$NEXT_PID" 2>/dev/null; then
SURVIVOR_PID="$NEXT_PID"
fi
elif ! kill -0 "$NEXT_PID" 2>/dev/null; then
echo "[entrypoint] next.js frontend (pid=$NEXT_PID) exited with code $EXIT_CODE$EXIT_MEANING" >&2
if kill -0 "$AGENT_PID" 2>/dev/null; then
SURVIVOR_PID="$AGENT_PID"
fi
else
# `wait -n` returned but both pids still resolve. This most commonly
# happens when a child was reaped before we ran `kill -0` (race), which
# means one IS actually dead — we just can't tell which. Escalate to
# ERROR + exit 1 so this path does not silently mask the real death.
# Under no-children-dead the shell would never reach this block.
echo "[entrypoint] ERROR: wait -n returned exit=$EXIT_CODE ($EXIT_MEANING) but both agent ($AGENT_PID) and next.js ($NEXT_PID) appear alive — treating as fatal race; the actual dying child's status has already been reaped" >&2
exit 1
fi
# Terminate the surviving sibling with a bounded grace window so it shuts
# down cleanly rather than getting SIGKILL'd by the container runtime at
# teardown.
if [ -n "$SURVIVOR_PID" ]; then
echo "[entrypoint] Terminating surviving sibling (pid=${SURVIVOR_PID}) to avoid orphan-reparent" >&2
# Capture kill failure: if `kill` returns non-zero AND the process is
# still alive, that's a real signal-delivery failure (e.g. EPERM) —
# surface it rather than letting `2>/dev/null` swallow the diagnosis.
if ! kill "$SURVIVOR_PID" 2>/dev/null; then
if kill -0 "$SURVIVOR_PID" 2>/dev/null; then
echo "[entrypoint] WARN: kill(SIGTERM) failed for survivor pid=${SURVIVOR_PID} but process is still alive — signal delivery refused (EPERM?)" >&2
fi
fi
for _ in 1 2 3 4 5; do
kill -0 "$SURVIVOR_PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$SURVIVOR_PID" 2>/dev/null; then
echo "[entrypoint] Survivor (pid=${SURVIVOR_PID}) did not exit within 5s — sending SIGKILL" >&2
if ! kill -9 "$SURVIVOR_PID" 2>/dev/null; then
if kill -0 "$SURVIVOR_PID" 2>/dev/null; then
echo "[entrypoint] WARN: kill(SIGKILL) failed for survivor pid=${SURVIVOR_PID} but process is still alive — cannot force-terminate (EPERM?)" >&2
fi
fi
fi
wait "$SURVIVOR_PID" 2>/dev/null || true
fi
exit "$EXIT_CODE"
@@ -0,0 +1,537 @@
name: Langroid
slug: langroid
category: emerging
language: python
logo: /logos/langroid.svg
description: CopilotKit integration with Langroid
partner_docs: null
repo: https://github.com/CopilotKit/CopilotKit/tree/main/showcase/integrations/langroid
copilotkit_version: 2.0.0
deployed: true
docs_mode: hidden
sort_order: 140
generative_ui:
- constrained-explicit
- a2ui-fixed-schema
- a2ui-dynamic-schema
interaction_modalities:
- sidebar
- embedded
- chat
not_supported_features:
- shared-state-read-write
- shared-state-streaming
- mcp-apps
- tool-rendering-reasoning-chain
# QUARANTINED pending a @copilotkit/react-core release. gen-ui-interrupt /
# interrupt-headless fail turn-2 on a useInterrupt/useHeadlessInterrupt
# RESUME-PATH hook bug in @copilotkit/react-core/v2: backend resumes + streams
# (HTTP 200) but the frontend never appends the confirmation assistant bubble,
# so the harness DOM settle-check times out. Fix is a published-package change
# (out of scope here); honestly marked not-supported (skipped-incapable, not
# green, not red) rather than a regression. Demos remain wired below.
- gen-ui-interrupt
- interrupt-headless
- reasoning-default-render
- agentic-chat-reasoning
features:
- cli-start
- agentic-chat
- hitl
- hitl-in-app
- hitl-in-chat
- hitl-in-chat-booking
- tool-rendering
- gen-ui-agent
- gen-ui-tool-based
- subagents
- chat-customization-css
- prebuilt-sidebar
- prebuilt-popup
- chat-slots
- headless-simple
- frontend-tools
- frontend-tools-async
- readonly-state-agent-context
- declarative-gen-ui
- auth
- headless-complete
- agent-config
- tool-rendering-default-catchall
- tool-rendering-custom-catchall
- open-gen-ui
- open-gen-ui-advanced
- voice
- multimodal
- byoc-hashbrown
- byoc-json-render
- beautiful-chat
- a2ui-fixed-schema
- shared-state-read
agent_config_pattern: shared-state
interrupt_pattern: promise-based
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 langroid"
- id: agentic-chat
name: Agentic Chat
description: Natural conversation with frontend tool execution
tags:
- chat-ui
route: /demos/agentic-chat
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/agentic-chat/page.tsx
- src/app/api/copilotkit/route.ts
- id: hitl
name: In-Chat Human in the Loop
description: User approves agent actions before execution
tags:
- interactivity
route: /demos/hitl
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/hitl/page.tsx
- src/app/api/copilotkit/route.ts
- id: 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/app/demos/hitl-in-chat/page.tsx
- src/app/demos/hitl-in-chat/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-chat-booking
name: In-Chat HITL (Booking)
description: Time-picker card rendered inline via useHumanInTheLoop for a booking flow
tags:
- interactivity
route: /demos/hitl-in-chat
animated_preview_url:
highlight:
- src/app/demos/hitl-in-chat/page.tsx
- src/app/demos/hitl-in-chat/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: hitl-in-app
name: In-App Human in the Loop
description:
Agent requests approval via async useFrontendTool; UI pops as an
app-level modal
tags:
- interactivity
route: /demos/hitl-in-app
animated_preview_url:
highlight:
- src/app/demos/hitl-in-app/page.tsx
- src/app/demos/hitl-in-app/approval-dialog.tsx
- src/app/api/copilotkit/route.ts
- id: tool-rendering
name: Tool Rendering
description: Custom render for tool calls inline in the chat stream
tags:
- generative-ui
route: /demos/tool-rendering
animated_preview_url:
highlight:
- src/agents/agent.py
- tools/get_weather.py
- tools/query_data.py
- tools/schedule_meeting.py
- tools/search_flights.py
- src/app/demos/tool-rendering/page.tsx
- src/app/api/copilotkit/route.ts
- id: gen-ui-tool-based
name: Tool-Based Generative UI
description: Agent uses tools to trigger UI generation
tags:
- generative-ui
route: /demos/gen-ui-tool-based
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/gen-ui-tool-based/page.tsx
- src/app/api/copilotkit/route.ts
- id: gen-ui-agent
name: Agentic Generative UI
description: Long-running agent tasks with generated UI
tags:
- generative-ui
route: /demos/gen-ui-agent
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/gen-ui-agent/page.tsx
- src/app/api/copilotkit/route.ts
- id: 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 shared state — UI writes preferences via
agent.setState, agent writes notes via the set_notes tool
tags:
- agent-state
route: /demos/shared-state-read-write
animated_preview_url:
highlight:
- src/agents/shared_state_read_write.py
- src/app/demos/shared-state-read-write/page.tsx
- src/app/demos/shared-state-read-write/preferences-card.tsx
- src/app/demos/shared-state-read-write/notes-card.tsx
- src/app/api/copilotkit-shared-state-read-write/route.ts
- id: subagents
name: Sub-Agents
description: Supervisor LLM delegates to research / writing / critique
sub-agents; live delegation log streams running -> completed transitions
tags:
- agent-capabilities
route: /demos/subagents
animated_preview_url:
highlight:
- src/agents/subagents.py
- src/app/demos/subagents/page.tsx
- src/app/demos/subagents/delegation-log.tsx
- src/app/api/copilotkit-subagents/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/app/demos/chat-customization-css/page.tsx
- src/app/demos/chat-customization-css/theme.css
- src/app/api/copilotkit/route.ts
- id: prebuilt-sidebar
name: "Pre-Built: Sidebar"
description: Docked sidebar chat via <CopilotSidebar />
tags:
- chat-ui
route: /demos/prebuilt-sidebar
animated_preview_url:
highlight:
- src/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/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 (welcomeScreen,
disclaimer, assistantMessage)
tags:
- chat-ui
route: /demos/chat-slots
animated_preview_url:
highlight:
- src/app/demos/chat-slots/page.tsx
- src/app/api/copilotkit/route.ts
- src/app/demos/chat-slots/slot-wrappers.tsx
- id: headless-simple
name: Headless Chat (Simple)
description: Minimal custom chat surface built on useAgent
tags:
- chat-ui
route: /demos/headless-simple
animated_preview_url:
highlight:
- src/app/demos/headless-simple/page.tsx
- src/app/api/copilotkit/route.ts
- id: frontend-tools
name: Frontend Tools (In-App Actions)
description: Agent invokes client-side handlers registered with useFrontendTool
tags:
- interactivity
route: /demos/frontend-tools
animated_preview_url:
highlight:
- src/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/app/demos/frontend-tools-async/page.tsx
- src/app/demos/frontend-tools-async/notes-card.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/app/demos/readonly-state-agent-context/page.tsx
- src/app/api/copilotkit/route.ts
- id: declarative-gen-ui
name: Declarative Generative UI
description:
A2UI dynamic-schema rendering — agent emits operations against a
client-declared catalog
tags:
- generative-ui
route: /demos/declarative-gen-ui
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/declarative-gen-ui/page.tsx
- src/app/demos/declarative-gen-ui/a2ui/catalog.ts
- src/app/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: auth
name: Authentication
description: Bearer-token gate via the V2 runtime's onRequest hook; unauth
requests are rejected with 401
tags:
- authentication
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: headless-complete
name: Headless Chat (Complete)
description: A full chat surface built from scratch on useAgent — no
CopilotChat, hand-rolled message list + bubbles
tags:
- chat-ui
route: /demos/headless-complete
animated_preview_url:
highlight:
- src/app/demos/headless-complete/page.tsx
- src/app/api/copilotkit/route.ts
- src/app/demos/headless-complete/chat/chat.tsx
- src/app/demos/headless-complete/hooks/use-tool-renderers.tsx
- src/app/demos/headless-complete/hooks/use-frontend-components.ts
- src/app/demos/headless-complete/hooks/use-headless-suggestions.ts
- src/app/demos/headless-complete/attachments/use-attachments-config.ts
- src/app/demos/headless-complete/tools/weather-card.tsx
- src/app/demos/headless-complete/tools/stock-card.tsx
- src/app/demos/headless-complete/tools/chart-card.tsx
- src/app/demos/headless-complete/tools/highlight-note.tsx
- id: agent-config
name: Agent Config Object
description: Typed config forwarded via CopilotKit provider properties; the
agent reads tone/expertise/responseLength per turn
tags:
- agent-capabilities
route: /demos/agent-config
animated_preview_url:
highlight:
- 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: 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: tool-rendering-reasoning-chain
name: Tool Rendering + Reasoning Chain (testing)
description:
Sequential tool calls with reasoning tokens rendered side-by-side.
NOTE — Langroid does not yet emit native reasoning AG-UI events; the slot
wires up only when reasoning content arrives over the wire
tags:
- generative-ui
route: /demos/tool-rendering-reasoning-chain
animated_preview_url:
highlight:
- 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/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 via the
runtime's openGenerativeUI middleware
tags:
- generative-ui
route: /demos/open-gen-ui
animated_preview_url:
highlight:
- src/app/demos/open-gen-ui/page.tsx
- src/agents/agent.py
- 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/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/agents/agent.py
- src/app/api/copilotkit-ogui/route.ts
- id: voice
name: Voice Input
description:
Speech-to-text via @copilotkit/voice — mic button or sample audio
populates the chat composer
tags:
- chat-ui
route: /demos/voice
animated_preview_url:
highlight:
- src/app/demos/voice/page.tsx
- src/app/demos/voice/sample-audio-button.tsx
- src/app/api/copilotkit-voice/[[...slug]]/route.ts
- id: multimodal
name: Multimodal Attachments
description:
Image + PDF uploads forwarded to a vision-capable agent (gpt-4o);
PDFs are flattened to text server-side
tags:
- chat-ui
- generative-ui
route: /demos/multimodal
animated_preview_url:
highlight:
- src/agents/multimodal_agent.py
- src/app/demos/multimodal/page.tsx
- src/app/demos/multimodal/sample-attachment-buttons.tsx
- src/app/api/copilotkit-multimodal/route.ts
- id: mcp-apps
name: MCP Apps
description:
MCP servers with UI resources rendered inline as sandboxed iframes
via the runtime's mcpApps middleware
tags:
- generative-ui
route: /demos/mcp-apps
animated_preview_url:
highlight:
- src/agents/mcp_apps_agent.py
- src/app/demos/mcp-apps/page.tsx
- src/app/api/copilotkit-mcp-apps/route.ts
- id: beautiful-chat
name: Beautiful Chat
description:
Polished landing-style chat shell over the shared Langroid agent —
gradient background, suggestion pills, and an example dashboard surface
tags:
- chat-ui
route: /demos/beautiful-chat
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/beautiful-chat/page.tsx
- src/app/demos/beautiful-chat/components/generative-ui/charts/bar-chart.tsx
- src/app/demos/beautiful-chat/components/generative-ui/charts/pie-chart.tsx
- src/app/api/copilotkit/route.ts
- src/app/demos/beautiful-chat/components/example-layout/index.tsx
- src/app/demos/beautiful-chat/components/example-canvas/index.tsx
- src/app/demos/beautiful-chat/hooks/use-example-suggestions.tsx
- src/app/demos/beautiful-chat/hooks/use-generative-ui-examples.tsx
- id: a2ui-fixed-schema
name: A2UI (Fixed Schema)
description:
Agent streams data into a frontend-authored fixed component schema;
backend ships JSON, agent fills in values
tags:
- generative-ui
route: /demos/a2ui-fixed-schema
animated_preview_url:
highlight:
- src/agents/a2ui_fixed_agent.py
- src/agents/a2ui_schemas/flight_schema.json
- src/app/demos/a2ui-fixed-schema/page.tsx
- src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts
- src/app/demos/a2ui-fixed-schema/a2ui/definitions.ts
- src/app/demos/a2ui-fixed-schema/a2ui/renderers.tsx
- src/app/api/copilotkit-a2ui-fixed-schema/route.ts
- id: gen-ui-interrupt
name: In-Chat HITL (useInterrupt — low-level primitive)
description: Interactive component rendered inline in the chat via the
lower-level `useInterrupt` primitive — direct control over the interrupt
lifecycle
tags:
- generative-ui
route: /demos/gen-ui-interrupt
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/gen-ui-interrupt/page.tsx
- src/app/demos/gen-ui-interrupt/_components/time-picker-card.tsx
- src/app/api/copilotkit/route.ts
- id: interrupt-headless
name: Headless Interrupt (testing)
description: Resolve interrupts from a plain button grid — no chat, no
useInterrupt render prop
tags:
- interactivity
route: /demos/interrupt-headless
animated_preview_url:
highlight:
- src/agents/agent.py
- src/app/demos/interrupt-headless/page.tsx
- src/app/api/copilotkit/route.ts
@@ -0,0 +1,25 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow iframe embedding from the showcase shell
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Frame-Options",
value: "ALLOWALL",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors *;",
},
],
},
];
},
typescript: { ignoreBuildErrors: true },
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
{
"name": "@copilotkit/showcase-langroid",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"next dev --turbopack\" \"PYTHONPATH=. python -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 --reload\"",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test:e2e": "playwright test"
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@copilotkit/a2ui-renderer": "1.61.2",
"@copilotkit/react-core": "1.61.2",
"@copilotkit/runtime": "1.61.2",
"@copilotkit/shared": "1.61.2",
"@copilotkit/voice": "1.61.2",
"@hashbrownai/core": "0.5.0-beta.4",
"@hashbrownai/react": "0.5.0-beta.4",
"@json-render/core": "0.18.0",
"@json-render/react": "0.18.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-separator": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^1.14.0",
"next": "^15.5.15",
"openai": "^5.9.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"recharts": "^2.15.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"yaml": "^2.8.4",
"zod": "^3.24.0"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"concurrently": "^9.1.0",
"postcss": "^8.5.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
},
"overrides": {
"@copilotkit/web-inspector": {
"@copilotkit/core": "1.61.2"
}
},
"pnpm": {
"overrides": {
"@copilotkit/web-inspector>@copilotkit/core": "1.61.2"
}
}
}
@@ -0,0 +1,35 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
trace: "on-first-retry",
extraHTTPHeaders: {
"X-AIMock-Context": "langroid",
},
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: process.env.CI
? undefined
: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
env: {
...process.env,
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL || "",
OPENAI_API_KEY: process.env.OPENAI_API_KEY || "",
},
},
});
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,18 @@
# Voice demo audio
Drop a small (<100KB) WAV file named `sample.wav` in this directory. The voice
demo (`/demos/voice`) fetches it client-side and POSTs to the transcription
endpoint so the flow works without mic permissions.
Expected content: an audio clip speaking the phrase
**"What is the weather in Tokyo?"** — the demo caption advertises that phrase
to the user, and the bundled QA checklist + E2E spec assert the transcribed
text contains "weather" and/or "Tokyo".
Generate locally, for example:
- macOS: `say -o sample.aiff "What is the weather in Tokyo?" && ffmpeg -i sample.aiff -ar 16000 -ac 1 sample.wav`
- Linux: `espeak-ng -w sample.wav "What is the weather in Tokyo?"`
- Windows: PowerShell `System.Speech.Synthesis.SpeechSynthesizer``SetOutputToWaveFile`
Target: 16kHz mono, 3-5s duration, <100KB.
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bd4aa7b049f1c3e324dfd15af4068d7f8fbf2eae1dd044df270dddc5f38a5c57
size 87078
@@ -0,0 +1,22 @@
# Demo Files — Multimodal Demo
This directory bundles sample files referenced by the `/demos/multimodal` page.
Required files (must be committed as binaries; see `.gitattributes` at repo root):
- `sample.png` — a small (< 50 KB) PNG that the "Try with sample image" button
injects into the chat. A CopilotKit logo or other recognizable brand mark
works well so the vision-capable agent has something to describe.
- `sample.pdf` — a small (< 50 KB) one-page PDF that the "Try with sample PDF"
button injects. The content must mention "CopilotKit" so E2E soft
assertions hold (e.g. a one-page export of the CopilotKit quickstart).
The page at `src/app/demos/multimodal/page.tsx` fetches these via the public
path (`/demo-files/sample.png`, `/demo-files/sample.pdf`), wraps the fetched
blob in a `File`, and routes it through the same `AttachmentsConfig.onUpload`
callback the paperclip button uses — so the sample path exercises the exact
same queueing code as a real user upload.
If these files are missing, the demo page still renders but the sample
buttons will surface a fetch error. The paperclip / drag-and-drop paths
continue to work without them.
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3da2afae36a1a81fd2c02f15e54bfc38b6c22e41655c31a5b54ff1e0e3daab41
size 2486
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:01aa5681de99461247543e9215c1e4da3242e26b2bee11593fcdbe209672d973
size 10083
@@ -0,0 +1,43 @@
# QA: Agent Config Object — Langroid
## Prerequisites
- Demo is deployed and accessible
- Agent backend reachable at `/api/copilotkit-agent-config`
- Langroid agent server running (see `/api/health`)
## Test Steps
### 1. Config UI
- [ ] Navigate to `/demos/agent-config`
- [ ] Verify `data-testid="agent-config-card"` is visible
- [ ] Verify Tone / Expertise / Response length selects are rendered
### 2. Forwarded properties reach the agent
- [ ] Change Tone to "enthusiastic"
- [ ] Send "Hello" and verify a response is produced; tone should read as noticeably upbeat/warm
- [ ] Change Expertise to "expert" and Response length to "detailed"
- [ ] Send "Explain how LLM tool calling works" — verify the response uses domain terminology freely and is multiple sentences (not 1 to 2)
- [ ] Change Response length to "concise" and Expertise to "beginner"
- [ ] Send the same question — response should be 1 to 2 sentences, avoid jargon, and define any technical term the first time it appears
### 3. Network inspection (optional, deeper verification)
- [ ] Open DevTools Network panel
- [ ] Send a message and inspect the POST to `/api/copilotkit-agent-config`
- [ ] In the request body, verify `forwardedProps.config.configurable.properties`
contains `tone`, `expertise`, and `responseLength` with the selected values
- [ ] The flat keys `forwardedProps.tone` / `.expertise` / `.responseLength`
should NOT be present — the route repacks them under `config.configurable.properties`
## Expected Results
- Selecting different config values visibly changes the assistant's voice,
depth of explanation, and response length.
- The `/api/copilotkit-agent-config` request body shows the repacked shape
(flat provider keys land under `forwardedProps.config.configurable.properties`).
- The Langroid backend receives the properties (via AG-UI `forwarded_props`)
and appends style directives to its system prompt for that run only; other
demos remain unaffected.
@@ -0,0 +1,11 @@
# QA: Agentic Chat (Reasoning) — Langroid
NOTE: The Langroid AG-UI adapter does not currently emit
`REASONING_MESSAGE_*` events. The custom `reasoningMessage` slot is wired,
but the reasoning card only renders once the backend emits such messages.
## Test Steps
- [ ] Navigate to /demos/agentic-chat-reasoning
- [ ] Verify chat input is visible
- [ ] Send a query; verify a normal assistant reply
@@ -0,0 +1,62 @@
# QA: Agentic Chat — Langroid
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the agentic-chat demo page
- [ ] Verify the chat interface loads with a text input placeholder "Type a message"
- [ ] Verify the background container (`data-testid="background-container"`) is visible
- [ ] Verify the default background color is the theme default (rgb(250, 250, 249))
- [ ] Send a basic message (e.g. "Hello")
- [ ] Verify the agent responds with a text message
### 2. Feature-Specific Checks
#### Suggestions
- [ ] Verify "Change background" suggestion button is visible
- [ ] Verify "Generate sonnet" suggestion button is visible
- [ ] Click the "Change background" suggestion
- [ ] Verify the suggestion either populates the input or sends the message
#### Background Change (useFrontendTool)
- [ ] Ask "Change the background to a sunset gradient"
- [ ] Verify the background container style changes from the default
- [ ] Verify the change_background tool returns a success status
#### Weather Render Tool (useRenderTool)
- [ ] Type "What's the weather in Tokyo?"
- [ ] Verify loading state shows "Loading weather..." (`data-testid="weather-info-loading"`)
- [ ] Verify WeatherCard renders (`data-testid="weather-info"`) with:
- [ ] City name displayed
- [ ] Temperature in degrees C
- [ ] Humidity percentage
- [ ] Wind speed in mph
- [ ] Conditions text
#### Agent Context
- [ ] Verify the agent knows the user's name is "Bob" (provided via useAgentContext)
- [ ] Ask "What is my name?" and verify the agent responds with "Bob"
### 3. Error Handling
- [ ] Send an empty message (should be handled gracefully)
- [ ] Verify no console errors during normal usage
- [ ] Send a very long message and verify no UI breakage
## Expected Results
- Chat loads within 3 seconds
- Agent responds within 10 seconds
- Background changes are instant after tool execution
- Weather card renders with all data fields populated
- No UI errors or broken layouts
+68
View File
@@ -0,0 +1,68 @@
# QA: Authentication — Langroid
## Prerequisites
- Demo deployed and accessible at /demos/auth
- Langroid agent backend healthy (check /api/health)
- The route `/api/copilotkit-auth` rejects requests missing the Bearer header
## Test Steps
### 1. Initial authenticated state
- [ ] Navigate to /demos/auth
- [ ] Verify the banner is visible with a green/success appearance (data-testid="auth-banner", data-authenticated="true")
- [ ] Verify auth-status text reads "✓ Signed in as demo user"
- [ ] Verify the "Sign out" button is visible and enabled (data-testid="auth-sign-out-button")
- [ ] Verify the "Sign in" button is NOT present
- [ ] Verify <CopilotChat /> is mounted below the banner
- [ ] Verify no auth-demo-error surface is shown (data-testid="auth-demo-error" absent)
- [ ] Verify no console errors on page load (the `/info` handshake should succeed)
### 2. Authenticated send → assistant response
- [ ] Type "Hello" and click send
- [ ] Within 30 seconds, an assistant response is rendered in the transcript
- [ ] No auth-demo-error surface appears
### 3. Sign out flips the banner and surfaces 401 without crashing
- [ ] Click "Sign out"
- [ ] Within 1 second, the banner flips to amber/warning appearance (data-authenticated="false")
- [ ] Verify auth-status text reads "⚠ Signed out — the agent will reject your messages until you sign in."
- [ ] Verify the "Sign in" button is visible (data-testid="auth-authenticate-button")
- [ ] Verify the "Sign out" button is no longer present
- [ ] Type "Hello again" and click send
- [ ] Within 15 seconds, the page-level error surface appears:
- `data-testid="auth-demo-error"` visible with text containing "401" and/or "Unauthorized"
- [ ] Verify the banner is STILL visible — the page must not white-screen
- [ ] Verify no assistant response appears for the unauthenticated send
### 4. Sign in clears the error and restores sends
- [ ] Click "Sign in"
- [ ] Within 1 second, the banner flips back to green (data-authenticated="true")
- [ ] Verify the auth-demo-error surface is cleared
- [ ] Type "Hello" and click send
- [ ] Within 30 seconds, an assistant response is rendered
### 5. Refresh resets state to authenticated
- [ ] Hard-reload the page
- [ ] Banner is green on first render (default state is authenticated; state does NOT persist)
- [ ] No error surface on first render
### 6. Error Handling
- [ ] With DevTools Network panel blocking /api/copilotkit-auth, send a message while authenticated
- [ ] Verify a network-level error surfaces cleanly (no uncaught promise rejection in console)
- [ ] Restore network; verify sends work again without a page reload
## Expected Results
- Page loads authenticated by default — no 401 crash on initial `/info` fetch
- Banner state flips within 1s of Sign out / Sign in clicks
- Post-sign-out sends produce a visible 401 error within 15s via auth-demo-error
- Page never white-screens after sign out — banner and composer remain mounted
- Authenticated sends produce an assistant response within 30s
- Refresh fully resets auth state (back to authenticated)
@@ -0,0 +1,20 @@
# QA: Chat Customization (CSS) — Langroid
## Prerequisites
- Demo is deployed
- Agent backend reachable via /api/health
## Test Steps
- [ ] Navigate to /demos/chat-customization-css
- [ ] Verify the scoped wrapper `.chat-css-demo-scope` renders (DOM inspector)
- [ ] Verify user bubble shows pink gradient (hot pink -> magenta)
- [ ] Verify assistant bubble shows amber monospace style
- [ ] Verify the textarea input has a dashed pink border
- [ ] Send a message; verify styled reply appears
## Expected Results
- Custom theme CSS applies only inside the demo wrapper
- Chat functions normally
@@ -0,0 +1,9 @@
# QA: Chat Slots — Langroid
## Test Steps
- [ ] Navigate to /demos/chat-slots
- [ ] Verify custom welcome screen with "Welcome to the Slots demo" card renders
- [ ] Verify custom disclaimer is visible beneath the input
- [ ] Send a message
- [ ] Verify the assistant reply is wrapped in the tinted "slot" card (custom-assistant-message)
@@ -0,0 +1,18 @@
# QA: Declarative Generative UI (A2UI) — Langroid
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy
- `/api/copilotkit-declarative-gen-ui` is configured with `injectA2UITool: false`
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/declarative-gen-ui`
- [ ] Verify the chat interface loads in a centered full-height layout
- [ ] Click the "Show a KPI dashboard" suggestion
- [ ] Verify the agent's `generate_a2ui` tool-call completes
- [ ] Verify one or more A2UI-rendered MetricCard/PieChart/BarChart surfaces
appear in the transcript
@@ -0,0 +1,9 @@
# QA: Frontend Tools (Async) — Langroid
## Test Steps
- [ ] Navigate to /demos/frontend-tools-async
- [ ] Ask "Find my notes about project planning."
- [ ] Verify the notes-card renders with matching keyword
- [ ] Verify the loading state shows briefly while the async handler resolves
- [ ] Try "Search my notes for anything related to auth" and verify auth-tagged notes appear
@@ -0,0 +1,9 @@
# QA: Frontend Tools — Langroid
## Test Steps
- [ ] Navigate to /demos/frontend-tools
- [ ] Verify the background-container renders with default background
- [ ] Ask "Change the background to a blue-to-purple gradient"
- [ ] Verify the tool invokes the frontend handler and the background style updates
- [ ] Verify the assistant confirms the change in chat
@@ -0,0 +1,62 @@
# QA: Agentic Generative UI — Langroid
## 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 — Langroid
## 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,23 @@
# QA: Headless Chat (Complete) — Langroid
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/headless-complete`
- [ ] Verify the page loads without `<CopilotChat />` — a hand-rolled message list + input
- [ ] Verify the input is focused and enabled
- [ ] Send a message and verify an assistant response bubble renders
- [ ] Verify a typing indicator appears while the agent is running
### 2. Tool Rendering
- [ ] Click the "Weather in Tokyo" suggestion
- [ ] Verify a branded WeatherCard appears inline in the message list
- [ ] Verify tool-call renderers (useRenderTool, useDefaultRenderTool) paint
without the built-in CopilotChat chrome
@@ -0,0 +1,9 @@
# QA: Headless Chat (Simple) — Langroid
## Test Steps
- [ ] Navigate to /demos/headless-simple
- [ ] Verify heading "Headless Chat (Simple)" visible
- [ ] Verify empty-state message "No messages yet"
- [ ] Type a message and press Enter; verify the user bubble appears
- [ ] Ask "show a card about cats"; verify a ShowCard renders from the useComponent registration
@@ -0,0 +1,10 @@
# QA: HITL In-App — Langroid
## Test Steps
- [ ] Navigate to /demos/hitl-in-app
- [ ] Verify the tickets panel renders with tickets #12345, #12346, #12347
- [ ] Ask "Please approve a $50 refund to Jordan Rivera on ticket #12345."
- [ ] Verify an approval dialog (`approval-dialog` testid) appears OUTSIDE the chat
- [ ] Click Approve; verify the dialog closes and the agent acknowledges the decision in chat
- [ ] Repeat with a Reject decision
@@ -0,0 +1,57 @@
# QA: Human in the Loop — Langroid
## 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,9 @@
# QA: Prebuilt Popup — Langroid
## Test Steps
- [ ] Navigate to /demos/prebuilt-popup
- [ ] Verify the floating launcher bubble renders
- [ ] Verify the popup opens by default showing "Ask the popup anything..." placeholder
- [ ] Send a message and verify reply
- [ ] Close and reopen the popup via the launcher
@@ -0,0 +1,9 @@
# QA: Prebuilt Sidebar — Langroid
## Test Steps
- [ ] Navigate to /demos/prebuilt-sidebar
- [ ] Verify main content heading "Sidebar demo" visible
- [ ] Verify sidebar docked on the side, open by default
- [ ] Send "hi" and verify agent responds
- [ ] Click the launcher to collapse/expand the sidebar
@@ -0,0 +1,10 @@
# QA: Readonly State (Agent Context) — Langroid
## Test Steps
- [ ] Navigate to /demos/readonly-state-agent-context
- [ ] Verify the context card renders with default Name "Atai"
- [ ] Change Name to "Jordan", verify published JSON updates
- [ ] Toggle recent-activity checkboxes, verify JSON updates
- [ ] Ask "What do you know about me?" — agent should reference the context values
- [ ] Change timezone, ask "What time is it for me?" — agent should reflect the new zone
@@ -0,0 +1,13 @@
# QA: Reasoning (Default Render) — Langroid
NOTE: The Langroid AG-UI adapter does not currently emit
`REASONING_MESSAGE_*` events, so no reasoning block will appear. This
demo proves the zero-config default render path compiles and renders; it
will show reasoning automatically once the adapter forwards thinking
events.
## Test Steps
- [ ] Navigate to /demos/reasoning-default-render
- [ ] Verify chat input is visible
- [ ] Send "hello"; verify the agent replies
@@ -0,0 +1,62 @@
# QA: Shared State (Read + Write) — Langroid
## Prerequisites
- Demo is deployed and accessible at `/demos/shared-state-read-write` on the dashboard host
- Agent backend is healthy (`/api/health`); `OPENAI_API_KEY` is set on Railway; the FastAPI agent server exposes `POST /shared-state-read-write` (see `src/agent_server.py`)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/shared-state-read-write`; verify the page renders within 3s with the left sidebar (preferences + notes cards) and the right-side `CopilotChat` pane
- [ ] Verify `data-testid="preferences-card"` is visible with heading "Your preferences"
- [ ] Verify `data-testid="notes-card"` is visible with heading "Agent notes" and empty-state `data-testid="notes-empty"` reading "No notes yet. Ask the agent to remember something."
- [ ] Verify the chat input placeholder is "Chat with the agent..."
- [ ] Verify all 3 suggestion pills are visible with verbatim titles: "Greet me", "Remember something", "Plan a weekend"
- [ ] Send "Hello" and verify an assistant text response appears within 10s
### 2. Feature-Specific Checks
#### UI Writes -> Agent Reads (preferences via `agent.setState`)
- [ ] Type "Atai" into `data-testid="pref-name"`; verify `data-testid="pref-state-json"` updates to include `"name": "Atai"`
- [ ] Change `data-testid="pref-tone"` to `formal`; verify the JSON preview reflects `"tone": "formal"`
- [ ] Change `data-testid="pref-language"` to `Spanish`; verify the JSON preview reflects `"language": "Spanish"`
- [ ] Click the `Cooking` and `Travel` interest pills; verify both show the selected style (border `#BEC2FF`, bg `#BEC2FF1A`) and the JSON preview's `interests` array contains both entries
- [ ] Send "What do you know about me?"; verify within 10s the assistant reply references the name "Atai", a formal tone, Spanish, and the Cooking/Travel interests (the Langroid handler injects these into the system prompt each turn — see `build_preferences_system_message` in `src/agents/shared_state_read_write.py`)
- [ ] Click the "Plan a weekend" suggestion; verify the reply is tailored to the selected interests
#### Agent Writes -> UI Reads (notes via `set_notes` tool)
- [ ] Click the "Remember something" suggestion (sends "Remember that I prefer morning meetings and that I don't eat dairy.")
- [ ] Within 15s verify `data-testid="notes-list"` appears in the notes card and contains at least 2 `data-testid="note-item"` entries mentioning "morning meetings" and "dairy"
- [ ] Verify `data-testid="notes-empty"` is no longer rendered
- [ ] Send "Also remember I live in Berlin."; verify within 15s the notes list grows (previous notes preserved, new note added) — confirms the agent passes the FULL updated list per `set_notes` contract
#### UI Writes Back to Agent-Authored Slice (clear notes)
- [ ] With notes present, verify `data-testid="notes-clear-button"` is visible
- [ ] Click the Clear button; verify the notes list disappears and `data-testid="notes-empty"` re-renders
- [ ] Ask "What do you remember about me?"; verify the agent no longer cites the cleared notes (state was written back by the UI via `agent.setState({ notes: [] })`)
#### Multi-Turn State Persistence
- [ ] Change tone to `playful` and add the `Music` interest; send "Write me a one-line haiku greeting."; verify the reply is playful and references music
- [ ] Send a follow-up "Do it again in French."; verify the reply remains playful, switches to French, and still acknowledges the music interest — confirms preferences persist across turns without being re-sent
- [ ] Reload the page; verify preferences reset to defaults (`tone: casual`, `language: English`, empty interests, empty name) and notes reset to empty (state is per-session, seeded by the page's `useEffect`)
### 3. Error Handling
- [ ] Attempt to send an empty message; verify it is a no-op (no user bubble, no assistant response)
- [ ] Deselect all interests and clear the name; send "Who am I?"; verify the agent answers without crashing (handler skips injection when `preferences` is empty)
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
- [ ] Hit `GET /api/copilotkit-shared-state-read-write` (or the dashboard's health surface) — should not error; the route only exposes POST but should not 500 on accidental GETs (Next.js will return its standard 405)
## Expected Results
- Page loads within 3 seconds; assistant text response within 10 seconds
- Preferences writes are reflected in `pref-state-json` synchronously on change
- Agent-authored notes appear in `notes-card` within 15 seconds of a "remember" prompt, and the full prior list is preserved on subsequent `set_notes` calls
- 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) — Langroid
## 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 — Langroid
## 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) — Langroid
## 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,56 @@
# QA: Sub-Agents — Langroid
## Prerequisites
- Demo is deployed and accessible at `/demos/subagents` on the dashboard host
- Agent backend is healthy (`/api/health`); `OPENAI_API_KEY` is set on Railway; the FastAPI agent server exposes `POST /subagents` (see `src/agent_server.py`)
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to `/demos/subagents`; verify the page renders within 3s with the delegation log on the left and the `CopilotChat` pane on the right
- [ ] Verify `data-testid="delegation-log"` is visible with header "Sub-agent delegations"
- [ ] Verify `data-testid="delegation-count"` shows `0 calls`
- [ ] Verify the empty-state copy "Ask the supervisor to complete a task. Every sub-agent it calls will appear here." is rendered
- [ ] Verify the chat input placeholder is "Give the supervisor a task..."
- [ ] Verify all 3 suggestion pills are visible with verbatim titles: "Write a blog post", "Explain a topic", "Summarize a topic"
### 2. Feature-Specific Checks
#### Live delegation log (running -> completed)
- [ ] Click the "Write a blog post" suggestion (sends a multi-step request that should trigger research -> write -> critique)
- [ ] Within 5s verify `data-testid="supervisor-running"` ("Supervisor running" pill) appears in the log header
- [ ] Within 10s verify the first `data-testid="delegation-entry"` appears with the Research badge (`🔎 Research`) and `running` status; the result body should show "Sub-agent running…"
- [ ] Within 30s verify the entry flips to `completed` status and a bulleted list of facts is rendered in the result body
- [ ] Verify a second `data-testid="delegation-entry"` appears with the Writing badge (`✍️ Writing`), goes through `running` -> `completed`, and renders a 1-paragraph draft
- [ ] Verify a third `data-testid="delegation-entry"` appears with the Critique badge (`🧐 Critique`) and renders 2-3 critiques
- [ ] Verify `data-testid="delegation-count"` updates to `3 calls` (or more if the supervisor delegates again)
- [ ] After the run finishes, verify `data-testid="supervisor-running"` is no longer rendered and the chat receives a brief final summary
#### Sequential chaining
- [ ] Send "Explain how large language models handle tool calling. Research, write a paragraph, then critique."
- [ ] Verify the delegations appear in order: Research, then Writing, then Critique (the supervisor passes the prior step's output through `task`)
- [ ] Verify each entry's `Task:` line references the user's topic and (for Writing/Critique) cites the prior step
#### Multi-message persistence
- [ ] After a completed run, send another task ("Summarize a topic …")
- [ ] Verify NEW delegation entries are appended to the log (existing entries from the prior turn remain visible, count grows)
- [ ] Reload the page; verify the delegation log resets to empty and `data-testid="delegation-count"` shows `0 calls`
### 3. Error Handling
- [ ] Send a trivially-conversational message like "Hi"; verify the supervisor either responds in plain text without delegating (count stays at `0 calls`) or delegates only once and finishes — no infinite loop
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
- [ ] If the secondary LLM fails (e.g. quota exhausted), verify a `failed` delegation entry is rendered with red status and a brief error message in the result body — the supervisor still produces a final user-facing message
## Expected Results
- Page loads within 3 seconds
- Each delegation transitions from `running` to `completed` (or `failed`) within ~30s
- Delegation log entries appear in submission order and preserve across multiple supervisor turns within a single run
- The supervisor returns a final natural-language summary after the last sub-agent completes
- No UI layout breaks, no uncaught console errors
@@ -0,0 +1,35 @@
# QA: Tool Rendering (Custom Catch-all) — Langroid
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
- Agent slug `tool-rendering-custom-catchall` is registered at `/api/copilotkit`
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the `tool-rendering-custom-catchall` demo page
- [ ] Verify the chat interface loads in a centered full-height layout
### 2. Feature-Specific Checks — Custom Wildcard Renderer
The page registers a single branded wildcard via `useDefaultRenderTool`
with a custom `render` function (`CustomCatchallRenderer`). Every tool
call paints through that same branded card.
- [ ] Click "Weather in SF"
- [ ] Verify a card with `data-testid="custom-catchall-card"` appears
- [ ] Verify `data-testid="custom-catchall-tool-name"` reads `get_weather`
- [ ] Verify `data-testid="custom-catchall-status"` transitions through
`streaming` / `running` and lands on `done`
- [ ] Expand Arguments and verify the args JSON is visible
- [ ] Expand Result and verify the mock weather payload is visible
- [ ] Verify NO built-in default card shows for this tool
### 3. Expected
- Every tool paints through the same branded card
- No per-tool renderers
- Status badge matches tool lifecycle
@@ -0,0 +1,47 @@
# QA: Tool Rendering (Default Catch-all) — Langroid
## Prerequisites
- Demo is deployed and accessible
- Agent backend is healthy (check /api/health)
- Agent slug `tool-rendering-default-catchall` is registered at `/api/copilotkit`
## Test Steps
### 1. Basic Functionality
- [ ] Navigate to the `tool-rendering-default-catchall` demo page
- [ ] Verify the chat interface loads in a centered full-height layout (max-width 4xl, `rounded-2xl`)
- [ ] Verify the chat input placeholder "Type a message" is visible
- [ ] Send a basic message and verify the Langroid agent responds
### 2. Feature-Specific Checks — Built-in Default Tool-Call UI
The page calls `useDefaultRenderTool()` with NO config, so every tool
call routes to CopilotKit's built-in `DefaultToolCallRenderer`.
#### Suggestions
- [ ] Verify "Weather in SF" suggestion pill is visible
- [ ] Verify "Find flights" suggestion pill is visible
- [ ] Verify "Weather in Tokyo" suggestion pill is visible
#### get_weather renders via the built-in default card
- [ ] Click "Weather in SF"
- [ ] Verify a default tool-call card appears with `get_weather` as header
- [ ] Verify status transitions `Running -> Done`
- [ ] Verify no `data-testid="custom-catchall-card"` and no
`data-testid="weather-card"` — only the built-in card paints.
#### search_flights renders via the same built-in card
- [ ] Click "Find flights"
- [ ] Verify a second default card with `search_flights` header
- [ ] Verify status lands on `Done`
### 3. Expected
- No per-tool branded cards
- No custom wildcard renderer
- Every tool call uses the package-provided default card
@@ -0,0 +1,61 @@
# QA: Tool Rendering — Langroid
## 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,15 @@
fastapi>=0.115.0
uvicorn>=0.34.0
langroid>=0.53.0
ag-ui-protocol>=0.1.14
python-dotenv>=1.0.0
# Direct-dependency pins: agui_adapter.py imports httpx / openai / pydantic
# explicitly. langroid already transitively pulls all three, but pinning
# floors here keeps behavior reproducible if langroid ever narrows its
# transitive requirements.
httpx>=0.27.0
openai>=1.40.0
pydantic>=2.0.0
# pypdf — flatten PDF attachments to text for the multimodal demo
# (gpt-4o accepts images natively but cannot read PDFs).
pypdf>=4.0.0,<7.0.0
@@ -0,0 +1,229 @@
"""
Agent Server for Langroid
FastAPI server that hosts the Langroid agent backend.
The Next.js CopilotKit runtime proxies requests here via AG-UI protocol.
Langroid does not have a native AG-UI adapter, so we implement a custom
SSE endpoint that translates between Langroid's ChatAgent and the AG-UI
event stream.
"""
import os
# 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`` CVDIAG loggers
# actually EMIT, and resolves the verbosity tier + PB writer. It imports
# pydantic/starlette only (NOT langroid / openai), so it is safe to run before
# the httpx hook install below — it does not construct any LLM httpx client.
import _shared.cvdiag_bootstrap # noqa: F401 (first non-stdlib import — bootstrap side effects)
import uvicorn # noqa: E402
from fastapi import FastAPI, Request # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from starlette.middleware.base import BaseHTTPMiddleware # noqa: E402
from starlette.responses import JSONResponse # noqa: E402
from dotenv import load_dotenv # noqa: E402
# ORDER-CRITICAL: install the global httpx hook BEFORE any agent module
# imports. Langroid / openai / pydantic-ai-style adapters construct
# httpx clients eagerly at agent-module import time.
from agents._cvdiag_backend import CvdiagBackendMiddleware # noqa: E402
from agents._header_forwarding import ( # noqa: E402
HeaderForwardingHTTPMiddleware,
install_global_httpx_hook,
)
install_global_httpx_hook()
from agents.agui_adapter import handle_run
from agents.reasoning_agent import reasoning_app
from agents.a2ui_fixed_agent import handle_run as handle_a2ui_fixed_schema
from agents.byoc_hashbrown_agent import handle_run as handle_byoc_hashbrown
from agents.byoc_json_render_agent import handle_run as handle_byoc_json_render
from agents.gen_ui_agent import handle_run as handle_gen_ui_agent
from agents.mcp_apps_agent import handle_run as handle_mcp_apps
from agents.multimodal_agent import handle_run as handle_multimodal
from agents.shared_state_read_write import (
handle_run as handle_shared_state_read_write,
)
from agents.subagents import handle_run as handle_subagents
load_dotenv()
app = FastAPI(title="Langroid Agent Server")
# Serve /health via middleware so it short-circuits BEFORE route resolution.
# Applied uniformly across every showcase FastAPI agent server so /health
# remains reachable even if future changes introduce a catch-all mount at "/".
class HealthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
if request.url.path == "/health" and request.method == "GET":
return JSONResponse({"status": "ok"})
return await call_next(request)
app.add_middleware(HealthMiddleware)
# Capture inbound CopilotKit ``x-*`` headers (e.g. ``x-aimock-context``)
# into a per-request ContextVar so any outbound LLM/provider httpx call
# made inside the request scope copies them onto its outbound request.
# Paired with ``install_global_httpx_hook`` at the top of this file.
app.add_middleware(HeaderForwardingHTTPMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# CVDIAG backend emitter (spec §3 Layer 2) — emits the HTTP-observable backend
# boundaries (request.ingress, sse.first_byte, sse.event, sse.aborted,
# response.complete, error.caught) as structured CVDIAG envelopes. Added LAST so
# it is the OUTERMOST layer: it observes ingress before any inner layer mutates
# the request and wraps the response stream so SSE boundaries fire as chunks
# flow. Gated behind ``CVDIAG_BACKEND_EMITTER`` (default OFF, canary-safe) — the
# middleware fast-paths to a bare pass-through when the flag is unset.
app.add_middleware(CvdiagBackendMiddleware)
@app.post("/")
async def run_agent(request: Request):
"""AG-UI /run endpoint — streams SSE events."""
return await handle_run(request)
# Reasoning-aware sub-app. Langroid's stock unified adapter calls OpenAI
# non-streaming and reads only message.content / message.tool_calls — it
# drops the model's reasoning_content channel, so the reasoning-default /
# reasoning-custom cells can never light up CopilotKit's reasoning slot via
# the unified agent. This custom sub-app streams the chat-completions call
# directly, captures delta.reasoning_content, and emits REASONING_MESSAGE_*
# events. The HttpAgent posts to /reasoning/; the outer Mount strips
# /reasoning and the inner Mount at "/" resolves ReasoningEndpoint. Mirrors
# ag2's /reasoning mount.
app.mount("/reasoning", reasoning_app)
# Per-demo endpoints for cells that need state-aware behavior the unified
# agent does not provide. Each handler implements its own AG-UI SSE
# pipeline (RUN_STARTED / STATE_SNAPSHOT / TEXT_* / TOOL_CALL_* / RUN_FINISHED)
# so it can read RunAgentInput.state and emit fresh snapshots when its
# tools mutate shared state. The Next.js runtime routes the demo's
# CopilotKit calls to /api/copilotkit-<slug>, which proxies to these
# endpoints via per-demo HttpAgent instances.
@app.post("/shared-state-read-write")
async def run_shared_state_read_write(request: Request):
"""Shared State (Read + Write) demo endpoint.
The UI writes ``preferences`` into agent state via ``agent.setState``;
the handler injects them into the system prompt every turn. The agent
writes ``notes`` via the ``set_notes`` tool; the handler emits a
STATE_SNAPSHOT so the UI re-renders.
"""
return await handle_shared_state_read_write(request)
@app.post("/gen-ui-agent")
async def run_gen_ui_agent(request: Request):
"""Agentic Generative UI demo endpoint.
The agent owns a ``steps`` slice of shared state and walks each step
pending -> in_progress -> completed by repeatedly calling a custom
``set_steps`` tool. Each call mutates local state and emits a fresh
STATE_SNAPSHOT so the UI's ``useAgent`` subscriber re-renders the
progress card in place.
"""
return await handle_gen_ui_agent(request)
@app.post("/subagents")
async def run_subagents(request: Request):
"""Sub-Agents demo endpoint.
A supervisor LLM delegates to research / writing / critique sub-agents
via tool calls. Each delegation appends a Delegation entry to
``state["delegations"]`` (running -> completed/failed) and emits a
STATE_SNAPSHOT so the UI's live delegation log updates.
"""
return await handle_subagents(request)
@app.post("/multimodal")
async def run_multimodal(request: Request):
"""Multimodal demo endpoint — vision-capable (gpt-4o).
Forwards image attachments to the model natively; flattens PDFs to
text via pypdf so the model can read them without needing file-part
support on the OpenAI API side.
"""
return await handle_multimodal(request)
@app.post("/byoc-hashbrown")
async def run_byoc_hashbrown(request: Request):
"""BYOC: Hashbrown demo endpoint.
Emits a hashbrown-shaped JSON envelope (`{"ui": [...]}`) that the
frontend's `useJsonParser` + `useUiKit` parses progressively as the
response streams.
"""
return await handle_byoc_hashbrown(request)
@app.post("/byoc-json-render")
async def run_byoc_json_render(request: Request):
"""BYOC: json-render demo endpoint.
Emits a flat element-map spec (`{"root", "elements"}`) that
@json-render/react renders against a Zod-validated catalog.
"""
return await handle_byoc_json_render(request)
@app.post("/a2ui-fixed-schema")
async def run_a2ui_fixed_schema(request: Request):
"""A2UI Fixed Schema demo endpoint.
The agent ships ``flight_schema.json`` as a fixed component tree and
only streams *data* into the data model at runtime. The
``display_flight`` tool returns an ``a2ui_operations`` container
(``create_surface`` + ``update_components`` + ``update_data_model``)
that the Next.js A2UI middleware detects and forwards to the
frontend renderer. The dedicated runtime route at
``api/copilotkit-a2ui-fixed-schema/route.ts`` is configured with
``injectA2UITool: false`` because the agent owns the tool itself.
"""
return await handle_a2ui_fixed_schema(request)
@app.post("/mcp-apps")
async def run_mcp_apps(request: Request):
"""MCP Apps demo endpoint.
Forwards the runtime-supplied MCP tool catalog to OpenAI; the runtime
middleware on the TypeScript side intercepts the resulting tool
calls, fetches the MCP UI resource, and renders the sandboxed
iframe.
"""
return await handle_mcp_apps(request)
def main():
"""Run the uvicorn server."""
port = int(os.getenv("PORT", "8000"))
uvicorn.run(
"agent_server:app",
host="0.0.0.0",
port=port,
reload=True,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,812 @@
"""_cvdiag_backend.py — backend-layer CVDIAG boundary instrumentation.
This module wires the spec §3 / §5 **11 backend boundaries** into a Python
showcase integration, emitting schema-v1 CVDIAG envelopes through the shared
``_shared.cvdiag_bootstrap.emit_cvdiag`` sink. It is the per-integration
companion to the header-forwarding shim (``_header_forwarding.py``): that file
forwards correlation headers onto outbound LLM calls and logs lightweight
``CVDIAG component=backend-<fw> boundary=...`` breadcrumbs; THIS file emits the
full structured ``CVDIAG {<json>}`` envelopes the harness/classifier consume.
The 11 backend boundaries (spec §5 / §6 tier matrix):
1. ``backend.request.ingress`` — HTTP request received (default)
2. ``backend.agent.enter`` — agent loop entered (default)
3. ``backend.llm.call.start`` — outbound LLM call dispatched (verbose)
4. ``backend.llm.call.heartbeat`` — fires ~10s while an LLM call is
outstanding (verbose)
5. ``backend.llm.call.response`` — LLM response received (verbose)
6. ``backend.sse.first_byte`` — first SSE byte written (verbose)
7. ``backend.sse.event`` — every SSE event written (debug)
8. ``backend.sse.aborted`` — stream terminated abnormally (default)
9. ``backend.agent.exit`` — agent loop exited (default)
10. ``backend.response.complete`` — HTTP response stream closed (default)
11. ``backend.error.caught`` — exception caught in the agent loop
(default)
Guarding
--------
ALL emission is gated behind the ``CVDIAG_BACKEND_EMITTER`` env flag, default
OFF. With the flag off this module is byte-for-byte inert — no envelope is
built, no stdout line is written, the middleware passes the request straight
through. This is the canary-safe default: the flag is flipped ON only after a
deploy is confirmed healthy.
Tier gating
-----------
Each boundary carries a tier per the §6 matrix. ``_shared.cvdiag_bootstrap``
resolves the active tier (default | verbose | debug) once at import; this
module suppresses a boundary whose tier exceeds the active tier so the
default-tier production emit stays within the §7 event-count budget.
Pure instrumentation
--------------------
Nothing here may throw into the request path. ``emit_cvdiag`` already swallows
its own errors; the helpers below additionally guard envelope construction so a
malformed metadata bag degrades to a dropped emit, never a 500.
Plan unit: L1-C.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import secrets
import time
import uuid
from typing import Any, Dict, Optional
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from _shared.cvdiag_bootstrap import _resolve_tier, current_tier, emit_cvdiag
logger = logging.getLogger(__name__)
# Framework tag — mirrors ``_header_forwarding._CVDIAG_FRAMEWORK`` so the
# structured envelopes and the breadcrumb log lines agree on the integration
# identity. (L1-D: change this single constant when copying to a sibling.)
_CVDIAG_FRAMEWORK = "langroid"
# ── 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 = "langroid"
# 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,464 @@
"""Langroid agent for the Declarative Generative UI (A2UI Fixed Schema) demo.
Fixed-schema A2UI: the component tree (schema) is authored ahead of time
as JSON and shipped with the backend. The agent only streams *data* into
the data model at runtime via the ``display_flight`` tool. The frontend
registers a matching catalog (see
``src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts``).
Mirrors the ``ag2`` integration's ``a2ui_fixed.py`` and the
``langgraph-python`` reference. The dedicated Next.js route at
``api/copilotkit-a2ui-fixed-schema/route.ts`` runs the A2UI middleware
with ``injectA2UITool: false`` because the backend agent owns the
``display_flight`` tool itself and emits an ``a2ui_operations`` container
in the tool result.
Wire pattern
------------
On each request we call OpenAI with a single ``display_flight`` tool
forced via ``tool_choice`` when the user prompt looks like a flight
query. The handler emits an AG-UI ``ToolCall`` triple for each tool call
the model produces, then immediately appends the ``a2ui_operations``
JSON as a tool-result text block. The runtime A2UI middleware on the
TypeScript side detects the ``a2ui_operations`` shape and forwards the
surface to the frontend renderer.
This handler is wired up by ``agent_server.py`` at
``POST /a2ui-fixed-schema``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from pathlib import Path
from typing import Any, AsyncGenerator
import httpx
import openai
import pydantic
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallResultEvent,
ToolCallStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
logger = logging.getLogger(__name__)
CATALOG_ID = "copilotkit://flight-fixed-catalog"
SURFACE_ID = "flight-fixed-schema"
_SCHEMAS_DIR = Path(__file__).parent / "a2ui_schemas"
def _load_schema(filename: str) -> list[dict[str, Any]]:
"""Load an A2UI fixed schema from the local schemas directory."""
with open(_SCHEMAS_DIR / filename, "r", encoding="utf-8") as fh:
return json.load(fh)
FLIGHT_SCHEMA = _load_schema("flight_schema.json")
SYSTEM_PROMPT = (
"You help users find flights. When asked about a flight, call "
"display_flight with origin (3-letter code), destination (3-letter "
"code), airline, and price (e.g. '$289'). Keep any chat reply to one "
"short sentence."
)
# OpenAI tool spec — single ``display_flight`` tool.
_DISPLAY_FLIGHT_TOOL: dict[str, Any] = {
"type": "function",
"function": {
"name": "display_flight",
"description": "Show a flight card for the given trip.",
"parameters": {
"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 _build_a2ui_operations(
*, origin: str, destination: str, airline: str, price: str
) -> dict[str, Any]:
"""Build the ``a2ui_operations`` container (v0.9 shape) the runtime
middleware detects in TOOL_CALL_RESULT events and forwards to the
frontend renderer.
Uses the v0.9 nested form (``{"version": "v0.9", "createSurface": {...}}``)
matching the ``copilotkit`` Python SDK's ``a2ui.render(...)`` output and
the claude-sdk-python / strands / google-adk peers. The legacy flat form
(``{"type": "create_surface", ...}``) is silently dropped by the renderer.
"""
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,
},
},
},
]
}
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
def _agui_messages_to_openai(messages: Any) -> list[dict[str, Any]]:
"""Reduce inbound AG-UI messages to a simple OpenAI message list.
We only need text-bearing user/assistant turns plus prior
``tool``-role messages keyed by ``tool_call_id`` so the model can
follow up after a ``display_flight`` call. Anything else is
skipped.
"""
out: list[dict[str, Any]] = []
if not messages:
return out
for msg in messages:
if isinstance(msg, dict):
role = msg.get("role")
content = msg.get("content")
tool_call_id = msg.get("tool_call_id")
tool_calls = msg.get("tool_calls")
else:
role = getattr(msg, "role", None)
content = getattr(msg, "content", None)
tool_call_id = getattr(msg, "tool_call_id", None)
tool_calls = getattr(msg, "tool_calls", None)
if role == "tool" and tool_call_id:
out.append(
{
"role": "tool",
"tool_call_id": str(tool_call_id),
"content": str(content or ""),
}
)
continue
if role == "assistant":
oai_msg: dict[str, Any] = {"role": "assistant"}
if isinstance(content, str) and content:
oai_msg["content"] = content
if tool_calls:
oai_tcs = []
for tc in tool_calls:
if isinstance(tc, dict):
tc_id = tc.get("id")
fn = tc.get("function", {})
fn_name = fn.get("name", "") if isinstance(fn, dict) else ""
fn_args = (
fn.get("arguments", "") if isinstance(fn, dict) else ""
)
else:
tc_id = getattr(tc, "id", None)
fn = getattr(tc, "function", None)
fn_name = getattr(fn, "name", "") if fn else ""
fn_args = getattr(fn, "arguments", "") if fn else ""
if tc_id and fn_name:
oai_tcs.append(
{
"id": str(tc_id),
"type": "function",
"function": {
"name": str(fn_name),
"arguments": str(fn_args),
},
}
)
if oai_tcs:
oai_msg["tool_calls"] = oai_tcs
if "content" not in oai_msg:
oai_msg["content"] = None
if "content" not in oai_msg and "tool_calls" not in oai_msg:
oai_msg["content"] = ""
out.append(oai_msg)
continue
if role in ("user", "system", "developer") and isinstance(content, str):
out.append({"role": role, "content": content})
return out
def _parse_tool_args(raw: Any) -> dict[str, Any] | None:
"""Parse OpenAI tool-call arguments (JSON string or dict) into a dict."""
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw or "{}")
except (ValueError, TypeError):
logger.warning("a2ui_fixed: failed to parse tool args: %r", raw)
return None
if isinstance(parsed, dict):
return parsed
return None
async def handle_run(request: Request) -> StreamingResponse:
"""AG-UI ``/a2ui-fixed-schema`` SSE handler.
Drives a single OpenAI chat-completions turn with the
``display_flight`` tool exposed. If the model produces a tool call,
we emit AG-UI ``TOOL_CALL_*`` events plus a tool-result text block
containing the ``a2ui_operations`` JSON the Next.js runtime A2UI
middleware detects and forwards to the frontend renderer.
"""
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception("a2ui_fixed: failed to parse body (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except (pydantic.ValidationError, TypeError, ValueError) as exc:
logger.exception("a2ui_fixed: invalid RunAgentInput (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
oai_messages = [
{"role": "system", "content": SYSTEM_PROMPT},
*_agui_messages_to_openai(run_input.messages),
]
model = os.getenv("LANGROID_MODEL", "gpt-4o-mini")
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
client = openai.AsyncOpenAI()
try:
completion = await client.chat.completions.create(
model=model,
messages=oai_messages,
tools=[_DISPLAY_FLIGHT_TOOL],
tool_choice="auto",
stream=False,
)
except (openai.APIError, httpx.HTTPError, asyncio.TimeoutError) as exc:
logger.exception("a2ui_fixed: OpenAI call failed")
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Agent run failed: {exc.__class__.__name__}",
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
message = completion.choices[0].message if completion.choices else None
text_content = getattr(message, "content", None) or ""
tool_calls = getattr(message, "tool_calls", None) or []
# Parent message wraps tool calls so the runtime middleware
# SSE parser can associate them — same pattern as the main
# adapter (see agui_adapter.py).
parent_id = str(uuid.uuid4())
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=parent_id,
)
)
if text_content:
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=parent_id,
delta=text_content,
)
)
for tc in tool_calls:
fn = getattr(tc, "function", None)
tool_name = getattr(fn, "name", None) if fn else None
raw_args = getattr(fn, "arguments", "{}") if fn else "{}"
tool_args = _parse_tool_args(raw_args)
call_id = getattr(tc, "id", None) or str(uuid.uuid4())
if tool_name != "display_flight" or tool_args is None:
logger.warning(
"a2ui_fixed: skipping unexpected tool call %s", tool_name
)
continue
yield _sse_line(
ToolCallStartEvent(
type=EventType.TOOL_CALL_START,
tool_call_id=call_id,
tool_call_name=tool_name,
parent_message_id=parent_id,
)
)
yield _sse_line(
ToolCallArgsEvent(
type=EventType.TOOL_CALL_ARGS,
tool_call_id=call_id,
delta=json.dumps(tool_args),
)
)
yield _sse_line(
ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
tool_call_id=call_id,
)
)
# Build the tool result containing the a2ui_operations
# container. The Next.js runtime A2UI middleware detects
# this shape in TOOL_CALL_RESULT events and forwards the
# surface ops to the frontend renderer.
operations = _build_a2ui_operations(
origin=str(tool_args.get("origin", "")),
destination=str(tool_args.get("destination", "")),
airline=str(tool_args.get("airline", "")),
price=str(tool_args.get("price", "")),
)
tool_result_msg_id = str(uuid.uuid4())
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=parent_id,
)
)
# Emit the a2ui_operations container via TOOL_CALL_RESULT so
# the A2UI middleware detects it. TextMessageContentEvent is
# not scanned for a2ui_operations — only tool results are.
yield _sse_line(
ToolCallResultEvent(
type=EventType.TOOL_CALL_RESULT,
tool_call_id=call_id,
message_id=tool_result_msg_id,
content=json.dumps(operations),
)
)
# Single tool call per turn — finish after the first.
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
# No tool call path — close the parent message and finish.
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=parent_id,
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,299 @@
"""BYOC: Hashbrown demo backend (Langroid).
Streams a single JSON object shaped like `@hashbrownai/react`'s
`useUiKit` schema so the frontend's progressive parser can turn it into
a sales dashboard as tokens arrive.
Wire format
-----------
The frontend (see ``src/app/demos/byoc-hashbrown/hashbrown-renderer.tsx``)
calls ``useJsonParser(content, kit.schema)``. ``kit.schema`` matches:
{
"ui": [
{ "metric": { "props": { "label": "...", "value": "..." } } },
{ "pieChart": { "props": { "title": "...", "data": "[{...}]" } } },
{ "barChart": { "props": { "title": "...", "data": "[{...}]" } } },
{ "dealCard": { "props": { "title": "...", "stage": "...", "value": 0 } } },
{ "Markdown": { "props": { "children": "..." } } }
]
}
This handler forces OpenAI's ``response_format: json_object`` mode and
streams the result as a single ``TEXT_MESSAGE`` triple. The progressive
parser on the frontend treats partial JSON gracefully — anything that
doesn't parse yet falls back to a no-op render until the next token
arrives.
The handler is wired up by ``agent_server.py`` at ``POST
/byoc-hashbrown``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from typing import Any, AsyncGenerator
import httpx
import openai
import pydantic
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
logger = logging.getLogger(__name__)
# Mirrors the langgraph-python byoc_hashbrown system prompt. The
# example payload at the bottom is critical — without a worked example
# the model frequently emits the wrong nesting (e.g. multi-key objects
# instead of single-key `{tagName: {props: {...}}}` entries).
_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}]"}}}]}
"""
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
def _flatten_user_messages(messages: Any) -> list[dict[str, Any]]:
"""Reduce inbound AG-UI messages to a simple OpenAI message list.
The hashbrown demo is single-turn-ish — we want the model to emit a
fresh JSON envelope for the latest user prompt, not a continuation.
Accept all `user`/`assistant` text-only turns; skip tool messages
(irrelevant — this agent has no tools).
"""
out: list[dict[str, Any]] = []
if not messages:
return out
for msg in messages:
role = (
getattr(msg, "role", None) if not isinstance(msg, dict) else msg.get("role")
)
content = (
getattr(msg, "content", None)
if not isinstance(msg, dict)
else msg.get("content")
)
if (
isinstance(role, str)
and role in ("user", "assistant")
and isinstance(content, str)
):
out.append({"role": role, "content": content})
return out
async def _stream_json_response(
*,
system_prompt: str,
user_messages: list[dict[str, Any]],
model: str,
) -> AsyncGenerator[str, None]:
"""Yield raw JSON text deltas from OpenAI streaming chat completion."""
client = openai.AsyncOpenAI()
stream = await client.chat.completions.create(
model=model,
messages=[{"role": "system", "content": system_prompt}, *user_messages],
response_format={"type": "json_object"},
stream=True,
)
async for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
text = getattr(delta, "content", None)
if text:
yield text
async def _run_byoc(
*,
system_prompt: str,
request: Request,
default_model: str,
) -> StreamingResponse:
"""Shared SSE plumbing for both BYOC demos.
Both endpoints differ only in their system prompt — extracted so the
sister `byoc_json_render_agent` module can call straight in without
duplicating the parsing / streaming / error envelope.
"""
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception("byoc: failed to parse body (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except (pydantic.ValidationError, TypeError, ValueError) as exc:
logger.exception("byoc: invalid RunAgentInput (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
user_messages = _flatten_user_messages(run_input.messages)
model = os.getenv("LANGROID_MODEL", default_model)
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
message_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START, message_id=message_id
)
)
try:
async for delta in _stream_json_response(
system_prompt=system_prompt,
user_messages=user_messages,
model=model,
):
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=message_id,
delta=delta,
)
)
except (openai.APIError, httpx.HTTPError, asyncio.TimeoutError) as exc:
logger.exception("byoc: OpenAI streaming call failed")
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END, message_id=message_id
)
)
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Agent run failed: {exc.__class__.__name__}",
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
yield _sse_line(
TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=message_id)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def handle_run(request: Request) -> StreamingResponse:
"""AG-UI ``/byoc-hashbrown`` SSE handler."""
return await _run_byoc(
system_prompt=_SYSTEM_PROMPT,
request=request,
default_model="gpt-4o-mini",
)
@@ -0,0 +1,158 @@
"""BYOC: json-render demo backend (Langroid).
Emits a single JSON object shaped like ``@json-render/react``'s flat
spec (``{ root, elements }``) so the frontend can feed it directly into
``<Renderer />`` against a Zod-validated catalog of three components —
MetricCard, BarChart, PieChart.
Mirrors the langgraph-python sibling (and matches Wave 4a's hashbrown
shape so the two BYOC rows on the dashboard are directly comparable).
The handler delegates the SSE plumbing to ``byoc_hashbrown_agent._run_byoc``
— only the system prompt differs.
Wired up by ``agent_server.py`` at ``POST /byoc-json-render``.
"""
from __future__ import annotations
from fastapi import Request
from fastapi.responses import StreamingResponse
from agents.byoc_hashbrown_agent import _run_byoc
_SYSTEM_PROMPT = """\
You are a sales-dashboard UI generator for a BYOC json-render demo.
When the user asks for a UI, respond with **exactly one JSON object** and
nothing else — no prose, no markdown fences, no leading explanation. The
object must match this schema (the "flat element map" format consumed by
`@json-render/react`):
{
"root": "<id of the root element>",
"elements": {
"<id>": {
"type": "<component name>",
"props": { ... component-specific props ... },
"children": [ "<id>", ... ]
},
...
}
}
Available components (use each name verbatim as "type"):
- MetricCard
props: { "label": string, "value": string, "trend": string | null }
Example trend strings: "+12% vs last quarter", "-3% vs last month", null.
- BarChart
props: {
"title": string,
"description": string | null,
"data": [ { "label": string, "value": number }, ... ]
}
- PieChart
props: {
"title": string,
"description": string | null,
"data": [ { "label": string, "value": number }, ... ]
}
Rules:
1. Output **only** valid JSON. No markdown code fences. No text outside
the object.
2. Every id referenced in `root` or any `children` array must be a key
in `elements`.
3. For a multi-component dashboard, use a root MetricCard and list the
charts in its `children` array, OR pick any element as root and list
the others as its children. Do not emit orphan elements.
4. Use realistic sales-domain values (revenue, pipeline, conversion,
categories, months) — the demo is a sales dashboard.
5. `children` is optional but when present must be an array of strings.
6. Never invent component types outside the three listed above.
### Worked example — "Show me the sales dashboard with metrics and a revenue chart"
{
"root": "revenue-metric",
"elements": {
"revenue-metric": {
"type": "MetricCard",
"props": {
"label": "Revenue (Q3)",
"value": "$1.24M",
"trend": "+18% vs Q2"
},
"children": ["revenue-bar"]
},
"revenue-bar": {
"type": "BarChart",
"props": {
"title": "Monthly revenue",
"description": "Revenue by month across Q3",
"data": [
{ "label": "Jul", "value": 380000 },
{ "label": "Aug", "value": 410000 },
{ "label": "Sep", "value": 450000 }
]
}
}
}
}
### 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.
"""
async def handle_run(request: Request) -> StreamingResponse:
"""AG-UI ``/byoc-json-render`` SSE handler."""
return await _run_byoc(
system_prompt=_SYSTEM_PROMPT,
request=request,
default_model="gpt-4o-mini",
)
@@ -0,0 +1,613 @@
"""gen-ui-agent demo — Langroid.
Mirrors ``langgraph-python/src/agents/gen_ui_agent.py`` and
``ms-agent-python/src/agents/gen_ui_agent.py``: the agent owns an explicit
``steps`` slice of shared state (each step is ``{id, title, status}``
with status in ``pending`` / ``in_progress`` / ``completed``) and walks
each step pending → in_progress → completed by repeatedly calling a
custom ``set_steps`` tool. Every ``set_steps`` call mutates the local
state dict and emits a fresh AG-UI ``STATE_SNAPSHOT`` so the frontend's
``useAgent`` subscriber re-renders the progress card in place.
Langroid does not provide a native shared-state channel — we implement
it directly on top of AG-UI's ``STATE_SNAPSHOT`` event, mirroring the
posture taken by ``shared_state_read_write.py``. The LLM is driven via
the OpenAI client directly (not langroid's ``ChatAgent``) so the
multi-turn tool-call loop has explicit control over state mutation and
event emission per iteration, and so aimock can intercept and
fixture-match each request by message history shape.
The handler is wired up by ``agent_server.py`` at ``POST /gen-ui-agent``
and reached from the frontend via the ``gen-ui-agent`` entry in
``src/app/api/copilotkit/route.ts``, which points its ``HttpAgent`` at
``${AGENT_URL}/gen-ui-agent``.
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from typing import Any, AsyncGenerator
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
import openai
logger = logging.getLogger(__name__)
# =====================================================================
# State shape (mirrors the UI's AgentState.steps in
# src/app/demos/gen-ui-agent/InlineAgentStateCard.tsx).
# =====================================================================
#
# { "steps": [ { "id": str, "title": str,
# "status": "pending" | "in_progress" | "completed" }, ... ] }
#
# Owned by the agent. The UI only READS via useAgent; it never writes
# back into this slice.
_VALID_STATUSES = frozenset({"pending", "in_progress", "completed"})
def _normalize_state(raw: Any) -> dict[str, Any]:
"""Coerce inbound RunAgentInput.state into our canonical dict.
AG-UI types ``state`` as ``Any``. A malformed frontend (or fresh
session shipping ``None``) is treated as "empty plan" — we don't
try to reconstruct shape from non-dicts. Steps that aren't dicts,
or that lack the canonical keys, are dropped silently.
"""
if not isinstance(raw, dict):
return {"steps": []}
steps_raw = raw.get("steps")
if not isinstance(steps_raw, list):
return {"steps": []}
steps: list[dict[str, Any]] = []
for s in steps_raw:
if not isinstance(s, dict):
continue
status = s.get("status")
if not isinstance(status, str) or status not in _VALID_STATUSES:
continue
title = s.get("title")
if not isinstance(title, str):
continue
step_id = s.get("id")
if not isinstance(step_id, str) or not step_id:
step_id = str(uuid.uuid4())
steps.append({"id": step_id, "title": title, "status": status})
return {"steps": steps}
def _sanitize_steps(raw: Any) -> list[dict[str, Any]] | None:
"""Coerce a ``set_steps`` argument into a clean steps list.
Returns ``None`` if the input isn't a list at all — the caller will
skip the snapshot emission rather than blank out the UI. Invalid
individual entries are dropped (defense in depth — the prompt is
strict, but a misbehaving model shouldn't break the UI).
"""
if not isinstance(raw, list):
return None
out: list[dict[str, Any]] = []
for s in raw:
if not isinstance(s, dict):
continue
status = s.get("status")
if not isinstance(status, str) or status not in _VALID_STATUSES:
continue
title = s.get("title")
if not isinstance(title, str):
continue
step_id = s.get("id")
if not isinstance(step_id, str) or not step_id:
step_id = str(uuid.uuid4())
out.append({"id": step_id, "title": title, "status": status})
return out
# =====================================================================
# ``set_steps`` tool — OpenAI function spec.
# =====================================================================
_SET_STEPS_TOOL_SPEC: dict[str, Any] = {
"type": "function",
"function": {
"name": "set_steps",
"description": (
"Publish the current plan and step statuses. Call this every "
"time a step transitions (including the first enumeration of "
"steps). Always include the FULL list of steps on each call — "
"this REPLACES the steps array in shared state."
),
"parameters": {
"type": "object",
"properties": {
"steps": {
"type": "array",
"description": (
"The complete source of truth for the plan: every "
"step with id, title, and status."
),
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Stable identifier for the step.",
},
"title": {
"type": "string",
"description": "Short human-readable description.",
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed"],
},
},
"required": ["id", "title", "status"],
},
},
},
"required": ["steps"],
},
},
}
_SYSTEM_PROMPT = (
"You are an agentic planner. For each user request, follow this exact "
"sequence:\n"
"1. Plan exactly 3 concrete steps and call `set_steps` ONCE with all "
'three steps at status="pending".\n'
'2. Step 1: call `set_steps` with step 1 at status="in_progress", '
'then call `set_steps` again with step 1 at status="completed".\n'
'3. Step 2: call `set_steps` with step 2 at status="in_progress", '
'then call `set_steps` again with step 2 at status="completed".\n'
'4. Step 3: call `set_steps` with step 3 at status="in_progress", '
'then call `set_steps` again with step 3 at status="completed".\n'
"5. Send ONE final conversational assistant message summarizing the "
"plan, then stop. Do not call any more tools after step 3 is "
"completed.\n"
"\n"
"Rules: never call set_steps in parallel — always wait for one call "
"to return before the next. Always send the FULL steps list on every "
"call (this REPLACES the array). After all three steps are completed "
"you MUST send a final assistant message and terminate."
)
# Bound on the tool-call loop. The prompt drives ~7 set_steps calls + 1
# final assistant turn, so 12 iterations gives ~70% headroom for retries
# without runaway cost if the model misbehaves.
_MAX_TOOL_ITERATIONS = 12
async def _call_openai(
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> Any:
"""Call the OpenAI chat completions API directly.
Uses ``openai.AsyncOpenAI()`` which reads ``OPENAI_API_KEY`` and
``OPENAI_BASE_URL`` from the environment (aimock sets the base URL
in the showcase).
"""
model = os.getenv("LANGROID_MODEL", "gpt-4.1")
client = openai.AsyncOpenAI()
response = await client.chat.completions.create(
model=model,
messages=messages,
tools=tools if tools else openai.NOT_GIVEN,
)
return response.choices[0].message
# =====================================================================
# AG-UI SSE helpers
# =====================================================================
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
def _agui_messages_to_openai(
messages: Any,
system_prompt: str,
) -> list[dict[str, Any]]:
"""Convert AG-UI messages to OpenAI chat completion format.
Mirrors ``shared_state_read_write._agui_messages_to_openai`` and the
main ``agui_adapter`` — preserves ``tool_calls`` and ``tool_call_id``
so aimock fixture matchers see the full multi-turn shape.
"""
oai_msgs: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
]
if not messages:
return oai_msgs
for msg in messages:
role = getattr(msg, "role", None)
if not isinstance(role, str):
if isinstance(msg, dict):
role = msg.get("role")
if not isinstance(role, str):
continue
if role == "tool":
tool_call_id = getattr(msg, "tool_call_id", None)
if isinstance(msg, dict):
tool_call_id = tool_call_id or msg.get("tool_call_id")
content = getattr(msg, "content", "") or ""
if isinstance(msg, dict):
content = content or msg.get("content", "")
if tool_call_id:
oai_msgs.append(
{
"role": "tool",
"tool_call_id": str(tool_call_id),
"content": str(content),
}
)
continue
if role == "assistant":
content = getattr(msg, "content", None)
if isinstance(msg, dict):
content = content or msg.get("content")
tool_calls_raw = getattr(msg, "tool_calls", None)
if isinstance(msg, dict):
tool_calls_raw = tool_calls_raw or msg.get("tool_calls")
oai_msg: dict[str, Any] = {"role": "assistant"}
if content:
oai_msg["content"] = str(content)
if tool_calls_raw:
oai_tcs = []
for tc in tool_calls_raw:
tc_id = getattr(tc, "id", None)
fn = getattr(tc, "function", None)
if fn is None and isinstance(tc, dict):
fn_name = tc.get("function", {}).get("name", "")
fn_args = tc.get("function", {}).get("arguments", "")
tc_id = tc_id or tc.get("id", "")
else:
fn_name = getattr(fn, "name", "") if fn else ""
fn_args = getattr(fn, "arguments", "") if fn else ""
if tc_id and fn_name:
oai_tcs.append(
{
"id": str(tc_id),
"type": "function",
"function": {
"name": str(fn_name),
"arguments": str(fn_args),
},
}
)
if oai_tcs:
oai_msg["tool_calls"] = oai_tcs
if "content" not in oai_msg:
oai_msg["content"] = None
else:
if "content" not in oai_msg:
oai_msg["content"] = ""
oai_msgs.append(oai_msg)
continue
if role in ("user", "system", "developer"):
content = getattr(msg, "content", None)
if isinstance(msg, dict):
content = content or msg.get("content")
if content is not None:
oai_msgs.append(
{
"role": role,
"content": str(content),
}
)
continue
return oai_msgs
def _extract_set_steps_calls(
response: Any,
) -> list[tuple[str, list[dict[str, Any]] | None, str]]:
"""Return ``(tool_call_id, sanitized_steps, raw_args_str)`` per
``set_steps`` call in the response.
Non-set_steps tool calls are skipped (defense — the model only has
one tool, but if it ever hallucinates another we'd rather ignore
than crash). ``sanitized_steps`` is ``None`` if the args couldn't
be coerced into a steps list — caller skips snapshot emission for
that entry.
"""
out: list[tuple[str, list[dict[str, Any]] | None, str]] = []
tool_calls = getattr(response, "tool_calls", None) or []
for tc in tool_calls:
fn = getattr(tc, "function", None)
name = getattr(fn, "name", None) if fn is not None else None
if name != "set_steps":
continue
tc_id = getattr(tc, "id", None) or str(uuid.uuid4())
raw_args = getattr(fn, "arguments", None) if fn is not None else None
args_str = raw_args if isinstance(raw_args, str) else json.dumps(raw_args or {})
parsed: Any = raw_args
if isinstance(raw_args, (str, bytes, bytearray)):
try:
parsed = json.loads(raw_args)
except (ValueError, TypeError):
out.append((str(tc_id), None, args_str))
continue
if not isinstance(parsed, dict):
out.append((str(tc_id), None, args_str))
continue
sanitized = _sanitize_steps(parsed.get("steps"))
out.append((str(tc_id), sanitized, args_str))
return out
# =====================================================================
# AG-UI SSE handler
# =====================================================================
async def handle_run(request: Request) -> StreamingResponse:
"""Handle one AG-UI ``/gen-ui-agent`` request.
Drives a bounded multi-turn loop with the LLM: each ``set_steps``
tool call updates local state and emits a fresh ``STATE_SNAPSHOT``
plus ``TOOL_CALL_*`` events, then feeds the tool result back to the
model for the next turn. The loop terminates when the model emits a
final text response (or, defensively, after ``_MAX_TOOL_ITERATIONS``
iterations).
"""
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception("gen-ui-agent: failed to parse body (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except Exception as exc: # noqa: BLE001 — pydantic.ValidationError surfaces here
logger.exception("gen-ui-agent: invalid RunAgentInput (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
state = _normalize_state(run_input.state)
oai_messages = _agui_messages_to_openai(run_input.messages or [], _SYSTEM_PROMPT)
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
# Initial baseline snapshot so a fresh session always sees the
# empty (or restored) steps array before the agent writes the
# plan. Mirrors shared_state_read_write's initial snapshot.
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
messages = list(oai_messages)
for _ in range(_MAX_TOOL_ITERATIONS):
try:
response = await _call_openai(messages, [_SET_STEPS_TOOL_SPEC])
except Exception as exc: # noqa: BLE001 — surface as RunError + finish
logger.exception("gen-ui-agent: _call_openai failed")
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Agent run failed: {exc.__class__.__name__}",
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
if response is None:
break
calls = _extract_set_steps_calls(response)
if not calls:
# No tool call this turn — stream any text and finish.
content = getattr(response, "content", None) or ""
if content:
msg_id = str(uuid.uuid4())
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=msg_id,
)
)
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=msg_id,
delta=content,
)
)
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=msg_id,
)
)
break
# Apply each set_steps call: update state, emit TOOL_CALL_*
# + STATE_SNAPSHOT, and accumulate the assistant + tool
# result messages for the follow-up LLM turn.
assistant_tool_calls: list[dict[str, Any]] = []
tool_result_msgs: list[dict[str, Any]] = []
for call_id, sanitized, raw_args_str in calls:
if sanitized is None:
logger.warning(
"gen-ui-agent: skipping set_steps call %s — args could "
"not be parsed as steps list",
call_id,
)
# Still record the tool result so the model's message
# history stays coherent and it can retry.
assistant_tool_calls.append(
{
"id": call_id,
"type": "function",
"function": {
"name": "set_steps",
"arguments": raw_args_str,
},
}
)
tool_result_msgs.append(
{
"role": "tool",
"tool_call_id": call_id,
"content": "Invalid steps payload — please retry with a list of steps.",
}
)
continue
state["steps"] = sanitized
yield _sse_line(
ToolCallStartEvent(
type=EventType.TOOL_CALL_START,
tool_call_id=call_id,
tool_call_name="set_steps",
)
)
yield _sse_line(
ToolCallArgsEvent(
type=EventType.TOOL_CALL_ARGS,
tool_call_id=call_id,
delta=json.dumps({"steps": sanitized}),
)
)
yield _sse_line(
ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
tool_call_id=call_id,
)
)
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
assistant_tool_calls.append(
{
"id": call_id,
"type": "function",
"function": {
"name": "set_steps",
"arguments": raw_args_str,
},
}
)
tool_result_msgs.append(
{
"role": "tool",
"tool_call_id": call_id,
"content": f"Published {len(sanitized)} step(s).",
}
)
# Append the assistant turn (with its tool_calls) + the tool
# results, so the next LLM call sees the full conversation
# and can decide to transition the next step or finalize.
messages.append(
{
"role": "assistant",
"content": None,
"tool_calls": assistant_tool_calls,
}
)
messages.extend(tool_result_msgs)
else:
logger.warning(
"gen-ui-agent: hit _MAX_TOOL_ITERATIONS=%d without a final "
"text turn — terminating the run",
_MAX_TOOL_ITERATIONS,
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,391 @@
"""MCP Apps demo backend (Langroid).
The CopilotKit runtime is wired with ``mcpApps: { servers: [...] }`` (see
``src/app/api/copilotkit-mcp-apps/route.ts``); the runtime auto-applies
the MCP Apps middleware, which injects the remote MCP server's tools
into the AG-UI run input at request time and emits the activity events
that CopilotKit's built-in ``MCPAppsActivityRenderer`` renders inline
in the chat as a sandboxed iframe.
Because the tool catalog is supplied by the runtime per-request (in
``RunAgentInput.tools``), the Python agent for this demo does **not**
declare any langroid ``ToolMessage`` subclasses. We forward the inbound
tool list straight to the OpenAI chat completions API and surface
whatever tool calls the model emits — the runtime middleware on the
TypeScript side picks them up, fetches the MCP UI resource, and emits
the activity events that render the iframe.
The handler is wired up by ``agent_server.py`` at ``POST /mcp-apps``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from typing import Any, AsyncGenerator
import httpx
import openai
import pydantic
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
logger = logging.getLogger(__name__)
# Speed-biased system prompt — mirrors the langgraph-python MCP Apps
# agent. We want one fast tool call that produces a correct-enough
# diagram; we are not optimizing for polish.
_SYSTEM_PROMPT = """\
You draw simple diagrams in Excalidraw via the MCP tool.
SPEED MATTERS. Produce a correct-enough diagram fast; do not optimize
for polish. Target: one tool call, done in seconds.
When the user asks for a diagram:
1. Call `create_view` ONCE with 3-5 elements total: shapes + arrows +
an optional title text.
2. Use straightforward shapes (rectangle, ellipse, diamond) with plain
`label` fields (`{"text": "...", "fontSize": 18}`) on them.
3. Connect with arrows. Endpoints can be element centers or simple
coordinates — you don't need edge anchors / fixedPoint bindings.
4. Include ONE `cameraUpdate` at the END of the elements array that
frames the whole diagram. Use an approved 4:3 size (600x450 or
800x600). No opening camera needed.
5. Reply with ONE short sentence describing what you drew.
Every element needs a unique string `id` (e.g. "b1", "a1", "title").
Standard sizes: rectangles 160x70, ellipses/diamonds 120x80, 40-80px
gap between shapes.
Do NOT:
- Call `read_me`. You already know the basic shape API.
- Make multiple `create_view` calls.
- Iterate or refine. Ship on the first shot.
- Add decorative colors / fills / zone backgrounds unless the user
explicitly asks for them.
- Add labels on arrows unless crucial.
"""
# ---------------------------------------------------------------------------
# AG-UI message → OpenAI message conversion (compact mirror of agui_adapter)
# ---------------------------------------------------------------------------
def _agui_messages_to_openai(messages: Any, system_prompt: str) -> list[dict[str, Any]]:
"""Translate AG-UI typed messages into OpenAI chat completion shape.
Mirrors the conversion in ``agents.agui_adapter`` but is duplicated
locally to keep this module's dependency surface small (the unified
adapter pulls in ``ALL_TOOLS`` and other unrelated machinery).
"""
out: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
if not messages:
return out
for msg in messages:
role = (
getattr(msg, "role", None) if not isinstance(msg, dict) else msg.get("role")
)
if not isinstance(role, str):
continue
if role == "tool":
tool_call_id = (
getattr(msg, "tool_call_id", None)
if not isinstance(msg, dict)
else msg.get("tool_call_id")
)
content = (
getattr(msg, "content", "")
if not isinstance(msg, dict)
else msg.get("content", "")
) or ""
if tool_call_id:
out.append(
{
"role": "tool",
"tool_call_id": str(tool_call_id),
"content": str(content),
}
)
continue
if role == "assistant":
content = (
getattr(msg, "content", None)
if not isinstance(msg, dict)
else msg.get("content")
)
tool_calls_raw = (
getattr(msg, "tool_calls", None)
if not isinstance(msg, dict)
else msg.get("tool_calls")
)
entry: dict[str, Any] = {"role": "assistant"}
if content:
entry["content"] = str(content)
if tool_calls_raw:
tcs: list[dict[str, Any]] = []
for tc in tool_calls_raw:
tc_id = getattr(tc, "id", None)
fn = getattr(tc, "function", None)
if fn is None and isinstance(tc, dict):
fn_name = (tc.get("function") or {}).get("name", "")
fn_args = (tc.get("function") or {}).get("arguments", "")
tc_id = tc_id or tc.get("id", "")
else:
fn_name = getattr(fn, "name", "") if fn else ""
fn_args = getattr(fn, "arguments", "") if fn else ""
if tc_id and fn_name:
tcs.append(
{
"id": str(tc_id),
"type": "function",
"function": {
"name": str(fn_name),
"arguments": str(fn_args),
},
}
)
if tcs:
entry["tool_calls"] = tcs
if "content" not in entry:
# OpenAI requires content to be null (not missing)
# when tool_calls are present.
entry["content"] = None
else:
if "content" not in entry:
entry["content"] = ""
out.append(entry)
continue
if role in ("user", "system", "developer"):
content = (
getattr(msg, "content", None)
if not isinstance(msg, dict)
else msg.get("content")
)
if isinstance(content, str):
out.append({"role": role, "content": content})
return out
def _runtime_tools_to_openai(tools: Any) -> list[dict[str, Any]]:
"""Convert the AG-UI ``RunAgentInput.tools`` array into OpenAI shape.
The MCP Apps middleware on the TypeScript side advertises the remote
MCP server's tools to the agent via this field. Each tool is a
``{ name, description, parameters }`` triple.
"""
if not tools:
return []
converted: list[dict[str, Any]] = []
for tool in tools:
name = (
getattr(tool, "name", None)
if not isinstance(tool, dict)
else tool.get("name")
)
if not isinstance(name, str) or not name:
continue
description = (
getattr(tool, "description", "")
if not isinstance(tool, dict)
else tool.get("description", "")
) or ""
parameters = (
getattr(tool, "parameters", None)
if not isinstance(tool, dict)
else tool.get("parameters")
)
if parameters is None:
parameters = {"type": "object", "properties": {}}
converted.append(
{
"type": "function",
"function": {
"name": name,
"description": str(description),
"parameters": parameters,
},
}
)
return converted
# ---------------------------------------------------------------------------
# SSE plumbing
# ---------------------------------------------------------------------------
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
async def handle_run(request: Request) -> StreamingResponse:
"""AG-UI ``/mcp-apps`` SSE handler — streams text + tool-call events."""
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception("mcp-apps: failed to parse body (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except (pydantic.ValidationError, TypeError, ValueError) as exc:
logger.exception("mcp-apps: invalid RunAgentInput (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
oai_messages = _agui_messages_to_openai(run_input.messages, _SYSTEM_PROMPT)
oai_tools = _runtime_tools_to_openai(getattr(run_input, "tools", None))
model = os.getenv("LANGROID_MODEL", "gpt-4o-mini")
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
try:
client = openai.AsyncOpenAI()
response = await client.chat.completions.create(
model=model,
messages=oai_messages,
tools=oai_tools if oai_tools else openai.NOT_GIVEN,
)
except (openai.APIError, httpx.HTTPError, asyncio.TimeoutError) as exc:
logger.exception("mcp-apps: OpenAI call failed")
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Agent run failed: {exc.__class__.__name__}",
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
choice = response.choices[0].message if response.choices else None
if choice is None:
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
# Emit any tool calls the model produced. The MCP Apps middleware on
# the TypeScript side intercepts these to fetch the UI resource and
# render the activity iframe.
tool_calls = getattr(choice, "tool_calls", None) or []
for tc in tool_calls:
tc_id = str(getattr(tc, "id", None) or uuid.uuid4())
fn = getattr(tc, "function", None)
tc_name = getattr(fn, "name", "") if fn else ""
tc_args = getattr(fn, "arguments", "") if fn else ""
if not tc_name:
continue
yield _sse_line(
ToolCallStartEvent(
type=EventType.TOOL_CALL_START,
tool_call_id=tc_id,
tool_call_name=tc_name,
)
)
if tc_args:
yield _sse_line(
ToolCallArgsEvent(
type=EventType.TOOL_CALL_ARGS,
tool_call_id=tc_id,
delta=str(tc_args),
)
)
yield _sse_line(
ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
tool_call_id=tc_id,
)
)
# Surface any narration text the model produced alongside the tool
# call. Many models reply with both a one-liner and a tool call.
content = getattr(choice, "content", None) or ""
if content:
msg_id = str(uuid.uuid4())
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START, message_id=msg_id
)
)
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=msg_id,
delta=content,
)
)
yield _sse_line(
TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=msg_id)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,382 @@
"""Multimodal demo backend (Langroid).
Vision-capable agent (gpt-4o) for the ``/demos/multimodal`` cell. Accepts
image and PDF attachments injected via CopilotChat's AttachmentsConfig
pipeline. Mirrors the langgraph-python sibling
(``showcase/integrations/langgraph-python/src/agents/multimodal_agent.py``)
so the two demos exercise comparable behavior.
Wire format
===========
Attachments arrive in user-message content as either:
1. ``{"type": "image", "source": {"type": "data", "value": "<b64>",
"mimeType": "image/png"}}`` — modern AG-UI shape that CopilotChat
emits natively.
2. ``{"type": "binary", "mimeType": "application/pdf", "data": "<b64>"}``
— legacy AG-UI binary part the langgraph-python integration's
``onRunInitialized`` shim normalizes to. Kept for interop in case a
future runtime path forwards through that converter.
Image parts are forwarded to OpenAI as ``image_url`` content parts with
inline ``data:<mime>;base64,...`` URLs; gpt-4o reads them natively. PDF
parts are flattened to text with ``pypdf`` (gpt-4o cannot read PDFs
directly), with a typed placeholder when extraction fails so the model
can at least tell the user the document was unreadable.
Wired up by ``agent_server.py`` at ``POST /multimodal``.
"""
from __future__ import annotations
import asyncio
import base64
import io
import json
import logging
import os
import uuid
from typing import Any, AsyncGenerator
import httpx
import openai
import pydantic
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
logger = logging.getLogger(__name__)
_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."
)
# ---------------------------------------------------------------------------
# PDF flattening
# ---------------------------------------------------------------------------
def _extract_pdf_text(b64: str) -> str:
"""Decode an inline-base64 PDF and extract its text.
Returns an empty string when decoding or extraction fails. Caller
decides whether to inline the text or substitute a placeholder.
"""
try:
raw = base64.b64decode(b64, validate=False)
except (ValueError, TypeError) as exc:
logger.warning("multimodal: base64 decode failed: %s", exc)
return ""
try:
# Lazy import — keeps the module importable even if pypdf is
# missing at dev-server boot.
from pypdf import PdfReader # type: ignore[import-not-found]
except ImportError as exc:
logger.warning(
"multimodal: pypdf not installed — PDF text unavailable: %s", 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: # noqa: BLE001 — pypdf failure shouldn't 500 the run
logger.warning("multimodal: pypdf extraction failed: %s", exc)
return ""
# ---------------------------------------------------------------------------
# Content-part normalization
# ---------------------------------------------------------------------------
def _normalize_part(part: Any) -> dict[str, Any] | None:
"""Map an inbound content part to an OpenAI content part.
Returns ``None`` when the part is not understood (caller can drop or
fall back to text). Recognises:
- ``{"type": "text", "text": "..."}`` — passthrough.
- ``{"type": "image", "source": {"type": "data", "value": "<b64>", "mimeType": "..."}}``
- ``{"type": "document", "source": {"type": "data", "value": "<b64>", "mimeType": "..."}}``
- ``{"type": "binary", "mimeType": "...", "data": "<b64>"}`` (legacy)
- ``{"type": "image_url", "image_url": {"url": "data:..."}}`` (already OpenAI shape)
- bare strings (treated as text).
- Pydantic model instances (e.g. ``TextInputContent``, ``ImageInputContent``,
``DocumentInputContent`` from ``ag_ui.core``) — converted to dicts via
``model_dump()`` so the rest of the function can use ``.get()``.
"""
if isinstance(part, str):
if not part:
return None
return {"type": "text", "text": part}
# Pydantic model instances (from ag_ui.core deserialization) are not
# dicts but expose model_dump(). Convert once so the rest of the
# function can use dict-style .get() access uniformly.
if not isinstance(part, dict):
if hasattr(part, "model_dump"):
part = part.model_dump(by_alias=True)
else:
return None
ptype = part.get("type")
if ptype == "text":
text = part.get("text")
if isinstance(text, str) and text:
return {"type": "text", "text": text}
return None
if ptype == "image_url":
# Already OpenAI shape — pass through after light validation.
image_url = part.get("image_url")
if isinstance(image_url, str) and image_url:
return {"type": "image_url", "image_url": {"url": image_url}}
if isinstance(image_url, dict):
url = image_url.get("url")
if isinstance(url, str) and url:
return {"type": "image_url", "image_url": {"url": url}}
return None
if ptype in ("image", "document"):
source = part.get("source")
if not isinstance(source, dict):
return None
if source.get("type") != "data":
# url-based parts not supported by this agent path today —
# the demo only emits inline base64.
return None
value = source.get("value")
mime = source.get("mimeType")
if not isinstance(value, str) or not isinstance(mime, str):
return None
if ptype == "image" or mime.startswith("image/"):
return {
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{value}"},
}
if "pdf" in mime.lower():
text = _extract_pdf_text(value)
if not text:
return {
"type": "text",
"text": "[Attached document: PDF could not be read.]",
}
return {"type": "text", "text": f"[Attached document]\n{text}"}
return None
if ptype == "binary":
mime = part.get("mimeType") or ""
data = part.get("data")
if not isinstance(mime, str) or not isinstance(data, str):
return None
if mime.startswith("image/"):
return {
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{data}"},
}
if "pdf" in mime.lower():
text = _extract_pdf_text(data)
if not text:
return {
"type": "text",
"text": "[Attached document: PDF could not be read.]",
}
return {"type": "text", "text": f"[Attached document]\n{text}"}
return None
return None
def _build_user_content(content: Any) -> Any:
"""Translate user-message content into an OpenAI-compatible payload.
Returns either a raw string (when there's only one text part — a
common single-message case) or the list-of-parts shape that gpt-4o
expects for multimodal turns.
"""
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
parts: list[dict[str, Any]] = []
for raw in content:
normalized = _normalize_part(raw)
if normalized is not None:
parts.append(normalized)
if not parts:
return ""
if len(parts) == 1 and parts[0].get("type") == "text":
return parts[0].get("text") or ""
return parts
def _build_messages(messages: Any, system_prompt: str) -> list[dict[str, Any]]:
"""Build the OpenAI messages list, preserving multimodal user parts."""
out: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
if not messages:
return out
for msg in messages:
role = (
getattr(msg, "role", None) if not isinstance(msg, dict) else msg.get("role")
)
if not isinstance(role, str):
continue
content = (
getattr(msg, "content", None)
if not isinstance(msg, dict)
else msg.get("content")
)
if role == "user":
built = _build_user_content(content)
if built:
out.append({"role": "user", "content": built})
elif role == "assistant":
if isinstance(content, str) and content:
out.append({"role": "assistant", "content": content})
elif role == "system":
if isinstance(content, str) and content:
out.append({"role": "system", "content": content})
return out
# ---------------------------------------------------------------------------
# SSE handler
# ---------------------------------------------------------------------------
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
async def handle_run(request: Request) -> StreamingResponse:
"""AG-UI ``/multimodal`` SSE handler — vision-capable streaming."""
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception("multimodal: failed to parse body (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except (pydantic.ValidationError, TypeError, ValueError) as exc:
logger.exception("multimodal: invalid RunAgentInput (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
oai_messages = _build_messages(run_input.messages, _SYSTEM_PROMPT)
# Force a vision-capable model. We deliberately ignore LANGROID_MODEL
# here — the unified text-only agents are configured with cheaper
# models, and this demo's whole point is the vision path.
model = os.getenv("MULTIMODAL_MODEL", "gpt-4o")
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
message_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START, message_id=message_id
)
)
try:
client = openai.AsyncOpenAI()
stream = await client.chat.completions.create(
model=model,
messages=oai_messages,
stream=True,
)
async for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
text = getattr(delta, "content", None)
if text:
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=message_id,
delta=text,
)
)
except (openai.APIError, httpx.HTTPError, asyncio.TimeoutError) as exc:
logger.exception("multimodal: OpenAI streaming call failed")
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END, message_id=message_id
)
)
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Agent run failed: {exc.__class__.__name__}",
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
yield _sse_line(
TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=message_id)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,337 @@
"""Langroid reasoning agent — emits AG-UI REASONING_MESSAGE_* events.
Backs two showcase cells (both share this one backend):
- reasoning-custom (custom amber ReasoningBlock slot)
- reasoning-default (CopilotKit's built-in reasoning card)
Mirrors `showcase/integrations/ag2/src/agents/reasoning_agent.py` plus its
`/reasoning` server mount in `ag2/src/agent_server.py`, adapted to Langroid.
Why a custom route instead of the stock unified adapter
-------------------------------------------------------
Langroid's stock showcase adapter (`agents/agui_adapter.py`) calls the OpenAI
chat-completions API NON-streaming and reads only `message.content` /
`message.tool_calls` from the response — it drops the `delta.reasoning_content`
side-channel entirely (the channel aimock fixtures populate via their
`reasoning` field, and that reasoning models emit in production). So the stock
adapter can never light up CopilotKit's reasoning slot.
This module mounts a small custom `/reasoning` sub-app (mirroring ag2's
`_run_reasoning_agent`) that:
1. Calls the OpenAI-compatible chat-completions endpoint directly
(streaming) with the agent's system prompt plus the full prior
conversation history (so follow-up questions keep their context, parity
with the agno reference) — a single LLM call, so it stays
aimock-friendly (no multi-call CoT loop).
2. Buffers the FULL upstream response, accumulating BOTH
`delta.reasoning_content` (native reasoning channel, what aimock's
`reasoning` field feeds) AND `delta.content` (the answer); it does not
forward upstream deltas incrementally.
3. Falls back to parsing <reasoning>...</reasoning> tags out of the text
when no native reasoning channel is present (defensive parity with
ag2's fallback path).
4. Emits each channel as a single CONTENT delta:
REASONING_MESSAGE_START/CONTENT/END for the buffered reasoning portion,
then TEXT_MESSAGE_START/CONTENT/END for the buffered answer.
The emitted channel is REASONING_MESSAGE_* (role "reasoning") — NOT THINKING_*,
which @ag-ui/client silently drops.
The global httpx hook installed in agent_server.py forwards the inbound
`x-aimock-context` header onto the outbound OpenAI call so aimock matches the
langroid-scoped fixture.
"""
from __future__ import annotations
import asyncio
import re
import sys
import traceback
import uuid
from typing import AsyncIterator
import openai
from ag_ui.core import (
BaseEvent,
EventType,
ReasoningMessageContentEvent,
ReasoningMessageEndEvent,
ReasoningMessageStartEvent,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
)
from ag_ui.encoder import EventEncoder
from fastapi import FastAPI
from starlette.endpoints import HTTPEndpoint
from starlette.requests import Request
from starlette.responses import StreamingResponse
SYSTEM_PROMPT = (
"You are a helpful assistant. For each user question, first think "
"step-by-step about the approach, then give a concise answer."
)
MODEL = "gpt-4o-mini"
_REASONING_PATTERN = re.compile(
r"<reasoning>(.*?)</reasoning>",
re.DOTALL | re.IGNORECASE,
)
def _coerce_content(content) -> str:
"""Coerce an AG-UI message's content into a plain string.
Handles the multimodal list shape (join the text parts) and the
None/non-string fallbacks — the same coercion the previous
single-turn `_extract_user_input` applied to the last user message.
"""
content = content or ""
if isinstance(content, str):
return content
if isinstance(content, list):
# Multimodal content: join the text parts. Coerce each part's text to
# a string — a None or non-str `text` (e.g. an image part) would make
# str.join raise TypeError, so fall back to "" for any non-str value.
def _part_text(part) -> str:
text = (
part.get("text", "")
if isinstance(part, dict)
else getattr(part, "text", "")
)
return text if isinstance(text, str) else ""
return "".join(_part_text(part) for part in content)
return str(content)
def _to_chat_messages(messages: list) -> list:
"""Map the AG-UI message list into chat-completions `messages`.
System prompt first, then every prior user/assistant turn (in order)
with its coerced text content. tool/system messages from the input are
skipped — only the conversation turns are threaded so follow-up
questions keep their context (parity with the agno reference, which
threads full history through Agno's Agent).
For a single user-message input this returns exactly
``[{system}, {user: <text>}]`` — byte-equal to the previous single-turn
behaviour, which the aimock D6 fixtures replay. When the input has no
user/assistant turns the result is ``[{system}, {user: ""}]`` (an empty
user turn), preserving the prior empty-input behaviour.
"""
chat: list = [{"role": "system", "content": SYSTEM_PROMPT}]
turns = [
{"role": role, "content": _coerce_content(getattr(msg, "content", ""))}
for msg in (messages or [])
for role in (getattr(msg, "role", None),)
if role in ("user", "assistant")
]
if turns:
chat.extend(turns)
else:
chat.append({"role": "user", "content": ""})
return chat
async def _run_reasoning_agent(
run_input: RunAgentInput,
) -> AsyncIterator[BaseEvent]:
"""Stream one reasoning run, synthesizing REASONING_MESSAGE_* events.
Mirrors ag2's `_run_reasoning_agent`: buffer the full response, split
reasoning from answer, emit REASONING_MESSAGE_* then TEXT_MESSAGE_*.
"""
run_id = run_input.run_id or str(uuid.uuid4())
thread_id = run_input.thread_id
# Track the in-flight message frame so a mid-stream failure can close it
# with the matching *_END before RUN_ERROR. @ag-ui/client's verifyEvents
# rejects a RUN_FINISHED while a text/tool frame is open, and the apply
# layer otherwise leaves a half-built message in client state.
reasoning_msg_id: str | None = None
text_msg_id: str | None = None
try:
chat_messages = _to_chat_messages(run_input.messages or [])
yield RunStartedEvent(
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
)
# Single streaming chat-completions call. The global httpx hook
# (installed in agent_server.py) forwards x-aimock-context so aimock
# matches the langroid-scoped fixture. OPENAI_BASE_URL points the
# client at aimock in local/D6 runs and at the real API in production.
client = openai.AsyncOpenAI()
response_stream = await client.chat.completions.create(
model=MODEL,
messages=chat_messages,
stream=True,
)
# Accumulate both channels. The stock langroid adapter drops
# reasoning_content, so we read the chat-completions stream directly
# to capture it.
full_text = ""
native_reasoning = ""
async for chunk in response_stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta is None:
continue
# Native reasoning channel — aimock `reasoning` field / reasoning
# models surface this as delta.reasoning_content.
reasoning_piece = getattr(delta, "reasoning_content", None)
if reasoning_piece:
native_reasoning += reasoning_piece
if delta.content:
full_text += delta.content
native_reasoning = native_reasoning.strip()
if native_reasoning:
# Native channel present — gold-standard parity path. The answer is
# the streamed text minus any stray <reasoning> tags.
reasoning_text = native_reasoning
answer_text = _REASONING_PATTERN.sub("", full_text).strip()
else:
# Fallback: parse <reasoning>...</reasoning> tags from the text
# (non-reasoning models / no-native-reasoning fixtures).
match = _REASONING_PATTERN.search(full_text)
if match:
reasoning_text = match.group(1).strip()
answer_text = (
full_text[: match.start()] + full_text[match.end() :]
).strip()
else:
reasoning_text = ""
answer_text = full_text.strip()
# The stream completed successfully but yielded neither reasoning nor
# answer text — the run would otherwise emit RUN_STARTED→RUN_FINISHED
# with zero message frames and no diagnostics. Log one server-side warn
# so a silent-empty run is visible (no synthetic message frames).
if not reasoning_text and not answer_text:
print(
"[reasoning] WARN: stream completed but produced no reasoning"
" or answer text",
file=sys.stderr,
flush=True,
)
# Emit reasoning message if we have reasoning content.
if reasoning_text:
reasoning_msg_id = str(uuid.uuid4())
yield ReasoningMessageStartEvent(
type=EventType.REASONING_MESSAGE_START,
message_id=reasoning_msg_id,
role="reasoning",
)
yield ReasoningMessageContentEvent(
type=EventType.REASONING_MESSAGE_CONTENT,
message_id=reasoning_msg_id,
delta=reasoning_text,
)
yield ReasoningMessageEndEvent(
type=EventType.REASONING_MESSAGE_END,
message_id=reasoning_msg_id,
)
reasoning_msg_id = None
# Emit a text message (only when non-empty answer text exists) so
# CopilotKit renders an assistant bubble.
if answer_text:
text_msg_id = str(uuid.uuid4())
yield TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=text_msg_id,
role="assistant",
)
yield TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=text_msg_id,
delta=answer_text,
)
yield TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=text_msg_id,
)
text_msg_id = None
yield RunFinishedEvent(
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
)
except asyncio.CancelledError: # noqa: TRY302 — propagate cancellation
raise
except Exception as exc: # noqa: BLE001
# Log the full failure server-side (never sent to the browser).
print(f"[reasoning] run failed: {exc!r}", file=sys.stderr, flush=True)
traceback.print_exc(file=sys.stderr)
# Close any message frame opened before the failure so the terminal
# RUN_ERROR is protocol-clean (no dangling *_START in client state).
if text_msg_id is not None:
yield TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=text_msg_id,
)
if reasoning_msg_id is not None:
yield ReasoningMessageEndEvent(
type=EventType.REASONING_MESSAGE_END,
message_id=reasoning_msg_id,
)
# Generic client-facing message — no raw exception text (which can
# carry provider URLs / request details) reaches the SSE stream.
# RUN_ERROR is terminal: @ag-ui/client's verifyEvents rejects ANY
# event after it, so we do NOT emit RUN_FINISHED here.
yield RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"agent run failed: {type(exc).__name__} (see server logs)",
)
class ReasoningEndpoint(HTTPEndpoint):
"""Starlette HTTPEndpoint that emits REASONING_MESSAGE_* + TEXT_MESSAGE_*.
Mounted at the sub-app root (``reasoning_app.mount("/", ...)``). The
agent_server mounts this sub-app at ``/reasoning``; the HttpAgent posts to
``/reasoning/`` (route.ts ``createAgent("/reasoning/")``), so the outer
Mount strips ``/reasoning`` and the inner Mount at ``/`` resolves here.
"""
async def post(self, request: Request) -> StreamingResponse:
encoder = EventEncoder()
run_input = RunAgentInput.model_validate_json(await request.body())
async def _gen() -> AsyncIterator[str]:
async for event in _run_reasoning_agent(run_input):
yield encoder.encode(event)
return StreamingResponse(
_gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
)
# FastAPI sub-app so agent_server.py can mount at /reasoning. Mounting the
# HTTPEndpoint at "/" — the HttpAgent posts to ``/reasoning/`` so the outer
# Mount strips ``/reasoning`` and this inner Mount at ``/`` resolves the
# endpoint. NEVER use Route("", ...) — an empty-path Route crashes Starlette
# at import time.
reasoning_app = FastAPI(title="Langroid Reasoning Agent")
reasoning_app.mount("/", ReasoningEndpoint)
@@ -0,0 +1,590 @@
"""Shared State (Read + Write) demo — Langroid.
Mirrors langgraph-python/src/agents/shared_state_read_write.py: full
bidirectional shared-state pattern between UI and agent.
- **UI -> agent (write)**: the UI owns a ``preferences`` object (name,
tone, language, interests) and writes it into agent state via
``agent.setState(...)`` from the React side. Every turn we read those
preferences out of ``RunAgentInput.state`` and prepend a system message
describing them, so the LLM adapts its response.
- **agent -> UI (read)**: the agent calls a ``set_notes`` tool to replace
the ``notes`` slice of shared state. The UI subscribes via ``useAgent``
and re-renders.
Langroid does not provide a native shared-state channel — we implement
it directly on top of AG-UI's ``STATE_SNAPSHOT`` event by emitting a
fresh snapshot whenever the agent mutates state.
The handler is wired up by ``agent_server.py`` at ``POST
/shared-state-read-write``.
LLM calls use the OpenAI client directly (not langroid's agent
abstraction) so that aimock can intercept and fixture-match requests by
message history shape (including ``hasToolResult`` matching on
``role: "tool"`` messages in the follow-up turn). The tool definition
for ``set_notes`` is passed as an OpenAI-format tool spec.
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from typing import Any, AsyncGenerator
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
import openai
logger = logging.getLogger(__name__)
# =====================================================================
# State shape (mirrors the UI's RWAgentState)
# =====================================================================
#
# {
# "preferences": { "name", "tone", "language", "interests": [...] },
# "notes": [str, ...]
# }
#
# `preferences` is owned by the UI. The agent only READS it.
# `notes` is owned by the agent. The agent calls `set_notes` to replace
# the array; the UI re-renders from shared state.
_VALID_TONES = frozenset({"formal", "casual", "playful"})
def _normalize_state(raw: Any) -> dict[str, Any]:
"""Coerce the inbound RunAgentInput.state into our canonical dict.
AG-UI types ``state`` as ``Any``, so a malformed frontend (or a
test fixture) could ship anything from ``None`` to a list. Anything
that isn't a dict is treated as "no state" — we don't try to recover
structure from it.
"""
if not isinstance(raw, dict):
return {"preferences": {}, "notes": []}
prefs = raw.get("preferences") if isinstance(raw.get("preferences"), dict) else {}
notes_raw = raw.get("notes")
notes = (
[n for n in notes_raw if isinstance(n, str)]
if isinstance(notes_raw, list)
else []
)
return {"preferences": prefs, "notes": notes}
def build_preferences_system_message(prefs: dict[str, Any]) -> str | None:
"""Render the UI-supplied preferences into a system-message string.
Returns ``None`` when no preference is set so the caller can skip
injection cleanly. Tone is sanitized against a closed set; unknown
values are silently dropped (matches the agent-config demo's
posture: a frontend bug should not 500 a turn).
"""
if not prefs:
return None
lines: list[str] = ["The user has shared these preferences with you:"]
name = prefs.get("name")
if isinstance(name, str) and name:
lines.append(f"- Name: {name}")
tone = prefs.get("tone")
if isinstance(tone, str) and tone in _VALID_TONES:
lines.append(f"- Preferred tone: {tone}")
language = prefs.get("language")
if isinstance(language, str) and language:
lines.append(f"- Preferred language: {language}")
interests = prefs.get("interests")
if isinstance(interests, list):
items = [i for i in interests if isinstance(i, str) and i]
if items:
lines.append(f"- Interests: {', '.join(items)}")
if len(lines) == 1:
# No usable keys — caller can skip injection.
return None
lines.append(
"Tailor every response to these preferences. Address the user "
"by name when appropriate."
)
return "\n".join(lines)
# =====================================================================
# `set_notes` tool — OpenAI function spec for the tool the agent uses
# to write the notes slice of shared state.
# =====================================================================
_SET_NOTES_TOOL_SPEC: dict[str, Any] = {
"type": "function",
"function": {
"name": "set_notes",
"description": (
"Replace the notes array in shared state with the FULL updated "
"list of short note strings (existing notes + any new ones). Use "
"whenever the user asks you to remember something, or when you "
"observe something worth surfacing in the UI's notes panel. Keep "
"each note short (< 120 chars)."
),
"parameters": {
"type": "object",
"properties": {
"notes": {
"type": "array",
"items": {"type": "string"},
"description": (
"The complete list of notes after the update. Always "
"include every previously-recorded note you want to "
"keep — this REPLACES the array."
),
},
},
"required": ["notes"],
},
},
}
_SYSTEM_PROMPT = (
"You are a helpful, concise assistant. The user's preferences are "
"supplied via shared state and will be added as a system message at "
"the start of every turn — always respect them.\n\n"
"When the user asks you to remember something, or when you observe "
"something worth surfacing in the UI's notes panel, call the "
"`set_notes` tool with the FULL updated list of short note strings "
"(existing notes + any new ones). NEVER pass a partial diff — always "
"the complete list.\n\n"
"Keep your prose replies brief — 1-2 sentences."
)
async def _call_openai(
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> Any:
"""Call the OpenAI chat completions API directly.
Uses ``openai.AsyncOpenAI()`` which reads ``OPENAI_API_KEY`` and
``OPENAI_BASE_URL`` from the environment (aimock sets the base URL
in the showcase). Returns the first choice's message object.
When ``tools`` is None or empty, omits the tools parameter so the
follow-up call (no tool needed) doesn't confuse the model into
re-calling tools.
"""
model = os.getenv("LANGROID_MODEL", "gpt-4.1")
client = openai.AsyncOpenAI()
response = await client.chat.completions.create(
model=model,
messages=messages,
tools=tools if tools else openai.NOT_GIVEN,
)
return response.choices[0].message
# =====================================================================
# AG-UI SSE handler
# =====================================================================
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
def _agui_messages_to_openai(
messages: Any,
system_prompt: str,
) -> list[dict[str, Any]]:
"""Convert AG-UI messages to OpenAI chat completion format.
Preserves structured fields (tool_calls, tool_call_id) so aimock's
``hasToolResult`` fixture matcher can detect ``role: "tool"`` messages
in follow-up turns. Mirrors ``agui_adapter._agui_messages_to_openai``.
"""
oai_msgs: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
]
if not messages:
return oai_msgs
for msg in messages:
role = getattr(msg, "role", None)
if not isinstance(role, str):
if isinstance(msg, dict):
role = msg.get("role")
if not isinstance(role, str):
continue
if role == "tool":
tool_call_id = getattr(msg, "tool_call_id", None)
if isinstance(msg, dict):
tool_call_id = tool_call_id or msg.get("tool_call_id")
content = getattr(msg, "content", "") or ""
if isinstance(msg, dict):
content = content or msg.get("content", "")
if tool_call_id:
oai_msgs.append(
{
"role": "tool",
"tool_call_id": str(tool_call_id),
"content": str(content),
}
)
continue
if role == "assistant":
content = getattr(msg, "content", None)
if isinstance(msg, dict):
content = content or msg.get("content")
tool_calls_raw = getattr(msg, "tool_calls", None)
if isinstance(msg, dict):
tool_calls_raw = tool_calls_raw or msg.get("tool_calls")
oai_msg: dict[str, Any] = {"role": "assistant"}
if content:
oai_msg["content"] = str(content)
if tool_calls_raw:
oai_tcs = []
for tc in tool_calls_raw:
tc_id = getattr(tc, "id", None)
fn = getattr(tc, "function", None)
if fn is None and isinstance(tc, dict):
fn_name = tc.get("function", {}).get("name", "")
fn_args = tc.get("function", {}).get("arguments", "")
tc_id = tc_id or tc.get("id", "")
else:
fn_name = getattr(fn, "name", "") if fn else ""
fn_args = getattr(fn, "arguments", "") if fn else ""
if tc_id and fn_name:
oai_tcs.append(
{
"id": str(tc_id),
"type": "function",
"function": {
"name": str(fn_name),
"arguments": str(fn_args),
},
}
)
if oai_tcs:
oai_msg["tool_calls"] = oai_tcs
if "content" not in oai_msg:
oai_msg["content"] = None
else:
if "content" not in oai_msg:
oai_msg["content"] = ""
oai_msgs.append(oai_msg)
continue
if role in ("user", "system", "developer"):
content = getattr(msg, "content", None)
if isinstance(msg, dict):
content = content or msg.get("content")
if content is not None:
oai_msgs.append(
{
"role": role,
"content": str(content),
}
)
continue
return oai_msgs
def _extract_set_notes_args(response: Any) -> tuple[list[str] | None, str | None]:
"""Pull a ``set_notes`` tool call out of an OpenAI ChatCompletionMessage.
Returns ``(notes, tool_call_id)`` when the response contains a
``set_notes`` call; returns ``(None, None)`` otherwise so the caller
can fall through to plain-text streaming. The ``tool_call_id`` is
needed to build the follow-up ``role: "tool"`` result message.
"""
tool_calls = getattr(response, "tool_calls", None) or []
for tc in tool_calls:
fn = getattr(tc, "function", None)
name = getattr(fn, "name", None) if fn is not None else None
if name != "set_notes":
continue
tc_id = getattr(tc, "id", None)
raw_args = getattr(fn, "arguments", None) if fn is not None else None
args: Any = raw_args
if isinstance(raw_args, (str, bytes, bytearray)):
try:
args = json.loads(raw_args)
except (ValueError, TypeError):
return None, None
if isinstance(args, dict):
notes = args.get("notes")
if isinstance(notes, list):
return [n for n in notes if isinstance(n, str)], tc_id
return None, None
async def handle_run(request: Request) -> StreamingResponse:
"""Handle one AG-UI ``/shared-state-read-write`` request.
Uses the OpenAI client directly (not langroid's agent abstraction)
so that aimock can fixture-match requests by full message history,
including ``hasToolResult`` matching on ``role: "tool"`` messages
in the follow-up turn after a ``set_notes`` tool call.
"""
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception(
"shared-state-read-write: failed to parse body (error_id=%s)",
error_id,
)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except Exception as exc: # noqa: BLE001 — pydantic.ValidationError is fine here
logger.exception(
"shared-state-read-write: invalid RunAgentInput (error_id=%s)",
error_id,
)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
state = _normalize_state(run_input.state)
prefs_msg = build_preferences_system_message(state.get("preferences") or {})
system_message = _SYSTEM_PROMPT
if prefs_msg is not None:
system_message = f"{_SYSTEM_PROMPT}\n\n{prefs_msg}"
# Build OpenAI-format messages from the AG-UI message history.
oai_messages = _agui_messages_to_openai(run_input.messages or [], system_message)
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
# Echo the inbound state back as the initial snapshot so the UI's
# subscription always has a known-good baseline (and so a fresh
# session sees the empty `notes` array even before the agent
# writes one).
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
try:
response = await _call_openai(oai_messages, [_SET_NOTES_TOOL_SPEC])
except Exception as exc: # noqa: BLE001 — surface as RunError + finish
logger.exception("shared-state-read-write: _call_openai failed")
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Agent run failed: {exc.__class__.__name__}",
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
if response is None:
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return
new_notes, oai_tool_call_id = _extract_set_notes_args(response)
if new_notes is not None:
# The agent decided to update the notes array. Apply, then
# ack via tool-call events + a fresh STATE_SNAPSHOT so the
# UI re-renders.
state["notes"] = new_notes
tool_call_id = oai_tool_call_id or str(uuid.uuid4())
yield _sse_line(
ToolCallStartEvent(
type=EventType.TOOL_CALL_START,
tool_call_id=tool_call_id,
tool_call_name="set_notes",
)
)
yield _sse_line(
ToolCallArgsEvent(
type=EventType.TOOL_CALL_ARGS,
tool_call_id=tool_call_id,
delta=json.dumps({"notes": new_notes}),
)
)
yield _sse_line(
ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
tool_call_id=tool_call_id,
)
)
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
# Build the follow-up message array with the tool result
# appended, so aimock can match it with hasToolResult: true.
# This mirrors LangGraph's tool execution loop: the assistant
# message (with tool_calls) + the tool result message go back
# to the LLM for the natural-language acknowledgement.
raw_args = (
getattr(
getattr(response.tool_calls[0], "function", None), "arguments", "{}"
)
if response.tool_calls
else "{}"
)
follow_up_messages = oai_messages + [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": tool_call_id,
"type": "function",
"function": {
"name": "set_notes",
"arguments": raw_args
if isinstance(raw_args, str)
else json.dumps(raw_args),
},
}
],
},
{
"role": "tool",
"tool_call_id": tool_call_id,
"content": "Notes updated.",
},
]
# Follow-up call WITHOUT tools — we don't want the model to
# re-call set_notes in the acknowledgement turn.
try:
follow_up = await _call_openai(follow_up_messages)
except Exception: # noqa: BLE001
logger.exception(
"shared-state-read-write: follow-up _call_openai failed"
)
follow_up = None
if follow_up is not None:
content = getattr(follow_up, "content", None) or ""
if content:
msg_id = str(uuid.uuid4())
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=msg_id,
)
)
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=msg_id,
delta=content,
)
)
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=msg_id,
)
)
else:
content = getattr(response, "content", None) or ""
if content:
msg_id = str(uuid.uuid4())
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START, message_id=msg_id
)
)
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=msg_id,
delta=content,
)
)
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END, message_id=msg_id
)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,621 @@
"""Sub-Agents demo — Langroid.
Mirrors langgraph-python/src/agents/subagents.py and the google-adk
subagents_agent.py: a top-level "supervisor" LLM orchestrates three
specialized sub-agents, exposed as tools:
- ``research_agent`` — gathers facts (3-5 bullets)
- ``writing_agent`` — drafts a polished paragraph
- ``critique_agent`` — reviews a draft and gives 2-3 critiques
Each sub-agent is its own ``langroid.ChatAgent`` with a single-task
system prompt and no tools. The supervisor delegates by emitting a tool
call against one of the three names; the SSE adapter intercepts the
call, runs the matching sub-agent synchronously, records a Delegation
entry into shared state (``running`` -> ``completed`` / ``failed``),
emits a ``STATE_SNAPSHOT`` so the UI re-renders, and then re-prompts
the supervisor with the sub-agent's output so it can chain (research
-> write -> critique) or summarize.
The handler is wired up by ``agent_server.py`` at ``POST /subagents``.
"""
# @region[supervisor-delegation-tools]
# @region[subagent-setup]
from __future__ import annotations
import functools
import json
import logging
import os
import uuid
from typing import Annotated, Any, AsyncGenerator, Literal, TypedDict
from ag_ui.core import (
EventType,
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallStartEvent,
)
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
import langroid as lr
import langroid.language_models as lm
from langroid.agent.tool_message import ToolMessage
logger = logging.getLogger(__name__)
# =====================================================================
# Shared state shape
# =====================================================================
class Delegation(TypedDict):
id: str
sub_agent: Literal["research_agent", "writing_agent", "critique_agent"]
task: str
status: Literal["running", "completed", "failed"]
result: str
# =====================================================================
# Sub-agent system prompts (single-task, no tools)
# =====================================================================
# In Langroid, each sub-agent is a `lr.ChatAgent` with a single-task
# `system_message` and no tools. The supervisor only ever sees the
# sub-agent's final-message content — no shared memory, no shared tools.
_RESEARCH_SYSTEM = (
"You are a research sub-agent. Given a topic, produce a concise "
"bulleted list of 3-5 key facts. No preamble, no closing."
)
_WRITING_SYSTEM = (
"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_SYSTEM = (
"You are an editorial critique sub-agent. Given a draft, give 2-3 "
"crisp, actionable critiques. No preamble."
)
_SUB_PROMPTS: dict[str, str] = {
"research_agent": _RESEARCH_SYSTEM,
"writing_agent": _WRITING_SYSTEM,
"critique_agent": _CRITIQUE_SYSTEM,
}
def _resolve_sub_model() -> str:
"""Resolve the sub-agent model.
Mirrors ``_resolve_a2ui_model`` in ``agents.agent``: bare model name
(langroid passes the string literally to the OpenAI SDK, which
rejects ``openai/gpt-4.1`` as "model not found").
"""
return os.getenv("SUBAGENT_MODEL") or os.getenv("LANGROID_MODEL") or "gpt-4.1"
@functools.lru_cache(maxsize=8)
def _build_sub_llm_config(name: str) -> lm.OpenAIGPTConfig:
"""Build (and memoize) the immutable ``OpenAIGPTConfig`` for one sub-agent.
Only the LLM config — which is stateless and credential-bearing — is
cached. The ``ChatAgent`` itself is rebuilt per call (see
``_build_sub_agent``) because ``lr.ChatAgent`` accumulates
``message_history`` across ``llm_response`` / ``llm_response_async``
calls and must NOT be shared across concurrent requests.
"""
# ``name`` participates in the cache key indirectly via per-name
# callsites; the config itself is identical across sub-agents today
# but keeping the parameter makes the cache robust if a future
# refactor varies model/temperature per sub-agent.
del name # currently unused — kept for cache-key shape stability
model = _resolve_sub_model()
return lm.OpenAIGPTConfig(
chat_model=model,
# Sub-agents are single-shot — non-streaming keeps the supervisor
# turn deterministic (we want the full result before recording
# the delegation as completed).
stream=False,
)
def _build_sub_agent(name: str) -> lr.ChatAgent:
"""Build a fresh ``ChatAgent`` for one sub-agent invocation.
A new agent is constructed on every call. Caching the agent
instance (e.g. via ``lru_cache``) would be unsafe: ``lr.ChatAgent``
accumulates ``message_history`` across ``llm_response_async`` calls,
so two concurrent users invoking the same sub-agent would
cross-contaminate each other's conversation history and grow the
token budget unboundedly across the process lifetime.
The immutable LLM config is cached separately (see
``_build_sub_llm_config``) so we don't pay credential-resolution
overhead per call.
"""
system_prompt = _SUB_PROMPTS[name]
llm_config = _build_sub_llm_config(name)
agent_config = lr.ChatAgentConfig(
llm=llm_config,
system_message=system_prompt,
)
return lr.ChatAgent(agent_config)
# @endregion[subagent-setup]
async def _invoke_sub_agent(name: str, task: str) -> str:
"""Run a sub-agent on ``task`` and return its final-message content.
Uses ``llm_response_async`` so the SSE writer stays cooperative —
a synchronous ``sub.llm_response(task)`` would block the event loop
for the entire LLM round-trip and stall any other concurrent SSE
responses sharing this worker.
Raises ``RuntimeError`` (with the exception class chained via
``__cause__``) on transport / SDK failures so the caller can record
a ``failed`` delegation. The original exception is preserved
server-side via ``logger.exception``.
"""
sub = _build_sub_agent(name)
try:
response = await sub.llm_response_async(task)
except Exception as exc: # noqa: BLE001 — see docstring
logger.exception("subagent %s call failed", name)
# Match the google-adk surface: only the class name leaks; the
# full traceback stays in server logs.
raise RuntimeError(
f"sub-agent call failed: {exc.__class__.__name__} "
"(see server logs for details)"
) from exc
if response is None:
raise RuntimeError("sub-agent returned no response")
content = getattr(response, "content", None) or ""
if not content:
raise RuntimeError("sub-agent returned empty content")
return content
# =====================================================================
# Supervisor tools (langroid ToolMessage subclasses)
# =====================================================================
# In Langroid, the supervisor delegates by emitting a tool call against
# one of these `ToolMessage` subclasses. The SSE adapter intercepts the
# call (rather than letting Langroid dispatch to `.handle`), runs the
# matching sub-agent, records a `Delegation` into shared state, and
# returns the sub-agent's output as the tool result.
class _SubAgentTool(ToolMessage):
"""Base class for the three supervisor delegation tools.
The actual sub-agent invocation happens in the SSE adapter (so we
can record delegations into shared state); this ``handle`` is a
placeholder that's never called in the normal flow — we intercept
the tool call before langroid would dispatch to it. Logging here
matches the frontend-tool pattern in ``agents.agent``.
"""
request: str = "_subagent_base" # overridden
purpose: str = "" # overridden
task: Annotated[
str,
"The exact task for the sub-agent. Pass relevant facts/draft "
"from prior delegations through this string.",
]
def handle(self) -> str:
logger.error(
"%s.handle fired server-side — adapter dispatch regression; "
"the supervisor sub-agent tool was not intercepted",
self.__class__.__name__,
)
return f"{self.request} dispatched"
class ResearchAgentTool(_SubAgentTool):
request: str = "research_agent"
purpose: str = (
"Delegate a research task to the research sub-agent. Use for: "
"gathering facts, background, definitions, statistics. Returns a "
"bulleted list of key facts."
)
class WritingAgentTool(_SubAgentTool):
request: str = "writing_agent"
purpose: str = (
"Delegate a drafting task to the writing sub-agent. Use for: "
"producing a polished paragraph, draft, or summary. Pass relevant "
"facts from prior research inside `task`."
)
class CritiqueAgentTool(_SubAgentTool):
request: str = "critique_agent"
purpose: str = (
"Delegate a critique task to the critique sub-agent. Use for: "
"reviewing a draft and suggesting concrete improvements."
)
_SUPERVISOR_TOOLS: tuple[type[ToolMessage], ...] = (
ResearchAgentTool,
WritingAgentTool,
CritiqueAgentTool,
)
# @endregion[supervisor-delegation-tools]
_SUB_AGENT_NAMES: frozenset[str] = frozenset(
t.default_value("request") for t in _SUPERVISOR_TOOLS
)
_SUPERVISOR_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."
)
def _create_supervisor() -> lr.ChatAgent:
model = os.getenv("LANGROID_MODEL", "gpt-4.1")
llm_config = lm.OpenAIGPTConfig(chat_model=model, stream=False)
agent_config = lr.ChatAgentConfig(llm=llm_config, system_message=_SUPERVISOR_PROMPT)
agent = lr.ChatAgent(agent_config)
agent.enable_message(list(_SUPERVISOR_TOOLS))
return agent
# =====================================================================
# AG-UI SSE handler
# =====================================================================
def _sse_line(event: Any) -> str:
if hasattr(event, "model_dump"):
data = event.model_dump(by_alias=True, exclude_none=True)
else:
data = dict(event)
return f"data: {json.dumps(data)}\n\n"
def _normalize_state(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
return {"delegations": []}
delegations = raw.get("delegations")
if not isinstance(delegations, list):
delegations = []
return {"delegations": list(delegations)}
def _build_conversation(messages: Any) -> str:
parts: list[str] = []
if not messages:
return ""
for msg in messages:
role = (
getattr(msg, "role", None)
if hasattr(msg, "role")
else (msg.get("role") if isinstance(msg, dict) else None)
)
content = (
getattr(msg, "content", None)
if hasattr(msg, "content")
else (msg.get("content") if isinstance(msg, dict) else None)
)
if isinstance(role, str) and isinstance(content, str):
parts.append(f"{role}: {content}")
return "\n".join(parts)
def _extract_sub_agent_calls(response: Any) -> list[tuple[str, str, str]]:
"""Pull supervisor sub-agent tool calls out of an LLMResponse.
Returns a list of (call_id, sub_agent_name, task). Skips any call
that doesn't target one of the three sub-agents or that has a
malformed ``task`` argument.
"""
out: list[tuple[str, str, str]] = []
tool_calls = getattr(response, "oai_tool_calls", None) or []
for tc in tool_calls:
fn = getattr(tc, "function", None)
name = getattr(fn, "name", None) if fn is not None else None
if name not in _SUB_AGENT_NAMES:
continue
raw_args = getattr(fn, "arguments", None) if fn is not None else None
args: Any = raw_args
if isinstance(raw_args, (str, bytes, bytearray)):
try:
args = json.loads(raw_args)
except (ValueError, TypeError):
continue
if not isinstance(args, dict):
continue
task = args.get("task")
if not isinstance(task, str) or not task:
continue
call_id = getattr(tc, "id", None) or str(uuid.uuid4())
out.append((call_id, name, task))
return out
def _append_delegation(state: dict[str, Any], *, sub_agent: str, task: str) -> str:
entry_id = str(uuid.uuid4())
entry: Delegation = {
"id": entry_id,
"sub_agent": sub_agent, # type: ignore[typeddict-item]
"task": task,
"status": "running",
"result": "",
}
state["delegations"] = [*state.get("delegations", []), entry]
return entry_id
def _update_delegation(
state: dict[str, Any], *, entry_id: str, status: str, result: str
) -> None:
delegations = list(state.get("delegations") or [])
for entry in delegations:
if entry.get("id") == entry_id:
entry["status"] = status
entry["result"] = result
state["delegations"] = delegations
return
logger.warning(
"subagents: delegation entry %s missing on update — final %s state "
"(result_length=%d) will not be rendered",
entry_id,
status,
len(result),
)
# Maximum number of supervisor turns per request. Belt-and-suspenders:
# the prompt already nudges the supervisor to delegate sequentially and
# return a summary, but a stuck loop (model keeps re-delegating without
# converging) would otherwise burn quota indefinitely.
_MAX_SUPERVISOR_TURNS = 6
async def handle_run(request: Request) -> StreamingResponse:
error_id = str(uuid.uuid4())
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as exc:
logger.exception("subagents: failed to parse body (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid JSON body",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=400,
)
try:
run_input = RunAgentInput(**body)
except Exception as exc: # noqa: BLE001
logger.exception("subagents: invalid RunAgentInput (error_id=%s)", error_id)
return JSONResponse(
{
"error": "Invalid RunAgentInput payload",
"errorId": error_id,
"class": exc.__class__.__name__,
},
status_code=422,
)
state = _normalize_state(run_input.state)
supervisor = _create_supervisor()
user_message = _build_conversation(run_input.messages)
thread_id = run_input.thread_id or str(uuid.uuid4())
async def event_stream() -> AsyncGenerator[str, None]:
run_id = str(uuid.uuid4())
yield _sse_line(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=thread_id,
run_id=run_id,
)
)
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
prompt = user_message
for turn in range(_MAX_SUPERVISOR_TURNS):
try:
response = await supervisor.llm_response_async(prompt)
except Exception as exc: # noqa: BLE001
logger.exception(
"subagents: supervisor.llm_response_async failed (turn=%d)",
turn,
)
# Use RunErrorEvent (the proper AG-UI primitive) so the UI
# can surface a real error state instead of rendering a raw
# JSON blob inside a chat bubble.
yield _sse_line(
RunErrorEvent(
type=EventType.RUN_ERROR,
message=f"Supervisor failed: {exc.__class__.__name__}",
)
)
break
if response is None:
break
calls = _extract_sub_agent_calls(response)
if not calls:
# Plain text — supervisor finished, stream and stop.
content = getattr(response, "content", None) or ""
if content:
msg_id = str(uuid.uuid4())
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=msg_id,
)
)
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=msg_id,
delta=content,
)
)
yield _sse_line(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=msg_id,
)
)
break
# Run each delegation in order. We stream a `running` snapshot
# before invoking the sub-agent and a `completed` / `failed`
# snapshot after, so the UI shows the in-flight row.
results: list[str] = []
for call_id, sub_name, task in calls:
# Emit the supervisor's tool call first — useful for any UI
# that wants to render the call envelope itself.
yield _sse_line(
ToolCallStartEvent(
type=EventType.TOOL_CALL_START,
tool_call_id=call_id,
tool_call_name=sub_name,
)
)
yield _sse_line(
ToolCallArgsEvent(
type=EventType.TOOL_CALL_ARGS,
tool_call_id=call_id,
delta=json.dumps({"task": task}),
)
)
yield _sse_line(
ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
tool_call_id=call_id,
)
)
entry_id = _append_delegation(state, sub_agent=sub_name, task=task)
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
try:
sub_result = await _invoke_sub_agent(sub_name, task)
except RuntimeError as exc:
_update_delegation(
state,
entry_id=entry_id,
status="failed",
result=str(exc),
)
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
results.append(f"[{sub_name} failed: {exc}]")
continue
_update_delegation(
state,
entry_id=entry_id,
status="completed",
result=sub_result,
)
yield _sse_line(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=state,
)
)
results.append(f"[{sub_name} result]\n{sub_result}")
# Re-prompt the supervisor with all delegation outputs so it
# can chain (research -> write -> critique) or summarize.
prompt = (
"The sub-agents you delegated to returned the following:\n\n"
+ "\n\n".join(results)
+ "\n\nDecide whether to delegate further or, if the work "
"is done, write a brief final summary for the user."
)
else:
# Loop finished without ``break`` — we hit the turn cap.
msg_id = str(uuid.uuid4())
cap_msg = (
"Supervisor reached the delegation cap "
f"({_MAX_SUPERVISOR_TURNS} turns) without finalizing. "
"Showing partial results; please refine your request."
)
yield _sse_line(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START, message_id=msg_id
)
)
yield _sse_line(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=msg_id,
delta=cap_msg,
)
)
yield _sse_line(
TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=msg_id)
)
yield _sse_line(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=thread_id,
run_id=run_id,
)
)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,54 @@
// Dedicated runtime for the A2UI — Fixed Schema cell (Langroid).
//
// Splitting into its own endpoint lets us set `a2ui.injectA2UITool: false` —
// the backend Langroid agent owns the `display_flight` tool which emits its
// own `a2ui_operations` container directly in the tool result.
//
// References:
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-a2ui-fixed-schema/route.ts
// - showcase/integrations/ag2/src/app/api/copilotkit-a2ui-fixed-schema/route.ts
// - src/agents/a2ui_fixed_agent.py (the Langroid backend)
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const a2uiFixedSchemaAgent = new HttpAgent({
url: `${AGENT_URL}/a2ui-fixed-schema`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "a2ui-fixed-schema": a2uiFixedSchemaAgent },
a2ui: {
// The backend agent emits its own `a2ui_operations` container inside
// the `display_flight` tool result (see src/agents/a2ui_fixed_agent.py).
// We still run the A2UI middleware so it detects the container in tool
// results and forwards surfaces to the frontend — but we do NOT inject
// a runtime `render_a2ui` tool on top of the agent's existing tools.
injectA2UITool: false,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-a2ui-fixed-schema",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,169 @@
// Dedicated runtime for the Agent Config Object demo (Langroid).
//
// The <CopilotKit properties={...}> provider forwards tone / expertise /
// responseLength on every run; the V1 Next.js runtime propagates those as
// top-level keys on the AG-UI `forwardedProps` payload.
//
// Upstream parity (see langgraph-python route for the canonical logic):
// the frontend's provider properties arrive flat on `forwardedProps`
// (e.g. `forwardedProps.tone`). The LangGraph showcase's Python graph
// reads them from `RunnableConfig.configurable.properties`, so the TS
// adapter there repacks flat keys into
// `forwardedProps.config.configurable.properties` before dispatching.
//
// Langroid's backend does not have a LangGraph RunnableConfig, but we
// mirror the same payload shape here so:
// 1. The frontend contract is identical across all showcases.
// 2. The Langroid Python backend can read the repacked location
// (`run_input.forwarded_props.config.configurable.properties`)
// deterministically — top-level flat keys would collide with any
// future AG-UI additions to `forwardedProps`.
//
// We subclass `HttpAgent` and override `requestInit` (the only place in
// the AG-UI client that serializes the body) so the repack happens once
// per request with no middleware plumbing.
//
// Scoped to its own endpoint so non-demo cells don't pay the cost of
// this repack and so the Playwright spec can assert request-body
// propagation against exactly one URL.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
import type { RunAgentInput } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Reserved AG-UI / LangGraph stream-payload keys that MUST NOT be repacked
// under `configurable.properties`. Anything outside this set is treated as
// user-supplied frontend state (tone / expertise / responseLength / ...) and
// moved into `forwardedProps.config.configurable.properties`.
//
// Keep this list in sync with the upstream canonical implementation:
// `showcase/integrations/langgraph-python/src/app/api/copilotkit-agent-config/route.ts`.
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",
]);
type RunInputWithForwardedProps = RunAgentInput & {
forwardedProps?: Record<string, unknown>;
};
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;
}
/**
* `HttpAgent` subclass that repacks provider `properties` (flat top-level
* keys on `forwardedProps`) into `forwardedProps.config.configurable.properties`
* before the body is serialized and POSTed to the Langroid Python backend.
*
* `requestInit` is the single place in the AG-UI client where the payload
* is serialized (`body: JSON.stringify(input)`), so overriding it here is
* the minimum-surface hook — no middleware plumbing, no clone semantics
* to preserve.
*/
class AgentConfigHttpAgent extends HttpAgent {
protected requestInit(input: RunAgentInput): RequestInit {
const repacked = repackForwardedPropsIntoConfigurable(
input as RunInputWithForwardedProps,
);
return super.requestInit(repacked as RunAgentInput);
}
}
const agentConfigAgent = new AgentConfigHttpAgent({ url: `${AGENT_URL}/` });
const agents = {
"agent-config-demo": agentConfigAgent,
// Internal components calling useAgent() with no args default to "default".
default: agentConfigAgent,
};
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records.
agents,
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-agent-config",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,59 @@
// Dedicated runtime for the /demos/auth cell (Langroid).
//
// Framework-native request authentication via the V2 runtime's `onRequest`
// hook. Validates a static `Authorization: Bearer <DEMO_TOKEN>` header;
// mismatch throws 401 before the request reaches the agent.
//
// Uses `createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` directly
// so the `hooks.onRequest` option is honored (the V1 Next.js adapter does
// not forward the `hooks` option).
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Reuse the unified Langroid agent — this demo shows the gate, not bespoke
// auth-aware agent behavior.
const authDemoAgent = new HttpAgent({ url: `${AGENT_URL}/` });
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>>
agents: {
"auth-demo": authDemoAgent,
default: authDemoAgent,
},
});
const BASE_PATH = "/api/copilotkit-auth";
const handler = createCopilotRuntimeHandler({
runtime,
basePath: BASE_PATH,
hooks: {
onRequest: ({ request }: { request: Request }) => {
const authHeader = request.headers.get("authorization");
if (authHeader !== DEMO_AUTH_HEADER) {
throw new Response(
JSON.stringify({
error: "unauthorized",
message:
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
}),
{
status: 401,
headers: { "content-type": "application/json" },
},
);
}
},
},
});
export const POST = (req: NextRequest) => handler(req);
export const GET = (req: NextRequest) => handler(req);
@@ -0,0 +1,74 @@
// Dedicated runtime for the Beautiful Chat flagship showcase cell (Langroid).
//
// Beautiful Chat exercises A2UI (dynamic + fixed schema) and Open
// Generative UI. The Langroid backend exposes a single unified agent on
// "/" that handles every request, so the cell routes there; the flagship
// behavior comes from the runtime flags below plus the frontend's per-cell
// registrations.
//
// Isolated on its own endpoint (mirroring beautiful-chat in the canonical)
// because the `openGenerativeUI` / `a2ui` runtime flags set global state on
// the probe response that would otherwise leak into the other cells sharing
// the default `/api/copilotkit` runtime.
//
// Mirrors showcase/integrations/pydantic-ai/src/app/api/copilotkit-beautiful-chat/route.ts.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import type { AbstractAgent } from "@ag-ui/client";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// The beautiful-chat page resolves <CopilotKit agent="beautiful-chat">
// here; internal components (headless-chat, example-canvas) also call
// `useAgent()` with no args, which defaults to agentId "default". Alias
// default to the same backend so those hooks resolve.
const agents: Record<string, AbstractAgent> = {
"beautiful-chat": new HttpAgent({ url: `${AGENT_URL}/` }),
default: new HttpAgent({ url: `${AGENT_URL}/` }),
};
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
openGenerativeUI: true,
a2ui: {
// The targeted unified Langroid agent ("/") already registers the
// `generate_a2ui` tool itself (GenerateA2UITool in ALL_TOOLS, enabled
// via create_agent), so the runtime must NOT inject a second copy —
// that would double-bind the render tool. Matches pydantic-ai's
// beautiful-chat (this file's mirror source).
injectA2UITool: false,
// Models follow the tool-usage guide and omit `catalogId`, and the
// middleware then falls back to the unregistered spec basic catalog
// ("Catalog not found" render error). Pin the catalog the page registers.
defaultCatalogId: "copilotkit://app-dashboard-catalog",
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-beautiful-chat",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
console.error(
`[copilotkit-beautiful-chat/route] ERROR: ${e.message}`,
e.stack,
);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,47 @@
// Dedicated runtime for the byoc-hashbrown demo (Langroid).
//
// The demo page wraps CopilotChat in HashBrownDashboard and overrides the
// assistant message slot with a renderer that consumes hashbrown-shaped
// structured output via `@hashbrownai/react`'s `useUiKit` + `useJsonParser`.
// The agent behind this endpoint is the FastAPI handler at
// `${AGENT_URL}/byoc-hashbrown` whose system prompt is tuned to emit the
// hashbrown JSON envelope (see `src/agents/byoc_hashbrown_agent.py`).
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const byocHashbrownAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-hashbrown`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: {
"byoc-hashbrown-demo": byocHashbrownAgent,
default: byocHashbrownAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-byoc-hashbrown",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,46 @@
/**
* Dedicated runtime for the BYOC json-render demo (Langroid).
*
* Mirrors the hashbrown route. The agent at `${AGENT_URL}/byoc-json-render`
* emits a flat element-map spec that the frontend's `<Renderer />` (from
* `@json-render/react`) renders against a Zod-validated catalog.
*/
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const byocJsonRenderAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-json-render`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: {
byoc_json_render: byocJsonRenderAgent,
default: byocJsonRenderAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-byoc-json-render",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,52 @@
// Dedicated runtime for the Declarative Generative UI (A2UI) cell (Langroid).
//
// The unified Langroid agent already owns a `generate_a2ui` tool (see
// src/agents/agent.py -> GenerateA2UITool). We route this demo here so we
// can set `a2ui.injectA2UITool: false` — the runtime must NOT auto-inject
// its own A2UI tool on top of the agent-owned one.
//
// The A2UI middleware still runs: it serialises the registered client
// catalog into the agent's context so the secondary LLM inside
// `generate_a2ui` knows which components to emit.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const declarativeGenUiAgent = new HttpAgent({ url: `${AGENT_URL}/` });
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: { "declarative-gen-ui": declarativeGenUiAgent },
a2ui: {
injectA2UITool: false,
// Models follow the tool-usage guide and omit `catalogId`, and the
// middleware then falls back to the unregistered spec basic catalog
// ("Catalog not found" render error). Pin the catalog the page registers.
defaultCatalogId: "declarative-gen-ui-catalog",
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-gen-ui",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,54 @@
// Dedicated runtime for the declarative-hashbrown demo (Langroid).
//
// The demo page wraps CopilotChat in HashBrownDashboard and overrides the
// assistant message slot with a renderer that consumes hashbrown-shaped
// structured output via `@hashbrownai/react`'s `useUiKit` + `useJsonParser`.
// The agent behind this endpoint is the FastAPI handler at
// `${AGENT_URL}/byoc-hashbrown` whose system prompt is tuned to emit the
// hashbrown JSON envelope (see `src/agents/byoc_hashbrown_agent.py`). The
// demo folder + route + agent slug were renamed from `byoc-hashbrown` to the
// canonical `declarative-hashbrown` surface; the page mounts
// <CopilotKit agent="declarative-hashbrown-demo">.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const declarativeHashbrownAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-hashbrown`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: {
"declarative-hashbrown-demo": declarativeHashbrownAgent,
default: declarativeHashbrownAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-hashbrown",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
console.error(
`[copilotkit-declarative-hashbrown/route] ERROR: ${e.message}`,
e.stack,
);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,52 @@
// Dedicated runtime for the declarative-json-render demo (Langroid).
//
// The demo page renders the agent's JSON output into a frontend-owned
// component catalog via @json-render/react. The agent behind this endpoint
// is the FastAPI handler at `${AGENT_URL}/byoc-json-render` (see
// `src/agents/byoc_json_render_agent.py`). The demo folder + route surface
// were renamed from `byoc-json-render` to the canonical
// `declarative-json-render`; the agent ID retains its legacy
// `byoc_json_render` name.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const byocJsonRenderAgent = new HttpAgent({
url: `${AGENT_URL}/byoc-json-render`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: {
byoc_json_render: byocJsonRenderAgent,
default: byocJsonRenderAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-declarative-json-render",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
console.error(
`[copilotkit-declarative-json-render/route] ERROR: ${e.message}`,
e.stack,
);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
};
@@ -0,0 +1,76 @@
// CopilotKit runtime for the MCP Apps cell (Langroid).
//
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware:
// when the agent calls a tool backed by an MCP UI resource, the
// middleware fetches the resource and emits the activity event that the
// built-in `MCPAppsActivityRenderer` (registered by CopilotKit
// internally) renders inline in the chat as a sandboxed iframe.
//
// The agent itself is the no-tools FastAPI handler at
// `${AGENT_URL}/mcp-apps` — see `src/agents/mcp_apps_agent.py`. The
// runtime forwards the MCP tool catalog through `RunAgentInput.tools`,
// the agent forwards it to OpenAI, and any tool calls the model emits
// flow back through the middleware.
//
// Reference:
// https://docs.copilotkit.ai/integrations/langgraph/generative-ui/mcp-apps
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const mcpAppsAgent = new HttpAgent({ url: `${AGENT_URL}/mcp-apps` });
// headless-complete shares this runtime (its page wires
// runtimeUrl="/api/copilotkit-mcp-apps") but is backed by the unified
// Langroid agent on "/" — the same backend the main route registers it
// against.
const headlessCompleteAgent = new HttpAgent({ url: `${AGENT_URL}/` });
// @region[runtime-mcpapps-config]
const runtime = new CopilotRuntime({
// @ts-expect-error -- see main route.ts; published CopilotRuntime's `agents`
// type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects
// plain Records. Fixed in source, pending release.
agents: {
"headless-complete": headlessCompleteAgent,
"mcp-apps": mcpAppsAgent,
default: mcpAppsAgent,
},
mcpApps: {
servers: [
{
type: "http",
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
// Always pin a stable `serverId`. Without it CopilotKit hashes the
// URL, and a URL change silently breaks restoration of persisted
// MCP Apps in prior conversation threads.
serverId: "excalidraw",
},
],
},
});
// @endregion[runtime-mcpapps-config]
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-mcp-apps",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,50 @@
// Dedicated runtime for the Multimodal Attachments demo (Langroid).
//
// Why its own route? The backing agent (`multimodal_agent.py`) runs a
// vision-capable model (gpt-4o). Every other cell in the showcase uses a
// text-only, cheaper model. Registering this agent under the shared
// `/api/copilotkit` runtime would silently upgrade *all* cells that share
// that runtime to a vision model whenever the browser routed to this one
// — wasting tokens and blurring the per-demo cost boundary.
//
// The page at `src/app/demos/multimodal/page.tsx` points its `runtimeUrl`
// here and sets `agent="multimodal-demo"`.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const multimodalAgent = new HttpAgent({ url: `${AGENT_URL}/multimodal` });
const agents = {
// The page mounts <CopilotKit agent="multimodal-demo">.
"multimodal-demo": multimodalAgent,
// useAgent() with no args defaults to "default"; alias for safety.
default: multimodalAgent,
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-multimodal",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
}),
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
// Dedicated runtime for the Open Generative UI demos (Langroid).
// Isolated here because the `openGenerativeUI` runtime flag sets
// `openGenerativeUIEnabled: true` globally on the probe response, which
// causes the CopilotKit provider's setTools effect to wipe per-demo
// `useFrontendTool`/`useComponent` registrations in the default runtime.
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const agents: Record<string, HttpAgent> = {
"open-gen-ui": new HttpAgent({ url: `${AGENT_URL}/` }),
"open-gen-ui-advanced": new HttpAgent({ url: `${AGENT_URL}/` }),
};
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-ogui",
serviceAdapter: new ExperimentalEmptyAdapter(),
// @region[minimal-runtime-flag]
// @region[advanced-runtime-config]
// Server-side config is identical for the minimal and advanced cells —
// the advanced behaviour (sandbox -> host function calls) is wired
// entirely on the frontend via `openGenerativeUI.sandboxFunctions` on
// the provider. The single `openGenerativeUI` flag below turns on
// Open Generative UI for the listed agent(s); the runtime middleware
// converts each agent's streamed `generateSandboxedUi` tool call into
// `open-generative-ui` activity events.
runtime: new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
openGenerativeUI: {
agents: ["open-gen-ui", "open-gen-ui-advanced"],
},
}),
// @endregion[advanced-runtime-config]
// @endregion[minimal-runtime-flag]
});
return await handleRequest(req);
} catch (error: unknown) {
const e = error as { message?: string; stack?: string };
return NextResponse.json(
{ error: e.message, stack: e.stack },
{ status: 500 },
);
}
};
@@ -0,0 +1,65 @@
// Dedicated runtime for the Shared State (Read + Write) demo (Langroid).
//
// The unified Langroid agent at POST / does not consume RunAgentInput.state
// or emit STATE_SNAPSHOT events. The shared-state-read-write demo needs
// both — UI -> agent writes via agent.setState, agent -> UI writes via
// the `set_notes` tool — so we point this runtime at a dedicated FastAPI
// endpoint (POST /shared-state-read-write) that implements its own AG-UI
// SSE pipeline with full state support.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
import crypto from "node:crypto";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const sharedStateReadWriteAgent = new HttpAgent({
url: `${AGENT_URL}/shared-state-read-write`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; same
// workaround as the main route.ts.
agents: {
"shared-state-read-write": sharedStateReadWriteAgent,
default: sharedStateReadWriteAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-shared-state-read-write",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
// Log full error details server-side (with a correlation id) but do
// NOT leak `error.message` / `error.stack` to the client — those can
// contain internal paths, library internals, or transient secrets.
// Operators correlate via `errorId` in logs.
const errorId = crypto.randomUUID();
const e = error instanceof Error ? error : new Error(String(error));
console.error(
JSON.stringify({
at: new Date().toISOString(),
level: "error",
route: "/api/copilotkit-shared-state-read-write",
errorId,
message: e.message,
stack: e.stack,
}),
);
return NextResponse.json(
{ error: "internal runtime error", errorId },
{ status: 500 },
);
}
};
@@ -0,0 +1,62 @@
// Dedicated runtime for the Sub-Agents demo (Langroid).
//
// Routes to POST /subagents on the FastAPI agent server, which runs a
// supervisor LLM that delegates to three specialized sub-agents
// (research / writing / critique). Each delegation is recorded into
// state["delegations"] and surfaced to the UI via STATE_SNAPSHOT events
// so the live delegation log can render running -> completed transitions.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
import crypto from "node:crypto";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const subagentsAgent = new HttpAgent({
url: `${AGENT_URL}/subagents`,
});
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents: {
subagents: subagentsAgent,
default: subagentsAgent,
},
});
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-subagents",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
// Log full error details server-side (with a correlation id) but do
// NOT leak `error.message` / `error.stack` to the client — those can
// contain internal paths, library internals, or transient secrets.
// Operators correlate via `errorId` in logs.
const errorId = crypto.randomUUID();
const e = error instanceof Error ? error : new Error(String(error));
console.error(
JSON.stringify({
at: new Date().toISOString(),
level: "error",
route: "/api/copilotkit-subagents",
errorId,
message: e.message,
stack: e.stack,
}),
);
return NextResponse.json(
{ error: "internal runtime error", errorId },
{ status: 500 },
);
}
};
@@ -0,0 +1,106 @@
// Dedicated runtime for the /demos/voice cell (Langroid).
//
// Goals
// -----
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
// composer renders the mic button.
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`), so recorded
// audio is transcribed and the transcript auto-sends.
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured.
//
// Implementation
// --------------
// Wires the **V2** `CopilotRuntime` directly (from `@copilotkit/runtime/v2`)
// because the V1 wrapper drops the `transcriptionService` option on the floor.
// V2 URL-routes on `/info`, `/agent/:id/run`, `/transcribe`, etc., so the
// route file lives at `[[...slug]]/route.ts` to catch every sub-path under
// `/api/copilotkit-voice`.
//
// The actual chat agent is the unified Langroid AG-UI backend that runs at
// `${AGENT_URL}/` (port 8000 by default). We register an `HttpAgent` against
// it under the "voice-demo" slug used by the page.
// @region[voice-runtime]
// @region[transcription-service-guard]
import type { NextRequest } from "next/server";
import {
CopilotRuntime,
TranscriptionService,
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
import OpenAI from "openai";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
const voiceDemoAgent = new HttpAgent({ url: `${AGENT_URL}/` });
/**
* Transcription service wrapper that reports a clean, typed auth error when
* OPENAI_API_KEY is not configured. When the key is present we delegate to
* the real OpenAI-backed service.
*
* "api key" substring in the thrown error is matched by the V2 runtime's
* `handleTranscribe` and mapped to `AUTH_FAILED → HTTP 401`.
*/
class GuardedOpenAITranscriptionService extends TranscriptionService {
private delegate: TranscriptionServiceOpenAI | null;
constructor() {
super();
const apiKey = process.env.OPENAI_API_KEY;
this.delegate = apiKey
? new TranscriptionServiceOpenAI({ openai: new OpenAI({ apiKey }) })
: null;
}
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
if (!this.delegate) {
throw new Error(
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
"Set OPENAI_API_KEY to enable voice transcription.",
);
}
return this.delegate.transcribeFile(options);
}
}
// @endregion[transcription-service-guard]
// Cache the runtime + handler across invocations so the transcription service
// is constructed once per Node process instead of per request.
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
function getHandler(): (req: Request) => Promise<Response> {
if (cachedHandler) return cachedHandler;
const runtime = new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in
// MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in
// source, pending release.
agents: {
// The page mounts <CopilotKit agent="voice-demo">; resolve that to the
// unified Langroid AG-UI endpoint.
"voice-demo": voiceDemoAgent,
// useAgent() with no args defaults to "default"; alias so any internal
// default-agent lookups resolve against the same agent.
default: voiceDemoAgent,
},
transcriptionService: new GuardedOpenAITranscriptionService(),
});
cachedHandler = createCopilotRuntimeHandler({
runtime,
basePath: "/api/copilotkit-voice",
});
return cachedHandler;
}
// Next.js App Router bindings. Catchall slug forwards every sub-path
// (`/info`, `/agent/:id/run`, `/transcribe`, ...) to the V2 handler.
export const POST = (req: NextRequest) => getHandler()(req);
export const GET = (req: NextRequest) => getHandler()(req);
export const PUT = (req: NextRequest) => getHandler()(req);
export const DELETE = (req: NextRequest) => getHandler()(req);
// @endregion[voice-runtime]
@@ -0,0 +1,184 @@
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
// The agent backend runs as a separate process on port 8000.
// This runtime proxies CopilotKit requests to it via AG-UI protocol.
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
// Per-request request/response logging is gated behind this flag (default off).
// Under d6 probe fan-out, unconditional per-request logs flooded Railway's
// 500-logs/sec cap and killed the replica ("Messages dropped" → container stop).
// Set SHOWCASE_ROUTE_DEBUG=1 to re-enable verbose per-request tracing locally.
const ROUTE_DEBUG =
process.env.SHOWCASE_ROUTE_DEBUG === "1" ||
process.env.SHOWCASE_ROUTE_DEBUG === "true";
console.log("[copilotkit/route] Initializing CopilotKit runtime");
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
function createAgent(path = "/") {
return new HttpAgent({ url: `${AGENT_URL}${path}` });
}
// Register the same agent under all names used by demo pages.
// The Langroid agent_server.py exposes a single unified agent on "/" that
// handles every request — so every entry here maps to the same HttpAgent.
const agentNames = [
"agentic_chat",
"human_in_the_loop",
"tool-rendering",
"gen-ui-tool-based",
"gen-ui-agent",
"shared-state-read",
"shared-state-write",
"shared-state-read-write",
"shared-state-streaming",
"subagents",
// Chat chrome variants — all share the unified agent. The frontend
// differentiates via UI composition only (CopilotChat vs Sidebar vs Popup,
// slots, headless useAgent).
"chat-customization-css",
"prebuilt-sidebar",
"prebuilt-popup",
"chat-slots",
"headless-simple",
// Frontend-tools variants — backend has no specialized tools; the frontend
// registers handlers via useFrontendTool and the agent calls them.
"frontend_tools",
"frontend-tools-async",
// HITL variants — use existing agent's schedule_meeting flow.
"hitl-in-chat",
"hitl-in-app",
// Read-only agent context — frontend exposes useAgentContext; same agent.
"readonly-state-agent-context",
// Tool rendering variants — all share the unified agent; frontend differs.
"tool-rendering-default-catchall",
"tool-rendering-custom-catchall",
"tool-rendering-reasoning-chain",
// Declarative A2UI + fixed-schema A2UI — use the agent's generate_a2ui tool.
"declarative-gen-ui",
"a2ui-fixed-schema",
// Agent-config, open-gen-ui, headless-complete all reuse the unified agent.
"agent-config",
"open-gen-ui",
"open-gen-ui-advanced",
"headless-complete",
// Interrupt demos (Strategy B — frontend-tool async handler)
"gen-ui-interrupt",
"interrupt-headless",
];
// Reasoning agent names — backed by the reasoning-enabled sub-app at
// /reasoning. Langroid's stock unified agent calls OpenAI non-streaming and
// drops the model's reasoning_content channel, so reasoning cells route here
// instead. Emits AG-UI REASONING_MESSAGE_* events that the frontend renders
// via the `reasoningMessage` slot (built-in card for `reasoning-default`,
// custom amber ReasoningBlock for `reasoning-custom`). `agentic-chat-reasoning`
// and `reasoning-default-render` are legacy aliases kept for any cell that
// still references them.
const reasoningAgentNames = [
"reasoning-default",
"reasoning-custom",
"reasoning-default-render",
"agentic-chat-reasoning",
];
const agents: Record<string, AbstractAgent> = {};
for (const name of agentNames) {
agents[name] = createAgent();
}
for (const name of reasoningAgentNames) {
agents[name] = createAgent("/reasoning/");
}
agents["default"] = createAgent();
// gen-ui-agent owns a typed `steps` slice of shared state that the
// unified `/` agent does not implement (it has no `set_steps` tool).
// Route this agent name at a dedicated backend endpoint that drives
// the pending -> in_progress -> completed state machine and emits
// STATE_SNAPSHOT events between transitions.
agents["gen-ui-agent"] = new HttpAgent({ url: `${AGENT_URL}/gen-ui-agent` });
console.log(
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
);
export const POST = async (req: NextRequest) => {
const url = req.url;
const contentType = req.headers.get("content-type");
if (ROUTE_DEBUG) {
console.log(
`[copilotkit/route] POST ${url} (content-type: ${contentType})`,
);
}
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
agents,
}),
});
const response = await handleRequest(req);
if (!response.ok) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
} else if (ROUTE_DEBUG) {
console.log(`[copilotkit/route] Response status: ${response.status}`);
}
return response;
} catch (error: unknown) {
// Log full details server-side (operators grep `errorId` to correlate),
// but never echo `err.message` / `err.stack` back to the HTTP client —
// that leaks internal paths, dependency versions, and stack traces.
const err = error instanceof Error ? error : new Error(String(error));
const errorId = crypto.randomUUID();
console.error(
JSON.stringify({
at: new Date().toISOString(),
level: "error",
scope: "copilotkit/route",
errorId,
message: err.message,
stack: err.stack,
}),
);
return NextResponse.json(
{ error: "internal runtime error", errorId },
{ status: 500 },
);
}
};
export const GET = async () => {
if (ROUTE_DEBUG) {
console.log("[copilotkit/route] GET /api/copilotkit (health probe)");
}
let agentStatus = "unknown";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
} catch (e: unknown) {
agentStatus = `unreachable (${(e as Error).message})`;
}
return NextResponse.json({
status: "ok",
agent_url: AGENT_URL,
agent_status: agentStatus,
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
NODE_ENV: process.env.NODE_ENV,
},
});
};
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
const token =
req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
if (!expectedToken || !token || token !== expectedToken) {
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
}
const AGENT_URL = process.env.AGENT_URL || "unknown";
// Agent connectivity
let agentStatus = "unknown";
let agentDetail = "";
try {
const res = await fetch(`${AGENT_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
agentStatus = res.ok ? "ok" : "error";
agentDetail = `HTTP ${res.status}`;
} catch (e: unknown) {
agentStatus = "down";
agentDetail = (e as Error).message;
}
const uptime = process.uptime();
const mem = process.memoryUsage();
return NextResponse.json({
integration: "langroid",
uptime: `${Math.floor(uptime / 60)}m ${Math.floor(uptime % 60)}s`,
agent: { url: AGENT_URL, status: agentStatus, detail: agentDetail },
memory: {
rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)}MB`,
},
env: {
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ? "set" : "NOT SET",
LANGSMITH_API_KEY: process.env.LANGSMITH_API_KEY ? "set" : "NOT SET",
},
nodeVersion: process.version,
});
}
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
status: "ok",
integration: "langroid",
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
const INTEGRATION_SLUG = "langroid";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export async function GET() {
const start = Date.now();
// Hit our own /api/copilotkit endpoint — tests the full deployed stack
const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL ||
`http://localhost:${process.env.PORT || 3000}`;
try {
const res = await fetch(`${baseUrl}/api/copilotkit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: "agent/run",
params: { agentId: "agentic_chat" },
body: {
threadId: `smoke-${Date.now()}`,
runId: `smoke-run-${Date.now()}`,
state: {},
messages: [
{
id: `smoke-msg-${Date.now()}`,
role: "user",
content: "Respond with exactly: OK",
},
],
tools: [],
context: [],
forwardedProps: {},
},
}),
signal: AbortSignal.timeout(45000),
});
const latency = Date.now() - start;
if (!res.ok) {
const errBody = await res.text().catch(() => "");
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "runtime_response",
error: `Runtime returned ${res.status}: ${errBody.slice(0, 200)}`,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
// TTFB: read first chunk only to confirm SSE stream started, then cancel
const reader = res.body?.getReader();
if (!reader) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned no readable body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
const { value, done } = await reader.read();
reader.cancel();
if (done || !value || value.length === 0) {
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage: "response_empty",
error: "Runtime returned empty response body",
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
return NextResponse.json({
status: "ok",
integration: INTEGRATION_SLUG,
latency_ms: latency,
timestamp: new Date().toISOString(),
});
} catch (e: unknown) {
const err = e instanceof Error ? e : new Error(String(e));
const latency = Date.now() - start;
let stage = "unknown";
if (err.name === "AbortError" || err.message.includes("timeout"))
stage = "timeout";
else if (
err.message.includes("fetch") ||
err.message.includes("ECONNREFUSED")
)
stage = "agent_unreachable";
else stage = "pipeline_error";
return NextResponse.json(
{
status: "error",
integration: INTEGRATION_SLUG,
stage,
error: err.message,
latency_ms: latency,
timestamp: new Date().toISOString(),
},
{ status: 502 },
);
}
}
@@ -0,0 +1,55 @@
// Shared fallback time-slot generator for the interrupt demos
// (`gen-ui-interrupt`, `interrupt-headless`). The interrupt backend
// (`src/agents/interrupt_agent.py`) supplies its own candidate slots
// inside the interrupt payload — these fallbacks only run if the
// payload arrives without them. Generating relative to `Date.now()`
// keeps the fallback from rotting, which previously had hardcoded
// dates that decayed within a week of being authored.
export interface TimeSlot {
label: string;
iso: string;
}
function atLocal(date: Date, hour: number, minute = 0): Date {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
hour,
minute,
0,
0,
);
}
function nextMonday(from: Date): Date {
// `getDay()` is 0=Sun, 1=Mon, ..., 6=Sat. We want the next Monday
// that's at LEAST 2 days away — otherwise "Monday" would collide
// with "Tomorrow" on Sunday (offset would be 1) or with itself on
// Monday (offset would be 0). Mirrors interrupt_agent.py.
const day = from.getDay();
let offset = (1 - day + 7) % 7;
if (offset <= 1) offset += 7;
const next = new Date(from);
next.setDate(from.getDate() + offset);
return next;
}
export function generateFallbackSlots(now: Date = new Date()): TimeSlot[] {
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const monday = nextMonday(now);
const candidates: Array<[string, Date]> = [
["Tomorrow 10:00 AM", atLocal(tomorrow, 10)],
["Tomorrow 2:00 PM", atLocal(tomorrow, 14)],
["Monday 9:00 AM", atLocal(monday, 9)],
["Monday 3:30 PM", atLocal(monday, 15, 30)],
];
return candidates.map(([label, date]) => ({
label,
iso: date.toISOString(),
}));
}
@@ -0,0 +1,12 @@
// Coerces a tool-call result into a typed object. Tool results arrive
// as strings when the agent emits JSON or as already-parsed objects
// when the runtime decoded them upstream — this helper handles both
// shapes and returns `{}` if the result is missing or unparseable.
export function parseJsonResult<T>(result: unknown): T {
if (!result) return {} as T;
try {
return (typeof result === "string" ? JSON.parse(result) : result) as T;
} catch {
return {} as T;
}
}
@@ -0,0 +1,21 @@
// Helper for the CopilotChat slot overrides. The slot prop types in
// `@copilotkit/react-core` are nominally typed against the *exact*
// default component identity, but a custom wrapper that returns a
// structurally compatible ReactElement is functionally a drop-in. This
// helper names that fact and centralizes the type assertion in one
// place — readers see `makeSlotOverride` and know it's about the slot
// contract, not arbitrary type-system gymnastics. Once the slot prop
// types accept structural compatibility, this helper can be deleted
// and the casts will resolve automatically.
import type { ComponentType } from "react";
// `any` on the input is intentional: the helper's purpose is to accept
// any component shape and assert it as the slot's expected type. A
// stricter constraint would defeat the whole point.
export function makeSlotOverride<TDefault>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>,
): TDefault {
return component as unknown as TDefault;
}
@@ -0,0 +1,31 @@
import * as React from "react";
/**
* ShadCN-style Badge primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "secondary" | "outline" | "success";
const variantClasses: Record<Variant, string> = {
default: "border-transparent bg-neutral-900 text-neutral-50",
secondary: "border-transparent bg-neutral-100 text-neutral-900",
outline: "border-neutral-200 text-neutral-700 bg-white",
success: "border-transparent bg-emerald-100 text-emerald-700",
};
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: Variant;
}
export function Badge({
className = "",
variant = "default",
...props
}: BadgeProps) {
return (
<div
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium tracking-wide ${variantClasses[variant]} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,48 @@
import * as React from "react";
/**
* ShadCN-style Button primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
type Variant = "default" | "outline" | "secondary" | "ghost" | "success";
type Size = "default" | "sm" | "lg";
const baseClasses =
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60";
const variantClasses: Record<Variant, string> = {
default: "bg-neutral-900 text-neutral-50 shadow-sm hover:bg-neutral-800",
outline:
"border border-neutral-200 bg-white text-neutral-900 shadow-sm hover:bg-neutral-100",
secondary: "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-200",
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
success:
"bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm hover:bg-emerald-50",
};
const sizeClasses: Record<Size, string> = {
default: "h-10 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-6",
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className = "", variant = "default", size = "default", ...props },
ref,
) => {
return (
<button
ref={ref}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
/>
);
},
);
Button.displayName = "Button";
@@ -0,0 +1,61 @@
import * as React from "react";
/**
* ShadCN-style Card primitive (inline-cloned for this demo).
* Plain Tailwind classes, no `cn()`/`cva` helpers.
*/
export const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm ${className}`}
{...props}
/>
));
Card.displayName = "Card";
export const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex flex-col space-y-1.5 p-5 pb-3 ${className}`}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
export const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className = "", ...props }, ref) => (
<h3
ref={ref}
className={`text-base font-semibold leading-none tracking-tight text-neutral-900 ${className}`}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
export const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div ref={ref} className={`p-5 pt-0 ${className}`} {...props} />
));
CardContent.displayName = "CardContent";
export const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className = "", ...props }, ref) => (
<div
ref={ref}
className={`flex items-center p-5 pt-0 ${className}`}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
@@ -0,0 +1,26 @@
import * as React from "react";
/**
* ShadCN-style Separator primitive (inline-cloned for this demo).
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
*/
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical";
}
export function Separator({
className = "",
orientation = "horizontal",
...props
}: SeparatorProps) {
const orientationClasses =
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
return (
<div
role="separator"
aria-orientation={orientation}
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,23 @@
"use client";
/**
* Fixed A2UI catalog — wires definitions to renderers.
*
* `includeBasicCatalog: true` merges CopilotKit's built-in components
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
* compose custom and basic components interchangeably.
*/
// @region[catalog-creation]
import { createCatalog } from "@copilotkit/a2ui-renderer";
import { definitions } from "./definitions";
import { renderers } from "./renderers";
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
export const catalog = createCatalog(definitions, renderers, {
catalogId: CATALOG_ID,
includeBasicCatalog: true,
});
// @endregion[catalog-creation]
@@ -0,0 +1,107 @@
/**
* A2UI catalog DEFINITIONS — platform-agnostic.
*
* Each entry declares a component name + its Zod props schema. The basic
* catalog (Card, Column, Row, Text, Button, …) ships with CopilotKit and
* is mixed in via `createCatalog(..., { includeBasicCatalog: true })`, so
* we only declare the project-specific additions and the visual overrides
* here. (Custom entries with the same name as a basic component override
* the basic one — Catalog dedupes by `comp.name`, last-write-wins.)
*
* IMPORTANT — path bindings: fields that can be bound to a data-model path
* (e.g. `{ path: "/origin" }` in the fixed schema JSON) must declare their
* Zod type as a union of `z.string()` and `z.object({ path: z.string() })`.
* The A2UI `GenericBinder` uses this union to detect the field as dynamic
* and resolve the path against the current data model at render time. Using
* plain `z.string()` causes the raw `{ path }` object to reach the
* renderer, which React then throws on (error #31 "object with keys {path}").
* This matches the canonical catalog's `DynString` helper:
* examples/integrations/langgraph-python/src/app/declarative-generative-ui/definitions.ts
*/
// @region[definitions-types]
import { z } from "zod";
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
/**
* Dynamic string: literal OR a data-model path binding. The GenericBinder
* resolves path bindings to the actual value at render time.
*/
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
export const definitions = {
/**
* Card override: gives the outer flight-card container a ShadCN look
* (rounded-xl, neutral-200 border, soft shadow). The basic catalog's
* Card uses inline styles; overriding here lets the demo's renderer
* adopt the demo's Tailwind aesthetic without touching the schema JSON.
*/
Card: {
description: "A container card with a single child.",
props: z.object({
child: z.string(),
}),
},
Title: {
description: "A prominent heading for the flight card.",
props: z.object({
text: DynString,
}),
},
Airport: {
description: "A 3-letter airport code, displayed large.",
props: z.object({
code: DynString,
}),
},
Arrow: {
description: "A right-pointing arrow used between airports.",
props: z.object({}),
},
AirlineBadge: {
description: "A pill-styled airline name tag.",
props: z.object({
name: DynString,
}),
},
PriceTag: {
description: "A stylized price display (e.g. '$289').",
props: z.object({
amount: DynString,
}),
},
/**
* Button override: swaps in an ActionButton renderer that tracks
* its own `done` state so clicking "Book flight" visually updates to
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
* so without this override the click fires the action but the button
* looks unchanged. Mirrors the pattern in beautiful-chat
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
*/
Button: {
description:
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
props: z.object({
child: z
.string()
.describe(
"The ID of the child component (e.g. a Text component for the label).",
),
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
action: z
.union([
z.object({
event: z.object({
name: z.string(),
context: z.record(z.any()).optional(),
}),
}),
z.null(),
])
.optional(),
}),
},
} satisfies CatalogDefinitions;
// @endregion[definitions-types]
export type Definitions = typeof definitions;
@@ -0,0 +1,110 @@
"use client";
/**
* A2UI catalog RENDERERS — React implementations for the custom components
* declared in `./definitions`. TypeScript enforces that the renderer map's
* keys and prop shapes match the definitions exactly.
*
* Visual style: ShadCN aesthetic (neutral palette, rounded-xl, subtle
* borders, clean typography). Tailwind utility classes only — no `cn()` /
* `cva` helpers, no shadcn CLI install. Inline-cloned primitives live in
* `../_components/`.
*/
import React from "react";
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
import type { Definitions } from "./definitions";
import { Card } from "../_components/card";
import { Badge } from "../_components/badge";
import { Button as UIButton } from "../_components/button";
import { Separator } from "../_components/separator";
// `DynString` props are typed as `string | { path }` (see definitions.ts), but
// the A2UI binder resolves path bindings before render — renderers only ever
// see resolved strings. One shared helper keeps that narrowing in one place.
const s = (v: unknown): string => (typeof v === "string" ? v : "");
// @region[renderers-tsx]
export const renderers: CatalogRenderers<Definitions> = {
/**
* Card override: ShadCN-style outer container. The basic catalog's Card
* uses inline styles; overriding here keeps the demo's tailwind aesthetic.
* The flight schema renders Card > Column > [Title, Row, …]; the inner
* Column adds the vertical spacing.
*/
Card: ({ props, children }) => (
<Card className="w-full max-w-md p-5" data-testid="a2ui-fixed-card">
{props.child ? children(props.child) : null}
</Card>
),
Title: ({ props }) => (
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
Itinerary
</p>
<h3 className="text-base font-semibold leading-none tracking-tight text-neutral-900">
{s(props.text)}
</h3>
</div>
<Badge variant="outline" className="font-mono">
1-stop · economy
</Badge>
</div>
),
Airport: ({ props }) => (
<div className="flex flex-col items-center">
<span className="font-mono text-2xl font-semibold tracking-wider text-neutral-900">
{s(props.code)}
</span>
</div>
),
Arrow: () => (
<div className="flex flex-1 items-center px-3">
<Separator className="flex-1 bg-neutral-200" />
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mx-1 text-neutral-400"
aria-hidden
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
<Separator className="flex-1 bg-neutral-200" />
</div>
),
AirlineBadge: ({ props }) => (
<Badge variant="secondary" className="uppercase tracking-[0.08em]">
{s(props.name)}
</Badge>
),
PriceTag: ({ props }) => (
<div className="flex items-baseline gap-1">
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
Total
</span>
<span className="font-mono text-base font-semibold text-neutral-900">
{s(props.amount)}
</span>
</div>
),
/**
* Button override: this is a pure-presentation demo, so the button just
* renders its label. The schema declares an `action` for visual fidelity,
* but the click handler is inert until the Python SDK exposes
* `action_handlers=` on `a2ui.render` (see `src/agents/a2ui_fixed.py`).
*/
Button: ({ props, children }) => (
<UIButton className="w-full">
{props.child ? children(props.child) : null}
</UIButton>
),
};
// @endregion[renderers-tsx]
@@ -0,0 +1,11 @@
"use client";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { useA2UIFixedSchemaSuggestions } from "./suggestions";
export function Chat() {
useA2UIFixedSchemaSuggestions();
return (
<CopilotChat agentId="a2ui-fixed-schema" className="h-full rounded-2xl" />
);
}
@@ -0,0 +1,41 @@
"use client";
/**
* Declarative Generative UI — A2UI Fixed Schema demo.
*
* In the fixed-schema flavor of A2UI, the component tree (schema) lives on
* the frontend and the agent only streams *data* into the data model. The
* flight card is ASSEMBLED from small sub-components in
* `src/agents/a2ui_schemas/flight_schema.json` (Card > Column > [Title, Row, …]).
*
* - Definitions (zod schemas): `./a2ui/definitions.ts`
* - Renderers (React): `./a2ui/renderers.tsx`
* - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog)
* - Agent: `src/agents/a2ui_fixed.py` (emits an `a2ui_operations` container)
*
* Reference:
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
*/
import React from "react";
import { CopilotKit } from "@copilotkit/react-core/v2";
import { catalog } from "./a2ui/catalog";
import { Chat } from "./chat";
export default function A2UIFixedSchemaDemo() {
return (
// `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.
<CopilotKit
runtimeUrl="/api/copilotkit-a2ui-fixed-schema"
agent="a2ui-fixed-schema"
a2ui={{ catalog: catalog }}
>
<div className="flex justify-center items-center h-screen w-full bg-neutral-50">
<div className="h-full w-full max-w-4xl border-x border-neutral-200 bg-white">
<Chat />
</div>
</div>
</CopilotKit>
);
}
@@ -0,0 +1,13 @@
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
export function useA2UIFixedSchemaSuggestions() {
useConfigureSuggestions({
suggestions: [
{
title: "Find SFO → JFK",
message: "Find me a flight from SFO to JFK on United for $289.",
},
],
available: "always",
});
}
@@ -0,0 +1,91 @@
"use client";
import type { ChangeEvent } from "react";
import {
type AgentConfig,
EXPERTISE_OPTIONS,
type Expertise,
RESPONSE_LENGTH_OPTIONS,
type ResponseLength,
TONE_OPTIONS,
type Tone,
} from "./config-types";
interface ConfigCardProps {
config: AgentConfig;
onToneChange: (tone: Tone) => void;
onExpertiseChange: (expertise: Expertise) => void;
onResponseLengthChange: (length: ResponseLength) => void;
}
export function ConfigCard({
config,
onToneChange,
onExpertiseChange,
onResponseLengthChange,
}: ConfigCardProps) {
return (
<div
data-testid="agent-config-card"
className="flex flex-col gap-2 rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-4 text-sm"
>
<h2 className="text-sm font-semibold">Agent Config</h2>
<p className="text-xs text-[var(--text-muted)]">
Change these and send a message to see the agent adapt.
</p>
<div className="flex flex-wrap gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Tone</span>
<select
data-testid="agent-config-tone-select"
value={config.tone}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onToneChange(e.target.value as Tone)
}
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
>
{TONE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Expertise</span>
<select
data-testid="agent-config-expertise-select"
value={config.expertise}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onExpertiseChange(e.target.value as Expertise)
}
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
>
{EXPERTISE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium">Response length</span>
<select
data-testid="agent-config-length-select"
value={config.responseLength}
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
onResponseLengthChange(e.target.value as ResponseLength)
}
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
>
{RESPONSE_LENGTH_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</label>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
"use client";
/**
* Publishes the current agent-config toggles to the agent runtime via
* `useAgentContext`. Lives inside the `<CopilotKit>` provider so the
* context store is reachable. The middleware on the Python side reads
* this entry off the agent's runtime context on every turn and routes
* it into the model's prompt.
*/
import { useAgentContext } from "@copilotkit/react-core/v2";
import type { AgentConfig } from "./config-types";
export function ConfigContextRelay({ config }: { config: AgentConfig }) {
useAgentContext({
description:
"Agent response preferences. Apply tone, expertise level, and response length to every reply.",
value: {
tone: config.tone,
expertise: config.expertise,
responseLength: config.responseLength,
},
});
return null;
}
@@ -0,0 +1,26 @@
export type Tone = "professional" | "casual" | "enthusiastic";
export type Expertise = "beginner" | "intermediate" | "expert";
export type ResponseLength = "concise" | "detailed";
export interface AgentConfig {
tone: Tone;
expertise: Expertise;
responseLength: ResponseLength;
}
export const DEFAULT_AGENT_CONFIG: AgentConfig = {
tone: "professional",
expertise: "intermediate",
responseLength: "concise",
};
export const TONE_OPTIONS: Tone[] = ["professional", "casual", "enthusiastic"];
export const EXPERTISE_OPTIONS: Expertise[] = [
"beginner",
"intermediate",
"expert",
];
export const RESPONSE_LENGTH_OPTIONS: ResponseLength[] = [
"concise",
"detailed",
];
@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { CopilotChat } from "@copilotkit/react-core/v2";
import { ConfigCard } from "./config-card";
import type { AgentConfig } from "./config-types";
interface DemoLayoutProps {
config: AgentConfig;
onToneChange: (tone: AgentConfig["tone"]) => void;
onExpertiseChange: (expertise: AgentConfig["expertise"]) => void;
onResponseLengthChange: (length: AgentConfig["responseLength"]) => void;
}
export function DemoLayout({
config,
onToneChange,
onExpertiseChange,
onResponseLengthChange,
}: DemoLayoutProps) {
return (
<div className="flex h-screen flex-col gap-3 p-6">
<ConfigCard
config={config}
onToneChange={onToneChange}
onExpertiseChange={onExpertiseChange}
onResponseLengthChange={onResponseLengthChange}
/>
<div className="flex-1 overflow-hidden rounded-md border border-[var(--border)]">
<CopilotChat
agentId="agent-config-demo"
className="h-full rounded-md"
/>
</div>
</div>
);
}

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