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,8 @@
|
||||
# Agno uses OpenAI as the model provider
|
||||
OPENAI_API_KEY=replace-with-your-key
|
||||
|
||||
# Agent backend URL (for the CopilotKit runtime proxy)
|
||||
AGENT_URL=http://localhost:8000
|
||||
|
||||
# Showcase
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
.env
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.venv/
|
||||
dist/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Shared modules (copied by CI)
|
||||
shared_frontend/
|
||||
@@ -0,0 +1,81 @@
|
||||
# Stage 1: Build Next.js frontend
|
||||
FROM node:22-slim AS frontend
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Python venv with agent deps
|
||||
#
|
||||
# True multi-stage split — the builder carries full `python:3.12` (compilers,
|
||||
# build-essential, dev headers needed to compile wheels that don't ship
|
||||
# pre-built for ``-slim``) and produces a self-contained ``/opt/venv`` that
|
||||
# the runner COPYs in as a single opaque tree. The runner never runs
|
||||
# ``pip install`` itself, so no compiler toolchain bloats the final image.
|
||||
FROM python:3.12.13 AS agent-builder
|
||||
WORKDIR /agent
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
COPY requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Stage 3: Production image with Node.js + Python (runtime only — no pip,
|
||||
# no build tools). Node.js is installed via NodeSource because the package
|
||||
# runs BOTH Next.js (frontend) and the Python agent inside this single image;
|
||||
# entrypoint.sh orchestrates both processes.
|
||||
FROM python:3.12.13-slim AS runner
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create unprivileged runtime user BEFORE any COPY so --chown resolves
|
||||
# by name and so recursive chown over /app is never needed (fast builds).
|
||||
# Mirrors the starter Dockerfile pattern for parity — Railway / any
|
||||
# platform that enforces non-root by policy needs this from the package
|
||||
# image too, not just the generated starter.
|
||||
RUN (groupadd --system --gid 1001 app 2>/dev/null || true) \
|
||||
&& (useradd --system --uid 1001 --gid 1001 --no-create-home app 2>/dev/null || true) \
|
||||
&& mkdir -p /home/app && chown app:app /home/app
|
||||
|
||||
# Python venv (prebuilt in agent-builder stage — no pip in the runner).
|
||||
COPY --chown=app:app --from=agent-builder /opt/venv /opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
|
||||
# Next.js build artifacts
|
||||
COPY --chown=app:app --from=frontend /app/.next ./.next
|
||||
COPY --chown=app:app --from=frontend /app/node_modules ./node_modules
|
||||
COPY --chown=app:app --from=frontend /app/package.json ./
|
||||
COPY --chown=app:app --from=frontend /app/public ./public
|
||||
|
||||
# Agent code
|
||||
COPY --chown=app:app src/agent_server.py ./
|
||||
COPY --chown=app:app src/agents/ ./agents/
|
||||
|
||||
# Shared Python tools (symlinked locally, copied into build context by CI)
|
||||
COPY --chown=app:app tools/ /app/tools/
|
||||
|
||||
# Shared CVDIAG bootstrap module (single source: showcase/integrations/_shared;
|
||||
# symlinked in source, dereferenced into this build context by the harness
|
||||
# stage_shared step). Lands at /app/_shared; /app is already on PYTHONPATH.
|
||||
COPY --chown=app:app _shared/ ./_shared/
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# Entrypoint
|
||||
COPY --chown=app:app entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 10000
|
||||
# Intentionally NOT setting `ENV NODE_ENV=production` at the image level.
|
||||
# NODE_ENV=production at the image level would leak into every child process
|
||||
# (Python agent, shell scripts, healthchecks) — most of which don't use it
|
||||
# the way Next.js does. entrypoint.sh scopes NODE_ENV=production to the
|
||||
# Next.js invocation only so non-Next children see the host's environment.
|
||||
CMD ["./entrypoint.sh"]
|
||||
@@ -0,0 +1,189 @@
|
||||
# Agno — Parity Notes
|
||||
|
||||
Tracking notes for feature-matrix parity between this package and
|
||||
`showcase/integrations/langgraph-python/` (canonical reference).
|
||||
|
||||
## Ported
|
||||
|
||||
See `manifest.yaml` for the authoritative list.
|
||||
|
||||
### Initial parity push
|
||||
|
||||
- `prebuilt-sidebar`, `prebuilt-popup` — chrome demos using the shared main agent
|
||||
- `chat-slots`, `chat-customization-css` — chat customization paths
|
||||
- `headless-simple` — minimal useAgent surface
|
||||
- `frontend-tools`, `frontend-tools-async` — useFrontendTool (sync + async handlers)
|
||||
- `readonly-state-agent-context` — useAgentContext read-only context
|
||||
- `tool-rendering-default-catchall`, `tool-rendering-custom-catchall` — wildcard-only tool rendering variants (new `get_stock_price` + `roll_dice` tools added to the Agno main agent)
|
||||
- `hitl-in-chat` booking flow — useHumanInTheLoop with a new `book_call` external-execution tool
|
||||
- `hitl-in-app` — frontend-tool + app-level approval dialog (frontend-only)
|
||||
|
||||
### Second pass (deferred-demo recovery)
|
||||
|
||||
- `agentic-chat-reasoning`, `reasoning-default-render`,
|
||||
`tool-rendering-reasoning-chain` — reasoning family. Verified Agno's AGUI
|
||||
interface emits `REASONING_MESSAGE_*` events (`agno/os/interfaces/agui/utils.py`
|
||||
imports `ReasoningMessageStartEvent` / `ReasoningMessageContentEvent` /
|
||||
`ReasoningMessageEndEvent`). Added a new `reasoning_agent` Python module
|
||||
with `reasoning=True` plus a second `AGUI` interface mounted at prefix
|
||||
`/reasoning`. The Next.js runtime aliases the three reasoning agent names to
|
||||
an `HttpAgent` targeting `/reasoning/agui`.
|
||||
- `headless-complete` — full chat from scratch on `useAgent` +
|
||||
`CopilotChatConfigurationProvider` + manual `useRenderToolCall` /
|
||||
`useRenderActivityMessage` / `useRenderCustomMessages` composition. Reuses
|
||||
the Agno main agent via the default `/api/copilotkit` endpoint. MCP-Apps
|
||||
activity surface is intentionally omitted — Agno's AGUI adapter doesn't
|
||||
expose an MCP-Apps runtime. Every other generative-UI branch (per-tool
|
||||
renderers, `useComponent` frontend tools, reasoning, custom messages,
|
||||
wildcard catch-all) is wired in.
|
||||
- `auth` — dedicated `/api/copilotkit-auth` runtime using
|
||||
`createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` with an
|
||||
`onRequest` hook that rejects requests lacking a static Bearer token.
|
||||
Authenticated target is the Agno main agent at `/agui`.
|
||||
|
||||
### Fourth pass (manifest fill — first half)
|
||||
|
||||
- `cli-start` — informational demo entry (no route/agent) advertising the
|
||||
copy-paste starter command for Agno. Mirrors the canonical
|
||||
`langgraph-python` cli-start cell.
|
||||
- `gen-ui-tool-based` — already shipped as a haiku-renderer demo (frontend-only
|
||||
via `useFrontendTool` + `render`); now declared in `manifest.yaml` so the
|
||||
showcase picks it up. Wired to the main agent under the `gen-ui-tool-based`
|
||||
alias in `src/app/api/copilotkit/route.ts`.
|
||||
- `hitl-in-chat-booking` — manifest entry pointing at the existing
|
||||
`hitl-in-chat` time-picker booking surface (same files, distinct cell). The
|
||||
Agno main agent's `book_call` external-execution tool already drives this
|
||||
flow.
|
||||
|
||||
### Fifth pass (manifest fill — second half)
|
||||
|
||||
- `mcp-apps` — runtime `mcpApps.servers` middleware against the public
|
||||
Excalidraw MCP server. Backed by a no-tools Agno agent
|
||||
(`mcp_apps_agent.py`) mounted at `/mcp-apps/agui` so the LLM only sees the
|
||||
MCP-injected toolset.
|
||||
- `open-gen-ui` / `open-gen-ui-advanced` — shared dedicated runtime
|
||||
(`/api/copilotkit-ogui`) with the `openGenerativeUI` flag that wires the
|
||||
middleware. Backed by a no-tools Agno agent (`open_gen_ui_agent.py`)
|
||||
mounted at `/open-gen-ui/agui`. Advanced cell adds host-side sandbox
|
||||
functions (`evaluateExpression`, `notifyHost`) on the provider.
|
||||
- `agent-config` — typed config object (tone/expertise/responseLength)
|
||||
forwarded via the provider's `properties` prop. The Agno backend mounts a
|
||||
custom AGUI handler at `/agent-config/agui` (`agent_server.py::
|
||||
_run_agent_config`) that reads `RunAgentInput.forwarded_props` and builds
|
||||
a per-request Agno Agent from `agents.agent_config_agent.build_agent(...)`
|
||||
before delegating to the stock AGUI stream mapper. Agno has no
|
||||
LangGraph-style configurable channel, so the per-request factory is the
|
||||
cleanest path to dynamic system prompts here.
|
||||
- `voice` — V2 runtime under `/api/copilotkit-voice` with a guarded
|
||||
`TranscriptionServiceOpenAI`. Targets the Agno main agent at `/agui` for
|
||||
the chat side; transcription is purely runtime-side.
|
||||
- `multimodal` — vision-capable Agno agent (`multimodal_agent.py`, gpt-4o)
|
||||
on its own `/multimodal/agui` interface, scoped via
|
||||
`/api/copilotkit-multimodal`. Image attachments forward natively; PDF
|
||||
flattening helper (`_maybe_flatten_pdf_part`) lives next to the agent for
|
||||
use if the AGUI converter needs assistance.
|
||||
- `byoc-hashbrown` — dedicated `/api/copilotkit-byoc-hashbrown` runtime +
|
||||
`byoc_hashbrown_agent.py` whose system prompt steers the LLM toward the
|
||||
hashbrown UI-kit envelope shape (`{ "ui": [...] }`).
|
||||
- `byoc-json-render` — dedicated `/api/copilotkit-byoc-json-render` runtime
|
||||
- `byoc_json_render_agent.py` whose system prompt steers the LLM toward
|
||||
the json-render flat element-tree spec (`{ root, elements }`).
|
||||
|
||||
### Sixth pass (A2UI fixed schema)
|
||||
|
||||
- `a2ui-fixed-schema` — dedicated `/api/copilotkit-a2ui-fixed-schema`
|
||||
runtime with `injectA2UITool: false`. New `a2ui_fixed_agent.py`
|
||||
mounted at `/a2ui-fixed-schema/agui` ships
|
||||
`flight_schema.json` + `booked_schema.json` and a single
|
||||
`display_flight` tool that emits an `a2ui_operations` container
|
||||
_directly_ — no secondary LLM call — so the LLM only fills in data
|
||||
(origin/destination/airline/price). `booked_schema.json` is shipped
|
||||
as a sibling for when the SDK exposes per-button action handlers
|
||||
for fixed-schema surfaces.
|
||||
|
||||
### Seventh pass (A2UI dynamic schema)
|
||||
|
||||
- `declarative-gen-ui` — dedicated `/api/copilotkit-declarative-gen-ui`
|
||||
runtime with `injectA2UITool: false`. New `a2ui_dynamic_agent.py`
|
||||
mounted at `/declarative-gen-ui/agui` owns its own `generate_a2ui`
|
||||
tool. Unlike the main agent's hardcoded-catalog `generate_a2ui`, this
|
||||
agent's tool reads the registered client catalog from
|
||||
`run_context.session_state["copilotkit"]["context"]` and feeds it to
|
||||
the secondary OpenAI client, so the rendered components stay in sync
|
||||
with whatever catalog the frontend registers via `<CopilotKit
|
||||
a2ui={{ catalog }}>`. See `src/app/demos/declarative-gen-ui/README.md`
|
||||
for the differences vs. the main-agent path.
|
||||
|
||||
### Third pass (state + multi-agent recovery)
|
||||
|
||||
- `shared-state-read-write` — bidirectional shared state with the UI
|
||||
writing `preferences` via `agent.setState(...)` and the agno agent
|
||||
writing `notes` back via a `set_notes` tool that mutates
|
||||
`run_context.session_state["notes"]`. Backed by a new
|
||||
`shared_state_read_write` agent module + a custom AGUI handler in
|
||||
`agent_server.py` mounted at `/shared-state-rw/agui`. The custom
|
||||
handler is a thin shim around `agno.os.interfaces.agui.utils`'s
|
||||
stream mapper that additionally emits a `StateSnapshotEvent` with the
|
||||
final `session_state` immediately before `RunFinishedEvent` — Agno's
|
||||
stock AGUI router does not emit state events, so without this shim
|
||||
agent-side state writes are invisible to a frontend subscribed via
|
||||
`useAgent({ updates: [OnStateChanged] })`.
|
||||
- `subagents` — supervisor agno agent delegating to three specialized
|
||||
sub-agents (research / writing / critique). Each sub-agent is itself
|
||||
an Agno `Agent(...)` with its own system prompt, invoked via the
|
||||
delegation tools' `_invoke_sub_agent` helper. Every delegation
|
||||
appends an entry to `session_state["delegations"]` (with `running`
|
||||
status pre-flight, then flipped to `completed`/`failed` post-flight).
|
||||
Reuses the same `/subagents/agui` state-aware AGUI handler so the
|
||||
delegation log re-renders live.
|
||||
|
||||
## Skipped
|
||||
|
||||
The following demos from the canonical LangGraph-Python reference are intentionally
|
||||
NOT ported to this package. Each has a concrete reason tied to a genuine framework
|
||||
capability difference or infrastructure requirement that we couldn't validate in
|
||||
this blitz pass.
|
||||
|
||||
### LangGraph-specific primitives (no direct Agno equivalent)
|
||||
|
||||
- `gen-ui-interrupt` — Uses LangGraph's `interrupt()` primitive to pause the graph
|
||||
mid-run and resolve from the UI. Agno's AgentOS AGUI adapter does not expose an
|
||||
equivalent long-running-resume primitive at this time. We already ship
|
||||
`hitl-in-chat-booking` + `hitl-in-app`, which cover the user-facing HITL scenario
|
||||
via Agno's native tool-approval path.
|
||||
- `interrupt-headless` — Same root cause as `gen-ui-interrupt`. Headless resume
|
||||
from a button grid requires a pause/resume handle the Agno AGUI adapter does
|
||||
not currently surface.
|
||||
|
||||
### Require dedicated runtimes we haven't wired yet
|
||||
|
||||
These demos depend on dedicated `/api/copilotkit-<variant>/route.ts` runtimes in
|
||||
the canonical reference. They are portable in principle — they just need new
|
||||
route files each and supporting Python wiring — but doing them right requires
|
||||
exercising Agno's runtime config paths we haven't validated yet. Deferred for a
|
||||
follow-up parity pass rather than faked in.
|
||||
|
||||
- `beautiful-chat` — combined runtime (openGenerativeUI + a2ui + mcpApps)
|
||||
- `byoc-hashbrown` — dedicated `/api/copilotkit-byoc-hashbrown` runtime, requires
|
||||
Agno structured-output streaming matching the hashbrown Zod catalog
|
||||
- `byoc-json-render` — dedicated `/api/copilotkit-byoc-json-render` runtime,
|
||||
requires streaming JSON-schema-constrained output from Agno
|
||||
- `multimodal` — dedicated `/api/copilotkit-multimodal` runtime; Agno supports
|
||||
multimodal input via `UserMessage(images=[...])` but wiring vision to an
|
||||
AGUI-served agent needs a runtime surface we haven't built
|
||||
- `voice` — dedicated `/api/copilotkit-voice` runtime + `@copilotkit/voice`;
|
||||
voice STT is a frontend concern independent of the Agno agent
|
||||
- `open-gen-ui`, `open-gen-ui-advanced` — dedicated `/api/copilotkit-ogui`
|
||||
runtime; requires openGenerativeUI middleware on a V2 runtime talking to
|
||||
an Agno agent
|
||||
- `agent-config` — dedicated `/api/copilotkit-agent-config` runtime with typed
|
||||
config forwarding; needs Agno dynamic-system-prompt wiring per-request
|
||||
- `mcp-apps` — requires Agno MCP client/server wiring; Agno has
|
||||
`agno.tools.mcp.MCPTools` but integration with the AGUI adapter's
|
||||
activity-message surface wasn't verified
|
||||
|
||||
### Not a real demo
|
||||
|
||||
- `cli-start` — Copy-paste starter command rendered by the dashboard as a
|
||||
command card with no route/agent. The equivalent starter for Agno is already
|
||||
advertised via `manifest.yaml`'s `starter:` section.
|
||||
+1
@@ -0,0 +1 @@
|
||||
../_shared
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"framework": "agno",
|
||||
"features": {
|
||||
"agentic-chat": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/agno/frontend-tools",
|
||||
"shell_docs_path": "/frontend-tools"
|
||||
},
|
||||
"hitl-in-chat": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/agno/human-in-the-loop",
|
||||
"shell_docs_path": "/generative-ui/your-components/interactive"
|
||||
},
|
||||
"tool-rendering": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/agno/generative-ui/tool-rendering",
|
||||
"shell_docs_path": "/generative-ui/tool-rendering"
|
||||
},
|
||||
"gen-ui-tool-based": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/agno/generative-ui/your-components/display-only",
|
||||
"shell_docs_path": "/generative-ui/your-components/display-only"
|
||||
},
|
||||
"gen-ui-agent": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/generative-ui/state-rendering",
|
||||
"shell_docs_path": "/generative-ui/state-rendering"
|
||||
},
|
||||
"shared-state-read-write": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/shared-state",
|
||||
"shell_docs_path": "/shared-state"
|
||||
},
|
||||
"shared-state-streaming": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/shared-state",
|
||||
"shell_docs_path": "/shared-state"
|
||||
},
|
||||
"subagents": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/agno",
|
||||
"shell_docs_path": "/multi-agent/subagents"
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cleanup() {
|
||||
kill $AGENT_PID $NEXTJS_PID $WATCHDOG_PID 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Disable Python stdout buffering so the FastAPI/uvicorn agent flushes
|
||||
# tracebacks and log lines immediately. Without this a silent crash during
|
||||
# module import can sit in Python's userspace buffer until the process
|
||||
# exits, by which point the container is already gone.
|
||||
export PYTHONUNBUFFERED=1
|
||||
|
||||
echo "========================================="
|
||||
echo "[entrypoint] Starting showcase package: agno"
|
||||
echo "[entrypoint] Time: $(date -u)"
|
||||
echo "[entrypoint] PORT=${PORT:-not set}"
|
||||
echo "[entrypoint] NODE_ENV=${NODE_ENV:-not set}"
|
||||
echo "========================================="
|
||||
|
||||
if [ -z "$OPENAI_API_KEY" ]; then
|
||||
echo "[entrypoint] WARNING: OPENAI_API_KEY is not set! Agent will fail."
|
||||
else
|
||||
echo "[entrypoint] OPENAI_API_KEY: set (${#OPENAI_API_KEY} chars)"
|
||||
fi
|
||||
|
||||
# Start agent backend on :8000 with log prefixing so its output is
|
||||
# distinguishable from Next.js in the Railway log stream.
|
||||
#
|
||||
# Belt-and-suspenders log flushing: `PYTHONUNBUFFERED=1` above exports the env
|
||||
# var, but a child process could in principle un-export or override it. The
|
||||
# `-u` flag to the Python interpreter forces unbuffered stdout/stderr at the
|
||||
# interpreter level and is not overridable by user code. Combined with the
|
||||
# `fflush()` inside the awk pipe below, this guarantees uvicorn request lines
|
||||
# and tracebacks reach Railway's log stream line-at-a-time rather than
|
||||
# block-buffered in pipe buffers.
|
||||
echo "[entrypoint] Starting Python agent on port 8000..."
|
||||
# --loop asyncio pins uvicorn to the stdlib asyncio event loop instead of its
|
||||
# default `auto`, which would select uvloop (pulled in by uvicorn[standard]).
|
||||
# This is load-bearing: the secondary OpenAI call inside the sync `generate_a2ui`
|
||||
# tool runs on a `loop.run_in_executor(...)` worker thread, and the
|
||||
# install_executor_contextvar_propagation() shim only propagates the
|
||||
# forwarded-header ContextVar into that thread under a stdlib BaseEventLoop —
|
||||
# it is inert under uvloop, so under uvloop the secondary call drops
|
||||
# x-aimock-context and aimock returns 503. This is the prod launch path
|
||||
# (agent_server.main()'s agent_os.serve(loop="asyncio") is not used here).
|
||||
python -u -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 --loop asyncio &> >(awk '{print "[agent] " $0; fflush()}') &
|
||||
AGENT_PID=$!
|
||||
sleep 2
|
||||
if kill -0 $AGENT_PID 2>/dev/null; then
|
||||
echo "[entrypoint] Agent started (PID: $AGENT_PID)"
|
||||
else
|
||||
echo "[entrypoint] ERROR: Agent failed to start — exiting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "========================================="
|
||||
echo "[entrypoint] Starting Next.js frontend on port ${PORT:-10000}..."
|
||||
echo "========================================="
|
||||
|
||||
PORT=${PORT:-10000}
|
||||
# Scope NODE_ENV=production to the Next.js invocation ONLY, not the whole
|
||||
# container environment. `ENV NODE_ENV=production` at the image level would
|
||||
# leak into every child process (Python agent, shell, healthchecks). `env`
|
||||
# prefix binds the value to this single exec.
|
||||
env NODE_ENV=production npx next start --port $PORT &> >(awk '{print "[nextjs] " $0; fflush()}') &
|
||||
NEXTJS_PID=$!
|
||||
|
||||
echo "[entrypoint] Next.js started (PID: $NEXTJS_PID)"
|
||||
|
||||
# Watchdog: Railway deploys of showcase packages have been observed to hit a
|
||||
# silent agent hang — the Python process stays alive (so `wait -n` never
|
||||
# fires and the container never restarts) but stops responding on :8000.
|
||||
# Poll the agent's /health endpoint every 30s; after 3 consecutive failures
|
||||
# (90s of unreachable agent), kill the agent process so `wait -n` returns
|
||||
# and Railway restarts the container. We kill the agent (not the whole
|
||||
# script) first so `set -e` + `wait -n; exit $?` handles the restart
|
||||
# through the normal path rather than a forced `exit` that would bypass
|
||||
# logging. Generalized from showcase/integrations/crewai-crews/entrypoint.sh
|
||||
# (PRs #4114 + #4115).
|
||||
(
|
||||
FAILS=0
|
||||
while sleep 30; do
|
||||
if ! kill -0 $AGENT_PID 2>/dev/null; then
|
||||
# Agent already dead — wait -n in the main shell will handle it.
|
||||
break
|
||||
fi
|
||||
if curl -fsS --max-time 5 http://127.0.0.1:8000/health > /dev/null 2>&1; then
|
||||
FAILS=0
|
||||
else
|
||||
FAILS=$((FAILS + 1))
|
||||
echo "[watchdog] Agent health probe failed (count=$FAILS)"
|
||||
if [ $FAILS -ge 3 ]; then
|
||||
echo "[watchdog] Agent unresponsive for ~90s — killing PID $AGENT_PID to trigger container restart"
|
||||
kill -9 $AGENT_PID 2>/dev/null || true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
) &
|
||||
WATCHDOG_PID=$!
|
||||
|
||||
echo "[entrypoint] Watchdog started (PID: $WATCHDOG_PID)"
|
||||
echo "[entrypoint] All processes running. Waiting..."
|
||||
|
||||
wait -n $AGENT_PID $NEXTJS_PID
|
||||
EXIT_CODE=$?
|
||||
if ! kill -0 $AGENT_PID 2>/dev/null; then
|
||||
echo "[entrypoint] Agent (PID: $AGENT_PID) exited with code $EXIT_CODE"
|
||||
elif ! kill -0 $NEXTJS_PID 2>/dev/null; then
|
||||
echo "[entrypoint] Next.js (PID: $NEXTJS_PID) exited with code $EXIT_CODE"
|
||||
else
|
||||
echo "[entrypoint] A process exited with code $EXIT_CODE"
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
@@ -0,0 +1,579 @@
|
||||
name: Agno
|
||||
slug: agno
|
||||
category: emerging
|
||||
language: python
|
||||
logo: /logos/agno.svg
|
||||
description: >-
|
||||
CopilotKit integration with Agno. Supports the full range of CopilotKit
|
||||
features including generative UI, shared state, human-in-the-loop, sub-agents,
|
||||
and streaming.
|
||||
partner_docs: null
|
||||
repo: https://github.com/CopilotKit/CopilotKit/tree/main/showcase/integrations/agno
|
||||
copilotkit_version: 2.0.0
|
||||
deployed: true
|
||||
docs_mode: authored
|
||||
sort_order: 90
|
||||
generative_ui:
|
||||
- constrained-explicit
|
||||
- a2ui-fixed-schema
|
||||
- a2ui-dynamic-schema
|
||||
interaction_modalities:
|
||||
- sidebar
|
||||
- embedded
|
||||
- chat
|
||||
features:
|
||||
- cli-start
|
||||
- agentic-chat
|
||||
- tool-rendering
|
||||
- gen-ui-agent
|
||||
- gen-ui-tool-based
|
||||
- shared-state-read-write
|
||||
- subagents
|
||||
- prebuilt-sidebar
|
||||
- prebuilt-popup
|
||||
- chat-slots
|
||||
- chat-customization-css
|
||||
- headless-simple
|
||||
- frontend-tools
|
||||
- frontend-tools-async
|
||||
- hitl
|
||||
- hitl-in-chat
|
||||
- hitl-in-chat-booking
|
||||
- hitl-in-app
|
||||
- readonly-state-agent-context
|
||||
- mcp-apps
|
||||
- open-gen-ui
|
||||
- open-gen-ui-advanced
|
||||
- agent-config
|
||||
- voice
|
||||
- multimodal
|
||||
- byoc-hashbrown
|
||||
- byoc-json-render
|
||||
- tool-rendering-default-catchall
|
||||
- tool-rendering-custom-catchall
|
||||
- reasoning-custom
|
||||
- reasoning-default
|
||||
- headless-complete
|
||||
- auth
|
||||
- beautiful-chat
|
||||
- a2ui-fixed-schema
|
||||
- declarative-gen-ui
|
||||
- shared-state-read
|
||||
not_supported_features:
|
||||
- shared-state-streaming
|
||||
- gen-ui-interrupt
|
||||
- interrupt-headless
|
||||
- reasoning-default-render
|
||||
- agentic-chat-reasoning
|
||||
- tool-rendering-reasoning-chain
|
||||
agent_config_pattern: shared-state
|
||||
|
||||
a2ui_pattern: schema-loading
|
||||
auth_pattern: runtime-onrequest
|
||||
demos:
|
||||
- id: cli-start
|
||||
name: CLI Start Command
|
||||
description: Copy-paste command to clone the canonical starter
|
||||
tags:
|
||||
- chat-ui
|
||||
command: "npx copilotkit@latest init --framework agno"
|
||||
- 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/main.py
|
||||
- src/app/demos/agentic-chat/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering
|
||||
name: Tool Rendering
|
||||
description: Backend agent tools rendered as UI components
|
||||
tags:
|
||||
- agent-capabilities
|
||||
route: /demos/tool-rendering
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.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-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/main.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/main.py
|
||||
- src/app/demos/shared-state-streaming/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: shared-state-read-write
|
||||
name: Shared State (Read + Write)
|
||||
description: Bidirectional agent state — UI writes preferences, agent writes notes back
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/shared-state-read-write
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/shared_state_read_write.py
|
||||
- src/agent_server.py
|
||||
- src/app/demos/shared-state-read-write/page.tsx
|
||||
- src/app/demos/shared-state-read-write/preferences-card.tsx
|
||||
- src/app/demos/shared-state-read-write/notes-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: subagents
|
||||
name: Sub-Agents
|
||||
description: Multiple agents with visible task delegation
|
||||
tags:
|
||||
- multi-agent
|
||||
route: /demos/subagents
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/subagents.py
|
||||
- src/agent_server.py
|
||||
- src/app/demos/subagents/page.tsx
|
||||
- src/app/demos/subagents/delegation-log.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: prebuilt-sidebar
|
||||
name: "Pre-Built: Sidebar"
|
||||
description: Docked sidebar chat via <CopilotSidebar />
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/prebuilt-sidebar
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/prebuilt-sidebar/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: prebuilt-popup
|
||||
name: "Pre-Built: Popup"
|
||||
description: Floating popup chat via <CopilotPopup />
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/prebuilt-popup
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/prebuilt-popup/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: chat-slots
|
||||
name: Chat Customization (Slots)
|
||||
description: Customize CopilotChat via its slot system
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/chat-slots
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/chat-slots/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- src/app/demos/chat-slots/slot-wrappers.tsx
|
||||
- id: chat-customization-css
|
||||
name: Chat Customization (CSS)
|
||||
description: Default CopilotChat re-themed via CopilotKit CSS variables
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/chat-customization-css
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/chat-customization-css/page.tsx
|
||||
- src/app/demos/chat-customization-css/theme.css
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: headless-simple
|
||||
name: Headless Chat (Simple)
|
||||
description: Minimal custom chat surface built on useAgent
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/headless-simple
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/headless-simple/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/app/demos/hitl/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: hitl-in-chat
|
||||
name: In-Chat Human in the Loop (Time Picker)
|
||||
description: Agent proposes a meeting time; user confirms or adjusts via an
|
||||
in-chat card before the agent proceeds
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/hitl-in-chat/page.tsx
|
||||
- src/app/demos/hitl-in-chat/time-picker-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: hitl-in-app
|
||||
name: In-App Human in the Loop (Frontend Tools + async HITL)
|
||||
description:
|
||||
Agent requests approval via useFrontendTool with an async handler;
|
||||
the approval UI pops up as an app-level modal OUTSIDE the chat
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-app
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/hitl-in-app/page.tsx
|
||||
- src/app/demos/hitl-in-app/approval-dialog.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: frontend-tools
|
||||
name: Frontend Tools (In-App Actions)
|
||||
description: Agent invokes client-side handlers registered with useFrontendTool
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/frontend-tools
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/frontend-tools/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: readonly-state-agent-context
|
||||
name: Readonly State (Agent Context)
|
||||
description: Frontend provides read-only context to the agent via useAgentContext
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/readonly-state-agent-context
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/readonly-state-agent-context/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering-default-catchall
|
||||
name: Tool Rendering (Default Catch-all)
|
||||
description: Out-of-the-box default tool-call card via useDefaultRenderTool()
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/tool-rendering-default-catchall
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/tool-rendering-default-catchall/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering-custom-catchall
|
||||
name: Tool Rendering (Custom Catch-all)
|
||||
description: Single branded wildcard renderer via useDefaultRenderTool
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/tool-rendering-custom-catchall
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/tool-rendering-custom-catchall/page.tsx
|
||||
- src/app/demos/tool-rendering-custom-catchall/custom-catchall-renderer.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: frontend-tools-async
|
||||
name: Frontend Tools (Async)
|
||||
description:
|
||||
useFrontendTool with an async handler — agent awaits a client-side
|
||||
notes-DB query and uses the returned result
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/frontend-tools-async
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/frontend-tools-async/page.tsx
|
||||
- src/app/demos/frontend-tools-async/notes-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: reasoning-custom
|
||||
name: Agentic Chat (Reasoning)
|
||||
description: Visible reasoning / thinking chain rendered via a custom
|
||||
reasoningMessage slot on the reasoning-enabled Agno agent
|
||||
tags:
|
||||
- chat-ui
|
||||
- reasoning
|
||||
route: /demos/reasoning-custom
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/reasoning_agent.py
|
||||
- src/app/demos/reasoning-custom/page.tsx
|
||||
- src/app/demos/reasoning-custom/reasoning-block.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: reasoning-default
|
||||
name: Reasoning (Default Render)
|
||||
description: Zero-config rendering of the agent's reasoning chain via
|
||||
CopilotKit's built-in reasoning message
|
||||
tags:
|
||||
- chat-ui
|
||||
- reasoning
|
||||
route: /demos/reasoning-default
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/reasoning_agent.py
|
||||
- src/app/demos/reasoning-default/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering-reasoning-chain
|
||||
name: Tool Rendering (Reasoning Chain)
|
||||
description: Reasoning card + sequential tool-call renderers (per-tool +
|
||||
catch-all) on the same reasoning-enabled Agno agent
|
||||
tags:
|
||||
- agent-capabilities
|
||||
- reasoning
|
||||
route: /demos/tool-rendering-reasoning-chain
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/reasoning_agent.py
|
||||
- src/app/demos/tool-rendering-reasoning-chain/page.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/reasoning-block.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/weather-card.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/flight-list-card.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/custom-catchall-renderer.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: headless-complete
|
||||
name: Headless Chat (Complete)
|
||||
description:
|
||||
Full chat built from scratch on useAgent — custom bubbles, input
|
||||
bar, and manual generative-UI composition via useRenderToolCall
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/headless-complete
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/headless-complete/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- src/app/demos/headless-complete/chat/chat.tsx
|
||||
- src/app/demos/headless-complete/hooks/use-tool-renderers.tsx
|
||||
- src/app/demos/headless-complete/hooks/use-frontend-components.ts
|
||||
- src/app/demos/headless-complete/hooks/use-headless-suggestions.ts
|
||||
- src/app/demos/headless-complete/attachments/use-attachments-config.ts
|
||||
- src/app/demos/headless-complete/tools/weather-card.tsx
|
||||
- src/app/demos/headless-complete/tools/stock-card.tsx
|
||||
- src/app/demos/headless-complete/tools/chart-card.tsx
|
||||
- src/app/demos/headless-complete/tools/highlight-note.tsx
|
||||
- id: gen-ui-interrupt
|
||||
name: Gen UI Interrupt (Frontend Tool + async Promise)
|
||||
description:
|
||||
In-chat time-picker card via useFrontendTool with an async handler
|
||||
that blocks until the user picks a slot — Agno adaptation of the LangGraph
|
||||
interrupt() primitive
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/gen-ui-interrupt
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/interrupt_agent.py
|
||||
- src/app/demos/gen-ui-interrupt/page.tsx
|
||||
- src/app/demos/gen-ui-interrupt/_components/time-picker-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: interrupt-headless
|
||||
name: Headless Interrupt (Frontend Tool + async Promise)
|
||||
description:
|
||||
Time-picker popup rendered outside the chat in the app surface via
|
||||
useFrontendTool with an async handler — Agno adaptation of the LangGraph
|
||||
headless interrupt pattern
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/interrupt-headless
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/interrupt_agent.py
|
||||
- src/app/demos/interrupt-headless/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: auth
|
||||
name: Authentication
|
||||
description: Bearer-token gate via the V2 runtime onRequest hook — dedicated
|
||||
/api/copilotkit-auth route rejects unauthenticated requests
|
||||
tags:
|
||||
- runtime
|
||||
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: gen-ui-tool-based
|
||||
name: Tool-Based Generative UI
|
||||
description: Agent uses tools to trigger UI generation — frontend renders a
|
||||
haiku card from a generate_haiku tool call
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/gen-ui-tool-based
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/gen-ui-tool-based/page.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 — same surface as hitl-in-chat, branded for the booking
|
||||
scenario
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.py
|
||||
- src/app/demos/hitl-in-chat/page.tsx
|
||||
- src/app/demos/hitl-in-chat/time-picker-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: mcp-apps
|
||||
name: MCP Apps
|
||||
description: MCP-provided tools rendered inline as sandboxed UI 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: open-gen-ui
|
||||
name: Open Generative UI
|
||||
description:
|
||||
Agent-authored HTML + CSS streamed into a sandboxed iframe 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/app/api/copilotkit-ogui/route.ts
|
||||
- id: open-gen-ui-advanced
|
||||
name: Open Generative UI (Advanced)
|
||||
description:
|
||||
Sandboxed iframe with host-side sandbox functions the agent's UI
|
||||
can call back into via Websandbox.connection.remote
|
||||
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/app/api/copilotkit-ogui/route.ts
|
||||
- id: agent-config
|
||||
name: Agent Config Object
|
||||
description:
|
||||
Frontend forwards a typed config object (tone, expertise, response
|
||||
length) the Agno agent reads per turn to compose its system prompt
|
||||
tags:
|
||||
- runtime
|
||||
route: /demos/agent-config
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent_config_agent.py
|
||||
- src/app/demos/agent-config/page.tsx
|
||||
- src/app/demos/agent-config/config-card.tsx
|
||||
- src/app/api/copilotkit-agent-config/route.ts
|
||||
- id: voice
|
||||
name: Voice Input
|
||||
description: Microphone + sample-audio button → /transcribe endpoint → text
|
||||
injected into 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 attachments forwarded to a vision-capable Agno agent
|
||||
via CopilotChat's attachments config
|
||||
tags:
|
||||
- chat-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: beautiful-chat
|
||||
name: Beautiful Chat
|
||||
description: Polished landing-style chat shell over the shared Agno agent —
|
||||
gradient background, suggestion pills, and an example dashboard surface
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/beautiful-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/main.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: declarative-gen-ui
|
||||
name: "Declarative Generative UI (A2UI Dynamic)"
|
||||
description:
|
||||
Agent dynamically composes UI per turn from a registered frontend
|
||||
catalog — secondary LLM call inside the backend `generate_a2ui` tool emits
|
||||
an a2ui_operations container that the A2UI middleware forwards to the
|
||||
catalog renderer
|
||||
tags:
|
||||
- generative-ui
|
||||
- a2ui
|
||||
route: /demos/declarative-gen-ui
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/a2ui_dynamic_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: a2ui-fixed-schema
|
||||
name: "A2UI Fixed Schema"
|
||||
description:
|
||||
Schema is authored ahead of time as JSON; the agent only streams
|
||||
data into the data model. `display_flight` emits an a2ui_operations
|
||||
container directly with no secondary LLM call
|
||||
tags:
|
||||
- generative-ui
|
||||
- a2ui
|
||||
route: /demos/a2ui-fixed-schema
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/a2ui_fixed_agent.py
|
||||
- src/agents/a2ui_schemas/flight_schema.json
|
||||
- src/agents/a2ui_schemas/booked_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
|
||||
managed_platform:
|
||||
name: Agent OS
|
||||
url: https://os.agno.com
|
||||
@@ -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,62 @@
|
||||
{
|
||||
"name": "@copilotkit/showcase-agno",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"next dev --turbopack\" \"PYTHONPATH=. python -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 --reload\"",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-ui/client": "0.0.57",
|
||||
"@copilotkit/a2ui-renderer": "1.61.2",
|
||||
"@copilotkit/react-core": "1.61.2",
|
||||
"@copilotkit/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.59.1",
|
||||
"@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": "agno",
|
||||
},
|
||||
},
|
||||
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,28 @@
|
||||
# QA: Agentic Chat (Reasoning) — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/agentic-chat-reasoning`
|
||||
- Agno agent backend healthy with the `reasoning_agent` module loaded
|
||||
(served at `/reasoning/agui`)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/agentic-chat-reasoning`
|
||||
- [ ] Send "Explain why the sky is blue in two short steps"
|
||||
- [ ] Verify a custom amber reasoning card (`data-testid="reasoning-block"`)
|
||||
appears BEFORE the final assistant answer
|
||||
- [ ] Verify the reasoning card shows a "Reasoning" pill and streamed thinking
|
||||
content
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] While the agent is running, the reasoning card shows "Thinking…"
|
||||
- [ ] After the run completes, the reasoning card shows "Agent reasoning"
|
||||
- [ ] The reasoning content is italic / visually distinct from the answer
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors
|
||||
@@ -0,0 +1,62 @@
|
||||
# QA: Agentic Chat — Agno
|
||||
|
||||
## 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 — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/auth`
|
||||
- Dedicated runtime at `/api/copilotkit-auth`
|
||||
- Agno main agent backend healthy at `/agui`
|
||||
|
||||
## 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,37 @@
|
||||
# QA: Chat Customization (CSS) — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/chat-customization-css`
|
||||
- Agent backend healthy (`/api/health`)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/chat-customization-css`
|
||||
- [ ] Verify the `.chat-css-demo-scope` wrapper is visible
|
||||
- [ ] Verify the themed chat input (`data-testid="copilot-chat-input"`) is inside the scope
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### CSS Variables
|
||||
|
||||
- [ ] Read computed styles on the scope:
|
||||
- `--copilot-kit-primary-color` = `#ff006e`
|
||||
- `--copilot-kit-background-color` = `#fff8f0`
|
||||
- `--copilot-kit-secondary-color` = `#fde047`
|
||||
|
||||
#### Input Font
|
||||
|
||||
- [ ] Verify the textarea uses Georgia serif font
|
||||
|
||||
#### Round-Trip Styling
|
||||
|
||||
- [ ] Send "hello"
|
||||
- [ ] Verify the user bubble background is a hot-pink linear-gradient containing `rgb(255, 0, 110)`
|
||||
- [ ] Verify the assistant bubble background is `rgb(253, 224, 71)` (amber `#fde047`)
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors
|
||||
@@ -0,0 +1,35 @@
|
||||
# QA: Chat Slots — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/chat-slots`
|
||||
- Agent backend healthy (`/api/health`)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/chat-slots`
|
||||
- [ ] Verify the custom welcome screen slot renders (`data-testid="custom-welcome-screen"`)
|
||||
- [ ] Verify the heading "Welcome to the Slots demo" is visible
|
||||
- [ ] Verify the "Custom Slot" badge is visible inside the welcome card
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Write a sonnet" suggestion pill renders
|
||||
- [ ] Verify "Tell me a joke" suggestion pill renders
|
||||
- [ ] Click "Tell me a joke"; verify an assistant bubble appears wrapped by `data-testid="custom-assistant-message"`
|
||||
|
||||
#### Custom Disclaimer Slot
|
||||
|
||||
- [ ] After the first assistant turn, verify `data-testid="custom-disclaimer"` is visible below the input
|
||||
|
||||
#### Persistence Across Turns
|
||||
|
||||
- [ ] Send a second message; verify at least two `data-testid="custom-assistant-message"` wrappers exist
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors during the entire flow
|
||||
@@ -0,0 +1,24 @@
|
||||
# QA: Frontend Tools (Async) — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/frontend-tools-async`
|
||||
- Agent backend healthy
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/frontend-tools-async`
|
||||
- [ ] Verify the chat renders
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Click the "Find project-planning notes" suggestion pill
|
||||
- [ ] Verify a `data-testid="notes-card"` appears
|
||||
- [ ] Verify `data-testid="notes-keyword"` shows the searched keyword
|
||||
- [ ] Verify matching notes (`data-testid="note-n1"` etc.) are rendered
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors
|
||||
@@ -0,0 +1,23 @@
|
||||
# QA: Frontend Tools — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/frontend-tools`
|
||||
- Agent backend healthy
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/frontend-tools`
|
||||
- [ ] Verify the chat renders with placeholder "Type a message"
|
||||
- [ ] Verify `data-testid="background-container"` is visible with the default background
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Ask "Change the background to a blue-to-purple gradient"
|
||||
- [ ] Verify the `change_background` frontend tool is invoked and the background-container style changes
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors
|
||||
@@ -0,0 +1,62 @@
|
||||
# QA: Agentic Generative UI — Agno
|
||||
|
||||
## 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 — Agno
|
||||
|
||||
## 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,43 @@
|
||||
# QA: Headless Chat (Complete) — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/headless-complete`
|
||||
- Agno main agent backend healthy (served at `/agui`)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/headless-complete`
|
||||
- [ ] Verify the custom chrome renders — header, scrollable messages area
|
||||
(`data-testid="headless-complete-messages"`), input bar
|
||||
- [ ] Verify empty-state hint: "Try weather, a stock, or a highlighted note."
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Weather card via backend tool
|
||||
|
||||
- [ ] Send "What's the weather in Tokyo?"
|
||||
- [ ] Verify a compact `WeatherCard` renders inside an assistant bubble
|
||||
- [ ] Verify the typing indicator shows while the agent is running
|
||||
|
||||
#### Stock card via backend tool
|
||||
|
||||
- [ ] Send "AAPL stock price"
|
||||
- [ ] Verify a compact `StockCard` renders with ticker, price, delta
|
||||
|
||||
#### Highlight note via frontend tool (useComponent)
|
||||
|
||||
- [ ] Send "Highlight 'meeting at 3pm' in yellow"
|
||||
- [ ] Verify a yellow `HighlightNote` card renders inline
|
||||
|
||||
#### Stop button
|
||||
|
||||
- [ ] Send a long-running prompt
|
||||
- [ ] Verify the Stop button appears while the agent is running
|
||||
- [ ] Click Stop and verify the agent halts
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors
|
||||
@@ -0,0 +1,27 @@
|
||||
# QA: Headless Chat (Simple) — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/headless-simple`
|
||||
- Agent backend healthy
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/headless-simple`
|
||||
- [ ] Verify the heading "Headless Chat (Simple)" is visible
|
||||
- [ ] Verify the textarea + "Send" button render
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Type "Say hi" and click Send
|
||||
- [ ] Verify the user bubble appears followed by an assistant text bubble
|
||||
- [ ] Ask "show a card about cats"
|
||||
- [ ] Verify a `ShowCard` renders in the transcript (title + body)
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Empty input disables the Send button
|
||||
- [ ] Shift+Enter does not submit
|
||||
- [ ] No uncaught console errors
|
||||
@@ -0,0 +1,31 @@
|
||||
# QA: In-App HITL (frontend-tool + app-level modal) — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/hitl-in-app`
|
||||
- Agent backend healthy
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/hitl-in-app`
|
||||
- [ ] Verify the support-inbox panel and three tickets (`#12345`, `#12346`, `#12347`) render
|
||||
- [ ] Verify chat renders on the right
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Click the "Approve refund for #12345" suggestion
|
||||
- [ ] Verify the approval dialog (`data-testid="approval-dialog"`) appears OUTSIDE the chat (overlays the page)
|
||||
- [ ] Click "Approve"
|
||||
- [ ] Verify the dialog closes and the agent continues with a follow-up
|
||||
|
||||
#### Reject path
|
||||
|
||||
- [ ] Click "Downgrade plan for #12346"
|
||||
- [ ] When the dialog appears, click "Reject"
|
||||
- [ ] Verify the agent acknowledges the rejection
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors
|
||||
@@ -0,0 +1,38 @@
|
||||
# QA: In-Chat HITL (useHumanInTheLoop) — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/hitl-in-chat`
|
||||
- Agent backend healthy
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/hitl-in-chat`
|
||||
- [ ] Verify chat renders with "Book a call with sales" and "Schedule a 1:1 with Alice" suggestion pills
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Click "Book a call with sales"
|
||||
- [ ] Verify `data-testid="time-picker-card"` is rendered with a grid of time slots
|
||||
- [ ] Click one of the time slot buttons
|
||||
- [ ] Verify `data-testid="time-picker-picked"` appears with the chosen slot label
|
||||
- [ ] Verify the agent receives the chosen time (assistant follow-up references the booking)
|
||||
|
||||
#### Cancel Path
|
||||
|
||||
- [ ] Re-run with "Schedule a 1:1 with Alice"
|
||||
- [ ] Click "None of these work"
|
||||
- [ ] Verify `data-testid="time-picker-cancelled"` appears
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors
|
||||
|
||||
## Note on the legacy /demos/hitl cell
|
||||
|
||||
The older `/demos/hitl` page in this package uses a StepSelector pattern driven by
|
||||
`generate_task_steps` and a different HITL hook; it remains in the manifest as
|
||||
`hitl-in-chat` (for UI-card parity). This file documents the newer `/demos/hitl-in-chat`
|
||||
route that mirrors the canonical langgraph-python `hitl-in-chat` demo.
|
||||
@@ -0,0 +1,35 @@
|
||||
# QA: Pre-Built Popup — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed at `/demos/prebuilt-popup`
|
||||
- Agent backend healthy (`/api/health`)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/prebuilt-popup`; verify heading "Popup demo — look for the floating launcher" is visible
|
||||
- [ ] Verify the popup is OPEN by default and exposes a themed placeholder "Ask the popup anything..."
|
||||
- [ ] Verify the floating toggle launcher is present
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Say hi" suggestion pill renders
|
||||
- [ ] Click the pill; verify "Say hi from the popup!" sends and an assistant text response appears within 30s
|
||||
|
||||
#### Chat Round-Trip
|
||||
|
||||
- [ ] Type "Hello" into the popup input and click the send button; verify an assistant bubble appears
|
||||
|
||||
#### Popup Toggle
|
||||
|
||||
- [ ] Click the popup close button; verify the popup unmounts / hides
|
||||
- [ ] Click the floating launcher; verify the popup re-mounts
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Empty message submit is a no-op
|
||||
- [ ] Console has no uncaught errors
|
||||
@@ -0,0 +1,43 @@
|
||||
# QA: Pre-Built Sidebar — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible at `/demos/prebuilt-sidebar`
|
||||
- Agent backend is healthy (`/api/health` or `/api/copilotkit` GET)
|
||||
- The underlying Agno agent (`src/agents/main.py`) is the shared showcase agent registered to the `prebuilt-sidebar` name on the runtime route
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/prebuilt-sidebar`; verify the main content renders with heading (h1 "Sidebar demo — click the launcher") and a paragraph mentioning `<CopilotSidebar />`
|
||||
- [ ] Verify the `<CopilotSidebar />` is rendered docked to one edge of the viewport and OPEN by default (`defaultOpen={true}`)
|
||||
- [ ] Verify the sidebar contains a chat input and its own launcher/toggle button
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Sidebar Toggle
|
||||
|
||||
- [ ] Click the sidebar close button; verify the sidebar collapses and `aria-hidden` flips to `true`
|
||||
- [ ] Click the launcher; verify it re-opens (`aria-hidden` returns to `false`)
|
||||
|
||||
#### Suggestions (`useConfigureSuggestions`)
|
||||
|
||||
- [ ] Verify a pill titled "Say hi" is rendered
|
||||
- [ ] Click the pill; verify "Say hi!" sends and an assistant response appears within 30s
|
||||
|
||||
#### Chat Round-Trip
|
||||
|
||||
- [ ] Type "Hello" and submit; verify an assistant bubble appears
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Attempt to send an empty message; verify it is a no-op
|
||||
- [ ] DevTools console shows no uncaught errors during any flow above
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page + sidebar render within 3 seconds
|
||||
- Assistant response within 30 seconds
|
||||
- Sidebar toggle is instant with no layout jank
|
||||
- No uncaught console errors
|
||||
@@ -0,0 +1,24 @@
|
||||
# QA: Readonly State (Agent Context) — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/readonly-state-agent-context`
|
||||
- Agent backend healthy
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/readonly-state-agent-context`
|
||||
- [ ] Verify the `data-testid="context-card"` is visible (left sidebar)
|
||||
- [ ] Verify the JSON preview (`data-testid="ctx-state-json"`) shows the initial context
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Change the name input (`data-testid="ctx-name"`) to "Alice"
|
||||
- [ ] Verify the JSON preview updates to show "Alice"
|
||||
- [ ] Ask the assistant "What do you know about me?" and verify it references the context
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors
|
||||
@@ -0,0 +1,26 @@
|
||||
# QA: Reasoning (Default Render) — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/reasoning-default-render`
|
||||
- Agno reasoning agent served at `/reasoning/agui`
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/reasoning-default-render`
|
||||
- [ ] Send "Why is the sky blue? Think step by step."
|
||||
- [ ] Verify the built-in `CopilotChatReasoningMessage` renders a collapsible
|
||||
"Thought for X seconds" card
|
||||
- [ ] Verify the final assistant answer appears after the reasoning card
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Expand the reasoning card; verify step-by-step content is visible
|
||||
- [ ] No custom slot override was required — this is zero-config reasoning
|
||||
rendering
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors
|
||||
@@ -0,0 +1,61 @@
|
||||
# QA: Shared State (Read + Write) — Agno
|
||||
|
||||
## 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; the Agno agent server exposes the `/shared-state-rw/agui` endpoint
|
||||
|
||||
## 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 synchronously 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 (Agno's dynamic instructions function reads `session_state["preferences"]` and prepends a preferences block on every turn)
|
||||
- [ ] Click the "Plan a weekend" suggestion; verify the reply is tailored to the selected interests
|
||||
|
||||
#### Agent Writes -> UI Reads (notes via `set_notes` tool)
|
||||
|
||||
- [ ] Click the "Remember something" suggestion (sends "Remember that I prefer morning meetings and that I don't eat dairy.")
|
||||
- [ ] Within 15s verify `data-testid="notes-list"` appears in the notes card and contains at least 2 `data-testid="note-item"` entries mentioning "morning meetings" and "dairy"
|
||||
- [ ] Verify `data-testid="notes-empty"` is no longer rendered
|
||||
- [ ] Send "Also remember I live in Berlin."; verify within 15s the notes list grows (previous notes preserved, new note added) — confirms the agent passes the FULL updated list per `set_notes` contract (the tool REPLACES the array)
|
||||
|
||||
#### 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 (Agno reuses the same `session_id`/`thread_id`)
|
||||
- [ ] 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 (the dynamic instructions function skips the preferences block when none are recognized)
|
||||
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads within 3 seconds; assistant text response within 10 seconds
|
||||
- Preferences writes are reflected in `pref-state-json` synchronously on change
|
||||
- Agent-authored notes appear in `notes-card` within 15 seconds of a "remember" prompt, and the full prior list is preserved on subsequent `set_notes` calls (the custom AGUI router emits a `StateSnapshotEvent` after every run so `useAgent` re-renders with the updated state)
|
||||
- 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) — Agno
|
||||
|
||||
## 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 — Agno
|
||||
|
||||
## 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) — Agno
|
||||
|
||||
## 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,53 @@
|
||||
# QA: Sub-Agents — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible at `/demos/subagents` on the dashboard host
|
||||
- Agent backend is healthy (`/api/health`); `OPENAI_API_KEY` is set; the Agno agent server exposes the `/subagents/agui` endpoint
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/subagents`; verify the page renders within 3s with a left-side delegation log panel and a right-side `CopilotChat` pane
|
||||
- [ ] Verify `data-testid="delegation-log"` is visible with heading "Sub-agent delegations"
|
||||
- [ ] Verify the empty-state `data-testid="delegation-empty"` reads "No delegations yet. Ask the supervisor to plan a deliverable."
|
||||
- [ ] Verify `data-testid="delegation-count"` reads "0 calls"
|
||||
- [ ] Verify the chat input placeholder is "Give the supervisor a task..."
|
||||
- [ ] Verify all 3 suggestion pills are visible with verbatim titles: "Write a blog post", "Explain a topic", "Summarize a topic"
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Live delegation log (research → write → critique)
|
||||
|
||||
- [ ] Click the "Write a blog post" suggestion (sends a request that asks for research → writing → critique on cold exposure training)
|
||||
- [ ] Within 5s verify `data-testid="supervisor-running"` appears next to the heading (the supervisor's `agent.isRunning` is true)
|
||||
- [ ] Within 30s verify at least 3 `data-testid="delegation-entry"` cards have appeared, in order: Research → Writing → Critique (matching the supervisor's instructed sequence)
|
||||
- [ ] Verify each card shows a sub-agent badge (Research blue, Writing emerald, Critique purple) and a status badge that ends in `completed` (emerald) once the sub-agent returns
|
||||
- [ ] During delegation, verify in-flight cards show `data-status="running"` with an amber border, an animated spinner, and the text "Sub-agent is working…"
|
||||
- [ ] After the run finishes, verify each completed card shows a non-empty result body (research bullets, draft paragraph, critique bullets) — the agno custom AGUI router emits a `StateSnapshotEvent` carrying `delegations` after each run, so the UI re-renders the final state
|
||||
- [ ] Verify `data-testid="delegation-count"` updates to reflect the total number of delegations made
|
||||
- [ ] Verify the supervisor's final chat message returns a concise summary referencing the work done
|
||||
|
||||
#### Sub-agent variety
|
||||
|
||||
- [ ] Click "Explain a topic"; verify a fresh `Research` → `Writing` → `Critique` sequence appears (delegations from the prior run remain in the log; the supervisor instructs `set_notes`-style replacement only on the supervisor agent — sub-agent log is append-only)
|
||||
- [ ] Click "Summarize a topic"; verify another sequence appears, growing the log
|
||||
|
||||
#### Failure handling
|
||||
|
||||
- [ ] (Best-effort, hard to provoke) If a sub-agent ever fails (network blip, provider error), verify the corresponding entry transitions to `data-status="failed"` with a red border and the body text reads `sub-agent call failed: <ExceptionClassName> (see server logs for details)` — no provider URLs / request IDs leaked to the UI
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message; verify it is a no-op
|
||||
- [ ] Reload the page; verify the delegation log resets to its empty state
|
||||
- [ ] Verify DevTools -> Console shows no uncaught errors during any flow above
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads within 3 seconds
|
||||
- First delegation entry appears within 10 seconds of a non-trivial task being submitted
|
||||
- Full research → write → critique chain completes within 60 seconds for typical prompts
|
||||
- Delegations list grows in real time, with `running` status visible while a sub-agent is working and `completed`/`failed` once it returns
|
||||
- No UI layout breaks, no uncaught console errors
|
||||
@@ -0,0 +1,25 @@
|
||||
# QA: Tool Rendering (Custom Catch-all) — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/tool-rendering-custom-catchall`
|
||||
- Agent backend healthy
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/tool-rendering-custom-catchall`
|
||||
- [ ] Verify chat renders with suggestion pills
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Click "Weather in SF"
|
||||
- [ ] Verify a `data-testid="custom-catchall-card"` renders (branded catch-all UI)
|
||||
- [ ] Verify `data-testid="custom-catchall-tool-name"` shows `get_weather`
|
||||
- [ ] Verify `data-testid="custom-catchall-status"` eventually shows "done"
|
||||
- [ ] Verify `data-testid="custom-catchall-args"` and `data-testid="custom-catchall-result"` render
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors
|
||||
@@ -0,0 +1,23 @@
|
||||
# QA: Tool Rendering (Default Catch-all) — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/tool-rendering-default-catchall`
|
||||
- Agent backend healthy
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/tool-rendering-default-catchall`
|
||||
- [ ] Verify chat renders with placeholder "Type a message"
|
||||
- [ ] Verify "Weather in SF", "Find flights", "Roll a d20" pills render
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
- [ ] Click "Weather in SF"; verify a default tool-call card is rendered showing the tool name `get_weather`
|
||||
- [ ] Click "Roll a d20"; verify a tool-call card appears for `roll_dice`
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors
|
||||
@@ -0,0 +1,38 @@
|
||||
# QA: Tool Rendering (Reasoning Chain) — Agno
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo deployed at `/demos/tool-rendering-reasoning-chain`
|
||||
- Agno reasoning agent served at `/reasoning/agui`, with `get_weather`,
|
||||
`search_flights`, `get_stock_price`, `roll_dice` tools available
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to `/demos/tool-rendering-reasoning-chain`
|
||||
- [ ] Send "What's the weather in Tokyo?"
|
||||
- [ ] Verify the custom amber reasoning card
|
||||
(`data-testid="reasoning-block"`) appears
|
||||
- [ ] Verify the `WeatherCard` (`data-testid="weather-card"`) renders after
|
||||
the tool completes
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Catch-all renderer
|
||||
|
||||
- [ ] Send "Roll a 20-sided die"
|
||||
- [ ] Verify the custom catch-all card
|
||||
(`data-testid="custom-catchall-card"` with `data-tool-name="roll_dice"`)
|
||||
renders with arguments and result
|
||||
|
||||
#### Flight chain
|
||||
|
||||
- [ ] Send "Find flights from SFO to JFK"
|
||||
- [ ] Verify the `FlightListCard` (`data-testid="flight-list-card"`) renders
|
||||
with at least one flight row
|
||||
- [ ] Verify origin / destination labels match the user's message
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] No uncaught console errors
|
||||
@@ -0,0 +1,61 @@
|
||||
# QA: Tool Rendering — Agno
|
||||
|
||||
## 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,7 @@
|
||||
agno==2.6.19
|
||||
openai>=1.88.0
|
||||
fastapi>=0.115.13
|
||||
uvicorn[standard]>=0.34.3
|
||||
ag-ui-protocol>=0.1.8
|
||||
python-dotenv>=1.0.0
|
||||
pypdf>=4.0.0
|
||||
@@ -0,0 +1,922 @@
|
||||
"""
|
||||
Agent Server for Agno
|
||||
|
||||
Uses AgentOS with the AG-UI interface to serve multiple Agno agents.
|
||||
The Next.js CopilotKit runtime proxies requests to each interface via AG-UI.
|
||||
|
||||
Interfaces:
|
||||
/agui → main agent (sales assistant, most demos)
|
||||
Custom handler that forwards tool results
|
||||
from AGUI messages so HITL round-trips work.
|
||||
/reasoning/agui → reasoning-capable agent
|
||||
/shared-state-rw/agui → bidirectional shared-state agent
|
||||
(custom router emits STATE_SNAPSHOT)
|
||||
/subagents/agui → supervisor with research/writing/critique
|
||||
sub-agents (custom router emits STATE_SNAPSHOT)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, AsyncIterator, List, Optional, Set, Union
|
||||
|
||||
# CVDIAG bootstrap — MUST be the first non-stdlib import (folded in from the
|
||||
# dropped L1-H slot). Importing this module configures the root logger via
|
||||
# ``logging.basicConfig`` so the ``agents._header_forwarding`` (and sibling
|
||||
# ``agents.*``) CVDIAG loggers actually EMIT (fixes the silent-drop bug), and
|
||||
# resolves the verbosity tier + PB writer. It imports pydantic/starlette only
|
||||
# (NOT agno), so it is safe to run before the agent imports below.
|
||||
import _shared.cvdiag_bootstrap # noqa: F401,E402 (first non-stdlib import — bootstrap side effects)
|
||||
|
||||
# ORDER-CRITICAL: install the global httpx hook BEFORE any agent module
|
||||
# imports. Agno constructs its ``OpenAIChat`` client at agent-module
|
||||
# import time, so the patch must be in place before those imports run.
|
||||
from agents._cvdiag_backend import CvdiagBackendMiddleware
|
||||
from agents._header_forwarding import (
|
||||
HeaderForwardingHTTPMiddleware,
|
||||
install_executor_contextvar_propagation,
|
||||
install_global_httpx_hook,
|
||||
)
|
||||
|
||||
install_global_httpx_hook()
|
||||
# Agno dispatches SYNC tools (e.g. the declarative gen-ui `generate_a2ui`
|
||||
# tool, which makes a secondary OpenAI call) onto the default
|
||||
# ThreadPoolExecutor via loop.run_in_executor(...), which does NOT
|
||||
# propagate ContextVars to the worker thread. Without this, the
|
||||
# forwarded-header ContextVar set on the inbound request task is empty by
|
||||
# the time the secondary call's outbound httpx hook fires, and aimock
|
||||
# can't match the right fixture for the request.
|
||||
install_executor_contextvar_propagation()
|
||||
|
||||
import dotenv
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
EventType,
|
||||
ReasoningMessageContentEvent,
|
||||
ReasoningMessageEndEvent,
|
||||
ReasoningMessageStartEvent,
|
||||
RunAgentInput,
|
||||
RunErrorEvent,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
StateSnapshotEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
)
|
||||
from ag_ui.core.types import Message as AGUIMessage
|
||||
from ag_ui.encoder import EventEncoder
|
||||
from agno.agent import Agent, RemoteAgent
|
||||
from agno.models.message import Message
|
||||
from agno.os import AgentOS
|
||||
from agno.os.interfaces.agui import AGUI
|
||||
|
||||
# TODO: migrate to agno 2.6.20+ API once agui.utils replacement is identified
|
||||
from agno.os.interfaces.agui.utils import (
|
||||
async_stream_agno_response_as_agui_events,
|
||||
extract_agui_user_input,
|
||||
validate_agui_state,
|
||||
)
|
||||
from agno.utils.log import log_debug, log_warning
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from agents.a2ui_dynamic_agent import agent as a2ui_dynamic_agent
|
||||
from agents.a2ui_fixed_agent import agent as a2ui_fixed_agent
|
||||
from agents.agent_config_agent import (
|
||||
agent as agent_config_agent,
|
||||
build_agent as build_agent_config_agent,
|
||||
)
|
||||
from agents.byoc_hashbrown_agent import agent as byoc_hashbrown_agent
|
||||
from agents.byoc_json_render_agent import agent as byoc_json_render_agent
|
||||
from agents.gen_ui_agent import agent as gen_ui_agent
|
||||
from agents.interrupt_agent import agent as interrupt_agent
|
||||
from agents.main import agent as main_agent
|
||||
from agents.mcp_apps_agent import agent as mcp_apps_agent
|
||||
from agents.multimodal_agent import agent as multimodal_agent
|
||||
from agents.open_gen_ui_agent import agent as open_gen_ui_agent
|
||||
from agents.reasoning_agent import agent as reasoning_agent
|
||||
from agents.shared_state_read_write import agent as shared_state_rw_agent
|
||||
from agents.subagents import agent as subagents_supervisor
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AGUI message conversion for HITL tool-result forwarding
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# agno >= 2.5.17 changed the stock AGUI router to use
|
||||
# `extract_agui_user_input()` which passes ONLY the last user message to
|
||||
# the agent. This works for simple chat but breaks Human-in-the-Loop
|
||||
# flows: the second request (after the user confirms/rejects in the HITL
|
||||
# UI) carries the tool result as an AGUI "tool" role message. Since
|
||||
# `extract_agui_user_input` discards all non-user messages, the tool
|
||||
# result never reaches the LLM and the agent just re-calls the tool and
|
||||
# pauses again.
|
||||
#
|
||||
# The helper below converts AGUI messages to agno Messages — the same
|
||||
# thing `convert_agui_messages_to_agno_messages` did in older agno
|
||||
# releases — so we can detect tool results and pass the full conversation
|
||||
# to the agent when they exist.
|
||||
|
||||
|
||||
def _has_tool_results(messages: List[AGUIMessage]) -> bool:
|
||||
"""Return True if the message list contains any tool-result messages."""
|
||||
return any(msg.role == "tool" for msg in messages)
|
||||
|
||||
|
||||
def _convert_agui_messages(messages: List[AGUIMessage]) -> List[Message]:
|
||||
"""Convert AG-UI messages to Agno messages (full conversation).
|
||||
|
||||
Mirrors the old `convert_agui_messages_to_agno_messages` from
|
||||
agno < 2.5.17. The LLM (and OpenAI's API) requires assistant
|
||||
``tool_calls`` and their ``tool`` results to stay paired: an
|
||||
orphan ``tool`` message with no matching assistant ``tool_calls``
|
||||
is rejected with a 400, and an assistant ``tool_calls`` whose
|
||||
result never arrived confuses the model. So we drop orphans on
|
||||
BOTH sides together, keeping only complete pairs.
|
||||
|
||||
A ``tool`` message with a falsy ``tool_call_id`` (empty string, or
|
||||
``None`` if the message bypassed pydantic validation) can never be
|
||||
paired, so it is skipped consistently across both passes — never
|
||||
added to the result and never allowed to poison dedup.
|
||||
"""
|
||||
# First pass: collect the tool_call ids that BOTH sides agree on —
|
||||
# an id that appears on an assistant ``tool_calls`` AND on a ``tool``
|
||||
# result message. Only these ids yield a complete, emittable pair.
|
||||
assistant_tool_call_ids: Set[str] = set()
|
||||
tool_result_ids: Set[str] = set()
|
||||
for msg in messages:
|
||||
if msg.role == "tool" and msg.tool_call_id:
|
||||
tool_result_ids.add(msg.tool_call_id)
|
||||
elif msg.role == "assistant" and msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
if tc.id:
|
||||
assistant_tool_call_ids.add(tc.id)
|
||||
|
||||
paired_ids: Set[str] = assistant_tool_call_ids & tool_result_ids
|
||||
|
||||
result: List[Message] = []
|
||||
seen_tool_ids: Set[str] = set()
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == "tool":
|
||||
tool_call_id = msg.tool_call_id
|
||||
# Drop orphans: falsy ids can never pair, and an id with no
|
||||
# matching assistant tool_call would be a 400 from OpenAI.
|
||||
if not tool_call_id or tool_call_id not in paired_ids:
|
||||
continue
|
||||
# Dedup retained ids only — falsy ids were already skipped,
|
||||
# so they can no longer poison this set.
|
||||
if tool_call_id in seen_tool_ids:
|
||||
continue
|
||||
seen_tool_ids.add(tool_call_id)
|
||||
result.append(
|
||||
Message(
|
||||
role="tool",
|
||||
tool_call_id=tool_call_id,
|
||||
content=msg.content,
|
||||
)
|
||||
)
|
||||
elif msg.role == "assistant":
|
||||
tool_calls = None
|
||||
if msg.tool_calls:
|
||||
# Keep only tool_calls whose result is present (paired).
|
||||
filtered = [tc for tc in msg.tool_calls if tc.id in paired_ids]
|
||||
if filtered:
|
||||
tool_calls = [tc.model_dump(exclude_none=True) for tc in filtered]
|
||||
if not msg.content and tool_calls is None:
|
||||
# Drop an empty assistant turn (no content + all tool_calls
|
||||
# orphaned): OpenAI rejects {role:"assistant"} with neither
|
||||
# content nor tool_calls, and it pollutes HITL history.
|
||||
continue
|
||||
result.append(
|
||||
Message(
|
||||
role="assistant",
|
||||
content=msg.content,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
)
|
||||
elif msg.role == "user":
|
||||
result.append(Message(role="user", content=msg.content))
|
||||
# system messages are skipped — agent builds its own
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HITL-aware AGUI handler for the main agent
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The stock AGUI handler passes only the last user message to the agent,
|
||||
# relying on agno's session DB for history. This works for standard chat
|
||||
# but breaks HITL: the second leg (tool-result) is dropped.
|
||||
#
|
||||
# This custom handler detects tool results in the incoming AGUI messages
|
||||
# and, when present, passes the full message list to the agent instead.
|
||||
# For first-leg requests (no tool results) it falls back to the stock
|
||||
# `extract_agui_user_input` behaviour.
|
||||
|
||||
|
||||
async def _run_main_agent_hitl_aware(
|
||||
agent: Union[Agent, RemoteAgent], run_input: RunAgentInput
|
||||
) -> AsyncIterator[BaseEvent]:
|
||||
"""Stream one agent run, forwarding tool results when present."""
|
||||
run_id = run_input.run_id or str(uuid.uuid4())
|
||||
thread_id = run_input.thread_id
|
||||
|
||||
try:
|
||||
messages = run_input.messages or []
|
||||
has_results = _has_tool_results(messages)
|
||||
|
||||
if has_results:
|
||||
# Second leg: convert full conversation so the LLM sees the
|
||||
# tool result and can generate a follow-up response.
|
||||
agent_input = _convert_agui_messages(messages)
|
||||
log_debug(
|
||||
"HITL-aware handler: forwarding full messages (tool results present)"
|
||||
)
|
||||
else:
|
||||
# First leg: extract only the user message (stock behaviour).
|
||||
agent_input = extract_agui_user_input(messages)
|
||||
log_debug("HITL-aware handler: extracting user input (no tool results)")
|
||||
|
||||
yield RunStartedEvent(
|
||||
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
|
||||
user_id: Optional[str] = None
|
||||
if run_input.forwarded_props and isinstance(run_input.forwarded_props, dict):
|
||||
user_id = run_input.forwarded_props.get("user_id")
|
||||
|
||||
session_state = validate_agui_state(run_input.state, thread_id) or {}
|
||||
|
||||
response_stream = agent.arun( # type: ignore[attr-defined]
|
||||
input=agent_input,
|
||||
session_id=thread_id,
|
||||
stream=True,
|
||||
stream_events=True,
|
||||
user_id=user_id,
|
||||
session_state=session_state,
|
||||
run_id=run_id,
|
||||
# When we pass full messages (HITL second leg), disable session
|
||||
# history to avoid duplicating messages the caller already sent.
|
||||
add_history_to_context=not has_results,
|
||||
)
|
||||
|
||||
async for event in async_stream_agno_response_as_agui_events(
|
||||
response_stream=response_stream, # type: ignore[arg-type]
|
||||
thread_id=thread_id,
|
||||
run_id=run_id,
|
||||
):
|
||||
yield event
|
||||
|
||||
except asyncio.CancelledError: # noqa: TRY302
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
yield RunErrorEvent(type=EventType.RUN_ERROR, message=str(exc))
|
||||
|
||||
|
||||
def _attach_hitl_aware_route(app: FastAPI, agent: Agent, prefix: str) -> None:
|
||||
"""Mount a HITL-aware AGUI POST endpoint at `<prefix>/agui`."""
|
||||
encoder = EventEncoder()
|
||||
route = f"{prefix.rstrip('/')}/agui"
|
||||
|
||||
async def _handler(run_input: RunAgentInput) -> StreamingResponse:
|
||||
async def _gen():
|
||||
async for event in _run_main_agent_hitl_aware(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": "*",
|
||||
},
|
||||
)
|
||||
|
||||
app.post(route, name=f"agui_hitl_aware_{prefix.strip('/')}")(_handler)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State-aware AGUI handler
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Agno's stock AGUI router (`agno.os.interfaces.agui`) does NOT emit
|
||||
# `StateSnapshotEvent` events back to the client. This means tools that
|
||||
# mutate `session_state` are invisible to a UI subscribed via
|
||||
# `useAgent({ updates: [OnStateChanged] })` — the round-trip is broken.
|
||||
#
|
||||
# For the shared-state-read-write and subagents demos we replicate the
|
||||
# stock router's behavior but emit a `StateSnapshotEvent` carrying the
|
||||
# final `session_state` immediately before the closing `RunFinishedEvent`.
|
||||
# That gives the UI the canonical bidirectional contract its langgraph-
|
||||
# python and google-adk siblings already have.
|
||||
|
||||
|
||||
async def _run_agent_with_state_snapshot(
|
||||
agent: Union[Agent, RemoteAgent], run_input: RunAgentInput
|
||||
) -> AsyncIterator[BaseEvent]:
|
||||
"""Stream one agent run, emitting STATE_SNAPSHOT before RUN_FINISHED.
|
||||
|
||||
Mirrors `agno.os.interfaces.agui.router.run_agent` but inserts a
|
||||
`StateSnapshotEvent` after the inner Agno stream completes. We also
|
||||
suppress the inner stream's `RunFinishedEvent` and emit our own at
|
||||
the very end so the snapshot lands inside the run window.
|
||||
"""
|
||||
run_id = run_input.run_id or str(uuid.uuid4())
|
||||
thread_id = run_input.thread_id
|
||||
|
||||
try:
|
||||
user_input = extract_agui_user_input(run_input.messages or [])
|
||||
|
||||
yield RunStartedEvent(
|
||||
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
|
||||
user_id: Optional[str] = None
|
||||
if run_input.forwarded_props and isinstance(run_input.forwarded_props, dict):
|
||||
user_id = run_input.forwarded_props.get("user_id")
|
||||
|
||||
session_state = validate_agui_state(run_input.state, thread_id) or {}
|
||||
|
||||
response_stream = agent.arun( # type: ignore[attr-defined]
|
||||
input=user_input,
|
||||
session_id=thread_id,
|
||||
stream=True,
|
||||
stream_events=True,
|
||||
user_id=user_id,
|
||||
session_state=session_state,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
async for event in async_stream_agno_response_as_agui_events(
|
||||
response_stream=response_stream, # type: ignore[arg-type]
|
||||
thread_id=thread_id,
|
||||
run_id=run_id,
|
||||
):
|
||||
# Suppress the inner RUN_STARTED / RUN_FINISHED — we already
|
||||
# emitted RUN_STARTED above and will emit RUN_FINISHED after
|
||||
# the snapshot. Yield everything else (text, tool calls,
|
||||
# reasoning, errors) verbatim.
|
||||
if event.type in (EventType.RUN_STARTED, EventType.RUN_FINISHED):
|
||||
continue
|
||||
yield event
|
||||
|
||||
# Snapshot the final session_state from the agent's session DB.
|
||||
# `agent.arun` mutates `session_state` in-place when tools call
|
||||
# `run_context.session_state[...] = ...`, but we read back via
|
||||
# the agent's own getter so we pick up any merged DB state too.
|
||||
final_state: Any = session_state
|
||||
try:
|
||||
getter = getattr(agent, "aget_session_state", None)
|
||||
if getter is not None:
|
||||
final_state = await getter(session_id=thread_id) # type: ignore[misc]
|
||||
else:
|
||||
sync_getter = getattr(agent, "get_session_state", None)
|
||||
if sync_getter is not None:
|
||||
final_state = sync_getter(session_id=thread_id)
|
||||
except Exception: # noqa: BLE001 — fall back to in-memory snapshot
|
||||
final_state = session_state
|
||||
|
||||
if not isinstance(final_state, dict):
|
||||
final_state = session_state if isinstance(session_state, dict) else {}
|
||||
|
||||
yield StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=final_state)
|
||||
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
|
||||
yield RunErrorEvent(type=EventType.RUN_ERROR, message=str(exc))
|
||||
|
||||
|
||||
def _attach_state_aware_route(app: FastAPI, agent: Agent, prefix: str) -> None:
|
||||
"""Mount a single state-aware AGUI POST endpoint at `<prefix>/agui`."""
|
||||
encoder = EventEncoder()
|
||||
route = f"{prefix.rstrip('/')}/agui"
|
||||
|
||||
async def _handler(run_input: RunAgentInput) -> StreamingResponse:
|
||||
async def _gen():
|
||||
async for event in _run_agent_with_state_snapshot(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": "*",
|
||||
},
|
||||
)
|
||||
|
||||
app.post(route, name=f"agui_state_aware_{prefix.strip('/')}")(_handler)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-request agent factory (Agent Config Object demo)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The CopilotKit provider's `properties` prop arrives as top-level keys on
|
||||
# `RunAgentInput.forwarded_props`. The Agent Config Object cell reads three
|
||||
# of those keys (tone, expertise, responseLength) and composes a fresh
|
||||
# system prompt per turn. Agno doesn't have a LangGraph-style configurable
|
||||
# channel, so we mount a custom AGUI handler that builds a per-request
|
||||
# Agno Agent and runs it through the stock AGUI stream mapper.
|
||||
|
||||
|
||||
async def _run_agent_config(run_input: RunAgentInput) -> AsyncIterator[BaseEvent]:
|
||||
"""Stream one Agent-Config run with a freshly-built system prompt."""
|
||||
run_id = run_input.run_id or str(uuid.uuid4())
|
||||
thread_id = run_input.thread_id
|
||||
|
||||
forwarded = (
|
||||
run_input.forwarded_props
|
||||
if isinstance(run_input.forwarded_props, dict)
|
||||
else None
|
||||
)
|
||||
|
||||
try:
|
||||
user_input = extract_agui_user_input(run_input.messages or [])
|
||||
|
||||
yield RunStartedEvent(
|
||||
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
|
||||
per_request_agent = build_agent_config_agent(forwarded)
|
||||
session_state = validate_agui_state(run_input.state, thread_id) or {}
|
||||
|
||||
response_stream = per_request_agent.arun( # type: ignore[attr-defined]
|
||||
input=user_input,
|
||||
session_id=thread_id,
|
||||
stream=True,
|
||||
stream_events=True,
|
||||
session_state=session_state,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
async for event in async_stream_agno_response_as_agui_events(
|
||||
response_stream=response_stream, # type: ignore[arg-type]
|
||||
thread_id=thread_id,
|
||||
run_id=run_id,
|
||||
):
|
||||
# The inner stream emits its own RUN_STARTED/RUN_FINISHED; we
|
||||
# already emitted RUN_STARTED and will close out below.
|
||||
if event.type in (EventType.RUN_STARTED, EventType.RUN_FINISHED):
|
||||
continue
|
||||
yield event
|
||||
|
||||
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
|
||||
yield RunErrorEvent(type=EventType.RUN_ERROR, message=str(exc))
|
||||
|
||||
|
||||
def _attach_agent_config_route(app: FastAPI, prefix: str) -> None:
|
||||
"""Mount a single Agent-Config AGUI POST endpoint at `<prefix>/agui`."""
|
||||
encoder = EventEncoder()
|
||||
route = f"{prefix.rstrip('/')}/agui"
|
||||
|
||||
async def _handler(run_input: RunAgentInput) -> StreamingResponse:
|
||||
async def _gen():
|
||||
async for event in _run_agent_config(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": "*",
|
||||
},
|
||||
)
|
||||
|
||||
app.post(route, name=f"agui_agent_config_{prefix.strip('/')}")(_handler)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reasoning-aware AGUI handler
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Agno's stock AGUI handler emits STEP_STARTED/STEP_FINISHED events for
|
||||
# reasoning, not the REASONING_MESSAGE_* events that CopilotKit expects.
|
||||
# And reasoning=True triggers a multi-call CoT loop that breaks with
|
||||
# aimock/fixture environments.
|
||||
#
|
||||
# This custom handler:
|
||||
# 1. Runs the agent with reasoning=False (single LLM call)
|
||||
# 2. Captures the model's NATIVE reasoning channel — OpenAI-compatible
|
||||
# chat-completions stream a `delta.reasoning_content` field which Agno's
|
||||
# OpenAIChat model surfaces on each `RunContentEvent.reasoning_content`.
|
||||
# That is the channel aimock fixtures populate (via their `reasoning`
|
||||
# field), and it is what reasoning models emit in production. Agno's
|
||||
# AGUI stream mapper (`async_stream_agno_response_as_agui_events`) DROPS
|
||||
# that channel entirely, so we tee the raw stream to accumulate it.
|
||||
# 3. Collects the streamed text content (the answer)
|
||||
# 4. Emits REASONING_MESSAGE_* events for the reasoning block — preferring
|
||||
# the native channel, then falling back to <reasoning>...</reasoning>
|
||||
# tag parsing of the text for no-native-reasoning fixtures
|
||||
# 5. Emits TEXT_MESSAGE_* events for the answer
|
||||
#
|
||||
# Mirrors the claude-sdk-python /reasoning handler, which forwards Anthropic
|
||||
# `thinking`/`thinking_delta` blocks as REASONING_MESSAGE_* directly. The
|
||||
# emitted channel is REASONING_MESSAGE_* (role "reasoning") — NOT THINKING_*,
|
||||
# which @ag-ui/client silently drops.
|
||||
|
||||
import re
|
||||
|
||||
from agno.run.agent import RunEvent
|
||||
|
||||
_REASONING_PATTERN = re.compile(
|
||||
r"<reasoning>(.*?)</reasoning>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
async def _tee_native_reasoning(
|
||||
# Carries Agno run events (RunContentEvent etc.) plus the terminal
|
||||
# RunOutput — NOT AG-UI's BaseEvent. Annotated ``Any`` because Agno's
|
||||
# concrete union (``RunOutputEvent | RunOutput``) does not line up with
|
||||
# the ``RunOutputEvent | TeamRunOutputEvent`` the downstream AGUI mapper
|
||||
# consumes, and a precise annotation here only moves the mismatch.
|
||||
response_stream: AsyncIterator[Any],
|
||||
reasoning_sink: dict,
|
||||
) -> AsyncIterator[Any]:
|
||||
"""Pass an Agno run stream through verbatim, accumulating the model's
|
||||
native ``reasoning_content`` channel into ``reasoning_sink["text"]``.
|
||||
|
||||
OpenAI-compatible providers (and aimock fixtures' ``reasoning`` field)
|
||||
stream reasoning as ``delta.reasoning_content`` deltas, which Agno's
|
||||
``OpenAIChat`` model surfaces on each ``RunContentEvent.reasoning_content``.
|
||||
Agno's AGUI stream mapper ignores that field, so we capture it here while
|
||||
forwarding every chunk untouched to the downstream mapper. No chunk is
|
||||
consumed or altered — the mapper still produces TEXT/TOOL events exactly
|
||||
as before; we only read the reasoning side-channel.
|
||||
"""
|
||||
async for chunk in response_stream:
|
||||
if getattr(chunk, "event", None) == RunEvent.run_content:
|
||||
delta = getattr(chunk, "reasoning_content", None)
|
||||
if delta:
|
||||
reasoning_sink["text"] = reasoning_sink.get("text", "") + delta
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _run_reasoning_agent(
|
||||
agent: Union[Agent, RemoteAgent], run_input: RunAgentInput
|
||||
) -> AsyncIterator[BaseEvent]:
|
||||
"""Stream one reasoning agent run, synthesizing REASONING_MESSAGE events."""
|
||||
run_id = run_input.run_id or str(uuid.uuid4())
|
||||
thread_id = run_input.thread_id
|
||||
|
||||
try:
|
||||
user_input = extract_agui_user_input(run_input.messages or [])
|
||||
|
||||
yield RunStartedEvent(
|
||||
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
|
||||
user_id: Optional[str] = None
|
||||
if run_input.forwarded_props and isinstance(run_input.forwarded_props, dict):
|
||||
user_id = run_input.forwarded_props.get("user_id")
|
||||
|
||||
session_state = validate_agui_state(run_input.state, thread_id) or {}
|
||||
|
||||
response_stream = agent.arun( # type: ignore[attr-defined]
|
||||
input=user_input,
|
||||
session_id=thread_id,
|
||||
stream=True,
|
||||
stream_events=True,
|
||||
user_id=user_id,
|
||||
session_state=session_state,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
# Collect the full text from the agent stream — we need to see the
|
||||
# complete response before we can split reasoning from answer, so
|
||||
# text and tool-call events are BUFFERED here and flushed after the
|
||||
# reasoning/answer split below (tool events are NOT interleaved with
|
||||
# the text in real time; they are emitted after the answer bubble).
|
||||
full_text = ""
|
||||
tool_events: list[BaseEvent] = []
|
||||
|
||||
# Tee the raw Agno stream to capture the model's native reasoning
|
||||
# channel (`RunContentEvent.reasoning_content`) — the channel aimock
|
||||
# fixtures populate and that the AGUI mapper drops. Accumulated here,
|
||||
# preferred over <reasoning>-tag parsing below.
|
||||
native_reasoning: dict = {}
|
||||
|
||||
async for event in async_stream_agno_response_as_agui_events(
|
||||
response_stream=_tee_native_reasoning(
|
||||
response_stream,
|
||||
native_reasoning,
|
||||
),
|
||||
thread_id=thread_id,
|
||||
run_id=run_id,
|
||||
):
|
||||
if event.type in (EventType.RUN_STARTED, EventType.RUN_FINISHED):
|
||||
continue
|
||||
# Propagate an inner-stream error instead of silently dropping
|
||||
# it — otherwise the run would report success with an empty or
|
||||
# partial message. Forward the RUN_ERROR and stop the run.
|
||||
if event.type == EventType.RUN_ERROR:
|
||||
yield event
|
||||
return
|
||||
# Accumulate text content
|
||||
if event.type == EventType.TEXT_MESSAGE_CONTENT:
|
||||
full_text += event.delta # type: ignore[attr-defined]
|
||||
# Buffer tool-call events; they're flushed after the answer.
|
||||
# TOOL_CALL_RESULT must be buffered too — the reasoning agent has
|
||||
# tools, and dropping the result loses the tool-result render in
|
||||
# the reasoning-chain demo. Results follow their START/ARGS/END so
|
||||
# the post-answer flush order below preserves correct sequencing.
|
||||
elif event.type in (
|
||||
EventType.TOOL_CALL_START,
|
||||
EventType.TOOL_CALL_ARGS,
|
||||
EventType.TOOL_CALL_END,
|
||||
EventType.TOOL_CALL_RESULT,
|
||||
):
|
||||
tool_events.append(event)
|
||||
# Skip text start/end — we'll re-emit with reasoning split
|
||||
|
||||
native_reasoning_text = (native_reasoning.get("text") or "").strip()
|
||||
|
||||
if native_reasoning_text:
|
||||
# Native reasoning channel present (reasoning model / aimock
|
||||
# `reasoning` fixture field). This is the production path and the
|
||||
# gold-standard parity channel — use it directly. The answer is
|
||||
# the streamed text minus any stray <reasoning> tags (defensive:
|
||||
# a native-reasoning fixture shouldn't also embed tags, but strip
|
||||
# them so they never leak into the visible answer bubble).
|
||||
reasoning_text = native_reasoning_text
|
||||
answer_text = _REASONING_PATTERN.sub("", full_text).strip()
|
||||
else:
|
||||
# Fallback: parse <reasoning>...</reasoning> tags from the text
|
||||
# (no-native-reasoning fixtures / non-reasoning models).
|
||||
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:
|
||||
# Fallback: check for "Reasoning:" prefix pattern (aimock
|
||||
# fixtures)
|
||||
lower = full_text.lower()
|
||||
if lower.startswith("reasoning:") or lower.startswith("reasoning step"):
|
||||
# Treat the whole text as containing reasoning — emit as
|
||||
# reasoning message so the ReasoningBlock renders, then
|
||||
# re-emit as a text message so CopilotKit's conversation
|
||||
# view has an assistant bubble the D5 probe can read.
|
||||
reasoning_text = full_text.strip()
|
||||
answer_text = full_text.strip()
|
||||
else:
|
||||
reasoning_text = ""
|
||||
answer_text = full_text.strip()
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
# Always emit a text message so CopilotKit renders an assistant
|
||||
# bubble in the conversation. Without this the frontend shows
|
||||
# nothing (reasoning events alone don't produce a visible message
|
||||
# in the default CopilotChat transcript).
|
||||
text_msg_id = str(uuid.uuid4())
|
||||
if answer_text or tool_events:
|
||||
yield TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=text_msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
if answer_text:
|
||||
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,
|
||||
)
|
||||
|
||||
# Emit any tool-call events that were collected
|
||||
for te in tool_events:
|
||||
yield te
|
||||
|
||||
yield RunFinishedEvent(
|
||||
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
|
||||
except asyncio.CancelledError: # noqa: TRY302
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
yield RunErrorEvent(type=EventType.RUN_ERROR, message=str(exc))
|
||||
|
||||
|
||||
def _attach_reasoning_route(app: FastAPI, agent: Agent, prefix: str) -> None:
|
||||
"""Mount a reasoning-aware AGUI POST endpoint at `<prefix>/agui`."""
|
||||
encoder = EventEncoder()
|
||||
route = f"{prefix.rstrip('/')}/agui"
|
||||
|
||||
async def _handler(run_input: RunAgentInput) -> StreamingResponse:
|
||||
async def _gen():
|
||||
async for event in _run_reasoning_agent(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": "*",
|
||||
},
|
||||
)
|
||||
|
||||
app.post(route, name=f"agui_reasoning_{prefix.strip('/')}")(_handler)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgentOS bootstrap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
agent_os = AgentOS(
|
||||
agents=[
|
||||
main_agent,
|
||||
interrupt_agent,
|
||||
a2ui_dynamic_agent,
|
||||
a2ui_fixed_agent,
|
||||
agent_config_agent,
|
||||
byoc_hashbrown_agent,
|
||||
byoc_json_render_agent,
|
||||
gen_ui_agent,
|
||||
mcp_apps_agent,
|
||||
multimodal_agent,
|
||||
open_gen_ui_agent,
|
||||
reasoning_agent,
|
||||
shared_state_rw_agent,
|
||||
subagents_supervisor,
|
||||
],
|
||||
interfaces=[
|
||||
# main_agent is mounted separately below via _attach_hitl_aware_route
|
||||
# so it can forward tool results for HITL round-trips.
|
||||
# reasoning_agent is mounted separately below via
|
||||
# _attach_reasoning_route so /reasoning/agui emits REASONING_MESSAGE_*
|
||||
# events instead of the stock AGUI STEP_STARTED/STEP_FINISHED.
|
||||
# No-tools agent for the MCP Apps cell. The CopilotKit runtime's
|
||||
# `mcpApps.servers` middleware injects MCP server tools at request
|
||||
# time, so the LLM only sees the MCP-provided toolset.
|
||||
AGUI(agent=mcp_apps_agent, prefix="/mcp-apps"), # -> /mcp-apps/agui
|
||||
# No-tools agent for the Open Generative UI cells. The runtime's
|
||||
# `openGenerativeUI` middleware injects the `generateSandboxedUi`
|
||||
# tool the LLM uses to author HTML+CSS for the sandboxed iframe.
|
||||
AGUI(agent=open_gen_ui_agent, prefix="/open-gen-ui"), # -> /open-gen-ui/agui
|
||||
# Vision-capable agent (gpt-4o) for the Multimodal Attachments cell.
|
||||
AGUI(agent=multimodal_agent, prefix="/multimodal"), # -> /multimodal/agui
|
||||
# BYOC: hashbrown — agent emits a hashbrown UI-kit envelope as a single
|
||||
# JSON object that the frontend renderer parses progressively.
|
||||
AGUI(agent=byoc_hashbrown_agent, prefix="/byoc-hashbrown"),
|
||||
# BYOC: json-render — agent emits a json-render spec the frontend
|
||||
# renderer mounts against a Zod-validated catalog.
|
||||
AGUI(agent=byoc_json_render_agent, prefix="/byoc-json-render"),
|
||||
# A2UI dynamic schema — agent owns `generate_a2ui` which calls a
|
||||
# secondary OpenAI client bound to `render_a2ui` and emits an
|
||||
# `a2ui_operations` container the runtime A2UI middleware forwards
|
||||
# to the frontend renderer.
|
||||
AGUI(agent=a2ui_dynamic_agent, prefix="/declarative-gen-ui"),
|
||||
# A2UI fixed schema — agent's `display_flight` tool emits an
|
||||
# `a2ui_operations` container directly (no secondary LLM) bound to
|
||||
# the pre-authored `flight_schema.json`.
|
||||
AGUI(agent=a2ui_fixed_agent, prefix="/a2ui-fixed-schema"),
|
||||
],
|
||||
)
|
||||
app = agent_os.get_app()
|
||||
|
||||
# HITL-aware route for the main agent. Replaces the stock AGUI interface
|
||||
# (``AGUI(agent=main_agent)``) so tool results from the CopilotKit runtime
|
||||
# are forwarded to the LLM on the second leg of HITL flows instead of being
|
||||
# silently dropped by ``extract_agui_user_input()``.
|
||||
_attach_hitl_aware_route(app, main_agent, "")
|
||||
|
||||
# Interrupt-adapted scheduling agent. Shared by gen-ui-interrupt and
|
||||
# interrupt-headless demos -- backend has tools=[], the frontend provides
|
||||
# `schedule_meeting` via `useFrontendTool` with an async Promise handler.
|
||||
_attach_hitl_aware_route(app, interrupt_agent, "/interrupt-adapted")
|
||||
|
||||
# Reasoning-aware route. Replaces the stock AGUI interface
|
||||
# (``AGUI(agent=reasoning_agent, prefix="/reasoning")``) so /reasoning/agui
|
||||
# emits REASONING_MESSAGE_* events (what CopilotKit's
|
||||
# CopilotChatReasoningMessage / custom reasoning slots render) instead of the
|
||||
# stock STEP_STARTED/STEP_FINISHED events.
|
||||
_attach_reasoning_route(app, reasoning_agent, "/reasoning")
|
||||
|
||||
# State-aware routes (bidirectional shared state via StateSnapshotEvent).
|
||||
# Mounted directly on the AgentOS FastAPI app so they share routing and
|
||||
# CORS with the stock AGUI interfaces above.
|
||||
_attach_state_aware_route(app, shared_state_rw_agent, "/shared-state-rw")
|
||||
_attach_state_aware_route(app, subagents_supervisor, "/subagents")
|
||||
# gen-ui-agent: planner that walks 3 steps through pending -> in_progress
|
||||
# -> completed via the `set_steps` tool. Each set_steps call mutates
|
||||
# session_state["steps"], and the state-aware router emits a
|
||||
# StateSnapshotEvent after the run so the UI's useAgent picks it up.
|
||||
_attach_state_aware_route(app, gen_ui_agent, "/gen-ui-agent")
|
||||
|
||||
|
||||
# Agent Config Object cell — builds a per-request Agno Agent whose system
|
||||
# prompt is composed from the CopilotKit provider's forwarded properties
|
||||
# (tone / expertise / responseLength).
|
||||
_attach_agent_config_route(app, "/agent-config")
|
||||
|
||||
|
||||
# Serve /health via middleware so it short-circuits BEFORE route resolution.
|
||||
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``) so
|
||||
# downstream LLM/provider httpx calls inside the request scope copy them
|
||||
# onto their outbound requests. Paired with ``install_global_httpx_hook``
|
||||
# at the top of this file.
|
||||
app.add_middleware(HeaderForwardingHTTPMiddleware)
|
||||
|
||||
# CVDIAG backend emitter (spec §3 Layer 2) — emits the HTTP-observable backend
|
||||
# boundaries (request.ingress, sse.first_byte, sse.event, sse.aborted,
|
||||
# response.complete, error.caught) as structured CVDIAG envelopes. Added LAST so
|
||||
# it is the OUTERMOST layer: it observes ingress before any inner layer mutates
|
||||
# the request and wraps the response stream so SSE boundaries fire as chunks
|
||||
# flow. Gated behind ``CVDIAG_BACKEND_EMITTER`` (default OFF, canary-safe) — the
|
||||
# middleware fast-paths to a bare pass-through when the flag is unset.
|
||||
app.add_middleware(CvdiagBackendMiddleware)
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the uvicorn server.
|
||||
|
||||
``loop="asyncio"`` pins uvicorn's event-loop factory to the stdlib
|
||||
``asyncio`` loop instead of letting its default ``loop="auto"`` select
|
||||
uvloop (installed transitively via ``uvicorn[standard]``). This is
|
||||
load-bearing for header forwarding on the SECONDARY OpenAI call inside
|
||||
the sync ``generate_a2ui`` tool: agno dispatches that sync tool onto a
|
||||
worker thread via ``loop.run_in_executor(...)``, and
|
||||
``install_executor_contextvar_propagation()`` (called at import time)
|
||||
only propagates the forwarded-header ContextVar into that worker thread
|
||||
when the loop is a stdlib ``BaseEventLoop`` subclass. Under uvloop the
|
||||
shim is inert (uvloop's loop is not a ``BaseEventLoop``), so the
|
||||
ContextVar is empty in the executor thread and the secondary call drops
|
||||
``x-aimock-context`` → aimock 503. ``AgentOS.serve(**kwargs)`` forwards
|
||||
``loop`` straight through to ``uvicorn.run``. The prod entrypoint, which
|
||||
launches uvicorn via its CLI rather than this ``serve`` path, pins the
|
||||
same loop via ``--loop asyncio`` in ``entrypoint.sh``.
|
||||
"""
|
||||
port = int(os.getenv("PORT", "8000"))
|
||||
agent_os.serve(
|
||||
app="agent_server:app",
|
||||
host="0.0.0.0",
|
||||
port=port,
|
||||
reload=True,
|
||||
loop="asyncio",
|
||||
)
|
||||
|
||||
|
||||
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 = "agno"
|
||||
|
||||
# ── Env gate ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_BACKEND_EMITTER_ENV = "CVDIAG_BACKEND_EMITTER"
|
||||
|
||||
|
||||
def cvdiag_backend_enabled() -> bool:
|
||||
"""True iff the backend emitter is explicitly enabled (default OFF).
|
||||
|
||||
Read live (not cached) so a test can toggle the env var per-case via
|
||||
``monkeypatch.setenv``; the cost is one ``os.environ`` lookup per emit,
|
||||
which is negligible against the JSON serialization that follows.
|
||||
"""
|
||||
return os.environ.get(_BACKEND_EMITTER_ENV) == "1"
|
||||
|
||||
|
||||
# ── Tier ordering (spec §6) ────────────────────────────────────────────────
|
||||
|
||||
_TIER_RANK = {"default": 0, "verbose": 1, "debug": 2}
|
||||
|
||||
# Per-boundary minimum tier required to emit (spec §6 matrix, backend rows).
|
||||
_BOUNDARY_TIER: Dict[str, str] = {
|
||||
"backend.request.ingress": "verbose",
|
||||
"backend.agent.enter": "default",
|
||||
"backend.llm.call.start": "verbose",
|
||||
"backend.llm.call.heartbeat": "verbose",
|
||||
"backend.llm.call.response": "verbose",
|
||||
"backend.sse.first_byte": "verbose",
|
||||
"backend.sse.event": "debug",
|
||||
"backend.sse.aborted": "default",
|
||||
"backend.agent.exit": "default",
|
||||
"backend.response.complete": "default",
|
||||
"backend.error.caught": "default",
|
||||
}
|
||||
|
||||
|
||||
def _active_tier() -> str:
|
||||
"""Resolve the verbosity tier from a LIVE env read.
|
||||
|
||||
``cvdiag_backend_enabled()`` reads ``CVDIAG_BACKEND_EMITTER`` live, so the
|
||||
tier MUST be read from the same live source — otherwise flipping
|
||||
``CVDIAG_VERBOSE`` / ``CVDIAG_DEBUG`` AFTER import arms the emitter but the
|
||||
tier stays frozen at the import-time ``setup()`` value, silently no-op'ing
|
||||
every verbose/debug-gated boundary. We reuse the bootstrap's
|
||||
``_resolve_tier`` so the §6 fail-closed DEBUG guard still applies (a
|
||||
production / unresolved DEBUG request raises → degrade to the frozen tier).
|
||||
"""
|
||||
try:
|
||||
return _resolve_tier(dict(os.environ))
|
||||
except RuntimeError:
|
||||
# Fail-closed DEBUG refusal: fall back to the import-time resolved tier
|
||||
# (never silently escalate to debug in production).
|
||||
return current_tier()
|
||||
|
||||
|
||||
def _tier_permits(boundary: str) -> bool:
|
||||
"""True iff the active tier is at-or-above the boundary's minimum tier."""
|
||||
need = _TIER_RANK.get(_BOUNDARY_TIER.get(boundary, "default"), 0)
|
||||
have = _TIER_RANK.get(_active_tier(), 0)
|
||||
return have >= need
|
||||
|
||||
|
||||
# ── Edge headers (spec §5 — 9-key allow-list + 12-name deny-list) ───────────
|
||||
|
||||
# The closed 9-key edge-header allow-list. Always-present in the envelope;
|
||||
# absent header → ``None``.
|
||||
_EDGE_ALLOW = (
|
||||
"cf-ray",
|
||||
"cf-mitigated",
|
||||
"cf-cache-status",
|
||||
"x-railway-edge",
|
||||
"x-railway-request-id",
|
||||
"x-hikari-trace",
|
||||
"retry-after",
|
||||
"via",
|
||||
"server",
|
||||
)
|
||||
|
||||
# Exact-match deny-list (spec §5). REJECTED even if accidentally present in the
|
||||
# allow-list — these carry client IP / geo PII and must never round-trip.
|
||||
_EDGE_DENY = frozenset(
|
||||
{
|
||||
"cf-ipcountry",
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcity",
|
||||
"cf-iplatitude",
|
||||
"cf-iplongitude",
|
||||
"cf-iptimezone",
|
||||
"cf-visitor",
|
||||
"cf-worker",
|
||||
"true-client-ip",
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
"forwarded",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def extract_edge_headers(headers: Any) -> Dict[str, Optional[str]]:
|
||||
"""Build the closed 9-key ``edge_headers`` bag from a headers mapping.
|
||||
|
||||
All nine keys are ALWAYS present; an absent (or deny-listed) header maps to
|
||||
``None``. ``headers`` is any case-insensitive mapping exposing ``.get`` /
|
||||
iteration of ``(name, value)`` pairs (Starlette ``Headers``, httpx, dict).
|
||||
"""
|
||||
bag: Dict[str, Optional[str]] = {k: None for k in _EDGE_ALLOW}
|
||||
if headers is None:
|
||||
return bag
|
||||
try:
|
||||
getter = headers.get
|
||||
except AttributeError:
|
||||
return bag
|
||||
for key in _EDGE_ALLOW:
|
||||
if key in _EDGE_DENY: # belt-and-braces: never emit a deny-listed key
|
||||
continue
|
||||
val = getter(key)
|
||||
if val is not None:
|
||||
bag[key] = str(val)
|
||||
return bag
|
||||
|
||||
|
||||
# ── PII scrub (spec §6) ──────────────────────────────────────────────────────
|
||||
|
||||
# Bearer tokens, OpenAI/Stripe-style secret keys, publishable keys, and URL
|
||||
# userinfo. Applied to any captured free-text metadata value
|
||||
# (``message_scrubbed``, stack frames) before it is emitted. The ``sk-``/``pk-``
|
||||
# key bodies allow hyphens/underscores so test-style keys such as the spec
|
||||
# regression fixture ``sk-test-12345`` are redacted alongside real production
|
||||
# keys (``sk-<48+ base62>``).
|
||||
#
|
||||
# Parity with the canonical TS scrubber (``harness/src/cvdiag/scrub.ts``):
|
||||
# * Bearer — grabs the WHOLE token (``\S+``) to match TS ``Bearer\s+\S+``;
|
||||
# the legacy ``[A-Za-z0-9._\-]+`` stopped at ``/``/``+``/``=`` and left an
|
||||
# un-redacted JWT tail (e.g. ``Bearer a.b.c/sig+more=`` → ``…/sig+more=``).
|
||||
# * URL userinfo — redacts BOTH ``scheme://user:pw@host`` AND colon-less
|
||||
# ``scheme://token@host`` (TS ``([scheme]://)[^/\s?#]*@``); the legacy
|
||||
# ``[^/\s:@]+:[^/\s@]+@`` required a mandatory ``:`` so a bare-token
|
||||
# authority such as ``https://ghp_xxx@host`` LEAKED. The userinfo class
|
||||
# excludes ``?``/``#`` so the match never crosses into the query/fragment.
|
||||
_SCRUB_PATTERNS = (
|
||||
re.compile(r"Bearer\s+\S+", re.IGNORECASE),
|
||||
re.compile(r"\bsk-[A-Za-z0-9][A-Za-z0-9_-]{3,}"),
|
||||
re.compile(r"\bpk-[A-Za-z0-9][A-Za-z0-9_-]{3,}"),
|
||||
re.compile(r"(?P<scheme>[a-z][a-z0-9+.\-]*://)[^/\s?#]*@", re.IGNORECASE),
|
||||
)
|
||||
|
||||
# Per-event field byte caps (spec §5). message_scrubbed ≤512B.
|
||||
_MESSAGE_CAP = 512
|
||||
|
||||
# Hard input-size guard (mirrors TS ``SCRUB_MAX_SCAN_LEN``): no regex ever runs
|
||||
# on a string longer than this. A longer value has only its bounded prefix
|
||||
# scanned and a self-describing ``…[unscanned:<N>]`` marker records the dropped
|
||||
# tail length, so an adversarial multi-KB string can never make the regex
|
||||
# engine scan unbounded input. 2 KB covers any legitimate metadata value with
|
||||
# headroom. Set below the byte cap so the marker survives the §5 byte clamp.
|
||||
_SCRUB_MAX_SCAN_LEN = 400
|
||||
|
||||
|
||||
def _run_scrub_regexes(s: str) -> str:
|
||||
"""Apply the secret regexes in sequence (TS ``runScrubRegexes`` parity)."""
|
||||
for pat in _SCRUB_PATTERNS:
|
||||
if pat.groupindex.get("scheme"):
|
||||
s = pat.sub(r"\g<scheme>[REDACTED]@", s)
|
||||
else:
|
||||
s = pat.sub("[REDACTED]", s)
|
||||
return s
|
||||
|
||||
|
||||
def scrub(text: Any) -> str:
|
||||
"""Redact secrets from a free-text value and cap it at 512 bytes.
|
||||
|
||||
Returns ``"[REDACTED]"`` substitutions for any matched secret pattern so a
|
||||
synthetic ``sk-test-12345`` in an exception message can never reach the
|
||||
emitted envelope. A value longer than ``_SCRUB_MAX_SCAN_LEN`` has only its
|
||||
bounded prefix scanned, with an ``…[unscanned:<N>]`` marker (TS parity).
|
||||
"""
|
||||
if text is None:
|
||||
return ""
|
||||
s = str(text)
|
||||
if len(s) > _SCRUB_MAX_SCAN_LEN:
|
||||
dropped_tail = len(s) - _SCRUB_MAX_SCAN_LEN
|
||||
s = f"{_run_scrub_regexes(s[:_SCRUB_MAX_SCAN_LEN])}…[unscanned:{dropped_tail}]"
|
||||
else:
|
||||
s = _run_scrub_regexes(s)
|
||||
encoded = s.encode("utf-8")
|
||||
if len(encoded) > _MESSAGE_CAP:
|
||||
s = encoded[:_MESSAGE_CAP].decode("utf-8", errors="ignore")
|
||||
return s
|
||||
|
||||
|
||||
# ── Envelope construction ──────────────────────────────────────────────────
|
||||
|
||||
_TEST_ID_HEADER = "x-test-id"
|
||||
_AIMOCK_CONTEXT_HEADER = "x-aimock-context"
|
||||
# UUIDv7 variant/version nibbles (RFC 9562) the schema regex requires.
|
||||
_SLUG_FALLBACK = "unknown"
|
||||
_DEMO_FALLBACK = "default"
|
||||
|
||||
|
||||
def _uuid7() -> str:
|
||||
"""Generate a lowercase-hyphenated UUIDv7 (RFC 9562) string.
|
||||
|
||||
48-bit Unix-ms timestamp, version nibble 7, variant 10 — matches the
|
||||
schema ``TEST_ID_PATTERN``. Used as the fallback ``test_id`` when no
|
||||
inbound ``x-test-id`` correlation header is present.
|
||||
"""
|
||||
unix_ms = int(time.time() * 1000) & ((1 << 48) - 1)
|
||||
rand_a = secrets.randbits(12)
|
||||
rand_b = secrets.randbits(62)
|
||||
msb = (unix_ms << 16) | (0x7 << 12) | rand_a
|
||||
lsb = (0b10 << 62) | rand_b
|
||||
return str(uuid.UUID(int=(msb << 64) | lsb))
|
||||
|
||||
|
||||
_UUID7_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
)
|
||||
|
||||
|
||||
def normalize_test_id(raw: Optional[str]) -> str:
|
||||
"""Return a schema-valid lowercased UUIDv7, minting one if ``raw`` is
|
||||
absent or not a well-formed UUIDv7."""
|
||||
if raw:
|
||||
candidate = raw.strip().lower()
|
||||
if _UUID7_RE.match(candidate):
|
||||
return candidate
|
||||
return _uuid7()
|
||||
|
||||
|
||||
def _span_id() -> str:
|
||||
"""16-hex span id, unique per emit (schema ``SPAN_ID_PATTERN``)."""
|
||||
return secrets.token_hex(8)
|
||||
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||
|
||||
|
||||
def _normalize_slug(raw: Optional[str]) -> str:
|
||||
"""Coerce the inbound ``x-aimock-context`` slug into the closed slug shape
|
||||
(``^[a-z][a-z0-9-]{0,63}$``), falling back to ``unknown`` when unusable."""
|
||||
if raw:
|
||||
candidate = raw.strip().lower()
|
||||
if _SLUG_RE.match(candidate):
|
||||
return candidate
|
||||
return _SLUG_FALLBACK
|
||||
|
||||
|
||||
def build_envelope(
|
||||
*,
|
||||
boundary: str,
|
||||
outcome: str,
|
||||
test_id: str,
|
||||
slug: str,
|
||||
demo: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
edge_headers: Optional[Dict[str, Optional[str]]] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
parent_span_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Assemble a schema-v1 backend envelope (``layer=backend``).
|
||||
|
||||
All envelope-required fields are populated; ``edge_headers`` defaults to the
|
||||
closed 9-key all-null bag when not supplied. ``metadata`` is passed through
|
||||
verbatim — unknown keys are stamped ``_metadata_dropped`` by the schema
|
||||
validator inside ``emit_cvdiag``.
|
||||
"""
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"test_id": test_id,
|
||||
"trace_id": test_id,
|
||||
"span_id": _span_id(),
|
||||
"parent_span_id": parent_span_id,
|
||||
"layer": "backend",
|
||||
"boundary": boundary,
|
||||
"slug": slug,
|
||||
"demo": demo,
|
||||
"ts": _now_iso(),
|
||||
"mono_ns": time.monotonic_ns(),
|
||||
"duration_ms": duration_ms,
|
||||
"outcome": outcome,
|
||||
"edge_headers": edge_headers or {k: None for k in _EDGE_ALLOW},
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""ISO-8601 millisecond-precision timestamp with a ``Z`` suffix."""
|
||||
# ``time.gmtime`` + manual ms keeps this dependency-free and 3.9-safe.
|
||||
now = time.time()
|
||||
secs = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now))
|
||||
ms = int((now - int(now)) * 1000)
|
||||
return f"{secs}.{ms:03d}Z"
|
||||
|
||||
|
||||
def emit_backend_boundary(
|
||||
boundary: str,
|
||||
*,
|
||||
outcome: str = "info",
|
||||
test_id: str,
|
||||
slug: str,
|
||||
demo: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
edge_headers: Optional[Dict[str, Optional[str]]] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
parent_span_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Emit one backend boundary envelope, honoring the env gate + tier matrix.
|
||||
|
||||
No-op when the emitter is disabled or the active tier does not permit this
|
||||
boundary. Never raises into the caller.
|
||||
"""
|
||||
if not cvdiag_backend_enabled():
|
||||
return
|
||||
if not _tier_permits(boundary):
|
||||
return
|
||||
try:
|
||||
envelope = build_envelope(
|
||||
boundary=boundary,
|
||||
outcome=outcome,
|
||||
test_id=test_id,
|
||||
slug=slug,
|
||||
demo=demo,
|
||||
metadata=metadata,
|
||||
edge_headers=edge_headers,
|
||||
duration_ms=duration_ms,
|
||||
parent_span_id=parent_span_id,
|
||||
)
|
||||
emit_cvdiag(envelope)
|
||||
except Exception as err: # noqa: BLE001 - instrumentation must not throw
|
||||
logger.warning("CVDIAG backend emit-failed boundary=%s error=%s", boundary, err)
|
||||
|
||||
|
||||
# ── Per-request correlation context ─────────────────────────────────────────
|
||||
|
||||
|
||||
class _RequestCtx:
|
||||
"""Holds the per-request correlation identity + timing the boundaries share.
|
||||
|
||||
Carried on ``request.state`` so the middleware, the LLM hook, and the agent
|
||||
hooks all stamp the same ``test_id`` / ``slug`` / ``demo`` onto their
|
||||
envelopes.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"test_id",
|
||||
"slug",
|
||||
"demo",
|
||||
"ingress_mono_ns",
|
||||
"sse_seq",
|
||||
"first_byte_emitted",
|
||||
"bytes_streamed",
|
||||
)
|
||||
|
||||
def __init__(self, *, test_id: str, slug: str, demo: str) -> None:
|
||||
self.test_id = test_id
|
||||
self.slug = slug
|
||||
self.demo = demo
|
||||
self.ingress_mono_ns = time.monotonic_ns()
|
||||
self.sse_seq = 0
|
||||
self.first_byte_emitted = False
|
||||
self.bytes_streamed = 0
|
||||
|
||||
|
||||
def _demo_from_path(path: str) -> str:
|
||||
"""Derive the ``demo`` label from the mounted sub-app path.
|
||||
|
||||
Each demo is mounted at ``/<demo>`` (e.g. ``/voice``, ``/byoc-hashbrown``);
|
||||
the root agent serves the default demo. Strip the leading slash and any
|
||||
trailing AG-UI segment so ``/byoc-hashbrown/`` → ``byoc-hashbrown`` and
|
||||
``/`` → ``default``.
|
||||
"""
|
||||
trimmed = path.strip("/")
|
||||
if not trimmed:
|
||||
return _DEMO_FALLBACK
|
||||
return trimmed.split("/", 1)[0] or _DEMO_FALLBACK
|
||||
|
||||
|
||||
# ── HTTP middleware: ingress / first_byte / sse.event / sse.aborted /
|
||||
# response.complete / error.caught ─────────────────────────────────────────
|
||||
|
||||
|
||||
class CvdiagBackendMiddleware(BaseHTTPMiddleware):
|
||||
"""Starlette middleware emitting the HTTP-observable backend boundaries.
|
||||
|
||||
Wires six of the eleven boundaries around the request lifecycle:
|
||||
|
||||
* ``backend.request.ingress`` on entry
|
||||
* ``backend.sse.first_byte`` on the first streamed chunk
|
||||
* ``backend.sse.event`` per streamed chunk (debug tier)
|
||||
* ``backend.sse.aborted`` on premature stream termination
|
||||
* ``backend.response.complete`` on clean stream close
|
||||
* ``backend.error.caught`` on any exception escaping the inner app
|
||||
|
||||
The agent/LLM boundaries (``agent.enter``, ``llm.call.*``, ``agent.exit``)
|
||||
are emitted by the agent hooks / LLM httpx hook installed separately, all
|
||||
keyed on the same ``test_id`` this middleware stamps onto ``request.state``.
|
||||
|
||||
Inert when ``CVDIAG_BACKEND_EMITTER`` is off: the dispatch fast-paths to a
|
||||
bare ``call_next`` with no envelope construction and no response wrapping.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
if not cvdiag_backend_enabled():
|
||||
return await call_next(request)
|
||||
|
||||
headers = request.headers
|
||||
ctx = _RequestCtx(
|
||||
test_id=normalize_test_id(headers.get(_TEST_ID_HEADER)),
|
||||
slug=_normalize_slug(headers.get(_AIMOCK_CONTEXT_HEADER)),
|
||||
demo=_demo_from_path(request.url.path),
|
||||
)
|
||||
request.state.cvdiag = ctx
|
||||
|
||||
emit_backend_boundary(
|
||||
"backend.request.ingress",
|
||||
outcome="info",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
edge_headers=extract_edge_headers(headers),
|
||||
metadata={
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"content_length": _int_or_none(headers.get("content-length")),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as exc: # noqa: BLE001 - observe then re-raise
|
||||
emit_backend_boundary(
|
||||
"backend.error.caught",
|
||||
outcome="err",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
metadata={
|
||||
"exception_type": type(exc).__name__,
|
||||
"message_scrubbed": scrub(str(exc)),
|
||||
"stack_brief": [],
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
return self._wrap_response(request, response, ctx)
|
||||
|
||||
def _wrap_response(
|
||||
self, request: Request, response: Response, ctx: "_RequestCtx"
|
||||
) -> Response:
|
||||
"""Wrap a streaming response so SSE boundaries fire as chunks flow.
|
||||
|
||||
Non-streaming responses are returned unwrapped after emitting
|
||||
``backend.response.complete`` directly.
|
||||
|
||||
NOTE: ``BaseHTTPMiddleware`` re-wraps the inner ``StreamingResponse`` as
|
||||
a private ``_StreamingResponse`` before it reaches us, so an
|
||||
``isinstance(response, StreamingResponse)`` check is always False here.
|
||||
Detect streaming by the presence of a ``body_iterator`` (which both the
|
||||
public and the private response carry) instead.
|
||||
"""
|
||||
if not hasattr(response, "body_iterator"):
|
||||
emit_backend_boundary(
|
||||
"backend.response.complete",
|
||||
outcome="ok",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
duration_ms=_elapsed_ms(ctx.ingress_mono_ns),
|
||||
edge_headers=extract_edge_headers(response.headers),
|
||||
metadata={
|
||||
"http_status": response.status_code,
|
||||
"content_length": _int_or_none(
|
||||
response.headers.get("content-length")
|
||||
),
|
||||
"total_duration_ms": _elapsed_ms(ctx.ingress_mono_ns),
|
||||
"sse_event_count": ctx.sse_seq,
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
inner = response.body_iterator
|
||||
edge = extract_edge_headers(response.headers)
|
||||
status = response.status_code
|
||||
|
||||
async def _instrumented():
|
||||
# ``completed`` distinguishes a clean stream exhaustion (→
|
||||
# response.complete) from an early termination (→ sse.aborted).
|
||||
#
|
||||
# IMPORTANT (Starlette ``BaseHTTPMiddleware`` quirk): when the INNER
|
||||
# endpoint generator raises mid-stream, Starlette swallows the error
|
||||
# internally and our ``async for`` simply ends — we never see an
|
||||
# exception there. The abort surface we CAN observe is the consumer
|
||||
# tearing the stream down early (client disconnect), which closes
|
||||
# this generator and raises ``GeneratorExit`` / ``CancelledError``
|
||||
# into it. We therefore catch ``BaseException`` (not just
|
||||
# ``Exception``) so a disconnect-driven abort is captured, and emit
|
||||
# ``backend.response.complete`` only on a clean exhaustion.
|
||||
completed = False
|
||||
terminated_kind = "rst"
|
||||
try:
|
||||
async for chunk in inner:
|
||||
ctx.bytes_streamed += len(chunk) if chunk else 0
|
||||
if not ctx.first_byte_emitted:
|
||||
ctx.first_byte_emitted = True
|
||||
emit_backend_boundary(
|
||||
"backend.sse.first_byte",
|
||||
outcome="info",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
edge_headers=edge,
|
||||
metadata={
|
||||
"delta_ms_from_ingress": _elapsed_ms(
|
||||
ctx.ingress_mono_ns
|
||||
)
|
||||
},
|
||||
)
|
||||
emit_backend_boundary(
|
||||
"backend.sse.event",
|
||||
outcome="info",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
metadata={
|
||||
"event_type": "chunk",
|
||||
"payload_size_bytes": len(chunk) if chunk else 0,
|
||||
"sequence_num": ctx.sse_seq,
|
||||
},
|
||||
)
|
||||
ctx.sse_seq += 1
|
||||
yield chunk
|
||||
completed = True
|
||||
except BaseException as exc: # noqa: BLE001 - observe abort then re-raise
|
||||
# GeneratorExit (disconnect) and CancelledError carry no
|
||||
# message; an in-iterator error would. Pick a termination_kind.
|
||||
terminated_kind = (
|
||||
"rst"
|
||||
if isinstance(exc, (GeneratorExit,))
|
||||
else (
|
||||
"timeout"
|
||||
if isinstance(exc, asyncio.CancelledError)
|
||||
else "chunk_error"
|
||||
)
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if completed:
|
||||
emit_backend_boundary(
|
||||
"backend.response.complete",
|
||||
outcome="ok",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
duration_ms=_elapsed_ms(ctx.ingress_mono_ns),
|
||||
edge_headers=edge,
|
||||
metadata={
|
||||
"http_status": status,
|
||||
"content_length": ctx.bytes_streamed,
|
||||
"total_duration_ms": _elapsed_ms(ctx.ingress_mono_ns),
|
||||
"sse_event_count": ctx.sse_seq,
|
||||
},
|
||||
)
|
||||
else:
|
||||
emit_backend_boundary(
|
||||
"backend.sse.aborted",
|
||||
outcome="err",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
edge_headers=edge,
|
||||
metadata={
|
||||
"termination_kind": terminated_kind,
|
||||
"bytes_before_abort": ctx.bytes_streamed,
|
||||
},
|
||||
)
|
||||
|
||||
response.body_iterator = _instrumented()
|
||||
return response
|
||||
|
||||
|
||||
def _int_or_none(raw: Any) -> Optional[int]:
|
||||
"""Parse an int header value, returning ``None`` on absence / malformed."""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _elapsed_ms(start_mono_ns: int) -> int:
|
||||
"""Whole milliseconds elapsed since a ``time.monotonic_ns`` start mark."""
|
||||
return max(0, (time.monotonic_ns() - start_mono_ns) // 1_000_000)
|
||||
|
||||
|
||||
# ── Agent + LLM boundaries ──────────────────────────────────────────────────
|
||||
|
||||
# The LLM-call boundaries (start / heartbeat / response) and the agent
|
||||
# enter/exit boundaries are emitted via the explicit helpers below. They are
|
||||
# called from the agent factory's hook points (strands ``HookProvider``) and
|
||||
# from the outbound httpx event hook, all keyed on the request ``ctx``.
|
||||
|
||||
|
||||
def emit_agent_enter(ctx: "_RequestCtx", *, agent_name: str, model_id: str) -> None:
|
||||
"""Emit ``backend.agent.enter`` (default tier)."""
|
||||
emit_backend_boundary(
|
||||
"backend.agent.enter",
|
||||
outcome="info",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
metadata={"agent_name": agent_name, "model_id": model_id},
|
||||
)
|
||||
|
||||
|
||||
def emit_agent_exit(
|
||||
ctx: "_RequestCtx", *, terminal_outcome: str, total_duration_ms: int
|
||||
) -> None:
|
||||
"""Emit ``backend.agent.exit`` (default tier)."""
|
||||
emit_backend_boundary(
|
||||
"backend.agent.exit",
|
||||
outcome="ok" if terminal_outcome == "ok" else "err",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
duration_ms=total_duration_ms,
|
||||
metadata={
|
||||
"terminal_outcome": terminal_outcome,
|
||||
"total_duration_ms": total_duration_ms,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class LlmCallScope:
|
||||
"""Async context manager spanning one outbound LLM call.
|
||||
|
||||
On ``__aenter__`` emits ``backend.llm.call.start`` and launches a heartbeat
|
||||
task that emits ``backend.llm.call.heartbeat`` every ``interval_s`` (≈10s)
|
||||
while the call is outstanding (verbose tier). On ``__aexit__`` emits
|
||||
``backend.llm.call.response`` with the measured latency.
|
||||
|
||||
All emission is gated/tiered through ``emit_backend_boundary``, so with the
|
||||
emitter off or at default tier this scope is effectively free (the
|
||||
heartbeat task still ticks but every emit is suppressed; callers that want
|
||||
zero task overhead can skip the scope when ``cvdiag_backend_enabled()`` is
|
||||
false).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: "_RequestCtx",
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
prompt_token_count_estimate: int = 0,
|
||||
interval_s: float = 10.0,
|
||||
) -> None:
|
||||
self._ctx = ctx
|
||||
self._provider = provider
|
||||
self._model = model
|
||||
self._prompt_tokens = prompt_token_count_estimate
|
||||
self._interval_s = interval_s
|
||||
self._start_mono_ns = 0
|
||||
self._hb_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def __aenter__(self) -> "LlmCallScope":
|
||||
self._start_mono_ns = time.monotonic_ns()
|
||||
emit_backend_boundary(
|
||||
"backend.llm.call.start",
|
||||
outcome="info",
|
||||
test_id=self._ctx.test_id,
|
||||
slug=self._ctx.slug,
|
||||
demo=self._ctx.demo,
|
||||
metadata={
|
||||
"provider": self._provider,
|
||||
"model": self._model,
|
||||
"prompt_token_count_estimate": self._prompt_tokens,
|
||||
},
|
||||
)
|
||||
self._hb_task = asyncio.ensure_future(self._heartbeat())
|
||||
return self
|
||||
|
||||
async def _heartbeat(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(self._interval_s)
|
||||
emit_backend_boundary(
|
||||
"backend.llm.call.heartbeat",
|
||||
outcome="info",
|
||||
test_id=self._ctx.test_id,
|
||||
slug=self._ctx.slug,
|
||||
demo=self._ctx.demo,
|
||||
metadata={
|
||||
"elapsed_ms_since_start": _elapsed_ms(self._start_mono_ns)
|
||||
},
|
||||
)
|
||||
except asyncio.CancelledError: # normal shutdown on call completion
|
||||
return
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||
if self._hb_task is not None:
|
||||
hb_task = self._hb_task
|
||||
self._hb_task = None
|
||||
hb_task.cancel()
|
||||
try:
|
||||
await hb_task
|
||||
except asyncio.CancelledError:
|
||||
# Cooperative cancellation (was ``except (CancelledError,
|
||||
# Exception)``, which swallowed the CALLER's cancel and broke
|
||||
# cooperative cancellation). Suppress ONLY the heartbeat task's
|
||||
# OWN cancellation — the one we just requested. If THIS task is
|
||||
# being cancelled by the caller (a pending cancellation request,
|
||||
# ``current_task().cancelling() > 0``), the CancelledError is the
|
||||
# caller's and MUST propagate. ``Task.cancelling()`` is 3.11+
|
||||
# (production runs 3.12); on older runtimes the attribute is
|
||||
# absent and we degrade to suppressing (the legacy behavior).
|
||||
current = asyncio.current_task()
|
||||
cancelling = getattr(current, "cancelling", None)
|
||||
if current is not None and cancelling is not None and cancelling() > 0:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - heartbeat body must never throw out
|
||||
pass
|
||||
emit_backend_boundary(
|
||||
"backend.llm.call.response",
|
||||
outcome="err" if exc_type is not None else "ok",
|
||||
test_id=self._ctx.test_id,
|
||||
slug=self._ctx.slug,
|
||||
demo=self._ctx.demo,
|
||||
duration_ms=_elapsed_ms(self._start_mono_ns),
|
||||
metadata={
|
||||
"provider": self._provider,
|
||||
"model": self._model,
|
||||
"response_token_count": None,
|
||||
"latency_ms": _elapsed_ms(self._start_mono_ns),
|
||||
"error_class": type(exc).__name__ if exc is not None else None,
|
||||
},
|
||||
)
|
||||
return False # never suppress the underlying exception
|
||||
@@ -0,0 +1,467 @@
|
||||
"""Standalone header-forwarding shim for showcase integrations.
|
||||
|
||||
Forward CopilotKit request-context headers (e.g. ``x-aimock-context``)
|
||||
onto outbound LLM/provider HTTP calls so the locally-served aimock test
|
||||
server can match the right fixture for each in-flight showcase request.
|
||||
|
||||
This module is a SELF-CONTAINED port of the langgraph-python reference
|
||||
shim at ``copilotkit/header_propagation.py`` plus a small Starlette HTTP
|
||||
middleware that extracts inbound ``x-*`` headers at request scope.
|
||||
|
||||
It is intentionally duplicated into every Python showcase integration
|
||||
that does NOT already depend on the ``copilotkit`` SDK so each backend
|
||||
has a single self-contained file it can import without adding a heavy
|
||||
``copilotkit`` (langchain-pulling) dependency.
|
||||
|
||||
What this module does
|
||||
---------------------
|
||||
Three things, kept deliberately small:
|
||||
|
||||
1. ``HeaderForwardingHTTPMiddleware`` — a Starlette/FastAPI HTTP
|
||||
middleware that, on every inbound request, extracts ``x-*`` prefixed
|
||||
headers and stashes them on a per-request ``contextvars.ContextVar``.
|
||||
2. ``install_httpx_hook(client)`` — attaches an httpx request event hook
|
||||
to the given LLM client's underlying httpx client (walking the
|
||||
``._client`` chain that modern provider SDKs wrap their httpx client
|
||||
behind). The hook copies the recorded headers onto outbound requests.
|
||||
3. ``set_forwarded_headers`` / ``get_forwarded_headers`` — direct
|
||||
ContextVar accessors for integrations that need to populate the
|
||||
header set from a non-HTTP source (e.g. LangGraph's RunnableConfig
|
||||
``configurable`` channel).
|
||||
|
||||
Scope and limits
|
||||
----------------
|
||||
* Only ``x-*`` prefixed headers are forwarded. ``authorization``,
|
||||
``content-type``, and any other non-``x-*`` headers are dropped.
|
||||
* Nothing is collected, persisted, or sent anywhere — the module only
|
||||
attaches headers to an HTTP request that the caller was already going
|
||||
to make. No telemetry, no out-of-band channel. (Diagnostic CVDIAG
|
||||
breadcrumbs ARE logged via the stdlib ``logging`` module: header
|
||||
PRESENCE plus a short value prefix only — never full header values.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
import warnings
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# CVDIAG correlation-header instrumentation tag for this integration. Each
|
||||
# showcase backend that copies this shim sets a distinct framework tag so the
|
||||
# CVDIAG breadcrumb trail identifies which backend captured/forwarded headers.
|
||||
_CVDIAG_FRAMEWORK = "agno"
|
||||
|
||||
# Correlation headers carried end-to-end through the showcase request chain.
|
||||
_DIAG_RUN_ID_HEADER = "x-diag-run-id"
|
||||
_DIAG_HOPS_HEADER = "x-diag-hops"
|
||||
_AIMOCK_CONTEXT_HEADER = "x-aimock-context"
|
||||
_TEST_ID_HEADER = "x-test-id"
|
||||
|
||||
|
||||
def _cvdiag(
|
||||
boundary: str,
|
||||
headers: Dict[str, str],
|
||||
*,
|
||||
status: str,
|
||||
hop: object = "-",
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""Emit a single standardized CVDIAG breadcrumb line.
|
||||
|
||||
Logs ONLY header presence + a short value prefix (never full header
|
||||
values). ``headers`` is the lowercased ``x-*`` header mapping for the
|
||||
current request context.
|
||||
"""
|
||||
slug = headers.get(_AIMOCK_CONTEXT_HEADER)
|
||||
run_id = headers.get(_DIAG_RUN_ID_HEADER, "none")
|
||||
test_id = headers.get(_TEST_ID_HEADER, "none")
|
||||
present = slug is not None
|
||||
prefix = (slug or "")[:12]
|
||||
logger.info(
|
||||
"CVDIAG component=backend-%s boundary=%s run_id=%s slug=%s "
|
||||
"header_present=%s header_value_prefix=%s hop=%s status=%s "
|
||||
"test_id=%s error=%s",
|
||||
_CVDIAG_FRAMEWORK,
|
||||
boundary,
|
||||
run_id,
|
||||
slug if present else "MISSING",
|
||||
"true" if present else "false",
|
||||
prefix,
|
||||
hop,
|
||||
status,
|
||||
test_id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
# Per-request storage for the headers the application has asked to forward
|
||||
# onto outbound LLM/provider calls.
|
||||
_forwarded_headers: contextvars.ContextVar[Dict[str, str]] = contextvars.ContextVar(
|
||||
"copilotkit_forwarded_headers"
|
||||
)
|
||||
|
||||
# Marker used to identify hooks we have already installed so the install
|
||||
# call is idempotent across repeated invocations on the same client.
|
||||
_HOOK_MARKER = "_copilotkit_forwarded_header_hook"
|
||||
|
||||
# Bound on how deep we'll walk a ``._client`` chain looking for event_hooks.
|
||||
# Modern provider SDKs (OpenAI, Anthropic, pydantic-ai wrappers, agno's
|
||||
# OpenAIChat, strands' OpenAIModel) wrap their httpx client behind 2-4
|
||||
# layers of ``._client`` indirection; 5 hops is enough headroom without
|
||||
# risking pathological loops.
|
||||
_MAX_CHAIN_DEPTH = 5
|
||||
|
||||
|
||||
def set_forwarded_headers(headers: Dict[str, str]) -> None:
|
||||
"""Record headers to forward onto outbound LLM/provider calls.
|
||||
|
||||
Only ``x-*`` prefixed headers are kept; everything else is dropped.
|
||||
"""
|
||||
filtered = {k.lower(): v for k, v in headers.items() if k.lower().startswith("x-")}
|
||||
_forwarded_headers.set(filtered)
|
||||
|
||||
|
||||
def get_forwarded_headers() -> Dict[str, str]:
|
||||
"""Return the headers recorded for the current request context."""
|
||||
return _forwarded_headers.get({})
|
||||
|
||||
|
||||
class HeaderForwardingHTTPMiddleware(BaseHTTPMiddleware):
|
||||
"""Starlette/FastAPI middleware that captures inbound ``x-*`` headers.
|
||||
|
||||
On every inbound HTTP request, copies all ``x-*`` prefixed headers
|
||||
onto the per-request ContextVar so any outbound httpx call made
|
||||
inside the request scope (the LLM call hop 2) sees them via
|
||||
``get_forwarded_headers()`` and the installed httpx event hook.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
headers = {
|
||||
k: v for k, v in request.headers.items() if k.lower().startswith("x-")
|
||||
}
|
||||
set_forwarded_headers(headers)
|
||||
captured = {k.lower(): v for k, v in headers.items()}
|
||||
_cvdiag(
|
||||
"contextvar-capture",
|
||||
captured,
|
||||
status="ok" if _AIMOCK_CONTEXT_HEADER in captured else "miss",
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _find_event_hooks_target(client: Any) -> Optional[Any]:
|
||||
"""Walk ``._client`` chain looking for the first httpx-style event_hooks.
|
||||
|
||||
Returns the target object, or ``None`` if not found within
|
||||
``_MAX_CHAIN_DEPTH`` hops.
|
||||
"""
|
||||
current = client
|
||||
for _ in range(_MAX_CHAIN_DEPTH + 1):
|
||||
if current is None:
|
||||
return None
|
||||
if hasattr(current, "event_hooks"):
|
||||
return current
|
||||
nxt = getattr(current, "_client", None)
|
||||
if nxt is current or nxt is None:
|
||||
return None
|
||||
current = nxt
|
||||
return None
|
||||
|
||||
|
||||
def _is_async_httpx_target(target: Any) -> bool:
|
||||
"""Best-effort detection: is this an httpx async client?
|
||||
|
||||
Detection is HIGH-CONFIDENCE when ``isinstance`` against the real
|
||||
``httpx.AsyncClient`` / ``httpx.Client`` succeeds. The MRO name-only
|
||||
fallback (matching a class literally named ``AsyncClient``) is
|
||||
LOW-CONFIDENCE: a wrapped/duck-typed client whose class happens to be
|
||||
named ``AsyncClient`` (or that is async but is NOT so named) can be
|
||||
misclassified, which would install a sync hook on an async client (an
|
||||
un-awaited coroutine → silent header drop) or vice versa. Each path
|
||||
emits a CVDIAG breadcrumb tagged with the chosen confidence so a
|
||||
misdetection is greppable in the logs. The return values themselves are
|
||||
unchanged — only the diagnostics are new.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
if isinstance(target, httpx.AsyncClient):
|
||||
_cvdiag(
|
||||
"async-detect",
|
||||
{},
|
||||
status="ok",
|
||||
error="path=isinstance-async confidence=high",
|
||||
)
|
||||
return True
|
||||
if isinstance(target, httpx.Client):
|
||||
_cvdiag(
|
||||
"async-detect",
|
||||
{},
|
||||
status="ok",
|
||||
error="path=isinstance-sync confidence=high",
|
||||
)
|
||||
return False
|
||||
except ImportError: # pragma: no cover
|
||||
pass
|
||||
|
||||
# Fall back to exact class-name match for wrapped/duck-typed clients.
|
||||
# LOW-CONFIDENCE: this can misdetect async-vs-sync for oddly-named
|
||||
# wrappers; the breadcrumb records the fallback so a wrong hook kind is
|
||||
# traceable to this path.
|
||||
for cls in type(target).__mro__:
|
||||
if cls.__name__ == "AsyncClient":
|
||||
_cvdiag(
|
||||
"async-detect",
|
||||
{},
|
||||
status="ok",
|
||||
error=(
|
||||
"path=mro-name-match confidence=low "
|
||||
f"target_type={type(target).__name__}"
|
||||
),
|
||||
)
|
||||
return True
|
||||
_cvdiag(
|
||||
"async-detect",
|
||||
{},
|
||||
status="ok",
|
||||
error=(f"path=default-sync confidence=low target_type={type(target).__name__}"),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _inject_diag_hop(request: Any, headers: Dict[str, str]) -> None:
|
||||
"""Append this backend's hop tag to ``x-diag-hops`` on the outbound
|
||||
request and emit the ``outbound-llm`` CVDIAG breadcrumb.
|
||||
|
||||
``x-diag-hops`` is a comma-separated trail of the backends that touched
|
||||
the request; appending ``backend-<framework>`` here records that this
|
||||
integration forwarded the correlation headers onto the LLM/provider
|
||||
call. ``x-diag-run-id`` is carried verbatim (already copied above via
|
||||
the ``headers`` loop) the same way ``x-aimock-context`` is.
|
||||
|
||||
GATED on diagnostic-header presence: the breadcrumb append and the
|
||||
outbound CVDIAG log fire ONLY when the forwarded headers carry a
|
||||
diagnostic header (``x-diag-run-id`` OR ``x-aimock-context``). When
|
||||
NEITHER is present this is a no-op, so the outbound request is
|
||||
byte-identical to pre-instrumentation behavior.
|
||||
"""
|
||||
if _DIAG_RUN_ID_HEADER not in headers and _AIMOCK_CONTEXT_HEADER not in headers:
|
||||
return
|
||||
|
||||
hop_tag = f"backend-{_CVDIAG_FRAMEWORK}"
|
||||
existing = headers.get(_DIAG_HOPS_HEADER, "")
|
||||
trail = [h for h in (existing.split(",") if existing else []) if h]
|
||||
trail.append(hop_tag)
|
||||
new_hops = ",".join(trail)
|
||||
request.headers[_DIAG_HOPS_HEADER] = new_hops
|
||||
|
||||
_cvdiag(
|
||||
"outbound-llm",
|
||||
headers,
|
||||
status="ok" if _AIMOCK_CONTEXT_HEADER in headers else "miss",
|
||||
hop=len(trail),
|
||||
)
|
||||
|
||||
|
||||
def install_httpx_hook(client: Any) -> None:
|
||||
"""Attach an httpx request event hook to ``client``'s httpx client.
|
||||
|
||||
Walks the ``._client`` chain to find the first object with an
|
||||
``event_hooks`` mapping, then appends a request hook that copies the
|
||||
ContextVar-recorded headers onto each outbound request.
|
||||
|
||||
Works with OpenAI / Anthropic / pydantic-ai / agno / strands client
|
||||
wrappers (all wrap httpx internally), as well as raw
|
||||
``httpx.Client`` / ``httpx.AsyncClient`` instances.
|
||||
|
||||
Idempotent: a marker attribute on the installed callable prevents
|
||||
double-installation on the same target.
|
||||
"""
|
||||
target = _find_event_hooks_target(client)
|
||||
|
||||
if target is None:
|
||||
msg = (
|
||||
f"install_httpx_hook: client of type {type(client).__name__} has no "
|
||||
"recognized event_hooks attribute; x-* headers will NOT be forwarded "
|
||||
"for this client"
|
||||
)
|
||||
warnings.warn(msg, stacklevel=2)
|
||||
# warnings.warn is invisible in most prod runtimes (filtered/once);
|
||||
# ALSO log at WARNING so a non-forwarding client surfaces.
|
||||
logger.warning("CVDIAG boundary=hook-install status=error error=%s", msg)
|
||||
_cvdiag("hook-install", {}, status="error", error="no-event-hooks-target")
|
||||
return
|
||||
|
||||
request_hooks = target.event_hooks.get("request", [])
|
||||
|
||||
# Idempotency: don't double-install on the same target.
|
||||
for existing in request_hooks:
|
||||
if getattr(existing, _HOOK_MARKER, False):
|
||||
return
|
||||
|
||||
is_async = _is_async_httpx_target(target)
|
||||
|
||||
if is_async:
|
||||
|
||||
async def _inject_headers_async(request):
|
||||
headers = get_forwarded_headers()
|
||||
for key, value in headers.items():
|
||||
request.headers[key] = value
|
||||
_inject_diag_hop(request, headers)
|
||||
|
||||
setattr(_inject_headers_async, _HOOK_MARKER, True)
|
||||
request_hooks.append(_inject_headers_async)
|
||||
else:
|
||||
|
||||
def _inject_headers(request):
|
||||
headers = get_forwarded_headers()
|
||||
for key, value in headers.items():
|
||||
request.headers[key] = value
|
||||
_inject_diag_hop(request, headers)
|
||||
|
||||
setattr(_inject_headers, _HOOK_MARKER, True)
|
||||
request_hooks.append(_inject_headers)
|
||||
|
||||
target.event_hooks["request"] = request_hooks
|
||||
|
||||
|
||||
# Module-scope sentinel preventing repeated global patching.
|
||||
_GLOBAL_HTTPX_PATCHED = False
|
||||
|
||||
|
||||
def install_global_httpx_hook() -> None:
|
||||
"""Patch ``httpx.Client`` / ``httpx.AsyncClient`` so EVERY future
|
||||
instance auto-attaches the forwarded-header hook on construction.
|
||||
|
||||
Use this when the LLM client is buried behind opaque framework
|
||||
machinery (AG2's ``ConversableAgent`` constructs OpenAI clients
|
||||
lazily, CrewAI uses litellm which constructs httpx clients per-call,
|
||||
etc.) and there is no single client instance to call
|
||||
:func:`install_httpx_hook` on at startup.
|
||||
|
||||
Safe to call at import time. Idempotent: a module-scope sentinel
|
||||
prevents repeated patching, and the per-instance idempotency check
|
||||
in :func:`install_httpx_hook` prevents double-hooking on each new
|
||||
client. Pre-existing ``httpx.Client`` instances are not retroactively
|
||||
hooked — only those constructed AFTER this call.
|
||||
"""
|
||||
global _GLOBAL_HTTPX_PATCHED
|
||||
if _GLOBAL_HTTPX_PATCHED:
|
||||
return
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError: # pragma: no cover
|
||||
return
|
||||
|
||||
_orig_sync_init = httpx.Client.__init__
|
||||
_orig_async_init = httpx.AsyncClient.__init__
|
||||
|
||||
def _patched_sync_init(self, *args, **kwargs):
|
||||
_orig_sync_init(self, *args, **kwargs)
|
||||
try:
|
||||
install_httpx_hook(self)
|
||||
except Exception as exc: # pragma: no cover - never break client construction
|
||||
# A failed hook install means x-aimock-context silently never
|
||||
# forwards (the whole point of this shim). Keep swallowing the
|
||||
# exception so client construction never breaks, but FAIL LOUD:
|
||||
# log at ERROR with the FULL detail (not 80-char-truncated) so a
|
||||
# broken install is visible, not buried at INFO.
|
||||
detail = f"sync-init {type(exc).__name__}: {exc}"
|
||||
logger.error(
|
||||
"CVDIAG boundary=hook-install status=error error=%s",
|
||||
detail,
|
||||
exc_info=True,
|
||||
)
|
||||
_cvdiag("hook-install", {}, status="error", error=detail)
|
||||
|
||||
def _patched_async_init(self, *args, **kwargs):
|
||||
_orig_async_init(self, *args, **kwargs)
|
||||
try:
|
||||
install_httpx_hook(self)
|
||||
except Exception as exc: # pragma: no cover
|
||||
# See _patched_sync_init: swallow to protect construction, but
|
||||
# FAIL LOUD at ERROR with full detail so a broken install (which
|
||||
# silently drops x-aimock-context forwarding) is visible.
|
||||
detail = f"async-init {type(exc).__name__}: {exc}"
|
||||
logger.error(
|
||||
"CVDIAG boundary=hook-install status=error error=%s",
|
||||
detail,
|
||||
exc_info=True,
|
||||
)
|
||||
_cvdiag("hook-install", {}, status="error", error=detail)
|
||||
|
||||
httpx.Client.__init__ = _patched_sync_init
|
||||
httpx.AsyncClient.__init__ = _patched_async_init
|
||||
_GLOBAL_HTTPX_PATCHED = True
|
||||
|
||||
|
||||
# Module-scope sentinel preventing repeated executor patching.
|
||||
_EXECUTOR_CTXVAR_PATCHED = False
|
||||
|
||||
|
||||
def install_executor_contextvar_propagation() -> None:
|
||||
"""Patch ``asyncio.events.AbstractEventLoop.run_in_executor`` so the
|
||||
parent task's ContextVars are propagated into the executor thread.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
Frameworks that dispatch a SYNC callable (e.g. agno running a sync
|
||||
tool, or a hand-rolled secondary LLM call) onto the default thread
|
||||
pool via ``loop.run_in_executor(None, functools.partial(...))`` lose
|
||||
the caller's context: the stock ``run_in_executor`` does NOT copy the
|
||||
caller's :pep:`567` context to the worker thread — so the
|
||||
:class:`HeaderForwardingHTTPMiddleware` ContextVar (set on the inbound
|
||||
request task) is empty inside the executor, and our outbound httpx
|
||||
hook sees no headers to forward.
|
||||
|
||||
``asyncio.to_thread`` (Python 3.9+) does copy context the right way;
|
||||
this patch makes plain ``run_in_executor`` behave the same. It only
|
||||
affects functions submitted via ``run_in_executor`` — coroutines and
|
||||
other constructs are unaffected.
|
||||
|
||||
Safe to call at import time. Idempotent via a module-scope sentinel.
|
||||
|
||||
Scope caveat: this patches ``asyncio.base_events.BaseEventLoop`` only.
|
||||
Pre-existing *stdlib asyncio* event-loop instances inherit the patch
|
||||
(``run_in_executor`` is defined on ``BaseEventLoop`` and resolved
|
||||
per-call via normal method resolution). It is INERT under uvloop —
|
||||
uvloop's loop does not subclass ``BaseEventLoop`` and resolves
|
||||
``run_in_executor`` from its own C implementation, so the stdlib
|
||||
method this patch rebinds is never consulted. Under uvloop, ContextVar
|
||||
propagation into ``run_in_executor`` worker threads is NOT provided by
|
||||
this shim.
|
||||
"""
|
||||
global _EXECUTOR_CTXVAR_PATCHED
|
||||
if _EXECUTOR_CTXVAR_PATCHED:
|
||||
return
|
||||
|
||||
import asyncio.base_events as _base_events
|
||||
|
||||
_orig_run_in_executor = _base_events.BaseEventLoop.run_in_executor
|
||||
|
||||
def _patched_run_in_executor(self, executor, func, *args):
|
||||
# Capture the CURRENT task's context at submit time, then run the
|
||||
# submitted callable inside that context on the worker thread.
|
||||
ctx = contextvars.copy_context()
|
||||
|
||||
def _ctx_wrapper(*a, **kw):
|
||||
return ctx.run(func, *a, **kw)
|
||||
|
||||
# Preserve __name__/__qualname__ for nicer tracebacks where possible.
|
||||
try:
|
||||
_ctx_wrapper.__wrapped__ = func # type: ignore[attr-defined]
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
return _orig_run_in_executor(self, executor, _ctx_wrapper, *args)
|
||||
|
||||
_base_events.BaseEventLoop.run_in_executor = _patched_run_in_executor
|
||||
_EXECUTOR_CTXVAR_PATCHED = True
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Agno agent for the Declarative Generative UI (A2UI Dynamic Schema) demo.
|
||||
|
||||
Mirrors the langgraph-python `a2ui_dynamic.py` pattern: the agent owns the
|
||||
`generate_a2ui` tool explicitly. When called, it invokes a secondary
|
||||
OpenAI client bound to `render_a2ui` (tool_choice forced) using the
|
||||
registered client catalog injected via the runtime's
|
||||
`state.copilotkit.context`. The tool result returns an `a2ui_operations`
|
||||
container which the runtime's A2UI middleware detects and forwards to the
|
||||
frontend renderer.
|
||||
|
||||
Why a separate agent (vs reusing the main agent's `generate_a2ui` tool)?
|
||||
The main agent uses a hardcoded internal catalog ID
|
||||
(`copilotkit://app-dashboard-catalog`) and ignores any runtime catalog the
|
||||
frontend registers via `<CopilotKit a2ui={{ catalog }}>`. This dedicated
|
||||
agent reads the runtime catalog from `session_state["copilotkit"]
|
||||
["context"]` so it stays in sync with the frontend renderer's catalog.
|
||||
|
||||
The dedicated runtime route (`api/copilotkit-declarative-gen-ui/route.ts`)
|
||||
sets `injectA2UITool: false` so the runtime does not double-bind a second
|
||||
A2UI tool on top of this one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import openai
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from agno.run import RunContext
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from agents._header_forwarding import get_forwarded_headers
|
||||
|
||||
from tools import (
|
||||
build_a2ui_operations_from_tool_call,
|
||||
RENDER_A2UI_TOOL_SCHEMA,
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a demo assistant for Declarative Generative UI (A2UI — Dynamic "
|
||||
"Schema). Whenever a response would benefit from a rich visual — a "
|
||||
"dashboard, status report, KPI summary, card layout, info grid, a "
|
||||
"pie/donut chart of part-of-whole breakdowns, a bar chart comparing "
|
||||
"values across categories, or anything more structured than plain text — "
|
||||
"call `generate_a2ui` to draw it. The registered catalog includes "
|
||||
"`Card`, `StatusBadge`, `Metric`, `InfoRow`, `PrimaryButton`, `PieChart`, "
|
||||
"and `BarChart` (in addition to the basic A2UI primitives). Prefer "
|
||||
"`PieChart` for part-of-whole breakdowns (sales by region, traffic "
|
||||
"sources, portfolio allocation) and `BarChart` for comparisons across "
|
||||
"categories (quarterly revenue, headcount by team, signups per month). "
|
||||
"`generate_a2ui` takes a single `context` argument summarising the "
|
||||
"conversation. Keep chat replies to one short sentence; let the UI do "
|
||||
"the talking."
|
||||
)
|
||||
|
||||
|
||||
def generate_a2ui(run_context: RunContext, context: str) -> str:
|
||||
"""Generate dynamic A2UI components based on the conversation.
|
||||
|
||||
A secondary LLM designs the UI schema and data. The result is
|
||||
returned as an `a2ui_operations` container for the A2UI middleware
|
||||
to detect and forward to the frontend renderer.
|
||||
|
||||
The runtime A2UI middleware injects the registered client catalog
|
||||
schema into `state.copilotkit.context` automatically. We pull it out
|
||||
of the per-run `session_state` (Agno's `validate_agui_state` mirrors
|
||||
`RunAgentInput.state` into `session_state`) so the secondary LLM
|
||||
knows which components are available — staying in sync with the
|
||||
frontend catalog.
|
||||
|
||||
Args:
|
||||
run_context: Agno run context (provides session_state).
|
||||
context (str): Conversation context summary the secondary LLM
|
||||
should design UI from.
|
||||
|
||||
Returns:
|
||||
str: A2UI operations as JSON.
|
||||
"""
|
||||
state = getattr(run_context, "session_state", None) or {}
|
||||
context_entries = []
|
||||
if isinstance(state, dict):
|
||||
ck = state.get("copilotkit") or {}
|
||||
if isinstance(ck, dict):
|
||||
entries = ck.get("context") or []
|
||||
if isinstance(entries, list):
|
||||
context_entries = entries
|
||||
|
||||
context_text_parts: list[str] = []
|
||||
for entry in context_entries:
|
||||
if isinstance(entry, dict):
|
||||
value = entry.get("value")
|
||||
if isinstance(value, str) and value:
|
||||
context_text_parts.append(value)
|
||||
catalog_context = "\n\n".join(context_text_parts)
|
||||
|
||||
system_prompt = (
|
||||
catalog_context if catalog_context else "Generate a useful dashboard UI."
|
||||
)
|
||||
if context and context.strip():
|
||||
system_prompt = f"{system_prompt}\n\nConversation context:\n{context}"
|
||||
|
||||
# Forward the request-scoped CopilotKit ``x-*`` headers (notably
|
||||
# ``x-aimock-context``) onto this SECONDARY OpenAI call explicitly.
|
||||
# agno runs under uvloop in prod, where the executor-contextvar shim is
|
||||
# inert; relying on the global httpx hook + ContextVar alone is fragile
|
||||
# across loop types. Reading the captured headers here (on the request
|
||||
# context that invoked the tool) and passing them as ``default_headers``
|
||||
# makes the secondary call carry the context REGARDLESS of loop type, so
|
||||
# aimock can match the fixture instead of returning 503 on an empty
|
||||
# ``x-aimock-context``.
|
||||
forwarded_headers = get_forwarded_headers()
|
||||
client = openai.OpenAI(default_headers=forwarded_headers or None)
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4.1",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Generate a dynamic A2UI dashboard based on the conversation."
|
||||
),
|
||||
},
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": RENDER_A2UI_TOOL_SCHEMA,
|
||||
}
|
||||
],
|
||||
tool_choice={
|
||||
"type": "function",
|
||||
"function": {"name": "render_a2ui"},
|
||||
},
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
if choice.message.tool_calls:
|
||||
args = json.loads(choice.message.tool_calls[0].function.arguments)
|
||||
result = build_a2ui_operations_from_tool_call(args)
|
||||
return json.dumps(result)
|
||||
|
||||
return json.dumps({"error": "LLM did not call render_a2ui"})
|
||||
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o", timeout=120),
|
||||
tools=[generate_a2ui],
|
||||
tool_call_limit=4,
|
||||
description=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Agno 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 langgraph-python `a2ui_fixed.py` reference. The dedicated
|
||||
runtime route at `api/copilotkit-a2ui-fixed-schema/route.ts` runs the
|
||||
A2UI middleware with `injectA2UITool: false` because the backend owns
|
||||
the rendering tool itself — it emits an `a2ui_operations` container
|
||||
directly without a secondary LLM call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from agno.tools import tool
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
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]:
|
||||
"""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")
|
||||
# `booked_schema.json` is shipped alongside `flight_schema.json` so the
|
||||
# schema is ready to wire up once the SDK exposes per-button action handlers
|
||||
# for fixed-schema surfaces (matching the langgraph-python reference).
|
||||
BOOKED_SCHEMA = _load_schema("booked_schema.json")
|
||||
|
||||
|
||||
@tool
|
||||
def display_flight(origin: str, destination: str, airline: str, price: str):
|
||||
"""Show a flight card for the given trip.
|
||||
|
||||
Emits an `a2ui_operations` container directly — NO secondary LLM
|
||||
call. The runtime A2UI middleware detects the container in the tool
|
||||
result and forwards the surface to the frontend renderer. The
|
||||
frontend catalog resolves component names against the local React
|
||||
components.
|
||||
|
||||
Args:
|
||||
origin (str): Origin airport code (e.g. "SFO").
|
||||
destination (str): Destination airport code (e.g. "JFK").
|
||||
airline (str): Airline name (e.g. "United").
|
||||
price (str): Price string (e.g. "$289").
|
||||
|
||||
Returns:
|
||||
str: A2UI operations as JSON.
|
||||
"""
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
return json.dumps({"a2ui_operations": operations})
|
||||
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o-mini", timeout=120),
|
||||
tools=[display_flight],
|
||||
tool_call_limit=4,
|
||||
description=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"id": "root",
|
||||
"component": "Column",
|
||||
"gap": 8,
|
||||
"children": ["title", "detail"]
|
||||
},
|
||||
{
|
||||
"id": "title",
|
||||
"component": "Text",
|
||||
"text": { "path": "/title" },
|
||||
"variant": "h2"
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"component": "Text",
|
||||
"text": { "path": "/detail" },
|
||||
"variant": "body"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
[
|
||||
{
|
||||
"id": "root",
|
||||
"component": "Card",
|
||||
"child": "content"
|
||||
},
|
||||
{
|
||||
"id": "content",
|
||||
"component": "Column",
|
||||
"children": ["title", "route", "meta", "bookButton"]
|
||||
},
|
||||
{
|
||||
"id": "title",
|
||||
"component": "Title",
|
||||
"text": "Flight Details"
|
||||
},
|
||||
{
|
||||
"id": "route",
|
||||
"component": "Row",
|
||||
"justify": "spaceBetween",
|
||||
"align": "center",
|
||||
"children": ["from", "arrow", "to"]
|
||||
},
|
||||
{
|
||||
"id": "from",
|
||||
"component": "Airport",
|
||||
"code": { "path": "/origin" }
|
||||
},
|
||||
{
|
||||
"id": "arrow",
|
||||
"component": "Arrow"
|
||||
},
|
||||
{
|
||||
"id": "to",
|
||||
"component": "Airport",
|
||||
"code": { "path": "/destination" }
|
||||
},
|
||||
{
|
||||
"id": "meta",
|
||||
"component": "Row",
|
||||
"justify": "spaceBetween",
|
||||
"align": "center",
|
||||
"children": ["airline", "price"]
|
||||
},
|
||||
{
|
||||
"id": "airline",
|
||||
"component": "AirlineBadge",
|
||||
"name": { "path": "/airline" }
|
||||
},
|
||||
{
|
||||
"id": "price",
|
||||
"component": "PriceTag",
|
||||
"amount": { "path": "/price" }
|
||||
},
|
||||
{
|
||||
"id": "bookButton",
|
||||
"component": "Button",
|
||||
"variant": "primary",
|
||||
"child": "bookButtonLabel",
|
||||
"action": {
|
||||
"event": {
|
||||
"name": "book_flight",
|
||||
"context": {
|
||||
"origin": { "path": "/origin" },
|
||||
"destination": { "path": "/destination" },
|
||||
"airline": { "path": "/airline" },
|
||||
"price": { "path": "/price" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bookButtonLabel",
|
||||
"component": "Text",
|
||||
"text": "Book flight"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Agno agent backing the Agent Config Object demo.
|
||||
|
||||
Reads three forwarded properties — ``tone``, ``expertise``,
|
||||
``responseLength`` — that the CopilotKit provider forwards on every run,
|
||||
and composes the system prompt dynamically per turn.
|
||||
|
||||
Agno does not have a LangGraph-style ``configurable`` channel; instead the
|
||||
custom AGUI handler in ``agent_server.py`` (mounted at
|
||||
``/agent-config/agui``) reads ``RunAgentInput.forwarded_props``, builds a
|
||||
fresh system prompt, and constructs a per-request Agno ``Agent`` with that
|
||||
prompt before invoking it. The factory in this module produces those
|
||||
per-request agents.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
Tone = Literal["professional", "casual", "enthusiastic"]
|
||||
Expertise = Literal["beginner", "intermediate", "expert"]
|
||||
ResponseLength = Literal["concise", "detailed"]
|
||||
|
||||
|
||||
DEFAULT_TONE: Tone = "professional"
|
||||
DEFAULT_EXPERTISE: Expertise = "intermediate"
|
||||
DEFAULT_RESPONSE_LENGTH: ResponseLength = "concise"
|
||||
|
||||
|
||||
VALID_TONES: set[str] = {"professional", "casual", "enthusiastic"}
|
||||
VALID_EXPERTISE: set[str] = {"beginner", "intermediate", "expert"}
|
||||
VALID_RESPONSE_LENGTHS: set[str] = {"concise", "detailed"}
|
||||
|
||||
|
||||
def read_properties(forwarded_props: dict | None) -> dict[str, str]:
|
||||
"""Read the forwarded ``properties`` dict with defensive defaults.
|
||||
|
||||
The CopilotKit provider forwards its ``properties`` prop as top-level
|
||||
keys on ``forwarded_props`` (see the runtime's run handler). This
|
||||
function never raises — every unrecognized value falls back to the
|
||||
matching ``DEFAULT_*`` constant.
|
||||
"""
|
||||
props = forwarded_props or {}
|
||||
|
||||
tone = props.get("tone", DEFAULT_TONE)
|
||||
expertise = props.get("expertise", DEFAULT_EXPERTISE)
|
||||
response_length = props.get("responseLength", DEFAULT_RESPONSE_LENGTH)
|
||||
|
||||
if tone not in VALID_TONES:
|
||||
tone = DEFAULT_TONE
|
||||
if expertise not in VALID_EXPERTISE:
|
||||
expertise = DEFAULT_EXPERTISE
|
||||
if response_length not in VALID_RESPONSE_LENGTHS:
|
||||
response_length = DEFAULT_RESPONSE_LENGTH
|
||||
|
||||
return {
|
||||
"tone": tone,
|
||||
"expertise": expertise,
|
||||
"response_length": response_length,
|
||||
}
|
||||
|
||||
|
||||
def build_system_prompt(tone: str, expertise: str, response_length: str) -> str:
|
||||
"""Compose a system prompt from the three axes."""
|
||||
tone_rules = {
|
||||
"professional": ("Use neutral, precise language. No emoji. Short sentences."),
|
||||
"casual": (
|
||||
"Use friendly, conversational language. Contractions OK. "
|
||||
"Light humor welcome."
|
||||
),
|
||||
"enthusiastic": (
|
||||
"Use upbeat, energetic language. Exclamation points OK. Emoji OK."
|
||||
),
|
||||
}
|
||||
expertise_rules = {
|
||||
"beginner": "Assume no prior knowledge. Define jargon. Use analogies.",
|
||||
"intermediate": (
|
||||
"Assume common terms are understood; explain specialized terms."
|
||||
),
|
||||
"expert": ("Assume technical fluency. Use precise terminology. Skip basics."),
|
||||
}
|
||||
length_rules = {
|
||||
"concise": "Respond in 1-3 sentences.",
|
||||
"detailed": ("Respond in multiple paragraphs with examples where relevant."),
|
||||
}
|
||||
return (
|
||||
"You are a helpful assistant.\n\n"
|
||||
f"Tone: {tone_rules[tone]}\n"
|
||||
f"Expertise level: {expertise_rules[expertise]}\n"
|
||||
f"Response length: {length_rules[response_length]}"
|
||||
)
|
||||
|
||||
|
||||
def build_agent(forwarded_props: dict | None) -> Agent:
|
||||
"""Build a per-request Agno agent whose system prompt reflects the
|
||||
forwarded provider properties.
|
||||
|
||||
Constructed fresh on each run so the system prompt is current; the
|
||||
Agno session DB still tracks history via ``session_id`` (the AGUI
|
||||
handler passes ``thread_id`` through).
|
||||
"""
|
||||
props = read_properties(forwarded_props)
|
||||
system_prompt = build_system_prompt(
|
||||
props["tone"], props["expertise"], props["response_length"]
|
||||
)
|
||||
return Agent(
|
||||
model=OpenAIChat(id="gpt-4o-mini", temperature=0.4, timeout=120),
|
||||
tools=[],
|
||||
description=system_prompt,
|
||||
)
|
||||
|
||||
|
||||
# A neutral default so AgentOS' agent-registry init doesn't fail before the
|
||||
# first run materialises a per-request agent.
|
||||
agent = build_agent(None)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Agno agent backing the byoc-hashbrown demo.
|
||||
|
||||
Emits structured output shaped for `@hashbrownai/react`'s `useUiKit` /
|
||||
`useJsonParser` pipeline. The frontend renderer parses the streaming
|
||||
content progressively and renders MetricCard / PieChart / BarChart /
|
||||
DealCard / Markdown components against the kit schema.
|
||||
|
||||
The agent has a heavy system prompt and no native tools — its sole job is
|
||||
to produce a single JSON object that the frontend kit can interpret.
|
||||
"""
|
||||
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """
|
||||
You are a sales-dashboard composer. Your output MUST be a SINGLE valid JSON
|
||||
object — no markdown fences, no commentary — shaped exactly like the
|
||||
hashbrown UI kit envelope:
|
||||
|
||||
{
|
||||
"ui": [
|
||||
{ "metric": { "props": { "label": "<string>", "value": "<string>" } } },
|
||||
{ "pieChart": { "props": { "title": "<string>", "data": "<JSON-string of [{label,value}, ...]>" } } },
|
||||
{ "barChart": { "props": { "title": "<string>", "data": "<JSON-string of [{label,value}, ...]>" } } },
|
||||
{ "dealCard": { "props": { "title": "<string>", "stage": "<one of: prospect|qualified|proposal|negotiation|closed-won|closed-lost>", "value": <number> } } },
|
||||
{ "Markdown": { "props": { "children": "<string>" } } }
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Output ONE top-level object with a "ui" array of component invocations.
|
||||
- Each entry in the "ui" array has exactly one key — the component name —
|
||||
whose value is `{ "props": { ... } }`.
|
||||
- For pieChart and barChart, the `data` prop is a JSON-encoded *string*,
|
||||
not a real array. Example:
|
||||
"data": "[{\\"label\\":\\"Enterprise\\",\\"value\\":600000}]"
|
||||
- Metric values are formatted strings (e.g. "$1.2M", "247 deals").
|
||||
- Use Markdown entries to add concise prose between visual components.
|
||||
- Pick realistic-looking sample numbers; do not call any tools.
|
||||
""".strip()
|
||||
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o", timeout=120),
|
||||
tools=[],
|
||||
description=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Agno agent backing the byoc-json-render demo.
|
||||
|
||||
Emits a json-render spec the frontend renderer mounts via
|
||||
`@json-render/react` against a Zod-validated catalog (MetricCard,
|
||||
BarChart, PieChart).
|
||||
"""
|
||||
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """
|
||||
You are a dashboard composer. Your output MUST be a SINGLE valid JSON
|
||||
object matching the @json-render flat element-tree spec:
|
||||
|
||||
{
|
||||
"root": "<id>",
|
||||
"elements": {
|
||||
"<id>": {
|
||||
"type": "MetricCard" | "BarChart" | "PieChart",
|
||||
"props": { ... },
|
||||
"children": ["<id>", ...] // optional
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
Available components (use as the element "type"):
|
||||
|
||||
- MetricCard
|
||||
props: { label: string, value: string, trend: string | null }
|
||||
- BarChart
|
||||
props: {
|
||||
title: string,
|
||||
description: string | null,
|
||||
data: [{ label: string, value: number }]
|
||||
}
|
||||
- PieChart
|
||||
props: {
|
||||
title: string,
|
||||
description: string | null,
|
||||
data: [{ label: string, value: number }]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Output ONE valid JSON object — no markdown fences, no commentary.
|
||||
- Every element has a string id; "root" must reference one of them.
|
||||
- Pick realistic-looking sample numbers; do not call any tools.
|
||||
- For dashboards with multiple elements, place a MetricCard at the root
|
||||
and chain other elements via the root's "children" array.
|
||||
""".strip()
|
||||
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o", timeout=120),
|
||||
tools=[],
|
||||
description=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Agno agent backing the gen-ui-agent (Agentic Generative UI) demo.
|
||||
|
||||
Mirrors `langgraph-python/src/agents/gen_ui_agent.py` and
|
||||
`ms-agent-python/src/agents/gen_ui_agent.py`. The agent plans a task as
|
||||
3 steps and walks each `pending` -> `in_progress` -> `completed`,
|
||||
calling `set_steps` after every transition. The frontend
|
||||
(`src/app/demos/gen-ui-agent/page.tsx`) subscribes to
|
||||
`agent.state.steps` via `useAgent({ updates: [OnStateChanged] })` and
|
||||
renders a live progress card.
|
||||
|
||||
State shape (mirrors LGP `GenUiAgentState.steps`):
|
||||
|
||||
{
|
||||
"steps": [
|
||||
{"id": "...", "title": "...",
|
||||
"status": "pending" | "in_progress" | "completed"},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Wiring: this agent is mounted via `_attach_state_aware_route` in
|
||||
`agent_server.py` at `/gen-ui-agent/agui`. That custom router emits a
|
||||
`StateSnapshotEvent` carrying the final `session_state` immediately
|
||||
before the closing `RunFinishedEvent`, which is what makes the agent's
|
||||
state writes visible to the UI's `useAgent({ updates: [OnStateChanged] })`
|
||||
subscription. Stock Agno AGUI does NOT emit STATE_SNAPSHOT — same
|
||||
pattern as `shared_state_read_write` and `subagents`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
import dotenv
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from agno.run import RunContext
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
|
||||
SYSTEM_PROMPT = dedent(
|
||||
"""
|
||||
You are an agentic planner. For each user request, follow this exact
|
||||
sequence:
|
||||
1. Plan exactly 3 concrete steps and call `set_steps` ONCE with all
|
||||
three steps at status="pending".
|
||||
2. Step 1: call `set_steps` with step 1 at status="in_progress",
|
||||
then call `set_steps` again with step 1 at status="completed".
|
||||
3. Step 2: call `set_steps` with step 2 at status="in_progress",
|
||||
then call `set_steps` again with step 2 at status="completed".
|
||||
4. Step 3: call `set_steps` with step 3 at status="in_progress",
|
||||
then call `set_steps` again with step 3 at status="completed".
|
||||
5. Send ONE final conversational assistant message summarizing the
|
||||
plan, then stop. Do not call any more tools after step 3 is
|
||||
completed.
|
||||
|
||||
Rules: never call set_steps in parallel — always wait for one call
|
||||
to return before the next. Always include the FULL list of steps
|
||||
on each call (set_steps replaces the steps array, it does not
|
||||
append). After all three steps are completed you MUST send a final
|
||||
assistant message and terminate.
|
||||
"""
|
||||
).strip()
|
||||
|
||||
|
||||
def set_steps(run_context: RunContext, steps: list[dict]) -> str:
|
||||
"""Publish the current plan + step statuses to shared state.
|
||||
|
||||
Always pass the FULL list of steps (every step with `id`, `title`,
|
||||
and `status` ∈ {'pending', 'in_progress', 'completed'}). This call
|
||||
REPLACES `session_state["steps"]` — the custom AGUI router in
|
||||
`agent_server.py` then emits a `StateSnapshotEvent` carrying the
|
||||
new state, which the frontend's `useAgent` subscription picks up
|
||||
and renders as the live progress card.
|
||||
"""
|
||||
if run_context.session_state is None:
|
||||
run_context.session_state = {}
|
||||
# Tolerate stray non-dict entries (defensive: avoid serialization
|
||||
# crashes mid-stream if the model emits something off-shape).
|
||||
cleaned: list[dict] = []
|
||||
for s in steps or []:
|
||||
if isinstance(s, dict):
|
||||
cleaned.append(s)
|
||||
run_context.session_state["steps"] = cleaned
|
||||
return f"Published {len(cleaned)} step(s)."
|
||||
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o-mini", timeout=120),
|
||||
tools=[set_steps],
|
||||
instructions=SYSTEM_PROMPT,
|
||||
description=(
|
||||
"Plans 3 steps and walks each pending -> in_progress -> completed "
|
||||
"via set_steps. Drives the gen-ui-agent demo's live progress card."
|
||||
),
|
||||
# 3 steps * 2 transitions + 1 initial enumeration = 7 expected calls;
|
||||
# give a small headroom for retries while bounding runaway loops.
|
||||
tool_call_limit=12,
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Agno scheduling agent -- interrupt-adapted.
|
||||
|
||||
This agent powers two demos (gen-ui-interrupt, interrupt-headless) that in the
|
||||
LangGraph showcase rely on the native `interrupt()` primitive with
|
||||
checkpoint/resume. Agno does NOT have that primitive, so we adapt using the
|
||||
same "Strategy B" pattern as the MS Agent Framework port: the backend agent's
|
||||
system prompt tells the LLM to call `schedule_meeting`, but no local
|
||||
implementation is registered -- the tool is provided entirely by the frontend
|
||||
via `useFrontendTool` with an async handler that returns a Promise resolving
|
||||
only once the user picks a time slot (or cancels).
|
||||
|
||||
See `src/agents/main.py` for the shared Agno agent used by most other demos.
|
||||
"""
|
||||
|
||||
# @region[backend-interrupt-tool]
|
||||
from __future__ import annotations
|
||||
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a scheduling assistant. Whenever the user asks you to book a call "
|
||||
"or schedule a meeting, you MUST call the `schedule_meeting` tool. Pass a "
|
||||
"short `topic` describing the purpose of the meeting and, if known, an "
|
||||
"`attendee` describing who the meeting is with.\n\n"
|
||||
"The `schedule_meeting` tool is implemented on the client: it surfaces a "
|
||||
"time-picker UI to the user and returns the user's selection. After the "
|
||||
"tool returns, briefly confirm whether the meeting was scheduled and at "
|
||||
"what time, or note that the user cancelled. Do NOT ask for approval "
|
||||
"yourself -- always call the tool and let the picker handle the decision.\n\n"
|
||||
"Keep responses short and friendly. After you finish executing tools, "
|
||||
"always send a brief final assistant message summarizing what happened so "
|
||||
"the message persists."
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o-mini", timeout=120),
|
||||
# No backend tools. `schedule_meeting` is registered on the frontend
|
||||
# via `useFrontendTool` and dispatched through the CopilotKit runtime.
|
||||
# When the agent calls `schedule_meeting`, the request is routed to
|
||||
# the frontend handler, which returns a Promise that only resolves
|
||||
# once the user picks a slot -- equivalent to `interrupt()` in the
|
||||
# LangGraph reference.
|
||||
tools=[],
|
||||
tool_call_limit=5,
|
||||
description="Scheduling assistant for the interrupt-adapted demos.",
|
||||
instructions=SYSTEM_PROMPT,
|
||||
)
|
||||
# @endregion[backend-interrupt-tool]
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Agno Sales Pipeline Agent with shared tools for showcase demos."""
|
||||
|
||||
# @region[weather-tool-backend]
|
||||
import json
|
||||
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from agno.tools import tool
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from tools import (
|
||||
get_weather_impl,
|
||||
query_data_impl,
|
||||
schedule_meeting_impl,
|
||||
search_flights_impl,
|
||||
build_a2ui_operations_from_tool_call,
|
||||
RENDER_A2UI_TOOL_SCHEMA,
|
||||
)
|
||||
from tools.types import Flight
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: str):
|
||||
"""
|
||||
Get the weather for a given location. Ensure location is fully spelled out.
|
||||
|
||||
Args:
|
||||
location (str): The location to get the weather for.
|
||||
|
||||
Returns:
|
||||
str: Weather data as JSON.
|
||||
"""
|
||||
return json.dumps(get_weather_impl(location))
|
||||
|
||||
|
||||
# @endregion[weather-tool-backend]
|
||||
|
||||
|
||||
@tool
|
||||
def query_data(query: str):
|
||||
"""
|
||||
Query financial database for chart data. Returns data suitable for pie or bar charts.
|
||||
|
||||
Args:
|
||||
query (str): The query to run against the financial database.
|
||||
|
||||
Returns:
|
||||
str: Query results as JSON.
|
||||
"""
|
||||
return json.dumps(query_data_impl(query))
|
||||
|
||||
|
||||
@tool(external_execution=True)
|
||||
def manage_sales_todos(todos: list[dict]):
|
||||
"""
|
||||
Manage the sales pipeline. Pass the complete list of sales todos.
|
||||
Always pass the COMPLETE list of todos.
|
||||
|
||||
Args:
|
||||
todos (list[dict]): The complete list of sales todos to maintain.
|
||||
"""
|
||||
|
||||
|
||||
@tool
|
||||
def schedule_meeting(reason: str):
|
||||
"""
|
||||
Schedule a meeting with user approval. Returns available time slots.
|
||||
|
||||
Args:
|
||||
reason (str): Reason for scheduling the meeting.
|
||||
|
||||
Returns:
|
||||
str: Meeting scheduling data as JSON.
|
||||
"""
|
||||
return json.dumps(schedule_meeting_impl(reason))
|
||||
|
||||
|
||||
@tool(external_execution=True, external_execution_silent=True)
|
||||
def request_user_approval(message: str, context: str = ""):
|
||||
"""
|
||||
Ask the operator to approve or reject an action before you take it.
|
||||
The operator will respond via an in-app modal dialog that appears
|
||||
OUTSIDE the chat surface. The tool returns an object of the shape
|
||||
{ approved: boolean, reason?: string }.
|
||||
|
||||
Args:
|
||||
message (str): Short summary of the action needing approval (include concrete numbers / IDs).
|
||||
context (str): Optional extra context — e.g. the ticket ID or policy rule.
|
||||
"""
|
||||
|
||||
|
||||
@tool(external_execution=True)
|
||||
def change_background(background: str):
|
||||
"""
|
||||
Change the background color of the chat.
|
||||
ONLY call this tool when the user explicitly asks to change the background.
|
||||
Never call it proactively or as part of another response.
|
||||
Can be anything that the CSS background attribute accepts. Prefer gradients.
|
||||
|
||||
Args:
|
||||
background (str): The CSS background value. Prefer gradients.
|
||||
"""
|
||||
|
||||
|
||||
@tool(external_execution=True, external_execution_silent=True)
|
||||
def book_call(topic: str, name: str):
|
||||
"""
|
||||
Ask the user to pick a time slot for a call. The picker UI presents
|
||||
fixed candidate slots; the user's choice is returned to the agent.
|
||||
|
||||
Args:
|
||||
topic (str): What the call is about (e.g. "Intro with sales").
|
||||
name (str): Name of the attendee (e.g. "Alice").
|
||||
"""
|
||||
|
||||
|
||||
@tool(external_execution=True, external_execution_silent=True)
|
||||
def generate_task_steps(steps: list[dict]):
|
||||
"""
|
||||
Generates a list of steps for the user to perform.
|
||||
Each step should have a description and status.
|
||||
|
||||
Args:
|
||||
steps (list[dict]): A list of step objects, each with 'description' (str)
|
||||
and 'status' ('enabled' or 'disabled').
|
||||
"""
|
||||
|
||||
|
||||
@tool
|
||||
def search_flights(flights: list[dict]):
|
||||
"""
|
||||
Search for flights and display the results as rich A2UI cards.
|
||||
Return exactly 2 flights.
|
||||
|
||||
Each flight must have: airline, airlineLogo, flightNumber, origin, destination,
|
||||
date (short readable format like "Tue, Mar 18"),
|
||||
departureTime, arrivalTime, duration (e.g. "4h 25m"),
|
||||
status (e.g. "On Time" or "Delayed"),
|
||||
statusColor (hex color for status dot),
|
||||
price (e.g. "$289"), and currency (e.g. "USD").
|
||||
|
||||
For airlineLogo use Google favicon API:
|
||||
https://www.google.com/s2/favicons?domain={airline_domain}&sz=128
|
||||
|
||||
Args:
|
||||
flights (list[dict]): List of flight objects to display.
|
||||
|
||||
Returns:
|
||||
str: A2UI operations as JSON.
|
||||
"""
|
||||
typed_flights = [Flight(**f) for f in flights]
|
||||
result = search_flights_impl(typed_flights)
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
@tool
|
||||
def get_stock_price(ticker: str):
|
||||
"""
|
||||
Get a mock current price for a stock ticker.
|
||||
|
||||
When the user asks about a single ticker, also consider pulling a
|
||||
related ticker for context (e.g. if they ask about 'AAPL', also
|
||||
fetch 'MSFT' or 'GOOGL' so the reply can compare).
|
||||
|
||||
Args:
|
||||
ticker (str): The ticker symbol to look up.
|
||||
|
||||
Returns:
|
||||
str: Mock price data as JSON.
|
||||
"""
|
||||
from random import choice, randint
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": round(100 + randint(0, 400) + randint(0, 99) / 100, 2),
|
||||
"change_pct": round(choice([-1, 1]) * (randint(0, 300) / 100), 2),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def roll_dice(sides: int = 6):
|
||||
"""
|
||||
Roll a single die with the given number of sides.
|
||||
|
||||
When the user asks for a roll, consider rolling twice with different
|
||||
numbers of sides so the reply can show a contrast (e.g. a d6 AND a d20).
|
||||
|
||||
Args:
|
||||
sides (int): The number of sides on the die. Defaults to 6.
|
||||
|
||||
Returns:
|
||||
str: Dice roll result as JSON.
|
||||
"""
|
||||
from random import randint
|
||||
|
||||
return json.dumps({"sides": sides, "result": randint(1, max(2, sides))})
|
||||
|
||||
|
||||
@tool
|
||||
def generate_a2ui(context: str):
|
||||
"""
|
||||
Generate dynamic A2UI components based on the conversation.
|
||||
|
||||
A secondary LLM designs the UI schema and data. The result is
|
||||
returned as an a2ui_operations container for the middleware to detect.
|
||||
|
||||
Args:
|
||||
context (str): Conversation context to generate UI for.
|
||||
|
||||
Returns:
|
||||
str: A2UI operations as JSON.
|
||||
"""
|
||||
import openai
|
||||
|
||||
client = openai.OpenAI()
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4.1",
|
||||
messages=[
|
||||
{"role": "system", "content": context or "Generate a useful dashboard UI."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Generate a dynamic A2UI dashboard based on the conversation.",
|
||||
},
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": RENDER_A2UI_TOOL_SCHEMA,
|
||||
}
|
||||
],
|
||||
tool_choice={"type": "function", "function": {"name": "render_a2ui"}},
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
if choice.message.tool_calls:
|
||||
args = json.loads(choice.message.tool_calls[0].function.arguments)
|
||||
result = build_a2ui_operations_from_tool_call(args)
|
||||
return json.dumps(result)
|
||||
|
||||
return json.dumps({"error": "LLM did not call render_a2ui"})
|
||||
|
||||
|
||||
agent = Agent(
|
||||
# Raise the HTTP timeout so requests routed through aimock don't time out
|
||||
# under normal load. The default httpx timeout is too short when aimock
|
||||
# is proxying to the upstream LLM — observed "Request timed out" errors
|
||||
# that crash the agent run and trigger watchdog restarts.
|
||||
model=OpenAIChat(id="gpt-4o", timeout=120),
|
||||
tools=[
|
||||
get_weather,
|
||||
query_data,
|
||||
manage_sales_todos,
|
||||
schedule_meeting,
|
||||
change_background,
|
||||
book_call,
|
||||
generate_task_steps,
|
||||
request_user_approval,
|
||||
search_flights,
|
||||
get_stock_price,
|
||||
roll_dice,
|
||||
generate_a2ui,
|
||||
],
|
||||
# Prevent runaway tool-call loops — same guard as the ag2 package.
|
||||
tool_call_limit=15,
|
||||
description="You are a helpful sales assistant for the CopilotKit showcase demos.",
|
||||
instructions="""
|
||||
SALES PIPELINE:
|
||||
When a user asks you to do anything regarding sales todos or the pipeline,
|
||||
use the manage_sales_todos tool. Always pass the COMPLETE LIST of todos.
|
||||
Be helpful in managing sales pipeline items.
|
||||
After using the tool, provide a brief summary of what you created, removed, or changed.
|
||||
|
||||
WEATHER:
|
||||
Only call the get_weather tool if the user asks about the weather.
|
||||
If the user does not specify a location, use "Everywhere ever in the whole wide world".
|
||||
|
||||
QUERY DATA:
|
||||
Use the query_data tool when the user asks for financial data, charts, or analytics.
|
||||
|
||||
SCHEDULE MEETING:
|
||||
Use the schedule_meeting tool when the user wants to schedule a meeting.
|
||||
|
||||
BACKGROUND:
|
||||
Only call change_background when the user explicitly asks to change colors/background.
|
||||
|
||||
BOOK CALL (HITL):
|
||||
When the user asks to book a call / schedule an intro / 1:1, call
|
||||
book_call with the topic and the person's name. The frontend renders a
|
||||
time picker; the user's choice is returned as the tool result.
|
||||
|
||||
TASK STEPS (HITL):
|
||||
When asked to plan something, use the generate_task_steps tool with a list of steps.
|
||||
Each step should have a description and status of "enabled".
|
||||
|
||||
FLIGHT SEARCH:
|
||||
Use search_flights when the user asks about flights. Generate 2 realistic flights.
|
||||
|
||||
STOCK PRICES:
|
||||
Use get_stock_price when the user asks about a ticker. Consider
|
||||
fetching a second related ticker for comparison when helpful.
|
||||
|
||||
DICE:
|
||||
Use roll_dice when the user asks to roll a die. Consider rolling a
|
||||
second time with a different number of sides for contrast.
|
||||
|
||||
DYNAMIC A2UI:
|
||||
Use generate_a2ui when the user asks for a dashboard or dynamic UI.
|
||||
|
||||
USER APPROVAL (HITL):
|
||||
When asked to take any action that affects a customer — for example
|
||||
issuing a refund, updating a plan, cancelling a subscription,
|
||||
escalating a ticket, or sending a credit — call request_user_approval
|
||||
FIRST with a short summary and optional context. Follow the tool
|
||||
result: if approved, confirm in one short sentence; if rejected,
|
||||
acknowledge and do not retry.
|
||||
""",
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""No-tools Agno agent for the MCP Apps demo.
|
||||
|
||||
The CopilotKit runtime's `mcpApps.servers` config auto-applies the MCP Apps
|
||||
middleware which fetches an MCP server's tools at request time and injects
|
||||
them into the agent's catalog. By giving this agent no native tools we keep
|
||||
the demo focused: the LLM only sees the MCP-provided tools, so any tool
|
||||
invocation visibly exercises the MCP Apps surface.
|
||||
"""
|
||||
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
agent = Agent(
|
||||
# Same timeout treatment as the main agent — keeps aimock-proxied requests
|
||||
# from timing out under load.
|
||||
model=OpenAIChat(id="gpt-4o", timeout=120),
|
||||
tools=[],
|
||||
tool_call_limit=15,
|
||||
description=(
|
||||
"You are a helpful assistant. The host runtime injects MCP-provided "
|
||||
"tools into your toolbox at request time; use them when appropriate "
|
||||
"to fulfil the user's request."
|
||||
),
|
||||
instructions="""
|
||||
When the user asks you to draw, sketch, or visualize something,
|
||||
prefer to use the MCP-provided drawing tools (e.g. Excalidraw)
|
||||
rather than describing the result in prose.
|
||||
""",
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Vision-capable Agno agent for the Multimodal Attachments demo.
|
||||
|
||||
Wave 2b design: a dedicated vision-capable agent scoped to `/demos/multimodal`
|
||||
so other demos keep their cheaper text-only model. Backed by ``gpt-4o`` so
|
||||
attached images are read natively. PDFs are flattened to text on the
|
||||
Python side via ``pypdf`` before the message is forwarded to the model.
|
||||
|
||||
Wire format
|
||||
-----------
|
||||
Attachments arrive here after travelling through:
|
||||
|
||||
CopilotChat → AG-UI message content parts (image / document)
|
||||
→ Agno AGUI converter (extracts data URLs)
|
||||
→ this agent
|
||||
|
||||
CopilotChat emits modern multimodal parts (`{ type: "image", source: { ... }}`,
|
||||
`{ type: "document", source: { ... }}`). The Agno AGUI router preserves
|
||||
the structured attachment payload via its tool/converter machinery; we
|
||||
inspect each user message and rewrite document parts (PDFs) into inline
|
||||
text before invoking the model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant. The user may attach images or documents "
|
||||
"(PDFs). When they do, analyze the attachment carefully and answer the "
|
||||
"user's question. If no attachment is present, answer the text question "
|
||||
"normally. Keep responses concise (1-3 sentences) unless asked to go deep."
|
||||
)
|
||||
|
||||
|
||||
def _extract_data_url_parts(url: str) -> tuple[str, str]:
|
||||
"""Split a ``data:<mime>;base64,<payload>`` URL into (mime, payload)."""
|
||||
if not url.startswith("data:"):
|
||||
return "", url
|
||||
header, _, payload = url.partition(",")
|
||||
if ":" not in header:
|
||||
return "", payload
|
||||
meta = header.split(":", 1)[1]
|
||||
mime = meta.split(";", 1)[0] if ";" in meta else meta
|
||||
return mime, payload
|
||||
|
||||
|
||||
def _extract_pdf_text(b64: str) -> str:
|
||||
"""Decode an inline-base64 PDF and extract its text. Returns "" on error."""
|
||||
try:
|
||||
raw = base64.b64decode(b64, validate=False)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
print(f"[multimodal_agent] base64 decode failed: {exc}")
|
||||
return ""
|
||||
|
||||
try:
|
||||
from pypdf import PdfReader # type: ignore[import-not-found]
|
||||
except ImportError as exc: # pragma: no cover - defensive
|
||||
print(
|
||||
"[multimodal_agent] pypdf not installed — PDF text extraction "
|
||||
f"unavailable: {exc}",
|
||||
)
|
||||
return ""
|
||||
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(raw))
|
||||
pages = [page.extract_text() or "" for page in reader.pages]
|
||||
return "\n\n".join(pages).strip()
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
print(f"[multimodal_agent] pypdf extraction failed: {exc}")
|
||||
return ""
|
||||
|
||||
|
||||
def _maybe_flatten_pdf_part(part: Any) -> Any:
|
||||
"""Flatten a PDF document content part into a plain text part."""
|
||||
if not isinstance(part, dict):
|
||||
return part
|
||||
|
||||
part_type = part.get("type")
|
||||
|
||||
if part_type == "document":
|
||||
source = part.get("source") or {}
|
||||
if not isinstance(source, dict):
|
||||
return part
|
||||
if source.get("type") != "data":
|
||||
return part
|
||||
mime = source.get("mimeType") or ""
|
||||
value = source.get("value")
|
||||
if not isinstance(value, str) or not isinstance(mime, str):
|
||||
return part
|
||||
if "pdf" not in mime.lower():
|
||||
return part
|
||||
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}"}
|
||||
|
||||
if part_type == "image_url":
|
||||
# Pass-through — gpt-4o reads image_url parts natively.
|
||||
return part
|
||||
|
||||
return part
|
||||
|
||||
|
||||
# Vision-capable model. gpt-4o consumes image content parts natively.
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o", timeout=120),
|
||||
tools=[],
|
||||
description=SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["agent", "_maybe_flatten_pdf_part"]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""No-tools Agno agent for the Open Generative UI demos.
|
||||
|
||||
The CopilotKit runtime's `openGenerativeUI` flag wires an
|
||||
OpenGenerativeUIMiddleware onto the listed agents. The middleware injects a
|
||||
single `generateSandboxedUi` tool into the run; the agent calls it, the
|
||||
middleware streams the call's HTML+CSS arguments out as
|
||||
`open-generative-ui` activity events, and the built-in
|
||||
`OpenGenerativeUIActivityRenderer` mounts the result inside a sandboxed
|
||||
iframe.
|
||||
|
||||
Giving the agent no native tools keeps the demo focused — the only tool the
|
||||
LLM ever sees is the one the OGUI middleware injects.
|
||||
"""
|
||||
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o", timeout=120),
|
||||
tools=[],
|
||||
tool_call_limit=10,
|
||||
description=(
|
||||
"You are a designer that authors small, self-contained sandboxed UIs "
|
||||
"via the generateSandboxedUi tool the runtime injects."
|
||||
),
|
||||
instructions="""
|
||||
Always satisfy a user UI request by calling the generateSandboxedUi
|
||||
tool the runtime injects. Do not describe the UI in prose; call the
|
||||
tool with complete HTML + CSS so the iframe renders the result.
|
||||
""",
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Reasoning-capable Agno agent for the reasoning family of demos.
|
||||
|
||||
Backs three showcase cells:
|
||||
- reasoning-custom (custom amber ReasoningBlock slot)
|
||||
- reasoning-default (CopilotKit's built-in reasoning card)
|
||||
- tool-rendering-reasoning-chain (reasoning + sequential tool calls)
|
||||
|
||||
Mirrors `showcase/integrations/langgraph-python/src/agents/reasoning_agent.py`
|
||||
(shared across the three reasoning demos there).
|
||||
|
||||
Uses reasoning=False with a custom AGUI handler in agent_server.py that
|
||||
emits REASONING_MESSAGE_* AG-UI events. The primary channel is Agno's
|
||||
native `reasoning_content` (chat-completions stream a `delta.reasoning_content`
|
||||
field surfaced on each `RunContentEvent.reasoning_content`), tee'd into the
|
||||
AG-UI reasoning stream; parsing of <reasoning>...</reasoning> XML tags in the
|
||||
model output remains as a defensive fallback when the native channel is empty.
|
||||
Running with reasoning=False avoids Agno's multi-call CoT loop (which breaks
|
||||
aimock fixtures) while still producing the proper AG-UI events that
|
||||
CopilotKit's frontend renders via the reasoningMessage slot.
|
||||
|
||||
For the reasoning-chain demo we also expose the same shared backend tools
|
||||
(`get_weather`, `search_flights`, `get_stock_price`, `roll_dice`) as the
|
||||
primary agent so the catch-all tool renderer can observe a full
|
||||
reasoning -> tool call -> reasoning -> tool call chain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from agno.tools import tool
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from tools import (
|
||||
get_weather_impl,
|
||||
search_flights_impl,
|
||||
)
|
||||
from tools.types import Flight
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: str):
|
||||
"""
|
||||
Get the weather for a given location. Ensure location is fully spelled out.
|
||||
|
||||
Args:
|
||||
location (str): The location to get the weather for.
|
||||
|
||||
Returns:
|
||||
str: Weather data as JSON.
|
||||
"""
|
||||
return json.dumps(get_weather_impl(location))
|
||||
|
||||
|
||||
@tool
|
||||
def search_flights(flights: list[dict]):
|
||||
"""
|
||||
Search for flights and display the results as rich A2UI cards.
|
||||
Return exactly 2 flights.
|
||||
|
||||
Args:
|
||||
flights (list[dict]): List of flight objects to display.
|
||||
|
||||
Returns:
|
||||
str: A2UI operations as JSON.
|
||||
"""
|
||||
typed_flights = [Flight(**f) for f in flights]
|
||||
result = search_flights_impl(typed_flights)
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
@tool
|
||||
def get_stock_price(ticker: str):
|
||||
"""
|
||||
Get a mock current price for a stock ticker.
|
||||
|
||||
Args:
|
||||
ticker (str): The ticker symbol to look up.
|
||||
|
||||
Returns:
|
||||
str: Mock price data as JSON.
|
||||
"""
|
||||
from random import choice, randint
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": round(100 + randint(0, 400) + randint(0, 99) / 100, 2),
|
||||
"change_pct": round(choice([-1, 1]) * (randint(0, 300) / 100), 2),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def roll_dice(sides: int = 6):
|
||||
"""
|
||||
Roll a single die with the given number of sides.
|
||||
|
||||
Args:
|
||||
sides (int): The number of sides on the die. Defaults to 6.
|
||||
|
||||
Returns:
|
||||
str: Dice roll result as JSON.
|
||||
"""
|
||||
from random import randint
|
||||
|
||||
return json.dumps({"sides": sides, "result": randint(1, max(2, sides))})
|
||||
|
||||
|
||||
# NOTE: reasoning=False (the default) is used here intentionally.
|
||||
#
|
||||
# Agno's reasoning=True triggers a multi-call Chain-of-Thought loop that
|
||||
# makes up to `reasoning_max_steps` sequential LLM calls. This breaks in
|
||||
# proxy/fixture environments (aimock, D5 probes) where only the first
|
||||
# call matches a fixture — subsequent calls don't match and either fall
|
||||
# through to the real API (slow, non-deterministic) or fail entirely.
|
||||
#
|
||||
# Instead, the custom AGUI handler in agent_server.py synthesizes
|
||||
# REASONING_MESSAGE_* AG-UI events from the agent's response text. The
|
||||
# system prompt instructs the model to prefix its answer with a reasoning
|
||||
# block delimited by <reasoning>...</reasoning> tags. The custom handler
|
||||
# parses those tags and emits proper AG-UI reasoning events that
|
||||
# CopilotKit's frontend renders via the reasoningMessage slot.
|
||||
#
|
||||
# This approach:
|
||||
# - Works with aimock (single LLM call)
|
||||
# - Emits proper AG-UI REASONING_MESSAGE_* events (unlike Agno's stock
|
||||
# AGUI handler which only emits STEP_STARTED/STEP_FINISHED)
|
||||
# - Keeps the demo visually identical to native reasoning models
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o-mini", timeout=120),
|
||||
tools=[get_weather, search_flights, get_stock_price, roll_dice],
|
||||
reasoning=False,
|
||||
tool_call_limit=10,
|
||||
description=(
|
||||
"You are a helpful assistant. For each user question, first think "
|
||||
"step-by-step about the approach, then answer concisely. When the "
|
||||
"question calls for a tool, call it explicitly rather than guessing."
|
||||
),
|
||||
instructions="""
|
||||
REASONING STYLE:
|
||||
Always begin your response with a reasoning block wrapped in
|
||||
<reasoning>...</reasoning> XML tags. Inside the tags, think
|
||||
step-by-step (two to four short steps is plenty). After the closing
|
||||
tag, give your concise final answer. Example:
|
||||
|
||||
<reasoning>
|
||||
Step 1: Identify what the user is asking.
|
||||
Step 2: Consider which tool to use.
|
||||
Step 3: Formulate the answer.
|
||||
</reasoning>
|
||||
|
||||
Here is my answer...
|
||||
|
||||
TOOLS (reasoning-chain cell):
|
||||
- get_weather: use when the user asks about weather.
|
||||
- search_flights: use when the user asks about flights. Generate 2
|
||||
realistic flights. Flight shape: airline, airlineLogo (Google
|
||||
favicon URL), flightNumber, origin, destination, date
|
||||
("Tue, Mar 18"), departureTime, arrivalTime, duration ("4h 25m"),
|
||||
status ("On Time"|"Delayed"), statusColor (hex), price ("$289"),
|
||||
currency ("USD").
|
||||
- get_stock_price: use when the user asks about a ticker. Consider
|
||||
fetching a second related ticker for comparison.
|
||||
- roll_dice: use when the user asks to roll a die. Consider rolling
|
||||
twice with different numbers of sides.
|
||||
""",
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Agno agent backing the Shared State (Read + Write) demo.
|
||||
|
||||
Mirrors `langgraph-python/src/agents/shared_state_read_write.py` and
|
||||
`google-adk/src/agents/shared_state_read_write_agent.py`.
|
||||
|
||||
Demonstrates the canonical bidirectional shared-state pattern between UI
|
||||
and agent:
|
||||
|
||||
- **UI -> agent (write)**: The UI owns a `preferences` object that it
|
||||
writes into agent state via `agent.setState(...)`. The Agno agent's
|
||||
dynamic instructions function reads `session_state["preferences"]`
|
||||
every turn and prepends a preferences block so the LLM adapts.
|
||||
|
||||
- **agent -> UI (read)**: The agent calls the `set_notes` tool to
|
||||
replace `session_state["notes"]`. Our custom AGUI router (see
|
||||
`agent_server.py`) emits a `StateSnapshotEvent` after every run so the
|
||||
UI's `useAgent({ updates: [OnStateChanged] })` reflects the change.
|
||||
|
||||
Together this matches the canonical bidirectional shared-state contract
|
||||
the langgraph-python and google-adk references implement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
import dotenv
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from agno.run import RunContext
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
|
||||
PREFS_BLOCK_HEADER = "[shared-state-read-write] preferences:"
|
||||
|
||||
|
||||
def _format_preferences(prefs: Any) -> str:
|
||||
"""Build the preferences block injected into the system prompt."""
|
||||
if not isinstance(prefs, dict) or not prefs:
|
||||
return ""
|
||||
lines = [PREFS_BLOCK_HEADER]
|
||||
if prefs.get("name"):
|
||||
lines.append(f"- Name: {prefs['name']}")
|
||||
if prefs.get("tone"):
|
||||
lines.append(f"- Preferred tone: {prefs['tone']}")
|
||||
if prefs.get("language"):
|
||||
lines.append(f"- Preferred language: {prefs['language']}")
|
||||
interests = prefs.get("interests") or []
|
||||
if interests:
|
||||
lines.append(f"- Interests: {', '.join(interests)}")
|
||||
if len(lines) == 1:
|
||||
# Truthy dict but no recognized keys — emit nothing rather than a
|
||||
# bare header. Mirrors the same guard used in google-adk's
|
||||
# shared_state_read_write_agent._build_prefs_block.
|
||||
return ""
|
||||
lines.append(
|
||||
"Tailor every response to these preferences. "
|
||||
"Address the user by name when appropriate."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_instructions(run_context: RunContext) -> str:
|
||||
"""Dynamic instructions: read latest preferences from session_state.
|
||||
|
||||
Agno re-evaluates this function on every run when `cache_callables`
|
||||
is False, so writes the UI makes via `agent.setState({preferences})`
|
||||
take effect on the very next turn.
|
||||
"""
|
||||
base = dedent(
|
||||
"""
|
||||
You are a helpful, concise assistant. The user's preferences are
|
||||
supplied via shared state and added as a system message at the start
|
||||
of every turn — always respect them.
|
||||
|
||||
When the user asks you to remember something, or you observe
|
||||
something worth surfacing in the UI's notes panel, call `set_notes`
|
||||
with the FULL updated list of short note strings (existing notes +
|
||||
any new ones). Keep each note under 120 characters. Always pass the
|
||||
complete list — `set_notes` REPLACES the notes array, it does not
|
||||
append.
|
||||
"""
|
||||
).strip()
|
||||
|
||||
prefs_block = _format_preferences(getattr(run_context, "session_state", None) or {})
|
||||
if prefs_block:
|
||||
return f"{prefs_block}\n\n{base}"
|
||||
return base
|
||||
|
||||
|
||||
def set_notes(run_context: RunContext, notes: list[str]) -> str:
|
||||
"""Replace the notes array in shared state with the full updated list.
|
||||
|
||||
Always pass the FULL list of short note strings (existing notes + new),
|
||||
not a diff. Keep each note short (< 120 chars).
|
||||
"""
|
||||
if run_context.session_state is None:
|
||||
run_context.session_state = {}
|
||||
# Coerce all entries to plain strings — tolerate models that pass
|
||||
# through stray dict/None entries from earlier turns rather than
|
||||
# crash the AGUI router with a serialization failure mid-stream.
|
||||
cleaned = [str(n) for n in (notes or []) if n is not None]
|
||||
run_context.session_state["notes"] = cleaned
|
||||
return f"Notes updated. ({len(cleaned)} total)"
|
||||
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o-mini", timeout=120),
|
||||
tools=[set_notes],
|
||||
# Re-evaluate instructions on every run so UI writes to
|
||||
# session_state["preferences"] are visible to the LLM on the very
|
||||
# next turn (rather than being cached at agent construction time).
|
||||
cache_callables=False,
|
||||
instructions=build_instructions,
|
||||
description=(
|
||||
"You adapt your responses to the user's stored preferences and use "
|
||||
"the set_notes tool to surface things worth remembering."
|
||||
),
|
||||
tool_call_limit=5,
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Agno agent backing the Sub-Agents demo.
|
||||
|
||||
Mirrors `langgraph-python/src/agents/subagents.py` and
|
||||
`google-adk/src/agents/subagents_agent.py`.
|
||||
|
||||
A supervisor Agno agent delegates work to three specialized sub-agents
|
||||
(research / writing / critique) exposed as tools. Each delegation
|
||||
appends an entry to `session_state["delegations"]` so the UI can render
|
||||
a live delegation log via `useAgent({ updates: [OnStateChanged] })`.
|
||||
|
||||
Each sub-agent is itself a full `Agent(...)` with its own system prompt
|
||||
— the supervisor only sees the sub-agent's final text response. This is
|
||||
the canonical Agno multi-agent pattern, surfaced to the frontend via
|
||||
shared state.
|
||||
"""
|
||||
|
||||
# @region[supervisor-delegation-tools]
|
||||
# @region[subagent-setup]
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import dotenv
|
||||
from agno.agent.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from agno.run import RunContext
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-agents (real Agno agents under the hood)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SUB_MODEL_ID = "gpt-4o-mini"
|
||||
|
||||
|
||||
# Each sub-agent is a full Agno `Agent(...)` with its own system prompt.
|
||||
# They don't share memory or tools with the supervisor — the supervisor
|
||||
# only sees their final text response, which is returned via the
|
||||
# delegation tool below.
|
||||
_research_agent = Agent(
|
||||
model=OpenAIChat(id=_SUB_MODEL_ID, timeout=120),
|
||||
description="Research sub-agent.",
|
||||
instructions=(
|
||||
"You are a research sub-agent. Given a topic, produce a concise "
|
||||
"bulleted list of 3-5 key facts. No preamble, no closing."
|
||||
),
|
||||
)
|
||||
|
||||
_writing_agent = Agent(
|
||||
model=OpenAIChat(id=_SUB_MODEL_ID, timeout=120),
|
||||
description="Writing sub-agent.",
|
||||
instructions=(
|
||||
"You are a writing sub-agent. Given a brief and optional source "
|
||||
"facts, produce a polished 1-paragraph draft. Be clear and "
|
||||
"concrete. No preamble."
|
||||
),
|
||||
)
|
||||
|
||||
_critique_agent = Agent(
|
||||
model=OpenAIChat(id=_SUB_MODEL_ID, timeout=120),
|
||||
description="Critique sub-agent.",
|
||||
instructions=(
|
||||
"You are an editorial critique sub-agent. Given a draft, give "
|
||||
"2-3 crisp, actionable critiques. No preamble."
|
||||
),
|
||||
)
|
||||
# @endregion[subagent-setup]
|
||||
|
||||
|
||||
def _invoke_sub_agent(sub_agent: Agent, task: str) -> str:
|
||||
"""Run a sub-agent on `task` and return its final message content."""
|
||||
result = sub_agent.run(input=task)
|
||||
content = getattr(result, "content", None)
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
if content is None:
|
||||
return ""
|
||||
return str(content).strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared-state helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _append_delegation(
|
||||
run_context: RunContext,
|
||||
*,
|
||||
sub_agent: str,
|
||||
task: str,
|
||||
status: str,
|
||||
result: str,
|
||||
) -> str:
|
||||
"""Append a delegation entry and return its id."""
|
||||
if run_context.session_state is None:
|
||||
run_context.session_state = {}
|
||||
delegations = list(run_context.session_state.get("delegations") or [])
|
||||
entry_id = str(uuid.uuid4())
|
||||
delegations.append(
|
||||
{
|
||||
"id": entry_id,
|
||||
"sub_agent": sub_agent,
|
||||
"task": task,
|
||||
"status": status,
|
||||
"result": result,
|
||||
}
|
||||
)
|
||||
run_context.session_state["delegations"] = delegations
|
||||
return entry_id
|
||||
|
||||
|
||||
def _update_delegation(
|
||||
run_context: RunContext,
|
||||
*,
|
||||
entry_id: str,
|
||||
status: str,
|
||||
result: str,
|
||||
) -> None:
|
||||
"""Mutate the delegation entry with `entry_id` in shared state.
|
||||
|
||||
If the entry has gone missing (another part of the system replaced
|
||||
`session_state["delegations"]`), log loudly and skip rather than
|
||||
appending a synthetic entry. Mirrors the conservative behavior used
|
||||
in the google-adk reference.
|
||||
"""
|
||||
if run_context.session_state is None:
|
||||
run_context.session_state = {}
|
||||
delegations = list(run_context.session_state.get("delegations") or [])
|
||||
for entry in delegations:
|
||||
if entry.get("id") == entry_id:
|
||||
entry["status"] = status
|
||||
entry["result"] = result
|
||||
run_context.session_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),
|
||||
)
|
||||
|
||||
|
||||
def _delegate(
|
||||
run_context: RunContext,
|
||||
*,
|
||||
sub_agent_name: str,
|
||||
sub_agent: Agent,
|
||||
task: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Common delegation flow: append running entry → invoke → update final."""
|
||||
entry_id = _append_delegation(
|
||||
run_context,
|
||||
sub_agent=sub_agent_name,
|
||||
task=task,
|
||||
status="running",
|
||||
result="",
|
||||
)
|
||||
try:
|
||||
result = _invoke_sub_agent(sub_agent, task)
|
||||
except Exception as exc: # noqa: BLE001 — sub-agent transport can fail anywhere
|
||||
logger.exception("subagents: sub-agent %s failed", sub_agent_name)
|
||||
# Surface only the exception class to the supervisor / frontend —
|
||||
# provider error strings can carry URLs / request IDs / partial
|
||||
# credentials. The full traceback stays in server logs.
|
||||
message = (
|
||||
f"sub-agent call failed: {exc.__class__.__name__} "
|
||||
"(see server logs for details)"
|
||||
)
|
||||
_update_delegation(
|
||||
run_context, entry_id=entry_id, status="failed", result=message
|
||||
)
|
||||
return {"status": "failed", "error": message}
|
||||
|
||||
_update_delegation(
|
||||
run_context, entry_id=entry_id, status="completed", result=result
|
||||
)
|
||||
return {"status": "completed", "result": result}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor tools (each tool delegates to one sub-agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Each function is a tool exposed to the supervisor agent. The supervisor
|
||||
# LLM "calls" these to delegate work; each call synchronously runs the
|
||||
# matching sub-agent, records the delegation into shared state, and
|
||||
# returns the sub-agent's output as the tool result the supervisor reads
|
||||
# on its next step.
|
||||
def research_agent(run_context: RunContext, task: str) -> dict[str, Any]:
|
||||
"""Delegate a research task to the research sub-agent.
|
||||
|
||||
Use for: gathering facts, background, definitions, statistics.
|
||||
Returns {status, result} on success or {status: "failed", error} on
|
||||
sub-agent failure.
|
||||
"""
|
||||
return _delegate(
|
||||
run_context,
|
||||
sub_agent_name="research_agent",
|
||||
sub_agent=_research_agent,
|
||||
task=task,
|
||||
)
|
||||
|
||||
|
||||
def writing_agent(run_context: RunContext, task: str) -> dict[str, Any]:
|
||||
"""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`. Same return shape
|
||||
as research_agent.
|
||||
"""
|
||||
return _delegate(
|
||||
run_context,
|
||||
sub_agent_name="writing_agent",
|
||||
sub_agent=_writing_agent,
|
||||
task=task,
|
||||
)
|
||||
|
||||
|
||||
def critique_agent(run_context: RunContext, task: str) -> dict[str, Any]:
|
||||
"""Delegate a critique task to the critique sub-agent.
|
||||
|
||||
Use for: reviewing a draft and suggesting concrete improvements.
|
||||
Same return shape as research_agent.
|
||||
"""
|
||||
return _delegate(
|
||||
run_context,
|
||||
sub_agent_name="critique_agent",
|
||||
sub_agent=_critique_agent,
|
||||
task=task,
|
||||
)
|
||||
|
||||
|
||||
# @endregion[supervisor-delegation-tools]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor (the agent we export)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SUPERVISOR_INSTRUCTION = (
|
||||
"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. Each tool returns a dict "
|
||||
"shaped {status: 'completed' | 'failed', result?: str, error?: str}. "
|
||||
"If a sub-agent fails, surface the failure briefly to the user "
|
||||
"(don't fabricate a result) and decide whether to retry. 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."
|
||||
)
|
||||
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id=_SUB_MODEL_ID, timeout=120),
|
||||
tools=[research_agent, writing_agent, critique_agent],
|
||||
description="Supervisor agent coordinating research / writing / critique sub-agents.",
|
||||
instructions=_SUPERVISOR_INSTRUCTION,
|
||||
tool_call_limit=10,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
// Dedicated runtime for the A2UI — Fixed Schema cell. Splitting into its
|
||||
// own endpoint lets us set `a2ui.injectA2UITool: false` — the backend Agno
|
||||
// agent owns the `display_flight` tool which emits its own
|
||||
// `a2ui_operations` container directly in the tool result (no secondary
|
||||
// LLM call).
|
||||
//
|
||||
// Reference:
|
||||
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-a2ui-fixed-schema/route.ts
|
||||
// - src/agents/a2ui_fixed_agent.py (the Agno 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/agui`,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-expect-error -- see main route.ts
|
||||
agents: { "a2ui-fixed-schema": a2uiFixedSchemaAgent },
|
||||
a2ui: {
|
||||
// The backend agent emits its own `a2ui_operations` container inside
|
||||
// `display_flight` (see src/agents/a2ui_fixed_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,52 @@
|
||||
// Dedicated runtime for the Agent Config Object demo (Agno).
|
||||
//
|
||||
// The page wraps <CopilotKit properties={...}>; CopilotKit forwards those
|
||||
// properties as top-level keys on `RunAgentInput.forwarded_props`, which
|
||||
// the Agno-side custom AGUI handler at `/agent-config/agui` reads to
|
||||
// build a per-request system prompt (see
|
||||
// `src/agent_server.py::_run_agent_config`).
|
||||
//
|
||||
// Scoped to its own endpoint so non-demo cells don't pay the cost of the
|
||||
// per-request agent factory and so the request-body propagation can be
|
||||
// asserted against exactly one URL.
|
||||
|
||||
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 agentConfigAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/agent-config/agui`,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
// @ts-expect-error -- see main route.ts
|
||||
"agent-config-demo": agentConfigAgent,
|
||||
// @ts-expect-error -- see main route.ts
|
||||
default: agentConfigAgent,
|
||||
},
|
||||
});
|
||||
|
||||
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,76 @@
|
||||
// Dedicated runtime for the /demos/auth cell.
|
||||
//
|
||||
// Demonstrates framework-native request authentication via the V2 runtime's
|
||||
// `onRequest` hook, which runs before routing and can short-circuit the
|
||||
// request by throwing a Response. We validate a static `Authorization: Bearer
|
||||
// <DEMO_TOKEN>` header; mismatch throws 401 before the request reaches the
|
||||
// agent.
|
||||
//
|
||||
// Implementation note: the V1 Next.js adapter
|
||||
// (`copilotRuntimeNextJSAppRouterEndpoint`) does NOT forward the `hooks`
|
||||
// option to the V2 fetch handler. To get `onRequest` wired, this route uses
|
||||
// `createCopilotRuntimeHandler` from `@copilotkit/runtime/v2` directly — the
|
||||
// framework-agnostic fetch handler that returns a plain
|
||||
// `(Request) => Promise<Response>`, which composes cleanly with a Next.js
|
||||
// App Router route export.
|
||||
//
|
||||
// The authenticated target is the Agno main agent served at /agui on the
|
||||
// AgentOS process. The point of this demo is the gate mechanism, not
|
||||
// per-user agent branching — authenticated users get the same behavior as
|
||||
// any other neutral demo.
|
||||
|
||||
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 neutral Agno main agent for the authenticated path.
|
||||
const authDemoAgent = new HttpAgent({ url: `${AGENT_URL}/agui` });
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
"auth-demo": authDemoAgent,
|
||||
// Fallback: useAgent() with no args resolves "default" — alias to the
|
||||
// same agent so hooks in the demo page resolve cleanly.
|
||||
default: authDemoAgent,
|
||||
},
|
||||
});
|
||||
|
||||
const BASE_PATH = "/api/copilotkit-auth";
|
||||
|
||||
// Framework-agnostic fetch handler with the auth gate wired up.
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: BASE_PATH,
|
||||
hooks: {
|
||||
onRequest: ({ request }) => {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (authHeader !== DEMO_AUTH_HEADER) {
|
||||
// Throwing a Response short-circuits the pipeline. The runtime maps
|
||||
// thrown Responses to the HTTP response verbatim (status + body).
|
||||
throw new Response(
|
||||
JSON.stringify({
|
||||
error: "unauthorized",
|
||||
message:
|
||||
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Next.js App Router bindings. The handler is framework-agnostic — it takes
|
||||
// a web Request and returns a web Response — so it drops straight into the
|
||||
// POST/GET exports without any adapter shim.
|
||||
export const POST = (req: NextRequest) => handler(req);
|
||||
export const GET = (req: NextRequest) => handler(req);
|
||||
@@ -0,0 +1,75 @@
|
||||
// Dedicated runtime for the Beautiful Chat flagship showcase cell (Agno).
|
||||
//
|
||||
// Beautiful Chat exercises A2UI (dynamic + fixed schema) and Open
|
||||
// Generative UI. The canonical langgraph-python reference ships MCP Apps
|
||||
// on the same runtime as well; the Agno port routes the cell at the shared
|
||||
// `main` Agno agent (`/agui`) since there is no dedicated beautiful-chat
|
||||
// backend endpoint — 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}/agui` }),
|
||||
default: new HttpAgent({ url: `${AGENT_URL}/agui` }),
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-expect-error -- see main route.ts
|
||||
agents,
|
||||
openGenerativeUI: true,
|
||||
a2ui: {
|
||||
// The targeted `main` Agno agent (`/agui`) already registers the
|
||||
// `generate_a2ui` tool itself (src/agents/main.py), so the runtime must
|
||||
// NOT inject a second copy — that would double-bind the render tool.
|
||||
// Matches pydantic-ai's beautiful-chat (this file's mirror source) and
|
||||
// the other Agno a2ui routes, all of which set this false.
|
||||
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,71 @@
|
||||
// Dedicated runtime for the byoc-hashbrown demo (Agno).
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
console.log(`[copilotkit-byoc-hashbrown/route] AGENT_URL: ${AGENT_URL}`);
|
||||
|
||||
// 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";
|
||||
|
||||
function createByocHashbrownAgent() {
|
||||
return new HttpAgent({
|
||||
url: `${AGENT_URL}/byoc-hashbrown/agui`,
|
||||
});
|
||||
}
|
||||
|
||||
// Register both the named agent and a default fallback so the runtime
|
||||
// can always resolve regardless of which agent name the frontend sends.
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"byoc-hashbrown-demo": createByocHashbrownAgent(),
|
||||
default: createByocHashbrownAgent(),
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
if (ROUTE_DEBUG) {
|
||||
console.log(`[copilotkit-byoc-hashbrown/route] POST ${url}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-byoc-hashbrown",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-expect-error -- see main route.ts
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
const response = await handleRequest(req);
|
||||
if (!response.ok) {
|
||||
console.log(
|
||||
`[copilotkit-byoc-hashbrown/route] Response status: ${response.status}`,
|
||||
);
|
||||
} else if (ROUTE_DEBUG) {
|
||||
console.log(
|
||||
`[copilotkit-byoc-hashbrown/route] Response status: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
console.error(`[copilotkit-byoc-hashbrown/route] ERROR: ${e.message}`);
|
||||
console.error(`[copilotkit-byoc-hashbrown/route] Stack: ${e.stack}`);
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
// Dedicated runtime for the byoc-json-render demo (Agno).
|
||||
|
||||
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/agui`,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-expect-error -- see main route.ts
|
||||
agents: { byoc_json_render: 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,62 @@
|
||||
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic Schema)
|
||||
// cell. Splitting into its own endpoint (mirroring beautiful-chat in the
|
||||
// langgraph-python reference) lets us set `a2ui.injectA2UITool: false` —
|
||||
// the backend Agno agent owns the `generate_a2ui` tool itself, so
|
||||
// double-binding from the runtime would duplicate the tool slot and confuse
|
||||
// the LLM.
|
||||
//
|
||||
// Reference:
|
||||
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-declarative-gen-ui/route.ts
|
||||
// - src/agents/a2ui_dynamic_agent.py (the Agno backend)
|
||||
|
||||
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}/declarative-gen-ui/agui`,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-expect-error -- see main route.ts
|
||||
agents: { "declarative-gen-ui": declarativeGenUiAgent },
|
||||
a2ui: {
|
||||
// The backend agent owns `generate_a2ui` explicitly (see
|
||||
// src/agents/a2ui_dynamic_agent.py), so the runtime MUST NOT
|
||||
// auto-inject its own A2UI tool on top. The A2UI middleware still runs
|
||||
// — it serialises the registered client catalog into the agent's
|
||||
// `state.copilotkit.context` so the secondary LLM inside
|
||||
// `generate_a2ui` knows which components to emit — and it still
|
||||
// detects the `a2ui_operations` container in the tool result and
|
||||
// streams rendered surfaces to the frontend.
|
||||
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,78 @@
|
||||
// Dedicated runtime for the declarative-hashbrown demo (Agno).
|
||||
//
|
||||
// The demo folder + route + agent slug were renamed from `byoc-hashbrown`
|
||||
// to the canonical `declarative-hashbrown` surface; the underlying Agno
|
||||
// agent still mounts at `/byoc-hashbrown/agui` (see src/agent_server.py).
|
||||
// The demo page mounts <CopilotKit agent="declarative-hashbrown-demo">.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
console.log(`[copilotkit-declarative-hashbrown/route] AGENT_URL: ${AGENT_URL}`);
|
||||
|
||||
// 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";
|
||||
|
||||
function createDeclarativeHashbrownAgent() {
|
||||
return new HttpAgent({
|
||||
url: `${AGENT_URL}/byoc-hashbrown/agui`,
|
||||
});
|
||||
}
|
||||
|
||||
// Register both the named agent and a default fallback so the runtime
|
||||
// can always resolve regardless of which agent name the frontend sends.
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
"declarative-hashbrown-demo": createDeclarativeHashbrownAgent(),
|
||||
default: createDeclarativeHashbrownAgent(),
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
if (ROUTE_DEBUG) {
|
||||
console.log(`[copilotkit-declarative-hashbrown/route] POST ${url}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-declarative-hashbrown",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-expect-error -- see main route.ts
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
const response = await handleRequest(req);
|
||||
if (!response.ok) {
|
||||
console.log(
|
||||
`[copilotkit-declarative-hashbrown/route] Response status: ${response.status}`,
|
||||
);
|
||||
} else if (ROUTE_DEBUG) {
|
||||
console.log(
|
||||
`[copilotkit-declarative-hashbrown/route] Response status: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
console.error(
|
||||
`[copilotkit-declarative-hashbrown/route] ERROR: ${e.message}`,
|
||||
);
|
||||
console.error(`[copilotkit-declarative-hashbrown/route] Stack: ${e.stack}`);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
// Dedicated runtime for the declarative-json-render demo (Agno).
|
||||
//
|
||||
// The demo page renders the agent's JSON output into a frontend-owned
|
||||
// component catalog via @json-render/react. The underlying Agno agent mounts
|
||||
// at `/byoc-json-render/agui` (see src/agent_server.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 { AbstractAgent, 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/agui`,
|
||||
});
|
||||
|
||||
// Register both the named agent and a `default` fallback so the runtime can
|
||||
// resolve regardless of whether the frontend sends an explicit agentId.
|
||||
const agents: Record<string, AbstractAgent> = {
|
||||
byoc_json_render: byocJsonRenderAgent,
|
||||
default: byocJsonRenderAgent,
|
||||
};
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-expect-error -- see main route.ts
|
||||
agents,
|
||||
});
|
||||
|
||||
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,82 @@
|
||||
// CopilotKit runtime for the MCP Apps cell (Agno).
|
||||
//
|
||||
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware to the
|
||||
// agent: when the agent calls a tool backed by an MCP UI resource, the
|
||||
// middleware fetches the resource and emits the activity event that the
|
||||
// built-in `MCPAppsActivityRenderer` (registered by CopilotKit internally)
|
||||
// renders in the chat as a sandboxed iframe.
|
||||
//
|
||||
// We attach a no-tools Agno agent here (the main agent at /agui has its own
|
||||
// rich tool catalog; an MCP-only cell is cleaner with no competing tools).
|
||||
// The MCP Apps middleware injects the MCP server's tools into the agent's
|
||||
// catalog at request time so the LLM can call them.
|
||||
//
|
||||
// Reference:
|
||||
// https://docs.copilotkit.ai/integrations/agno/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";
|
||||
|
||||
// Backed by the Agno main AGUI interface. The MCP Apps middleware wires
|
||||
// MCP server tools into the run alongside whatever tools the agent already
|
||||
// has — for the cleanest UX the dedicated `/mcp-apps/agui` interface (see
|
||||
// agent_server.py) registers a no-tools Agno agent so the LLM only sees
|
||||
// the MCP-provided toolset.
|
||||
const mcpAppsAgent = new HttpAgent({ url: `${AGENT_URL}/mcp-apps/agui` });
|
||||
|
||||
// headless-complete shares this runtime (its page wires
|
||||
// runtimeUrl="/api/copilotkit-mcp-apps") but is backed by the main Agno
|
||||
// agent at /agui — the same backend the main route registers it against.
|
||||
const headlessCompleteAgent = new HttpAgent({ url: `${AGENT_URL}/agui` });
|
||||
|
||||
// @region[runtime-mcpapps-config]
|
||||
// The `mcpApps.servers` config is all you need server-side. The runtime
|
||||
// auto-applies the MCP Apps middleware: on each MCP tool call it fetches
|
||||
// the associated UI resource and emits an `activity` event that the
|
||||
// built-in `MCPAppsActivityRenderer` renders inline in the chat.
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
// @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.
|
||||
"headless-complete": headlessCompleteAgent,
|
||||
"mcp-apps": 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,48 @@
|
||||
// Dedicated runtime for the Multimodal Attachments demo (Agno).
|
||||
//
|
||||
// Why its own route? The backing graph (multimodal_agent) runs a vision-
|
||||
// capable model (gpt-4o). Other showcase cells use cheaper text-only models;
|
||||
// scoping the vision capability — and its cost — to exactly the cell that
|
||||
// exercises it matches the pattern used by the LangGraph reference.
|
||||
//
|
||||
// The page at src/app/demos/multimodal/page.tsx points its `runtimeUrl` at
|
||||
// this endpoint and sets `agent="multimodal-demo"` (resolved below).
|
||||
|
||||
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/agui`,
|
||||
});
|
||||
|
||||
const agents = {
|
||||
"multimodal-demo": multimodalAgent,
|
||||
default: multimodalAgent,
|
||||
} as const;
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-multimodal",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-expect-error -- 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,64 @@
|
||||
// Dedicated runtime for the Open Generative UI demos (Agno).
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// The Agno backend exposes a no-tools agent at `/open-gen-ui/agui` (see
|
||||
// `agent_server.py`); the runtime middleware turns each
|
||||
// `generateSandboxedUi` tool call the agent emits into an
|
||||
// `open-generative-ui` activity event the built-in renderer mounts inside a
|
||||
// sandboxed iframe.
|
||||
|
||||
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 openGenUiAgent = new HttpAgent({ url: `${AGENT_URL}/open-gen-ui/agui` });
|
||||
const openGenUiAdvancedAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/open-gen-ui/agui`,
|
||||
});
|
||||
|
||||
const agents = {
|
||||
"open-gen-ui": openGenUiAgent,
|
||||
"open-gen-ui-advanced": openGenUiAdvancedAgent,
|
||||
} as const;
|
||||
|
||||
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).
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-expect-error -- see main route.ts
|
||||
agents,
|
||||
openGenerativeUI: {
|
||||
agents: ["open-gen-ui", "open-gen-ui-advanced"],
|
||||
},
|
||||
}),
|
||||
// @endregion[advanced-runtime-config]
|
||||
// @endregion[minimal-runtime-flag]
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
// Dedicated runtime for the /demos/voice cell (Agno).
|
||||
//
|
||||
// Goals:
|
||||
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
|
||||
// composer renders the mic button.
|
||||
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
|
||||
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`).
|
||||
// 3. Return a typed 401 when `OPENAI_API_KEY` isn't set so the UI surfaces a
|
||||
// clean error instead of a 5xx.
|
||||
//
|
||||
// Implementation: wires the V2 `CopilotRuntime` directly (the V1 wrapper
|
||||
// drops the `transcriptionService` option on the floor). V2 URL-routes on
|
||||
// `/info`, `/agent/:id/run`, `/transcribe`, etc., so the route lives at
|
||||
// `[[...slug]]/route.ts` to catch every sub-path.
|
||||
|
||||
// @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 { TranscriptionServiceOpenAI } from "@copilotkit/voice";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import OpenAI from "openai";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const voiceDemoAgent = new HttpAgent({ url: `${AGENT_URL}/agui` });
|
||||
|
||||
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) {
|
||||
// "api key" substring → handleTranscribe maps to AUTH_FAILED → 401.
|
||||
throw new Error(
|
||||
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
|
||||
"Set OPENAI_API_KEY to enable voice transcription.",
|
||||
);
|
||||
}
|
||||
return this.delegate.transcribeFile(options);
|
||||
}
|
||||
}
|
||||
// @endregion[transcription-service-guard]
|
||||
|
||||
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
|
||||
function getHandler(): (req: Request) => Promise<Response> {
|
||||
if (cachedHandler) return cachedHandler;
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
// @ts-expect-error -- see main route.ts
|
||||
"voice-demo": voiceDemoAgent,
|
||||
// @ts-expect-error -- see main route.ts
|
||||
default: voiceDemoAgent,
|
||||
},
|
||||
transcriptionService: new GuardedOpenAITranscriptionService(),
|
||||
});
|
||||
|
||||
cachedHandler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-voice",
|
||||
});
|
||||
return cachedHandler;
|
||||
}
|
||||
|
||||
export const POST = (req: NextRequest) => getHandler()(req);
|
||||
export const GET = (req: NextRequest) => getHandler()(req);
|
||||
export const PUT = (req: NextRequest) => getHandler()(req);
|
||||
export const DELETE = (req: NextRequest) => getHandler()(req);
|
||||
// @endregion[voice-runtime]
|
||||
@@ -0,0 +1,190 @@
|
||||
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";
|
||||
|
||||
console.log("[copilotkit/route] Initializing CopilotKit runtime");
|
||||
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
|
||||
|
||||
// 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";
|
||||
|
||||
function createMainAgent() {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/agui` });
|
||||
}
|
||||
|
||||
function createReasoningAgent() {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/reasoning/agui` });
|
||||
}
|
||||
|
||||
// State-aware agents are served by a custom AGUI handler in agent_server.py
|
||||
// that emits StateSnapshotEvent after every run. Stock Agno AGUI does NOT
|
||||
// emit state events, so demos that depend on agent-side state writes
|
||||
// (set_notes, delegations) must point at these dedicated routes.
|
||||
function createSharedStateRWAgent() {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/shared-state-rw/agui` });
|
||||
}
|
||||
|
||||
function createSubagentsAgent() {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/subagents/agui` });
|
||||
}
|
||||
|
||||
// gen-ui-agent: agent owns its own state schema (`steps`) and mutates it
|
||||
// via the `set_steps` tool. Needs the state-aware AGUI router so the
|
||||
// frontend's `useAgent({ updates: [OnStateChanged] })` receives the
|
||||
// StateSnapshotEvent each run — stock Agno AGUI does not emit one.
|
||||
function createGenUiAgent() {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/gen-ui-agent/agui` });
|
||||
}
|
||||
|
||||
// Main agent backs most demos. The Next.js runtime aliases the single
|
||||
// Agno `main` agent under every demo cell name so per-cell frontend
|
||||
// tool/component registrations scope correctly.
|
||||
const mainAgentNames = [
|
||||
"agentic_chat",
|
||||
"human_in_the_loop",
|
||||
"hitl-in-chat",
|
||||
"hitl-in-app",
|
||||
"tool-rendering",
|
||||
"tool-rendering-default-catchall",
|
||||
"tool-rendering-custom-catchall",
|
||||
"gen-ui-tool-based",
|
||||
"shared-state-read",
|
||||
"shared-state-write",
|
||||
"shared-state-streaming",
|
||||
// Neutral / chrome demos reusing the default agent.
|
||||
"prebuilt-sidebar",
|
||||
"prebuilt-popup",
|
||||
"chat-slots",
|
||||
"chat-customization-css",
|
||||
"headless-simple",
|
||||
"headless-complete",
|
||||
"frontend_tools",
|
||||
"frontend-tools-async",
|
||||
"readonly-state-agent-context",
|
||||
"agent-config",
|
||||
];
|
||||
|
||||
// Interrupt-adapted demos: gen-ui-interrupt and interrupt-headless share
|
||||
// the same Agno scheduling agent at /interrupt-adapted/agui. The agent has
|
||||
// tools=[]; `schedule_meeting` is provided by the frontend via
|
||||
// `useFrontendTool` with an async Promise handler.
|
||||
function createInterruptAgent() {
|
||||
return new HttpAgent({ url: `${AGENT_URL}/interrupt-adapted/agui` });
|
||||
}
|
||||
|
||||
const interruptAgentNames = ["gen-ui-interrupt", "interrupt-headless"];
|
||||
|
||||
// Reasoning agent names — backed by the reasoning-enabled Agno agent at
|
||||
// /reasoning/agui. Emits AG-UI REASONING_MESSAGE_* events that the
|
||||
// frontend renders via CopilotChatReasoningMessage (or a custom slot).
|
||||
const reasoningAgentNames = [
|
||||
"agentic-chat-reasoning",
|
||||
"reasoning-default-render",
|
||||
"tool-rendering-reasoning-chain",
|
||||
"reasoning-default",
|
||||
"reasoning-custom",
|
||||
];
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {};
|
||||
for (const name of mainAgentNames) {
|
||||
agents[name] = createMainAgent();
|
||||
}
|
||||
for (const name of interruptAgentNames) {
|
||||
agents[name] = createInterruptAgent();
|
||||
}
|
||||
for (const name of reasoningAgentNames) {
|
||||
agents[name] = createReasoningAgent();
|
||||
}
|
||||
// Bidirectional shared-state agent — UI writes preferences, agent writes
|
||||
// notes back via set_notes and the custom AGUI router emits a
|
||||
// StateSnapshotEvent that the frontend's useAgent picks up.
|
||||
// gen-ui-agent — owns its own `steps` state schema and mutates it via the
|
||||
// `set_steps` tool. Routes to the state-aware AGUI handler that emits a
|
||||
// StateSnapshotEvent each run so the frontend's progress card updates.
|
||||
agents["gen-ui-agent"] = createGenUiAgent();
|
||||
agents["shared-state-read-write"] = createSharedStateRWAgent();
|
||||
// Sub-agents supervisor — appends to state["delegations"] every time a
|
||||
// research / writing / critique sub-agent is delegated to. Same custom
|
||||
// AGUI router emits the StateSnapshotEvent needed for the live log.
|
||||
agents["subagents"] = createSubagentsAgent();
|
||||
agents["default"] = createMainAgent();
|
||||
|
||||
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-expect-error -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await handleRequest(req);
|
||||
if (!response.ok) {
|
||||
console.log(`[copilotkit/route] Response status: ${response.status}`);
|
||||
} else if (ROUTE_DEBUG) {
|
||||
console.log(`[copilotkit/route] Response status: ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error(`[copilotkit/route] ERROR: ${err.message}`);
|
||||
console.error(`[copilotkit/route] Stack: ${err.stack}`);
|
||||
return NextResponse.json(
|
||||
{ error: err.message, stack: err.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = async () => {
|
||||
if (ROUTE_DEBUG) {
|
||||
console.log("[copilotkit/route] GET /api/copilotkit (health probe)");
|
||||
}
|
||||
|
||||
let agentStatus = "unknown";
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
|
||||
} catch (e: unknown) {
|
||||
agentStatus = `unreachable (${(e as Error).message})`;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
agent_url: AGENT_URL,
|
||||
agent_status: agentStatus,
|
||||
env: {
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// Token-gated: SHOWCASE_DEBUG_TOKEN must be set in env and matched
|
||||
const token =
|
||||
req.headers.get("x-debug-token") || req.nextUrl.searchParams.get("token");
|
||||
const expectedToken = process.env.SHOWCASE_DEBUG_TOKEN;
|
||||
|
||||
if (!expectedToken || !token || token !== expectedToken) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
// Agent connectivity
|
||||
let agentStatus = "unknown";
|
||||
let agentDetail = "";
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
agentStatus = res.ok ? "ok" : "error";
|
||||
agentDetail = `HTTP ${res.status}`;
|
||||
} catch (e: unknown) {
|
||||
agentStatus = "down";
|
||||
agentDetail = (e as Error).message;
|
||||
}
|
||||
|
||||
const uptime = process.uptime();
|
||||
const mem = process.memoryUsage();
|
||||
|
||||
return NextResponse.json({
|
||||
integration: "agno",
|
||||
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: "agno",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const INTEGRATION_SLUG = "agno";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function GET() {
|
||||
const start = Date.now();
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
`http://localhost:${process.env.PORT || 3000}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/copilotkit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
method: "agent/run",
|
||||
params: { agentId: "agentic_chat" },
|
||||
body: {
|
||||
threadId: `smoke-${Date.now()}`,
|
||||
runId: `smoke-run-${Date.now()}`,
|
||||
state: {},
|
||||
messages: [
|
||||
{
|
||||
id: `smoke-msg-${Date.now()}`,
|
||||
role: "user",
|
||||
content: "Respond with exactly: OK",
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
context: [],
|
||||
forwardedProps: {},
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(45000),
|
||||
});
|
||||
|
||||
const latency = Date.now() - start;
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage: "runtime_response",
|
||||
error: `Runtime returned ${res.status}: ${errBody.slice(0, 200)}`,
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// TTFB: read first chunk only to confirm SSE stream started, then cancel
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage: "response_empty",
|
||||
error: "Runtime returned no readable body",
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
reader.cancel();
|
||||
if (done || !value || value.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage: "response_empty",
|
||||
error: "Runtime returned empty response body",
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
integration: INTEGRATION_SLUG,
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
const latency = Date.now() - start;
|
||||
|
||||
let stage = "unknown";
|
||||
if (err.name === "AbortError" || err.message.includes("timeout"))
|
||||
stage = "timeout";
|
||||
else if (
|
||||
err.message.includes("fetch") ||
|
||||
err.message.includes("ECONNREFUSED")
|
||||
)
|
||||
stage = "agent_unreachable";
|
||||
else stage = "pipeline_error";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
integration: INTEGRATION_SLUG,
|
||||
stage,
|
||||
error: err.message,
|
||||
latency_ms: latency,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Shared fallback time-slot generator for the interrupt demos
|
||||
// (`gen-ui-interrupt`, `interrupt-headless`). The interrupt backend
|
||||
// (`src/agents/interrupt_agent.py`) supplies its own candidate slots
|
||||
// inside the interrupt payload — these fallbacks only run if the
|
||||
// payload arrives without them. Generating relative to `Date.now()`
|
||||
// keeps the fallback from rotting, which previously had hardcoded
|
||||
// dates that decayed within a week of being authored.
|
||||
|
||||
export interface TimeSlot {
|
||||
label: string;
|
||||
iso: string;
|
||||
}
|
||||
|
||||
function atLocal(date: Date, hour: number, minute = 0): Date {
|
||||
return new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function nextMonday(from: Date): Date {
|
||||
// `getDay()` is 0=Sun, 1=Mon, ..., 6=Sat. We want the next Monday
|
||||
// that's at LEAST 2 days away — otherwise "Monday" would collide
|
||||
// with "Tomorrow" on Sunday (offset would be 1) or with itself on
|
||||
// Monday (offset would be 0). Mirrors interrupt_agent.py.
|
||||
const day = from.getDay();
|
||||
let offset = (1 - day + 7) % 7;
|
||||
if (offset <= 1) offset += 7;
|
||||
const next = new Date(from);
|
||||
next.setDate(from.getDate() + offset);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function generateFallbackSlots(now: Date = new Date()): TimeSlot[] {
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
const monday = nextMonday(now);
|
||||
|
||||
const candidates: Array<[string, Date]> = [
|
||||
["Tomorrow 10:00 AM", atLocal(tomorrow, 10)],
|
||||
["Tomorrow 2:00 PM", atLocal(tomorrow, 14)],
|
||||
["Monday 9:00 AM", atLocal(monday, 9)],
|
||||
["Monday 3:30 PM", atLocal(monday, 15, 30)],
|
||||
];
|
||||
|
||||
return candidates.map(([label, date]) => ({
|
||||
label,
|
||||
iso: date.toISOString(),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Coerces a tool-call result into a typed object. Tool results arrive
|
||||
// as strings when the agent emits JSON or as already-parsed objects
|
||||
// when the runtime decoded them upstream — this helper handles both
|
||||
// shapes and returns `{}` if the result is missing or unparseable.
|
||||
export function parseJsonResult<T>(result: unknown): T {
|
||||
if (!result) return {} as T;
|
||||
try {
|
||||
return (typeof result === "string" ? JSON.parse(result) : result) as T;
|
||||
} catch {
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Helper for the CopilotChat slot overrides. The slot prop types in
|
||||
// `@copilotkit/react-core` are nominally typed against the *exact*
|
||||
// default component identity, but a custom wrapper that returns a
|
||||
// structurally compatible ReactElement is functionally a drop-in. This
|
||||
// helper names that fact and centralizes the type assertion in one
|
||||
// place — readers see `makeSlotOverride` and know it's about the slot
|
||||
// contract, not arbitrary type-system gymnastics. Once the slot prop
|
||||
// types accept structural compatibility, this helper can be deleted
|
||||
// and the casts will resolve automatically.
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
// `any` on the input is intentional: the helper's purpose is to accept
|
||||
// any component shape and assert it as the slot's expected type. A
|
||||
// stricter constraint would defeat the whole point.
|
||||
export function makeSlotOverride<TDefault>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
component: ComponentType<any>,
|
||||
): TDefault {
|
||||
return component as unknown as TDefault;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Badge primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
type Variant = "default" | "secondary" | "outline" | "success";
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: "border-transparent bg-neutral-900 text-neutral-50",
|
||||
secondary: "border-transparent bg-neutral-100 text-neutral-900",
|
||||
outline: "border-neutral-200 text-neutral-700 bg-white",
|
||||
success: "border-transparent bg-emerald-100 text-emerald-700",
|
||||
};
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
className = "",
|
||||
variant = "default",
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium tracking-wide ${variantClasses[variant]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Button primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
type Variant = "default" | "outline" | "secondary" | "ghost" | "success";
|
||||
type Size = "default" | "sm" | "lg";
|
||||
|
||||
const baseClasses =
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60";
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: "bg-neutral-900 text-neutral-50 shadow-sm hover:bg-neutral-800",
|
||||
outline:
|
||||
"border border-neutral-200 bg-white text-neutral-900 shadow-sm hover:bg-neutral-100",
|
||||
secondary: "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-200",
|
||||
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
|
||||
success:
|
||||
"bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm hover:bg-emerald-50",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-11 rounded-md px-6",
|
||||
};
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ className = "", variant = "default", size = "default", ...props },
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Card primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes, no `cn()`/`cva` helpers.
|
||||
*/
|
||||
export const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`rounded-xl border border-neutral-200 bg-white text-neutral-950 shadow-sm ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
export const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex flex-col space-y-1.5 p-5 pb-3 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
export const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={`text-base font-semibold leading-none tracking-tight text-neutral-900 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
export const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div ref={ref} className={`p-5 pt-0 ${className}`} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className = "", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex items-center p-5 pt-0 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* ShadCN-style Separator primitive (inline-cloned for this demo).
|
||||
* Plain Tailwind classes; uses a div instead of Radix to keep dependencies minimal.
|
||||
*/
|
||||
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
className = "",
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorProps) {
|
||||
const orientationClasses =
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px";
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
className={`shrink-0 bg-neutral-200 ${orientationClasses} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Fixed A2UI catalog — wires definitions to renderers.
|
||||
*
|
||||
* `includeBasicCatalog: true` merges CopilotKit's built-in components
|
||||
* (Card, Column, Row, Text, Button, Divider, …) into this catalog, so
|
||||
* the agent's fixed schema (src/agents/a2ui_schemas/flight_schema.json) can
|
||||
* compose custom and basic components interchangeably.
|
||||
*/
|
||||
// @region[catalog-creation]
|
||||
import { createCatalog } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import { definitions } from "./definitions";
|
||||
import { renderers } from "./renderers";
|
||||
|
||||
export const CATALOG_ID = "copilotkit://flight-fixed-catalog";
|
||||
|
||||
export const catalog = createCatalog(definitions, renderers, {
|
||||
catalogId: CATALOG_ID,
|
||||
includeBasicCatalog: true,
|
||||
});
|
||||
// @endregion[catalog-creation]
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* A2UI catalog DEFINITIONS — platform-agnostic.
|
||||
*
|
||||
* Each entry declares a component name + its Zod props schema. The basic
|
||||
* catalog (Card, Column, Row, Text, Button, …) ships with CopilotKit and
|
||||
* is mixed in via `createCatalog(..., { includeBasicCatalog: true })`, so
|
||||
* we only declare the project-specific additions and the visual overrides
|
||||
* here. (Custom entries with the same name as a basic component override
|
||||
* the basic one — Catalog dedupes by `comp.name`, last-write-wins.)
|
||||
*
|
||||
* IMPORTANT — path bindings: fields that can be bound to a data-model path
|
||||
* (e.g. `{ path: "/origin" }` in the fixed schema JSON) must declare their
|
||||
* Zod type as a union of `z.string()` and `z.object({ path: z.string() })`.
|
||||
* The A2UI `GenericBinder` uses this union to detect the field as dynamic
|
||||
* and resolve the path against the current data model at render time. Using
|
||||
* plain `z.string()` causes the raw `{ path }` object to reach the
|
||||
* renderer, which React then throws on (error #31 "object with keys {path}").
|
||||
* This matches the canonical catalog's `DynString` helper:
|
||||
* examples/integrations/langgraph-python/src/app/declarative-generative-ui/definitions.ts
|
||||
*/
|
||||
// @region[definitions-types]
|
||||
import { z } from "zod";
|
||||
import type { CatalogDefinitions } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
/**
|
||||
* Dynamic string: literal OR a data-model path binding. The GenericBinder
|
||||
* resolves path bindings to the actual value at render time.
|
||||
*/
|
||||
const DynString = z.union([z.string(), z.object({ path: z.string() })]);
|
||||
|
||||
export const definitions = {
|
||||
/**
|
||||
* Card override: gives the outer flight-card container a ShadCN look
|
||||
* (rounded-xl, neutral-200 border, soft shadow). The basic catalog's
|
||||
* Card uses inline styles; overriding here lets the demo's renderer
|
||||
* adopt the demo's Tailwind aesthetic without touching the schema JSON.
|
||||
*/
|
||||
Card: {
|
||||
description: "A container card with a single child.",
|
||||
props: z.object({
|
||||
child: z.string(),
|
||||
}),
|
||||
},
|
||||
Title: {
|
||||
description: "A prominent heading for the flight card.",
|
||||
props: z.object({
|
||||
text: DynString,
|
||||
}),
|
||||
},
|
||||
Airport: {
|
||||
description: "A 3-letter airport code, displayed large.",
|
||||
props: z.object({
|
||||
code: DynString,
|
||||
}),
|
||||
},
|
||||
Arrow: {
|
||||
description: "A right-pointing arrow used between airports.",
|
||||
props: z.object({}),
|
||||
},
|
||||
AirlineBadge: {
|
||||
description: "A pill-styled airline name tag.",
|
||||
props: z.object({
|
||||
name: DynString,
|
||||
}),
|
||||
},
|
||||
PriceTag: {
|
||||
description: "A stylized price display (e.g. '$289').",
|
||||
props: z.object({
|
||||
amount: DynString,
|
||||
}),
|
||||
},
|
||||
/**
|
||||
* Button override: swaps in an ActionButton renderer that tracks
|
||||
* its own `done` state so clicking "Book flight" visually updates to
|
||||
* a "Booked ✓" confirmation. The basic catalog's Button is stateless,
|
||||
* so without this override the click fires the action but the button
|
||||
* looks unchanged. Mirrors the pattern in beautiful-chat
|
||||
* (src/app/demos/beautiful-chat/declarative-generative-ui/renderers.tsx).
|
||||
*/
|
||||
Button: {
|
||||
description:
|
||||
"An interactive button with an action event. Use 'child' with a Text component ID for the label. After click, the button shows a confirmation state.",
|
||||
props: z.object({
|
||||
child: z
|
||||
.string()
|
||||
.describe(
|
||||
"The ID of the child component (e.g. a Text component for the label).",
|
||||
),
|
||||
variant: z.enum(["primary", "secondary", "ghost"]).optional(),
|
||||
// Union with { event } so GenericBinder resolves this as ACTION → callable () => void.
|
||||
action: z
|
||||
.union([
|
||||
z.object({
|
||||
event: z.object({
|
||||
name: z.string(),
|
||||
context: z.record(z.any()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.null(),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
} satisfies CatalogDefinitions;
|
||||
// @endregion[definitions-types]
|
||||
|
||||
export type Definitions = typeof definitions;
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A2UI catalog RENDERERS — React implementations for the custom components
|
||||
* declared in `./definitions`. TypeScript enforces that the renderer map's
|
||||
* keys and prop shapes match the definitions exactly.
|
||||
*
|
||||
* Visual style: ShadCN aesthetic (neutral palette, rounded-xl, subtle
|
||||
* borders, clean typography). Tailwind utility classes only — no `cn()` /
|
||||
* `cva` helpers, no shadcn CLI install. Inline-cloned primitives live in
|
||||
* `../_components/`.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { CatalogRenderers } from "@copilotkit/a2ui-renderer";
|
||||
|
||||
import type { Definitions } from "./definitions";
|
||||
import { Card } from "../_components/card";
|
||||
import { Badge } from "../_components/badge";
|
||||
import { Button as UIButton } from "../_components/button";
|
||||
import { Separator } from "../_components/separator";
|
||||
|
||||
// `DynString` props are typed as `string | { path }` (see definitions.ts), but
|
||||
// the A2UI binder resolves path bindings before render — renderers only ever
|
||||
// see resolved strings. One shared helper keeps that narrowing in one place.
|
||||
const s = (v: unknown): string => (typeof v === "string" ? v : "");
|
||||
|
||||
// @region[renderers-tsx]
|
||||
export const renderers: CatalogRenderers<Definitions> = {
|
||||
/**
|
||||
* Card override: ShadCN-style outer container. The basic catalog's Card
|
||||
* uses inline styles; overriding here keeps the demo's tailwind aesthetic.
|
||||
* The flight schema renders Card > Column > [Title, Row, …]; the inner
|
||||
* Column adds the vertical spacing.
|
||||
*/
|
||||
Card: ({ props, children }) => (
|
||||
<Card className="w-full max-w-md p-5" data-testid="a2ui-fixed-card">
|
||||
{props.child ? children(props.child) : null}
|
||||
</Card>
|
||||
),
|
||||
Title: ({ props }) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
|
||||
Itinerary
|
||||
</p>
|
||||
<h3 className="text-base font-semibold leading-none tracking-tight text-neutral-900">
|
||||
{s(props.text)}
|
||||
</h3>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
1-stop · economy
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
Airport: ({ props }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="font-mono text-2xl font-semibold tracking-wider text-neutral-900">
|
||||
{s(props.code)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
Arrow: () => (
|
||||
<div className="flex flex-1 items-center px-3">
|
||||
<Separator className="flex-1 bg-neutral-200" />
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mx-1 text-neutral-400"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
<Separator className="flex-1 bg-neutral-200" />
|
||||
</div>
|
||||
),
|
||||
AirlineBadge: ({ props }) => (
|
||||
<Badge variant="secondary" className="uppercase tracking-[0.08em]">
|
||||
{s(props.name)}
|
||||
</Badge>
|
||||
),
|
||||
PriceTag: ({ props }) => (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-neutral-500">
|
||||
Total
|
||||
</span>
|
||||
<span className="font-mono text-base font-semibold text-neutral-900">
|
||||
{s(props.amount)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
/**
|
||||
* Button override: this is a pure-presentation demo, so the button just
|
||||
* renders its label. The schema declares an `action` for visual fidelity,
|
||||
* but the click handler is inert until the Python SDK exposes
|
||||
* `action_handlers=` on `a2ui.render` (see `src/agents/a2ui_fixed.py`).
|
||||
*/
|
||||
Button: ({ props, children }) => (
|
||||
<UIButton className="w-full">
|
||||
{props.child ? children(props.child) : null}
|
||||
</UIButton>
|
||||
),
|
||||
};
|
||||
// @endregion[renderers-tsx]
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
import { useA2UIFixedSchemaSuggestions } from "./suggestions";
|
||||
|
||||
export function Chat() {
|
||||
useA2UIFixedSchemaSuggestions();
|
||||
return (
|
||||
<CopilotChat agentId="a2ui-fixed-schema" className="h-full rounded-2xl" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Declarative Generative UI — A2UI Fixed Schema demo.
|
||||
*
|
||||
* In the fixed-schema flavor of A2UI, the component tree (schema) lives on
|
||||
* the frontend and the agent only streams *data* into the data model. The
|
||||
* flight card is ASSEMBLED from small sub-components in
|
||||
* `src/agents/a2ui_schemas/flight_schema.json` (Card > Column > [Title, Row, …]).
|
||||
*
|
||||
* - Definitions (zod schemas): `./a2ui/definitions.ts`
|
||||
* - Renderers (React): `./a2ui/renderers.tsx`
|
||||
* - Catalog wiring: `./a2ui/catalog.ts` (includes the basic catalog)
|
||||
* - Agent: `src/agents/a2ui_fixed.py` (emits an `a2ui_operations` container)
|
||||
*
|
||||
* Reference:
|
||||
* https://docs.copilotkit.ai/integrations/langgraph/generative-ui/a2ui/fixed-schema
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { CopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { catalog } from "./a2ui/catalog";
|
||||
import { Chat } from "./chat";
|
||||
|
||||
export default function A2UIFixedSchemaDemo() {
|
||||
return (
|
||||
// `a2ui.catalog` wires the fixed catalog into the A2UI activity renderer.
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-a2ui-fixed-schema"
|
||||
agent="a2ui-fixed-schema"
|
||||
a2ui={{ catalog: catalog }}
|
||||
>
|
||||
<div className="flex justify-center items-center h-screen w-full bg-neutral-50">
|
||||
<div className="h-full w-full max-w-4xl border-x border-neutral-200 bg-white">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useConfigureSuggestions } from "@copilotkit/react-core/v2";
|
||||
|
||||
export function useA2UIFixedSchemaSuggestions() {
|
||||
useConfigureSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
title: "Find SFO → JFK",
|
||||
message: "Find me a flight from SFO to JFK on United for $289.",
|
||||
},
|
||||
],
|
||||
available: "always",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import {
|
||||
type AgentConfig,
|
||||
EXPERTISE_OPTIONS,
|
||||
type Expertise,
|
||||
RESPONSE_LENGTH_OPTIONS,
|
||||
type ResponseLength,
|
||||
TONE_OPTIONS,
|
||||
type Tone,
|
||||
} from "./config-types";
|
||||
|
||||
interface ConfigCardProps {
|
||||
config: AgentConfig;
|
||||
onToneChange: (tone: Tone) => void;
|
||||
onExpertiseChange: (expertise: Expertise) => void;
|
||||
onResponseLengthChange: (length: ResponseLength) => void;
|
||||
}
|
||||
|
||||
export function ConfigCard({
|
||||
config,
|
||||
onToneChange,
|
||||
onExpertiseChange,
|
||||
onResponseLengthChange,
|
||||
}: ConfigCardProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="agent-config-card"
|
||||
className="flex flex-col gap-2 rounded-md border border-[var(--border)] bg-[var(--bg-surface)] p-4 text-sm"
|
||||
>
|
||||
<h2 className="text-sm font-semibold">Agent Config</h2>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Change these and send a message to see the agent adapt.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">Tone</span>
|
||||
<select
|
||||
data-testid="agent-config-tone-select"
|
||||
value={config.tone}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
onToneChange(e.target.value as Tone)
|
||||
}
|
||||
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
|
||||
>
|
||||
{TONE_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">Expertise</span>
|
||||
<select
|
||||
data-testid="agent-config-expertise-select"
|
||||
value={config.expertise}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
onExpertiseChange(e.target.value as Expertise)
|
||||
}
|
||||
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
|
||||
>
|
||||
{EXPERTISE_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">Response length</span>
|
||||
<select
|
||||
data-testid="agent-config-length-select"
|
||||
value={config.responseLength}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
onResponseLengthChange(e.target.value as ResponseLength)
|
||||
}
|
||||
className="rounded border border-[var(--border)] bg-[var(--bg-muted)] px-2 py-1 text-sm"
|
||||
>
|
||||
{RESPONSE_LENGTH_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Publishes the current agent-config toggles to the agent runtime via
|
||||
* `useAgentContext`. Lives inside the `<CopilotKit>` provider so the
|
||||
* context store is reachable. The middleware on the Python side reads
|
||||
* this entry off the agent's runtime context on every turn and routes
|
||||
* it into the model's prompt.
|
||||
*/
|
||||
|
||||
import { useAgentContext } from "@copilotkit/react-core/v2";
|
||||
import type { AgentConfig } from "./config-types";
|
||||
|
||||
export function ConfigContextRelay({ config }: { config: AgentConfig }) {
|
||||
useAgentContext({
|
||||
description:
|
||||
"Agent response preferences. Apply tone, expertise level, and response length to every reply.",
|
||||
value: {
|
||||
tone: config.tone,
|
||||
expertise: config.expertise,
|
||||
responseLength: config.responseLength,
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type Tone = "professional" | "casual" | "enthusiastic";
|
||||
export type Expertise = "beginner" | "intermediate" | "expert";
|
||||
export type ResponseLength = "concise" | "detailed";
|
||||
|
||||
export interface AgentConfig {
|
||||
tone: Tone;
|
||||
expertise: Expertise;
|
||||
responseLength: ResponseLength;
|
||||
}
|
||||
|
||||
export const DEFAULT_AGENT_CONFIG: AgentConfig = {
|
||||
tone: "professional",
|
||||
expertise: "intermediate",
|
||||
responseLength: "concise",
|
||||
};
|
||||
|
||||
export const TONE_OPTIONS: Tone[] = ["professional", "casual", "enthusiastic"];
|
||||
export const EXPERTISE_OPTIONS: Expertise[] = [
|
||||
"beginner",
|
||||
"intermediate",
|
||||
"expert",
|
||||
];
|
||||
export const RESPONSE_LENGTH_OPTIONS: ResponseLength[] = [
|
||||
"concise",
|
||||
"detailed",
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { ConfigCard } from "./config-card";
|
||||
import type { AgentConfig } from "./config-types";
|
||||
|
||||
interface DemoLayoutProps {
|
||||
config: AgentConfig;
|
||||
onToneChange: (tone: AgentConfig["tone"]) => void;
|
||||
onExpertiseChange: (expertise: AgentConfig["expertise"]) => void;
|
||||
onResponseLengthChange: (length: AgentConfig["responseLength"]) => void;
|
||||
}
|
||||
|
||||
export function DemoLayout({
|
||||
config,
|
||||
onToneChange,
|
||||
onExpertiseChange,
|
||||
onResponseLengthChange,
|
||||
}: DemoLayoutProps) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col gap-3 p-6">
|
||||
<ConfigCard
|
||||
config={config}
|
||||
onToneChange={onToneChange}
|
||||
onExpertiseChange={onExpertiseChange}
|
||||
onResponseLengthChange={onResponseLengthChange}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden rounded-md border border-[var(--border)]">
|
||||
<CopilotChat
|
||||
agentId="agent-config-demo"
|
||||
className="h-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Agent Config Object — typed config knobs (tone / expertise / responseLength)
|
||||
* forwarded from the provider into the agent so its behavior changes per turn.
|
||||
*
|
||||
* Wiring: the toggles live in `useAgentConfig`. Each render the resolved
|
||||
* config is published to the agent via `useAgentContext` — the v2 idiom
|
||||
* for "frontend → agent runtime context" in LangGraph 0.6+. The Python
|
||||
* graph picks it up through `CopilotKitMiddleware`, which routes the
|
||||
* context entry into the model's prompt before each call.
|
||||
*
|
||||
* (LangGraph 0.6 deprecated `configurable` in favor of `context`; the
|
||||
* `properties` prop on `<CopilotKit>` still works for v1-style relays
|
||||
* but goes through `forwardedProps` and does not land in `RunnableConfig`
|
||||
* in @ag-ui/langgraph 0.0.31. `useAgentContext` is the supported path.)
|
||||
*/
|
||||
|
||||
import { CopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
import { DemoLayout } from "./demo-layout";
|
||||
import { ConfigContextRelay } from "./config-context-relay";
|
||||
import { useAgentConfig } from "./use-agent-config";
|
||||
|
||||
export default function AgentConfigDemoPage() {
|
||||
const { config, setTone, setExpertise, setResponseLength } = useAgentConfig();
|
||||
|
||||
return (
|
||||
// @region[provider-setup]
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit-agent-config"
|
||||
agent="agent-config-demo"
|
||||
>
|
||||
<ConfigContextRelay config={config} />
|
||||
<DemoLayout
|
||||
config={config}
|
||||
onToneChange={setTone}
|
||||
onExpertiseChange={setExpertise}
|
||||
onResponseLengthChange={setResponseLength}
|
||||
/>
|
||||
</CopilotKit>
|
||||
// @endregion[provider-setup]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
type AgentConfig,
|
||||
DEFAULT_AGENT_CONFIG,
|
||||
type Expertise,
|
||||
type ResponseLength,
|
||||
type Tone,
|
||||
} from "./config-types";
|
||||
|
||||
export interface UseAgentConfigHandle {
|
||||
config: AgentConfig;
|
||||
setTone: (tone: Tone) => void;
|
||||
setExpertise: (expertise: Expertise) => void;
|
||||
setResponseLength: (length: ResponseLength) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useAgentConfig(): UseAgentConfigHandle {
|
||||
const [config, setConfig] = useState<AgentConfig>(DEFAULT_AGENT_CONFIG);
|
||||
|
||||
const setTone = useCallback(
|
||||
(tone: Tone) => setConfig((prev) => ({ ...prev, tone })),
|
||||
[],
|
||||
);
|
||||
const setExpertise = useCallback(
|
||||
(expertise: Expertise) => setConfig((prev) => ({ ...prev, expertise })),
|
||||
[],
|
||||
);
|
||||
const setResponseLength = useCallback(
|
||||
(responseLength: ResponseLength) =>
|
||||
setConfig((prev) => ({ ...prev, responseLength })),
|
||||
[],
|
||||
);
|
||||
const reset = useCallback(() => setConfig(DEFAULT_AGENT_CONFIG), []);
|
||||
|
||||
return { config, setTone, setExpertise, setResponseLength, reset };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Agentic Chat
|
||||
|
||||
## What This Demo Shows
|
||||
|
||||
The simplest CopilotKit surface: a plain agentic chat backed by a LangGraph (Python) agent.
|
||||
|
||||
- **Natural Conversation**: Chat with your Copilot in a familiar chat interface
|
||||
- **Streaming Responses**: Assistant messages stream in token-by-token via AG-UI
|
||||
- **Suggestion Chips**: A starter suggestion is rendered as a quick-action chip
|
||||
|
||||
## How to Interact
|
||||
|
||||
Click the suggestion chip, or type your own prompt. For example:
|
||||
|
||||
- "Write a short sonnet about AI"
|
||||
- "Explain the difference between an LLM and an agent"
|
||||
- "Give me three ideas for a weekend project"
|
||||
|
||||
## Technical Details
|
||||
|
||||
**Provider** — `CopilotKit` wires the page to the runtime:
|
||||
|
||||
- `runtimeUrl="/api/copilotkit"` points at the Next.js route that proxies to the agent
|
||||
- `agent="agentic_chat"` selects the LangGraph agent defined in `langgraph.json`
|
||||
|
||||
**Chat surface** — `CopilotChat` renders the full chat UI with input, message list, and streaming.
|
||||
|
||||
**Suggestions** — `useConfigureSuggestions` registers a static suggestion that appears as a clickable chip below the chat input.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user