chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
**/node_modules
|
||||
.next
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
**/vitest.config.ts
|
||||
**/test-setup.ts
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
.env
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.venv/
|
||||
dist/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Shared modules (copied by CI)
|
||||
shared_frontend/
|
||||
@@ -0,0 +1,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
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+425
@@ -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;
|
||||
+13211
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
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+52
@@ -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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
+65
@@ -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";
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Separator primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
|
||||
*/
|
||||
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
className = "",
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorProps) {
|
||||
const orientationClasses =
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Fixed A2UI catalog — wires definitions to renderers.
|
||||
*
|
||||
* `includeBasicCatalog: true` merges CopilotKit's built-in components
|
||||
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
|
||||
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
|
||||
* compose custom and basic components interchangeably.
|
||||
*/
|
||||
// @region[catalog-creation]
|
||||
import { createCatalog } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import { definitions } from "./definitions";
|
||||
import { renderers } from "./renderers";
|
||||
|
||||
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
|
||||
|
||||
export const catalog = createCatalog(definitions, renderers, {
|
||||
catalogId: CATALOG_ID,
|
||||
includeBasicCatalog: true,
|
||||
});
|
||||
// @endregion[catalog-creation]
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* A2UI catalog DEFINITIONS — platform-agnostic.
|
||||
*
|
||||
* Each entry declares a component name + its Zod props schema. The basic
|
||||
* catalog (Card, Column, Row, Text, Button, …) ships with CopilotKit and
|
||||
* is mixed in via `createCatalog(..., { includeBasicCatalog: true })`, so
|
||||
* we only declare the project-specific additions and the visual overrides
|
||||
* here. (Custom entries with the same name as a basic component override
|
||||
* the basic one — Catalog dedupes by `comp.name`, last-write-wins.)
|
||||
*
|
||||
* IMPORTANT — path bindings: fields that can be bound to a data-model path
|
||||
* (e.g. `{ path: "/origin" }` in the fixed schema JSON) must declare their
|
||||
* Zod type as a union of `z.string()` and `z.object({ path: z.string() })`.
|
||||
* The A2UI `GenericBinder` uses this union to detect the field as dynamic
|
||||
* and resolve the path against the current data model at render time. Using
|
||||
* plain `z.string()` causes the raw `{ path }` object to reach the
|
||||
* renderer, which React then throws on (error #31 "object with keys {path}").
|
||||
* This matches the canonical catalog's `DynString` helper:
|
||||
* examples/integrations/langgraph-python/src/app/declarative-generative-ui/definitions.ts
|
||||
*/
|
||||
// @region[definitions-types]
|
||||
import { z } from "zod";
|
||||
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
/**
|
||||
* Dynamic string: literal OR a data-model path binding. The GenericBinder
|
||||
* resolves path bindings to the actual value at render time.
|
||||
*/
|
||||
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
|
||||
|
||||
export const definitions = {
|
||||
/**
|
||||
* Card override: gives the outer flight-card container a ShadCN look
|
||||
* (rounded-xl, neutral-200 border, soft shadow). The basic catalog's
|
||||
* Card uses inline styles; overriding here lets the demo's renderer
|
||||
* adopt the demo's Tailwind aesthetic without touching the schema JSON.
|
||||
*/
|
||||
Card: {
|
||||
description: "A container card with a single child.",
|
||||
props: z.object({
|
||||
child: z.string(),
|
||||
}),
|
||||
},
|
||||
Title: {
|
||||
description: "A prominent heading for the flight card.",
|
||||
props: z.object({
|
||||
text: DynString,
|
||||
}),
|
||||
},
|
||||
Airport: {
|
||||
description: "A 3-letter airport code, displayed large.",
|
||||
props: z.object({
|
||||
code: DynString,
|
||||
}),
|
||||
},
|
||||
Arrow: {
|
||||
description: "A right-pointing arrow used between airports.",
|
||||
props: z.object({}),
|
||||
},
|
||||
AirlineBadge: {
|
||||
description: "A pill-styled airline name tag.",
|
||||
props: z.object({
|
||||
name: DynString,
|
||||
}),
|
||||
},
|
||||
PriceTag: {
|
||||
description: "A stylized price display (e.g. '$289').",
|
||||
props: z.object({
|
||||
amount: DynString,
|
||||
}),
|
||||
},
|
||||
/**
|
||||
* Button override: swaps in an ActionButton renderer that tracks
|
||||
* its own `done` state so clicking "Book flight" visually updates to
|
||||
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
|
||||
* so without this override the click fires the action but the button
|
||||
* looks unchanged. Mirrors the pattern in beautiful-chat
|
||||
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
|
||||
*/
|
||||
Button: {
|
||||
description:
|
||||
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
|
||||
props: z.object({
|
||||
child: z
|
||||
.string()
|
||||
.describe(
|
||||
"The ID of the child component (e.g. a Text component for the label).",
|
||||
),
|
||||
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
|
||||
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
|
||||
action: z
|
||||
.union([
|
||||
z.object({
|
||||
event: z.object({
|
||||
name: z.string(),
|
||||
context: z.record(z.any()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
} satisfies CatalogDefinitions;
|
||||
// @endregion[definitions-types]
|
||||
|
||||
export type Definitions = typeof definitions;
|
||||
@@ -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
Reference in New Issue
Block a user