chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
"""Shared validators for A2UI dynamic-schema agents.
|
||||
|
||||
The dynamic-schema flow has a secondary LLM produce a flat array of
|
||||
components. The renderer rejects entries missing `id` or `component`
|
||||
("Cannot create component root without a type" infinite-loop), so every
|
||||
agent that builds an A2UI surface dynamically needs to sanitize the
|
||||
LLM's output before forwarding it. These helpers are factored out so
|
||||
each agent's tool body stays focused on the demo-specific bits
|
||||
(catalog id, system prompt, data shape).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def sanitize_a2ui_components(raw: list) -> list[dict]:
|
||||
"""Drop entries that aren't dicts or are missing `id`/`component`."""
|
||||
return [
|
||||
c for c in raw if isinstance(c, dict) and c.get("id") and c.get("component")
|
||||
]
|
||||
|
||||
|
||||
def has_root_component(components: list[dict]) -> bool:
|
||||
"""True iff `components` contains an entry with `id == "root"`."""
|
||||
return any(c.get("id") == "root" for c in components)
|
||||
@@ -0,0 +1,430 @@
|
||||
"""_cvdiag_backend.py — schema-v1 backend CVDIAG emitter for langgraph-python.
|
||||
|
||||
This is the LGP (langgraph-python) realization of the §3 backend layer: it wires
|
||||
the 11 backend boundaries through the shared ``_shared.cvdiag_bootstrap.emit_cvdiag``
|
||||
single-source emitter. It runs ALONGSIDE the legacy free-form ``_cvdiag()`` log
|
||||
lines in ``_header_forwarding_middleware.py`` (dual-emit during the transition):
|
||||
|
||||
- legacy ``_cvdiag()`` keeps writing the human-grep ``CVDIAG component=...`` line,
|
||||
- this module writes the structured schema-v1 ``CVDIAG {json}`` envelope.
|
||||
|
||||
Guard: every emit here is gated on ``CVDIAG_BACKEND_EMITTER=1`` (default OFF). With
|
||||
the guard off this module is a pure no-op — it never validates, never writes, never
|
||||
throws into the observed boundary.
|
||||
|
||||
The 11 backend boundaries (spec §3 / §5):
|
||||
backend.request.ingress, backend.agent.enter, backend.llm.call.start,
|
||||
backend.llm.call.heartbeat (VERBOSE tier — periodic ~10s asyncio task),
|
||||
backend.llm.call.response, backend.sse.first_byte, backend.sse.event (DEBUG tier),
|
||||
backend.sse.aborted, backend.agent.exit, backend.response.complete,
|
||||
backend.error.caught.
|
||||
|
||||
Pure instrumentation: like the shared emitter, nothing here raises into the caller;
|
||||
the one place we ``await`` (the heartbeat task) is cancelled cleanly in a finally.
|
||||
|
||||
Plan unit: L1-I.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from _shared.cvdiag_bootstrap import _resolve_tier, current_tier, emit_cvdiag
|
||||
|
||||
# ── Tier gating ───────────────────────────────────────────────────────────────
|
||||
# The shared bootstrap already resolved the tier (default | verbose | debug) and
|
||||
# applied the §6 fail-closed DEBUG guard. We mirror the §6 tier matrix locally so
|
||||
# VERBOSE-only (heartbeat) and DEBUG-only (sse.event) boundaries are suppressed at
|
||||
# the wrong tier rather than relying on the consumer to filter.
|
||||
_VERBOSE_TIERS = frozenset({"verbose", "debug"})
|
||||
_DEBUG_TIERS = frozenset({"debug"})
|
||||
|
||||
_SLUG = "langgraph-python"
|
||||
_HEARTBEAT_INTERVAL_S = 10.0
|
||||
|
||||
|
||||
def emitter_enabled() -> bool:
|
||||
"""True when the schema-v1 backend emitter is armed (``CVDIAG_BACKEND_EMITTER=1``).
|
||||
|
||||
Default OFF: a missing/any-other value disables every emit in this module.
|
||||
"""
|
||||
return os.environ.get("CVDIAG_BACKEND_EMITTER") == "1"
|
||||
|
||||
|
||||
def _active_tier() -> str:
|
||||
"""Resolve the verbosity tier from a LIVE env read.
|
||||
|
||||
``emitter_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 (heartbeat, sse.event). 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 _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _mono_ns() -> int:
|
||||
return time.monotonic_ns()
|
||||
|
||||
|
||||
def _span_id() -> str:
|
||||
"""16 hex chars (8 random bytes) — matches SPAN_ID_PATTERN."""
|
||||
return uuid.uuid4().hex[:16]
|
||||
|
||||
|
||||
def _coerce_test_id(raw: Optional[str]) -> str:
|
||||
"""Return a schema-valid UUIDv7 ``test_id``.
|
||||
|
||||
When the inbound header carries a valid UUIDv7 we keep it (this is the
|
||||
propagation we are measuring). When it is absent/malformed we synthesize a
|
||||
deterministic-shape UUIDv7 so the envelope still validates — but the
|
||||
propagation gate measures the *inbound* presence, not this fallback.
|
||||
"""
|
||||
from _shared.cvdiag_schema import (
|
||||
TEST_ID_PATTERN,
|
||||
) # local import: cheap, avoids cycle
|
||||
import re
|
||||
|
||||
if isinstance(raw, str) and re.match(TEST_ID_PATTERN, raw):
|
||||
return raw
|
||||
# Synthesize a UUIDv7-shaped value (version nibble 7, variant 8..b).
|
||||
hexs = uuid.uuid4().hex
|
||||
return f"{hexs[0:8]}-{hexs[8:12]}-7{hexs[13:16]}-8{hexs[17:20]}-{hexs[20:32]}"
|
||||
|
||||
|
||||
def extract_test_id(headers: Dict[str, str]) -> Optional[str]:
|
||||
"""Return the inbound ``x-test-id`` header value, or ``None`` when absent.
|
||||
|
||||
This is the raw inbound value used by the propagation-reliability gate — it
|
||||
is NOT coerced/synthesized here so the gate can measure true propagation.
|
||||
"""
|
||||
raw = headers.get("x-test-id")
|
||||
return raw if isinstance(raw, str) and raw else None
|
||||
|
||||
|
||||
def _empty_edge_headers() -> Dict[str, Any]:
|
||||
return {
|
||||
"cf-ray": None,
|
||||
"cf-mitigated": None,
|
||||
"cf-cache-status": None,
|
||||
"x-railway-edge": None,
|
||||
"x-railway-request-id": None,
|
||||
"x-hikari-trace": None,
|
||||
"retry-after": None,
|
||||
"via": None,
|
||||
"server": None,
|
||||
}
|
||||
|
||||
|
||||
def _edge_headers_from(headers: Dict[str, str]) -> Dict[str, Any]:
|
||||
"""Project the inbound header bag onto the closed 9-key edge-header shape.
|
||||
|
||||
Only the 9 allow-listed keys are carried; everything else is dropped (the
|
||||
envelope's per-boundary model + EdgeHeaders ``extra=forbid`` enforce this).
|
||||
"""
|
||||
allow = _empty_edge_headers()
|
||||
for key in list(allow.keys()):
|
||||
val = headers.get(key)
|
||||
if isinstance(val, str) and val:
|
||||
allow[key] = val
|
||||
return allow
|
||||
|
||||
|
||||
def _emit(
|
||||
boundary: str,
|
||||
*,
|
||||
headers: Dict[str, str],
|
||||
trace_id: str,
|
||||
outcome: str = "ok",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
demo: str = "chat",
|
||||
tier_gate: Optional[frozenset] = None,
|
||||
) -> None:
|
||||
"""Build + emit one schema-v1 envelope, guarded + tier-filtered.
|
||||
|
||||
No-op when the emitter is disabled OR the boundary's tier gate excludes the
|
||||
resolved tier. Never raises (delegates to the shared emitter's safety).
|
||||
"""
|
||||
if not emitter_enabled():
|
||||
return
|
||||
if tier_gate is not None and _active_tier() not in tier_gate:
|
||||
return
|
||||
envelope = {
|
||||
"schema_version": 1,
|
||||
"test_id": _coerce_test_id(headers.get("x-test-id")),
|
||||
"trace_id": trace_id,
|
||||
"span_id": _span_id(),
|
||||
"parent_span_id": None,
|
||||
"layer": "backend",
|
||||
"boundary": boundary,
|
||||
"slug": _SLUG,
|
||||
"demo": demo,
|
||||
"ts": _now_iso(),
|
||||
"mono_ns": _mono_ns(),
|
||||
"duration_ms": duration_ms,
|
||||
"outcome": outcome,
|
||||
"edge_headers": _edge_headers_from(headers),
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
emit_cvdiag(envelope)
|
||||
|
||||
|
||||
class CvdiagBackendRun:
|
||||
"""Per-model-call CVDIAG run context for the LGP middleware.
|
||||
|
||||
Constructed inside ``awrap_model_call`` (and the sync ``wrap_model_call``);
|
||||
owns the trace correlation id, the ingress monotonic anchor, and the
|
||||
heartbeat asyncio task. All methods are no-ops when the emitter is disabled.
|
||||
"""
|
||||
|
||||
def __init__(self, headers: Dict[str, str]) -> None:
|
||||
self._headers = dict(headers)
|
||||
# Correlate every boundary in this run under one trace_id. Prefer the
|
||||
# inbound x-diag-run-id breadcrumb so probe/backend rows join; fall back
|
||||
# to a synthesized id.
|
||||
self._trace_id = (
|
||||
headers.get("x-diag-run-id") or headers.get("x-test-id") or uuid.uuid4().hex
|
||||
)
|
||||
self._ingress_mono = _mono_ns()
|
||||
self._first_byte_emitted = False
|
||||
self._sse_seq = 0
|
||||
self._heartbeat_task: Optional[asyncio.Task] = None
|
||||
|
||||
# ── Lifecycle boundaries ──────────────────────────────────────────────
|
||||
def request_ingress(self) -> None:
|
||||
_emit(
|
||||
"backend.request.ingress",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={"method": "POST", "path": "/threads", "content_length": None},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
|
||||
def agent_enter(
|
||||
self, agent_name: Optional[str] = None, model_id: Optional[str] = None
|
||||
) -> None:
|
||||
_emit(
|
||||
"backend.agent.enter",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={"agent_name": agent_name, "model_id": model_id},
|
||||
)
|
||||
|
||||
def llm_call_start(
|
||||
self, provider: Optional[str] = None, model: Optional[str] = None
|
||||
) -> None:
|
||||
_emit(
|
||||
"backend.llm.call.start",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"prompt_token_count_estimate": None,
|
||||
},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
|
||||
def llm_call_response(
|
||||
self,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
latency_ms: Optional[int] = None,
|
||||
error_class: Optional[str] = None,
|
||||
) -> None:
|
||||
_emit(
|
||||
"backend.llm.call.response",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="err" if error_class else "ok",
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"response_token_count": None,
|
||||
"latency_ms": latency_ms,
|
||||
"error_class": error_class,
|
||||
},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
|
||||
def sse_first_byte(self) -> None:
|
||||
"""Emit ``backend.sse.first_byte`` once, with the ingress→first-byte delta."""
|
||||
if self._first_byte_emitted:
|
||||
return
|
||||
self._first_byte_emitted = True
|
||||
delta_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.sse.first_byte",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={"delta_ms_from_ingress": delta_ms},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
|
||||
def sse_event(
|
||||
self, event_type: Optional[str] = None, payload_size_bytes: Optional[int] = None
|
||||
) -> None:
|
||||
"""Emit ``backend.sse.event`` (DEBUG tier — suppressed below debug)."""
|
||||
self._sse_seq += 1
|
||||
_emit(
|
||||
"backend.sse.event",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={
|
||||
"event_type": event_type,
|
||||
"payload_size_bytes": payload_size_bytes,
|
||||
"sequence_num": self._sse_seq,
|
||||
},
|
||||
tier_gate=_DEBUG_TIERS,
|
||||
)
|
||||
|
||||
def sse_aborted(
|
||||
self,
|
||||
termination_kind: Optional[str] = None,
|
||||
bytes_before_abort: Optional[int] = None,
|
||||
) -> None:
|
||||
_emit(
|
||||
"backend.sse.aborted",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="err",
|
||||
metadata={
|
||||
"termination_kind": termination_kind,
|
||||
"bytes_before_abort": bytes_before_abort,
|
||||
},
|
||||
)
|
||||
|
||||
def agent_exit(self, terminal_outcome: str = "ok") -> None:
|
||||
total_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.agent.exit",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="err" if terminal_outcome == "err" else "ok",
|
||||
metadata={
|
||||
"terminal_outcome": terminal_outcome,
|
||||
"total_duration_ms": total_ms,
|
||||
},
|
||||
)
|
||||
|
||||
def response_complete(
|
||||
self,
|
||||
http_status: Optional[int] = 200,
|
||||
sse_event_count: Optional[int] = None,
|
||||
) -> None:
|
||||
total_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.response.complete",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
metadata={
|
||||
"http_status": http_status,
|
||||
"content_length": None,
|
||||
"total_duration_ms": total_ms,
|
||||
"sse_event_count": sse_event_count
|
||||
if sse_event_count is not None
|
||||
else self._sse_seq,
|
||||
},
|
||||
)
|
||||
|
||||
def error_caught(self, exc: BaseException) -> None:
|
||||
_emit(
|
||||
"backend.error.caught",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="err",
|
||||
metadata={
|
||||
"exception_type": type(exc).__name__,
|
||||
"message_scrubbed": "<scrubbed>",
|
||||
"stack_brief": None,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
|
||||
# ── Heartbeat (VERBOSE tier — periodic asyncio task) ──────────────────
|
||||
async def _heartbeat_loop(self) -> None:
|
||||
"""Emit ``backend.llm.call.heartbeat`` every ~10s while the LLM call runs."""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
||||
elapsed_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.llm.call.heartbeat",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="info",
|
||||
metadata={"elapsed_ms_since_start": elapsed_ms},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Clean cancellation when the LLM call finishes — swallow.
|
||||
return
|
||||
|
||||
def start_heartbeat(self) -> None:
|
||||
"""Arm the heartbeat task (no-op when disabled or below VERBOSE tier)."""
|
||||
if not emitter_enabled() or _active_tier() not in _VERBOSE_TIERS:
|
||||
return
|
||||
if self._heartbeat_task is not None:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
self._heartbeat_task = loop.create_task(self._heartbeat_loop())
|
||||
|
||||
async def stop_heartbeat(self) -> None:
|
||||
"""Cancel + await the heartbeat task. Safe to call when never started.
|
||||
|
||||
Cooperative cancellation: the legacy ``except (CancelledError,
|
||||
Exception)`` swallowed the CALLER's CancelledError, breaking cooperative
|
||||
cancellation (a client-disconnect / request-cancel that arrives while we
|
||||
await the heartbeat task would be lost). We suppress ONLY the heartbeat
|
||||
task's OWN cancellation — the one we just requested — and re-raise when
|
||||
THIS task is being cancelled by the caller (a pending cancellation
|
||||
request, ``current_task().cancelling() > 0``). ``Task.cancelling()`` is
|
||||
3.11+ (production runs 3.12); on older runtimes the attribute is absent
|
||||
and we degrade to suppressing (the legacy behavior).
|
||||
"""
|
||||
task = self._heartbeat_task
|
||||
if task is None:
|
||||
return
|
||||
self._heartbeat_task = None
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
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
|
||||
return
|
||||
|
||||
def emit_heartbeat_once(self) -> None:
|
||||
"""Synchronous single heartbeat emit (used by the sync wrap path + tests)."""
|
||||
elapsed_ms = int((_mono_ns() - self._ingress_mono) / 1_000_000)
|
||||
_emit(
|
||||
"backend.llm.call.heartbeat",
|
||||
headers=self._headers,
|
||||
trace_id=self._trace_id,
|
||||
outcome="info",
|
||||
metadata={"elapsed_ms_since_start": elapsed_ms},
|
||||
tier_gate=_VERBOSE_TIERS,
|
||||
)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Minimal header-forwarding-only AgentMiddleware.
|
||||
|
||||
Some showcase demos (reasoning, tool-rendering-reasoning-chain, the
|
||||
sub-agents in `subagents.py`) intentionally avoid the full
|
||||
`CopilotKitMiddleware` because they don't need its frontend-tool
|
||||
injection, App-Context surfacing, or state-note features — they're
|
||||
minimal demos of LangGraph capabilities.
|
||||
|
||||
But every showcase request goes through aimock (the locally-served
|
||||
LLM mock), and aimock requires the ``x-aimock-context`` header (and
|
||||
friends) on every ``/v1/responses`` and ``/v1/chat/completions``
|
||||
request to match the right fixture. Without middleware to populate
|
||||
the header-forwarding ContextVar from the LangGraph RunnableConfig
|
||||
``configurable``, those requests go out without the header and aimock
|
||||
returns 404, breaking the demo.
|
||||
|
||||
This middleware does ONLY that header propagation — nothing else.
|
||||
It reuses copilotkit's own primitives (kept private but exported by
|
||||
the installed package at the module level) so the propagation logic
|
||||
is identical to the full middleware. No App-Context injection, no
|
||||
tool-merging, no state-to-prompt surfacing, no Bedrock message
|
||||
fix-up.
|
||||
|
||||
CVDIAG instrumentation (diagnostic only — DOES NOT change WHERE
|
||||
headers come from): after the existing
|
||||
``_extract_forwarded_headers_from_config()`` populates copilotkit's
|
||||
forwarded-headers ContextVar, we read it back via
|
||||
``get_forwarded_headers()`` and emit a structured ``CVDIAG`` log line
|
||||
at the configurable-read boundary recording whether
|
||||
``x-aimock-context`` actually arrived on the LangGraph configurable
|
||||
channel (``header_present=false`` is the alarm we are hunting). We
|
||||
also append this layer's hop tag to ``x-diag-hops`` on the SAME
|
||||
ContextVar the httpx hook already forwards from — so the breadcrumb
|
||||
and correlation headers (``x-diag-run-id``, ``x-diag-hops``) ride
|
||||
along on the outbound LLM call exactly the way ``x-aimock-context``
|
||||
does, without introducing any new forwarding source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Dict
|
||||
|
||||
from langchain.agents.middleware import (
|
||||
AgentMiddleware,
|
||||
AgentState,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
)
|
||||
|
||||
# Reuse the installed copilotkit's existing header-forwarding helpers so
|
||||
# the behaviour stays bit-identical to the full CopilotKitMiddleware's
|
||||
# header-propagation step. These are module-level functions in
|
||||
# copilotkit 0.1.94's copilotkit_lg_middleware module.
|
||||
from copilotkit.copilotkit_lg_middleware import (
|
||||
_extract_forwarded_headers_from_config,
|
||||
_ensure_httpx_hook,
|
||||
)
|
||||
|
||||
# CVDIAG-only: read/append the forwarded-header ContextVar copilotkit
|
||||
# already populates. set_forwarded_headers is used SOLELY to append the
|
||||
# diagnostic hop breadcrumb onto the SAME channel x-aimock-context rides;
|
||||
# it does not introduce a new forwarding source.
|
||||
from copilotkit.header_propagation import (
|
||||
get_forwarded_headers,
|
||||
set_forwarded_headers,
|
||||
)
|
||||
|
||||
# CVDIAG schema-v1 backend emitter (L1-I). Dual-emit: this rides ALONGSIDE the
|
||||
# legacy free-form _cvdiag() log lines below — it writes the structured
|
||||
# schema-v1 CVDIAG envelope through the shared single-source emitter, guarded by
|
||||
# CVDIAG_BACKEND_EMITTER (default OFF). With the guard off it is a pure no-op.
|
||||
from src.agents._cvdiag_backend import CvdiagBackendRun
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CVDIAG_COMPONENT = "backend-langgraph-py"
|
||||
_CVDIAG_HOP_TAG = "backend-langgraph-py"
|
||||
|
||||
|
||||
def _cvdiag(
|
||||
boundary: str,
|
||||
headers: Dict[str, str],
|
||||
status: str,
|
||||
*,
|
||||
hop: Any = "-",
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""Emit a single CVDIAG log line in the shared cross-language convention.
|
||||
|
||||
Never logs full header values — only a 12-char prefix of
|
||||
``x-aimock-context``.
|
||||
"""
|
||||
slug = headers.get("x-aimock-context")
|
||||
header_present = isinstance(slug, str) and len(slug) > 0
|
||||
run_id = headers.get("x-diag-run-id", "none")
|
||||
test_id = headers.get("x-test-id", "none")
|
||||
prefix = slug[:12] if header_present else ""
|
||||
logger.info(
|
||||
"CVDIAG component=%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_COMPONENT,
|
||||
boundary,
|
||||
run_id,
|
||||
slug if header_present else "MISSING",
|
||||
str(header_present).lower(),
|
||||
prefix,
|
||||
hop,
|
||||
status,
|
||||
test_id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
def _instrument_and_breadcrumb() -> None:
|
||||
"""Read the configurable-read result, log it, and append the diag hop.
|
||||
|
||||
Called immediately AFTER
|
||||
``_extract_forwarded_headers_from_config()`` has populated the
|
||||
ContextVar. Reads the headers back, emits the configurable-read
|
||||
CVDIAG line (wrapping the previously-silent "no x-aimock-context in
|
||||
configurable" case as an alarm), then — only when x-aimock-context
|
||||
is present — appends this layer's hop tag to ``x-diag-hops`` on the
|
||||
SAME ContextVar so the breadcrumb rides the existing forwarding path.
|
||||
"""
|
||||
headers = dict(get_forwarded_headers())
|
||||
has_context = (
|
||||
isinstance(headers.get("x-aimock-context"), str)
|
||||
and len(headers.get("x-aimock-context", "")) > 0
|
||||
)
|
||||
|
||||
if has_context:
|
||||
_cvdiag("configurable-read", headers, "ok")
|
||||
else:
|
||||
# The alarm we are hunting: the configurable channel reached this
|
||||
# middleware without x-aimock-context. Surface it instead of the
|
||||
# previous silent no-op.
|
||||
_cvdiag(
|
||||
"configurable-read",
|
||||
headers,
|
||||
"miss" if headers else "error",
|
||||
error="x-aimock-context-absent-in-configurable"
|
||||
if headers
|
||||
else "no-forwarded-headers-in-configurable",
|
||||
)
|
||||
# Nothing to breadcrumb onto — do not invent a forwarding source.
|
||||
return
|
||||
|
||||
# Append this layer's hop tag to x-diag-hops on the SAME ContextVar the
|
||||
# httpx hook forwards from. This rides the existing path; no new source.
|
||||
existing_hops = headers.get("x-diag-hops", "")
|
||||
headers["x-diag-hops"] = (
|
||||
f"{existing_hops},{_CVDIAG_HOP_TAG}"
|
||||
if isinstance(existing_hops, str) and existing_hops
|
||||
else _CVDIAG_HOP_TAG
|
||||
)
|
||||
set_forwarded_headers(headers)
|
||||
|
||||
hop = len([h for h in headers["x-diag-hops"].split(",") if h])
|
||||
_cvdiag("outbound-llm", headers, "ok", hop=hop)
|
||||
|
||||
|
||||
class HeaderForwardingMiddleware(AgentMiddleware[AgentState, Any]):
|
||||
"""AgentMiddleware that only forwards inbound x-* headers.
|
||||
|
||||
Behaviourally a no-op except for two calls inside both
|
||||
``wrap_model_call`` and ``awrap_model_call``:
|
||||
|
||||
1. ``_extract_forwarded_headers_from_config()`` — read the
|
||||
``x-*`` keys from the active LangGraph RunnableConfig
|
||||
(``context`` and ``configurable``) and populate the
|
||||
header-forwarding ContextVar.
|
||||
2. ``_ensure_httpx_hook(request.model)`` — install copilotkit's
|
||||
httpx event hook on the model's underlying HTTP client(s)
|
||||
so the next outgoing LLM request picks the headers up.
|
||||
|
||||
No App-Context injection, no tool-merging, no state-surfacing,
|
||||
no Bedrock message fix-up — strictly header propagation.
|
||||
|
||||
CVDIAG: ``_instrument_and_breadcrumb()`` is inserted between the
|
||||
two steps purely to OBSERVE the configurable-read boundary and tag
|
||||
the existing breadcrumb. It does not change where headers come from.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "HeaderForwardingMiddleware"
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse:
|
||||
_extract_forwarded_headers_from_config()
|
||||
_instrument_and_breadcrumb()
|
||||
_ensure_httpx_hook(request.model)
|
||||
|
||||
# CVDIAG schema-v1 dual-emit (L1-I). No-op when CVDIAG_BACKEND_EMITTER off.
|
||||
headers = dict(get_forwarded_headers())
|
||||
run = CvdiagBackendRun(headers)
|
||||
model_name = _model_name(request)
|
||||
run.request_ingress()
|
||||
run.agent_enter(agent_name=self.name, model_id=model_name)
|
||||
run.llm_call_start(provider="langchain", model=model_name)
|
||||
run.emit_heartbeat_once()
|
||||
start_ns = time.monotonic_ns()
|
||||
try:
|
||||
response = handler(request)
|
||||
except BaseException as exc: # noqa: BLE001 - re-raised after observing
|
||||
run.error_caught(exc)
|
||||
run.agent_exit(terminal_outcome="err")
|
||||
raise
|
||||
latency_ms = int((time.monotonic_ns() - start_ns) / 1_000_000)
|
||||
run.llm_call_response(
|
||||
provider="langchain", model=model_name, latency_ms=latency_ms
|
||||
)
|
||||
run.sse_first_byte()
|
||||
run.sse_event(event_type="response", payload_size_bytes=None)
|
||||
run.agent_exit(terminal_outcome="ok")
|
||||
run.response_complete(http_status=200)
|
||||
return response
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
_extract_forwarded_headers_from_config()
|
||||
_instrument_and_breadcrumb()
|
||||
_ensure_httpx_hook(request.model)
|
||||
|
||||
# CVDIAG schema-v1 dual-emit (L1-I). No-op when CVDIAG_BACKEND_EMITTER off.
|
||||
headers = dict(get_forwarded_headers())
|
||||
run = CvdiagBackendRun(headers)
|
||||
model_name = _model_name(request)
|
||||
run.request_ingress()
|
||||
run.agent_enter(agent_name=self.name, model_id=model_name)
|
||||
run.llm_call_start(provider="langchain", model=model_name)
|
||||
run.start_heartbeat()
|
||||
start_ns = time.monotonic_ns()
|
||||
try:
|
||||
response = await handler(request)
|
||||
except BaseException as exc: # noqa: BLE001 - re-raised after observing
|
||||
await run.stop_heartbeat()
|
||||
run.error_caught(exc)
|
||||
run.agent_exit(terminal_outcome="err")
|
||||
raise
|
||||
await run.stop_heartbeat()
|
||||
latency_ms = int((time.monotonic_ns() - start_ns) / 1_000_000)
|
||||
run.llm_call_response(
|
||||
provider="langchain", model=model_name, latency_ms=latency_ms
|
||||
)
|
||||
run.sse_first_byte()
|
||||
run.sse_event(event_type="response", payload_size_bytes=None)
|
||||
run.agent_exit(terminal_outcome="ok")
|
||||
run.response_complete(http_status=200)
|
||||
return response
|
||||
|
||||
|
||||
def _model_name(request: ModelRequest) -> str:
|
||||
"""Best-effort model identifier off the ModelRequest (never raises)."""
|
||||
try:
|
||||
model = getattr(request, "model", None)
|
||||
for attr in ("model_name", "model", "model_id"):
|
||||
val = getattr(model, attr, None)
|
||||
if isinstance(val, str) and val:
|
||||
return val
|
||||
except Exception: # noqa: BLE001 - instrumentation must not throw
|
||||
pass
|
||||
return "unknown"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""LangGraph agent for the Declarative Generative UI (A2UI — Dynamic Schema) demo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
# Cross-reference: showcase/integrations/google-adk/src/agents/declarative_gen_ui_agent.py
|
||||
# Both integrations register the same a2ui catalog (Card / Row / Column /
|
||||
# Text / Metric / PieChart / BarChart / DataTable / StatusBadge / InfoRow /
|
||||
# PrimaryButton — see each integration's
|
||||
# src/app/demos/declarative-gen-ui/a2ui/definitions.ts, which are
|
||||
# byte-identical across LP and ADK).
|
||||
#
|
||||
# The fictional sales dataset and the per-question composition rules
|
||||
# are injected via App Context from
|
||||
# showcase/integrations/langgraph-python/src/app/demos/declarative-gen-ui/sales-context.ts
|
||||
# (a frontend file shared byte-for-byte with the ADK integration — see
|
||||
# its DUPLICATION NOTICE).
|
||||
#
|
||||
# Keep this SYSTEM_PROMPT and the ADK `_INSTRUCTION` aligned in spirit.
|
||||
# Minor wording differences are tolerated (e.g. this prompt uses shape
|
||||
# words — "table"/"pie"/"bar" — as question-category descriptors, while
|
||||
# ADK names the rendered components — "DataTable"/"PieChart"/"BarChart"
|
||||
# — in the analogous slot), but the structural rules and the component
|
||||
# name set must match the catalog above.
|
||||
SYSTEM_PROMPT = (
|
||||
"You are the embedded sales analyst for Vantage Threads, the fictional "
|
||||
"B2B apparel company described in your App Context. Answer every "
|
||||
"business question by calling `generate_a2ui` to draw a rich visual "
|
||||
"surface, and keep the chat reply to one short sentence.\n"
|
||||
"\n"
|
||||
"Ground every number in the sales dataset from App Context — never "
|
||||
"invent figures that contradict it. Follow the dashboard composition "
|
||||
"rules from App Context when choosing components: pick the component "
|
||||
"by the shape of the question (snapshot → composed KPI dashboard with "
|
||||
"charts; team performance → table; risk → status badges; single "
|
||||
"account → info rows; part-of-whole → pie; trend/comparison → bar). "
|
||||
"Never ask the user which chart they want. `generate_a2ui` takes no "
|
||||
"arguments and handles the rendering automatically. Compose "
|
||||
"generously — a dashboard should feel like a real analytics product, "
|
||||
"not a single widget."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def generate_a2ui() -> dict:
|
||||
"""Generate a dynamic A2UI dashboard surface from the current conversation.
|
||||
|
||||
Takes no arguments. The CopilotKit runtime middleware
|
||||
(`a2ui.injectA2UITool: true`) intercepts the call and drives a
|
||||
secondary-LLM `render_a2ui` planner to emit the surface ops; this
|
||||
Python body should NEVER execute in normal operation. It exists only
|
||||
so the LP agent's declared `tools=` list mirrors the ADK sibling
|
||||
(`declarative_gen_ui_agent.py`) and the SYSTEM_PROMPT's
|
||||
`generate_a2ui` reference resolves to a registered tool name.
|
||||
|
||||
If this body actually runs, the CopilotKit a2ui middleware is
|
||||
misconfigured and silently returning an empty surface would hide the
|
||||
real bug — fail loud per `fail-loud-discipline`.
|
||||
"""
|
||||
raise RuntimeError(
|
||||
"generate_a2ui called directly — CopilotKit a2ui.injectA2UITool "
|
||||
"middleware should intercept this call before it reaches the "
|
||||
"agent. Check the route configuration at "
|
||||
"app/api/copilotkit-declarative-gen-ui/route.ts."
|
||||
)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-4o")),
|
||||
tools=[generate_a2ui],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
LangGraph 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 loaded at startup via `a2ui.load_schema(...)`. The agent only
|
||||
streams *data* into the data model at runtime. The frontend registers a
|
||||
matching catalog (see `src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts`)
|
||||
that pins the schema's component names to real React implementations.
|
||||
|
||||
Reference:
|
||||
examples/integrations/langgraph-python/agent/src/a2ui_fixed_schema.py
|
||||
"""
|
||||
|
||||
# @region[backend-render-operations]
|
||||
# @region[backend-schema-json-load]
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
from copilotkit import CopilotKitMiddleware, a2ui
|
||||
from langchain.agents import create_agent
|
||||
from langchain.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
CATALOG_ID = "copilotkit://flight-fixed-catalog"
|
||||
SURFACE_ID = "flight-fixed-schema"
|
||||
|
||||
_SCHEMAS_DIR = Path(__file__).parent / "a2ui_schemas"
|
||||
|
||||
# The schema is JSON so it can be authored and reviewed independently of the
|
||||
# Python code. `a2ui.load_schema` is just a thin `json.load` wrapper.
|
||||
FLIGHT_SCHEMA = a2ui.load_schema(_SCHEMAS_DIR / "flight_schema.json")
|
||||
# @endregion[backend-schema-json-load]
|
||||
|
||||
|
||||
class Flight(TypedDict):
|
||||
"""Shape the LLM should fill in when calling `display_flight`.
|
||||
|
||||
LangGraph serializes this TypedDict into the tool's JSON schema, so
|
||||
defining it narrowly is how we steer the LLM to produce data that fits
|
||||
the frontend `FlightCard` component's props.
|
||||
"""
|
||||
|
||||
origin: str
|
||||
destination: str
|
||||
airline: str
|
||||
price: str
|
||||
|
||||
|
||||
@tool
|
||||
def display_flight(origin: str, destination: str, airline: str, price: str) -> str:
|
||||
"""Show a flight card for the given trip.
|
||||
|
||||
Use short airport codes (e.g. "SFO", "JFK") for origin/destination and a
|
||||
price string like "$289".
|
||||
|
||||
After this tool returns, the flight card is already rendered to the user
|
||||
via the A2UI surface — the JSON returned here is the surface descriptor
|
||||
the renderer consumes, NOT a status code. Do NOT call this tool again
|
||||
for the same flight (the user already sees the card). Reply with one
|
||||
short confirmation sentence and stop.
|
||||
"""
|
||||
# The A2UI middleware detects the `a2ui_operations` container in this
|
||||
# tool result and forwards the ops to the frontend renderer. The frontend
|
||||
# catalog resolves component names to the local React components.
|
||||
#
|
||||
# Note: schema-swap-on-action (e.g. swapping to a "booked" schema when
|
||||
# the card's button is clicked) will be added once the Python SDK
|
||||
# exposes `action_handlers=` on `a2ui.render`.
|
||||
return a2ui.render(
|
||||
operations=[
|
||||
a2ui.create_surface(SURFACE_ID, catalog_id=CATALOG_ID),
|
||||
a2ui.update_components(SURFACE_ID, FLIGHT_SCHEMA),
|
||||
a2ui.update_data_model(
|
||||
SURFACE_ID,
|
||||
{
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"airline": airline,
|
||||
"price": price,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
# @endregion[backend-render-operations]
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[display_flight],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=(
|
||||
"You help users find flights. When asked about a flight, call "
|
||||
"`display_flight` exactly ONCE with origin, destination, airline, "
|
||||
"and price. The tool's JSON return value is an A2UI surface "
|
||||
"descriptor — the flight card is already rendered to the user; do "
|
||||
"NOT call `display_flight` again for the same trip. After the tool "
|
||||
"returns, reply with one short confirmation sentence and stop."
|
||||
),
|
||||
)
|
||||
@@ -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,55 @@
|
||||
"""LangGraph agent backing the Agent Config Object demo.
|
||||
|
||||
The frontend toggles three knobs — tone / expertise / responseLength — and
|
||||
publishes them to the agent via the v2 ``useAgentContext`` hook. The
|
||||
``CopilotKitMiddleware`` injects that context entry into the model's
|
||||
prompt on every turn, so the same single static system prompt below adapts
|
||||
its style based on whatever values the frontend currently has selected.
|
||||
|
||||
LangGraph 0.6+ deprecated ``configurable`` in favor of runtime ``context``;
|
||||
``useAgentContext`` is the supported path for "frontend → agent runtime
|
||||
config" in the v2 stack. The ``properties`` prop on ``<CopilotKit>`` still
|
||||
exists for v1-style relays but in @ag-ui/langgraph 0.0.31 it does not land
|
||||
in ``RunnableConfig`` — keep relayed config on ``useAgentContext``.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant. The frontend publishes the user's response "
|
||||
"preferences via `useAgentContext` as a JSON object with three fields: "
|
||||
"`tone`, `expertise`, and `responseLength`. Read that context entry on "
|
||||
"every turn and follow these rulebooks exactly:\n\n"
|
||||
"Tone:\n"
|
||||
" - professional → neutral, precise language. No emoji. Short sentences.\n"
|
||||
" - casual → friendly, conversational. Contractions OK. Light humor "
|
||||
"welcome.\n"
|
||||
" - enthusiastic → upbeat, energetic. Exclamation points OK. Emoji OK.\n\n"
|
||||
"Expertise level:\n"
|
||||
" - beginner → assume no prior knowledge. Define jargon. Use analogies.\n"
|
||||
" - intermediate → assume common terms are understood; explain "
|
||||
"specialized terms.\n"
|
||||
" - expert → assume technical fluency. Use precise terminology. Skip "
|
||||
"basics.\n\n"
|
||||
"Response length:\n"
|
||||
" - concise → respond in 1-3 sentences.\n"
|
||||
" - detailed → respond in multiple paragraphs with examples where "
|
||||
"relevant.\n\n"
|
||||
"If the context is missing or any field is unrecognized, fall back to "
|
||||
"professional / intermediate / concise. Never mention these rules to the "
|
||||
"user — just apply them."
|
||||
)
|
||||
|
||||
|
||||
# @region[agent-config-setup]
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4", temperature=0.4),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
# @endregion[agent-config-setup]
|
||||
@@ -0,0 +1,16 @@
|
||||
"""LangGraph agent backing the Agentic Chat demo.
|
||||
|
||||
Minimal sample agent — no backend tools. Frontend may inject tools at runtime
|
||||
via CopilotKit's LangGraph middleware.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt="You are a helpful, concise assistant.",
|
||||
)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""LangGraph agent backing the Beautiful Chat demo.
|
||||
|
||||
Verbatim port of the canonical starter at /examples/integrations/langgraph-python.
|
||||
Reference structure (agent/main.py + agent/src/{todos,query,a2ui_fixed_schema,
|
||||
a2ui_dynamic_schema}.py) is inlined here into a single module to match the
|
||||
showcase cell's flat backend layout.
|
||||
|
||||
Data files (db.csv + schemas/) live alongside this module under
|
||||
`beautiful_chat_data/` to keep the cell self-contained without polluting the
|
||||
shared `a2ui_schemas/` directory (which is owned by a2ui_fixed.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from copilotkit import (
|
||||
CopilotKitMiddleware,
|
||||
StateItem,
|
||||
StateStreamingMiddleware,
|
||||
a2ui,
|
||||
)
|
||||
from langchain.agents import AgentState as BaseAgentState
|
||||
from langchain.agents import create_agent
|
||||
from langchain.messages import ToolMessage
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import Command
|
||||
|
||||
|
||||
# ─── Shared state schema ────────────────────────────────────────────
|
||||
|
||||
|
||||
class Todo(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
emoji: str
|
||||
status: Literal["pending", "completed"]
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
todos: list[Todo]
|
||||
|
||||
|
||||
# ─── Todo tools ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def manage_todos(todos: list[Todo], runtime: ToolRuntime) -> Command:
|
||||
"""
|
||||
Manage the current todos.
|
||||
"""
|
||||
# Ensure all todos have IDs that are unique
|
||||
for todo in todos:
|
||||
if "id" not in todo or not todo["id"]:
|
||||
todo["id"] = str(uuid.uuid4())
|
||||
|
||||
# Update the state
|
||||
return Command(
|
||||
update={
|
||||
"todos": todos,
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content="Successfully updated todos",
|
||||
name="manage_todos",
|
||||
id=str(uuid.uuid4()),
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_todos(runtime: ToolRuntime):
|
||||
"""
|
||||
Get the current todos.
|
||||
"""
|
||||
return runtime.state.get("todos", [])
|
||||
|
||||
|
||||
todo_tools = [
|
||||
manage_todos,
|
||||
get_todos,
|
||||
]
|
||||
|
||||
|
||||
# ─── Data query tool ────────────────────────────────────────────────
|
||||
|
||||
# Read data at module load time to avoid file I/O issues in
|
||||
# LangGraph Cloud's sandboxed tool execution environment.
|
||||
_DATA_DIR = Path(__file__).parent / "beautiful_chat_data"
|
||||
_csv_path = _DATA_DIR / "db.csv"
|
||||
with open(_csv_path) as _f:
|
||||
_cached_data = list(csv.DictReader(_f))
|
||||
|
||||
|
||||
@tool
|
||||
def query_data(query: str):
|
||||
"""
|
||||
Query the database, takes natural language. Always call before showing a chart or graph.
|
||||
"""
|
||||
return _cached_data
|
||||
|
||||
|
||||
# ─── A2UI fixed-schema tool: flight search ──────────────────────────
|
||||
|
||||
CATALOG_ID = "copilotkit://app-dashboard-catalog"
|
||||
SURFACE_ID = "flight-search-results"
|
||||
|
||||
|
||||
class Flight(TypedDict, total=False):
|
||||
# All fields marked optional (`total=False`) so the LLM (or aimock fixture)
|
||||
# can omit auxiliary fields like `id` / `statusIcon` without tripping
|
||||
# langchain's tool-arg validation. Previously these were required and any
|
||||
# missing field surfaced as `Error invoking tool 'search_flights' with
|
||||
# kwargs ... flights.N.id: Field required` — the agent treated the error
|
||||
# string as the tool result and the surface never rendered.
|
||||
airline: str
|
||||
airlineLogo: str
|
||||
flightNumber: str
|
||||
origin: str
|
||||
destination: str
|
||||
date: str
|
||||
departureTime: str
|
||||
arrivalTime: str
|
||||
duration: str
|
||||
status: str
|
||||
price: str
|
||||
|
||||
|
||||
def _build_flight_components(flights: list[dict]) -> list[dict]:
|
||||
"""Build a flat A2UI component tree with one literal FlightCard per flight.
|
||||
|
||||
Avoids the structural-children template form (Row.children = { componentId,
|
||||
path }), which the GenericBinder only expands correctly for components whose
|
||||
schema declares STRUCTURAL children — sibling demos work because their
|
||||
schemas use literal-string-array children. Inlining the values per-flight
|
||||
sidesteps the template path entirely and renders identically.
|
||||
"""
|
||||
flight_card_ids: list[str] = []
|
||||
components: list[dict] = []
|
||||
for index, flight in enumerate(flights):
|
||||
card_id = f"flight-card-{index}"
|
||||
flight_card_ids.append(card_id)
|
||||
components.append(
|
||||
{
|
||||
"id": card_id,
|
||||
"component": "FlightCard",
|
||||
"airline": flight.get("airline", ""),
|
||||
"airlineLogo": flight.get("airlineLogo", ""),
|
||||
"flightNumber": flight.get("flightNumber", ""),
|
||||
"origin": flight.get("origin", ""),
|
||||
"destination": flight.get("destination", ""),
|
||||
"date": flight.get("date", ""),
|
||||
"departureTime": flight.get("departureTime", ""),
|
||||
"arrivalTime": flight.get("arrivalTime", ""),
|
||||
"duration": flight.get("duration", ""),
|
||||
"status": flight.get("status", ""),
|
||||
"price": flight.get("price", ""),
|
||||
}
|
||||
)
|
||||
root: dict = {
|
||||
"id": "root",
|
||||
"component": "Row",
|
||||
"children": flight_card_ids,
|
||||
"gap": 16,
|
||||
}
|
||||
return [root, *components]
|
||||
|
||||
|
||||
@tool
|
||||
def search_flights(flights: list[Flight]) -> str:
|
||||
"""Search for flights and display the results as rich cards. Return exactly 2 flights.
|
||||
|
||||
Each flight must have: airline (e.g. "United Airlines"),
|
||||
airlineLogo (use Google favicon API: https://www.google.com/s2/favicons?domain={airline_domain}&sz=128
|
||||
e.g. "https://www.google.com/s2/favicons?domain=united.com&sz=128" for United,
|
||||
"https://www.google.com/s2/favicons?domain=delta.com&sz=128" for Delta,
|
||||
"https://www.google.com/s2/favicons?domain=aa.com&sz=128" for American,
|
||||
"https://www.google.com/s2/favicons?domain=alaskaair.com&sz=128" for Alaska),
|
||||
flightNumber, origin, destination,
|
||||
date (short readable format like "Tue, Mar 18" — use near-future dates),
|
||||
departureTime, arrivalTime,
|
||||
duration (e.g. "4h 25m"), status (e.g. "On Time" or "Delayed"),
|
||||
and price (e.g. "$289").
|
||||
"""
|
||||
return a2ui.render(
|
||||
operations=[
|
||||
a2ui.create_surface(SURFACE_ID, catalog_id=CATALOG_ID),
|
||||
a2ui.update_components(SURFACE_ID, _build_flight_components(flights)),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ─── A2UI dynamic-schema tool: LLM-generated UI ─────────────────────
|
||||
|
||||
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
|
||||
|
||||
# ─── Graph ──────────────────────────────────────────────────────────
|
||||
|
||||
model = ChatOpenAI(model="gpt-5.4", model_kwargs={"parallel_tool_calls": False})
|
||||
|
||||
agent = create_agent(
|
||||
model=model,
|
||||
tools=[query_data, *todo_tools, search_flights],
|
||||
middleware=[
|
||||
CopilotKitMiddleware(),
|
||||
StateStreamingMiddleware(
|
||||
StateItem(state_key="todos", tool="manage_todos", tool_argument="todos")
|
||||
),
|
||||
],
|
||||
state_schema=AgentState,
|
||||
system_prompt="""
|
||||
You are a polished, professional demo assistant. Keep responses to 1-2 sentences.
|
||||
|
||||
Tool guidance:
|
||||
- Flights: call search_flights to show flight cards with a pre-built schema.
|
||||
- Dashboards & rich UI: call generate_a2ui to create dashboard UIs with metrics,
|
||||
charts, tables, and cards. It handles rendering automatically.
|
||||
- Charts: call query_data first, then render with the chart component.
|
||||
- Todos: enable app mode first, then manage todos.
|
||||
- A2UI actions: when you see a log_a2ui_event result (e.g. "view_details"),
|
||||
respond with a brief confirmation. The UI already updated on the frontend.
|
||||
""",
|
||||
)
|
||||
|
||||
graph = agent
|
||||
@@ -0,0 +1,41 @@
|
||||
date,category,subcategory,amount,type,notes
|
||||
2026-01-05,Revenue,Enterprise Subscriptions,28000,income,3 new enterprise customers (Acme Corp, TechFlow, DataViz Inc)
|
||||
2026-01-05,Revenue,Pro Tier Upgrades,18000,income,24 users upgraded from free to pro
|
||||
2026-01-08,Revenue,API Usage Overages,9500,income,High API usage from top 5 customers
|
||||
2026-01-10,Expenses,Engineering Salaries,42000,expense,7 engineers + 2 contractors
|
||||
2026-01-10,Expenses,Product Team,18000,expense,PM and 2 designers
|
||||
2026-01-12,Expenses,AWS Infrastructure,8200,expense,Increased compute for new AI features
|
||||
2026-01-15,Expenses,Marketing - Paid Ads,12000,expense,Google Ads and LinkedIn campaigns
|
||||
2026-01-18,Revenue,Consulting Services,14500,income,Custom integration for Acme Corp
|
||||
2026-01-20,Expenses,Customer Success,15000,expense,3 CSMs + support tools (Intercom)
|
||||
2026-01-22,Expenses,AI Model Costs,4200,expense,OpenAI API usage for product features
|
||||
2026-01-25,Revenue,Marketplace Sales,12800,income,Template and plugin sales
|
||||
2026-01-28,Expenses,Office & Equipment,3500,expense,New laptops and coworking spaces
|
||||
2026-02-03,Revenue,Enterprise Subscriptions,31000,income,2 new customers + expansion from TechFlow
|
||||
2026-02-03,Revenue,Pro Tier Upgrades,22500,income,31 upgrades + reduced churn
|
||||
2026-02-05,Revenue,API Usage Overages,11800,income,DataViz Inc heavy API usage spike
|
||||
2026-02-07,Expenses,Engineering Salaries,42000,expense,Same headcount as January
|
||||
2026-02-07,Expenses,Product Team,18000,expense,No changes to product team
|
||||
2026-02-10,Expenses,AWS Infrastructure,9500,expense,Traffic spike from viral social post
|
||||
2026-02-12,Expenses,Marketing - Paid Ads,15000,expense,Increased ad spend for Q1 push
|
||||
2026-02-14,Revenue,Consulting Services,18000,income,2 custom projects (TechFlow + new client)
|
||||
2026-02-18,Expenses,Customer Success,16500,expense,Hired 1 additional CSM
|
||||
2026-02-20,Expenses,AI Model Costs,5800,expense,Increased usage from new AI features launch
|
||||
2026-02-22,Revenue,Marketplace Sales,14200,income,Top template hit featured list
|
||||
2026-02-25,Expenses,Conference & Travel,4500,expense,Team attended SaaS Conference 2026
|
||||
2026-02-27,Revenue,Partnership Revenue,11500,income,Referral fees from integration partners
|
||||
2026-03-02,Revenue,Enterprise Subscriptions,35000,income,Major win: Fortune 500 customer signed
|
||||
2026-03-02,Revenue,Pro Tier Upgrades,26000,income,42 upgrades - best month yet
|
||||
2026-03-05,Revenue,API Usage Overages,13200,income,Consistent high usage across top tier
|
||||
2026-03-08,Expenses,Engineering Salaries,48000,expense,Hired 1 senior engineer for AI team
|
||||
2026-03-08,Expenses,Product Team,21000,expense,Promoted designer to senior level
|
||||
2026-03-10,Expenses,AWS Infrastructure,11000,expense,Scaled infrastructure for enterprise client
|
||||
2026-03-12,Expenses,Marketing - Paid Ads,18000,expense,Doubled down on successful campaigns
|
||||
2026-03-14,Revenue,Consulting Services,21500,income,Fortune 500 onboarding + 2 other projects
|
||||
2026-03-16,Expenses,Customer Success,19500,expense,Hired dedicated enterprise CSM
|
||||
2026-03-18,Expenses,AI Model Costs,7200,expense,Fortune 500 client heavy AI usage
|
||||
2026-03-20,Revenue,Marketplace Sales,15800,income,3 new templates in top 10
|
||||
2026-03-22,Expenses,Sales & BD,12000,expense,Hired first sales rep for enterprise
|
||||
2026-03-24,Revenue,Partnership Revenue,14200,income,New integration partnerships launched
|
||||
2026-03-26,Expenses,Security & Compliance,6500,expense,SOC 2 audit and security tools
|
||||
2026-03-28,Revenue,Training & Workshops,10200,income,Conducted 2 customer training sessions
|
||||
|
+37
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"id": "root",
|
||||
"component": "Row",
|
||||
"children": {
|
||||
"componentId": "flight-card",
|
||||
"path": "/flights"
|
||||
},
|
||||
"gap": 16
|
||||
},
|
||||
{
|
||||
"id": "flight-card",
|
||||
"component": "FlightCard",
|
||||
"airline": { "path": "airline" },
|
||||
"airlineLogo": { "path": "airlineLogo" },
|
||||
"flightNumber": { "path": "flightNumber" },
|
||||
"origin": { "path": "origin" },
|
||||
"destination": { "path": "destination" },
|
||||
"date": { "path": "date" },
|
||||
"departureTime": { "path": "departureTime" },
|
||||
"arrivalTime": { "path": "arrivalTime" },
|
||||
"duration": { "path": "duration" },
|
||||
"status": { "path": "status" },
|
||||
"price": { "path": "price" },
|
||||
"action": {
|
||||
"event": {
|
||||
"name": "book_flight",
|
||||
"context": {
|
||||
"flightNumber": { "path": "flightNumber" },
|
||||
"origin": { "path": "origin" },
|
||||
"destination": { "path": "destination" },
|
||||
"price": { "path": "price" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""LangGraph agent backing the declarative-hashbrown demo.
|
||||
|
||||
Emits hashbrown-shaped structured output that the ported HashBrownDashboard
|
||||
renderer (`src/app/demos/declarative-hashbrown/hashbrown-renderer.tsx`) progressively
|
||||
parses via `@hashbrownai/react`'s `useJsonParser` + `useUiKit`.
|
||||
|
||||
Wire format
|
||||
-----------
|
||||
`@hashbrownai/react`'s `useJsonParser(content, kit.schema)` expects the agent
|
||||
to stream a JSON object literal matching `kit.schema` — NOT the `<ui>...</ui>`
|
||||
XML-style examples shown inside `useUiKit({ examples })`. Those XML examples
|
||||
are the hashbrown prompt DSL that hashbrown compiles into a schema description
|
||||
when driving the LLM directly (e.g. `useUiChat`/`useUiCompletion`). Because
|
||||
this demo drives the LLM via langgraph instead, we must mirror what
|
||||
hashbrown's own schema wire format looks like:
|
||||
|
||||
{
|
||||
"ui": [
|
||||
{ "metric": { "props": { "label": "...", "value": "..." } } },
|
||||
{ "pieChart": { "props": { "title": "...", "data": "[{...}]" } } },
|
||||
{ "barChart": { "props": { "title": "...", "data": "[{...}]" } } },
|
||||
{ "dealCard": { "props": { "title": "...", "stage": "prospect", "value": 100000 } } },
|
||||
{ "Markdown": { "props": { "children": "## heading\\nbody" } } }
|
||||
]
|
||||
}
|
||||
|
||||
Every node is a single-key object `{tagName: {props: {...}}}`. The tag names
|
||||
and prop schemas match `useSalesDashboardKit()` in
|
||||
`hashbrown-renderer.tsx`. `pieChart` and `barChart` receive `data` as a
|
||||
JSON-encoded string (this was intentional in PR #4252 to keep the schema
|
||||
stable under partial streaming).
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
from src.agents.byoc_hashbrown_prompt import BYOC_HASHBROWN_SYSTEM_PROMPT
|
||||
|
||||
# Force JSON-object output mode. The frontend's `useJsonParser` bails to
|
||||
# `null` on any non-JSON prefix (code fences, prose preamble, etc.), so
|
||||
# leaving the model free to wander out of JSON is what left the renderer
|
||||
# empty in practice. `response_format={"type": "json_object"}` tells
|
||||
# OpenAI to refuse to emit anything but a single JSON object, which
|
||||
# aligns the wire-level contract with what the parser accepts.
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(
|
||||
model="gpt-5.4",
|
||||
model_kwargs={"response_format": {"type": "json_object"}},
|
||||
),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=BYOC_HASHBROWN_SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""System prompt for byoc_hashbrown_agent.
|
||||
|
||||
Lives alongside the agent module so the agent file stays focused on
|
||||
LangGraph setup. The prompt is long because it documents the
|
||||
component-by-component wire format that `@hashbrownai/react`'s streaming
|
||||
JSON parser expects on the frontend — see `byoc_hashbrown_agent.py` for
|
||||
the why.
|
||||
"""
|
||||
|
||||
BYOC_HASHBROWN_SYSTEM_PROMPT = """\
|
||||
You are a sales analytics assistant that replies by emitting a single JSON
|
||||
object consumed by a streaming JSON parser on the frontend.
|
||||
|
||||
ALWAYS respond with a single JSON object of the form:
|
||||
|
||||
{
|
||||
"ui": [
|
||||
{ <componentName>: { "props": { ... } } },
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Do NOT wrap the response in code fences. Do NOT include any preface or
|
||||
explanation outside the JSON object. The response MUST be valid JSON.
|
||||
|
||||
Available components and their prop schemas:
|
||||
|
||||
- "metric": { "props": { "label": string, "value": string } }
|
||||
A KPI card. `value` is a pre-formatted string like "$1.2M" or "248".
|
||||
|
||||
- "pieChart": { "props": { "title": string, "data": string } }
|
||||
A donut chart. `data` is a JSON-encoded STRING (embedded JSON) of an
|
||||
array of {label, value} objects with at least 3 segments, e.g.
|
||||
"data": "[{\\"label\\":\\"Enterprise\\",\\"value\\":600000}]".
|
||||
|
||||
- "barChart": { "props": { "title": string, "data": string } }
|
||||
A vertical bar chart. `data` is a JSON-encoded STRING of an array of
|
||||
{label, value} objects with at least 3 bars, typically time-ordered.
|
||||
|
||||
- "dealCard": { "props": { "title": string, "stage": string, "value": number } }
|
||||
A single sales deal. `stage` MUST be one of: "prospect", "qualified",
|
||||
"proposal", "negotiation", "closed-won", "closed-lost". `value` is a
|
||||
raw number (no currency symbol or comma).
|
||||
|
||||
- "Markdown": { "props": { "children": string } }
|
||||
Short explanatory text. Use for section headings and brief summaries.
|
||||
Standard markdown is supported in `children`.
|
||||
|
||||
Rules:
|
||||
- Always produce plausible sample data when the user asks for a dashboard or
|
||||
chart — do not refuse for lack of data.
|
||||
- Prefer 3-6 rows of data in charts; keep labels short.
|
||||
- Use "Markdown" for short headings or linking sentences between visual
|
||||
components. Do not emit long prose.
|
||||
- Do not emit components that are not listed above.
|
||||
- `data` props on charts MUST be a JSON STRING — escape inner quotes.
|
||||
|
||||
Example response (sales dashboard):
|
||||
{"ui":[{"Markdown":{"props":{"children":"## Q4 Sales Summary"}}},{"metric":{"props":{"label":"Total Revenue","value":"$1.2M"}}},{"metric":{"props":{"label":"New Customers","value":"248"}}},{"pieChart":{"props":{"title":"Revenue by Segment","data":"[{\\"label\\":\\"Enterprise\\",\\"value\\":600000},{\\"label\\":\\"SMB\\",\\"value\\":400000},{\\"label\\":\\"Startup\\",\\"value\\":200000}]"}}},{"barChart":{"props":{"title":"Monthly Revenue","data":"[{\\"label\\":\\"Oct\\",\\"value\\":350000},{\\"label\\":\\"Nov\\",\\"value\\":400000},{\\"label\\":\\"Dec\\",\\"value\\":450000}]"}}}]}
|
||||
"""
|
||||
@@ -0,0 +1,160 @@
|
||||
"""LangGraph agent backing the BYOC json-render demo.
|
||||
|
||||
Emits a single JSON object shaped like `@json-render/react`'s flat spec
|
||||
format (`{ root, elements }`) so the frontend can feed it directly into
|
||||
`<Renderer />` against a Zod-validated catalog of three components —
|
||||
MetricCard, BarChart, PieChart.
|
||||
|
||||
The scenario mirrors the declarative-hashbrown demo so the two BYOC rows on the
|
||||
dashboard are directly comparable. The only difference is the rendering
|
||||
technology; the catalog shape and suggestion prompts are identical.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """
|
||||
You are a sales-dashboard UI generator for a BYOC json-render demo.
|
||||
|
||||
When the user asks for a UI, respond with **exactly one JSON object** and
|
||||
nothing else — no prose, no markdown fences, no leading explanation. The
|
||||
object must match this schema (the "flat element map" format consumed by
|
||||
`@json-render/react`):
|
||||
|
||||
{
|
||||
"root": "<id of the root element>",
|
||||
"elements": {
|
||||
"<id>": {
|
||||
"type": "<component name>",
|
||||
"props": { ... component-specific props ... },
|
||||
"children": [ "<id>", ... ]
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
Available components (use each name verbatim as "type"):
|
||||
|
||||
- MetricCard
|
||||
props: { "label": string, "value": string, "trend": string | null }
|
||||
Example trend strings: "+12% vs last quarter", "-3% vs last month", null.
|
||||
|
||||
- BarChart
|
||||
props: {
|
||||
"title": string,
|
||||
"description": string | null,
|
||||
"data": [ { "label": string, "value": number }, ... ]
|
||||
}
|
||||
|
||||
- PieChart
|
||||
props: {
|
||||
"title": string,
|
||||
"description": string | null,
|
||||
"data": [ { "label": string, "value": number }, ... ]
|
||||
}
|
||||
|
||||
Rules:
|
||||
|
||||
1. Output **only** valid JSON. No markdown code fences. No text outside
|
||||
the object.
|
||||
2. Every id referenced in `root` or any `children` array must be a key
|
||||
in `elements`.
|
||||
3. For a multi-component dashboard, use a root MetricCard and list the
|
||||
charts in its `children` array, OR pick any element as root and list
|
||||
the others as its children. Do not emit orphan elements.
|
||||
4. Use realistic sales-domain values (revenue, pipeline, conversion,
|
||||
categories, months) — the demo is a sales dashboard.
|
||||
5. `children` is optional but when present must be an array of strings.
|
||||
6. Never invent component types outside the three listed above.
|
||||
|
||||
### Worked example — "Show me the sales dashboard with metrics and a revenue chart"
|
||||
|
||||
{
|
||||
"root": "revenue-metric",
|
||||
"elements": {
|
||||
"revenue-metric": {
|
||||
"type": "MetricCard",
|
||||
"props": {
|
||||
"label": "Revenue (Q3)",
|
||||
"value": "$1.24M",
|
||||
"trend": "+18% vs Q2"
|
||||
},
|
||||
"children": ["revenue-bar"]
|
||||
},
|
||||
"revenue-bar": {
|
||||
"type": "BarChart",
|
||||
"props": {
|
||||
"title": "Monthly revenue",
|
||||
"description": "Revenue by month across Q3",
|
||||
"data": [
|
||||
{ "label": "Jul", "value": 380000 },
|
||||
{ "label": "Aug", "value": 410000 },
|
||||
{ "label": "Sep", "value": 450000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Worked example — "Break down revenue by category as a pie chart"
|
||||
|
||||
{
|
||||
"root": "category-pie",
|
||||
"elements": {
|
||||
"category-pie": {
|
||||
"type": "PieChart",
|
||||
"props": {
|
||||
"title": "Revenue by category",
|
||||
"description": "Share of total revenue by product category",
|
||||
"data": [
|
||||
{ "label": "Enterprise", "value": 540000 },
|
||||
{ "label": "SMB", "value": 310000 },
|
||||
{ "label": "Self-serve", "value": 220000 },
|
||||
{ "label": "Partner", "value": 170000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
### Worked example — "Show me monthly expenses as a bar chart"
|
||||
|
||||
{
|
||||
"root": "expense-bar",
|
||||
"elements": {
|
||||
"expense-bar": {
|
||||
"type": "BarChart",
|
||||
"props": {
|
||||
"title": "Monthly expenses",
|
||||
"description": "Operating expenses by month",
|
||||
"data": [
|
||||
{ "label": "Jul", "value": 210000 },
|
||||
{ "label": "Aug", "value": 225000 },
|
||||
{ "label": "Sep", "value": 240000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Respond with the JSON object only.
|
||||
"""
|
||||
|
||||
|
||||
# Force JSON-object output mode. The frontend's `parseSpec` already
|
||||
# tolerates code fences and prose preamble via `extractJsonObject`, but
|
||||
# locking the model to JSON at the API layer removes the ambiguity
|
||||
# entirely — the only thing the LLM can emit is a single JSON object,
|
||||
# which is exactly what `<Renderer />` needs.
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(
|
||||
model="gpt-5.4",
|
||||
temperature=0.2,
|
||||
model_kwargs={"response_format": {"type": "json_object"}},
|
||||
),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT.strip(),
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""LangGraph agent backing the Frontend Tools demo.
|
||||
|
||||
This cell demonstrates `useFrontendTool` with a synchronous handler.
|
||||
The backend graph registers no tools of its own — CopilotKit forwards
|
||||
the frontend tool schema(s) to the agent at runtime, and the handler
|
||||
executes in the browser. CopilotKitMiddleware is attached so frontend
|
||||
tools, shared state, and agent context flow into every turn.
|
||||
|
||||
Like the sibling `frontend_tools_async` cell, the agent has no custom
|
||||
behavior beyond a permissive system prompt — the demo's value is in
|
||||
showing the wiring contract, not the agent logic.
|
||||
"""
|
||||
|
||||
# region: middleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt="You are a helpful, concise assistant.",
|
||||
)
|
||||
# endregion
|
||||
@@ -0,0 +1,32 @@
|
||||
"""LangGraph agent backing the Frontend Tools (Async) demo.
|
||||
|
||||
This cell demonstrates `useFrontendTool` with an ASYNC handler. The
|
||||
frontend registers a `query_notes` tool whose handler awaits a simulated
|
||||
client-side DB query (500ms latency) and returns matching notes. The
|
||||
agent uses the returned result to summarize what it found.
|
||||
|
||||
Like the sibling `frontend_tools` cell, the backend graph registers no
|
||||
tools of its own — CopilotKit forwards the frontend tool schema(s) to
|
||||
the agent at runtime, and the handler executes in the browser.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant that can search the user's personal notes. "
|
||||
"When the user asks about their notes, call the `query_notes` tool with "
|
||||
"a concise keyword extracted from their request. The tool is provided "
|
||||
"by the frontend at runtime and runs entirely in the user's browser — "
|
||||
"you do not need to implement it yourself. After the tool returns, "
|
||||
"summarize the matching notes clearly and concisely. If no notes match, "
|
||||
"say so plainly and offer to try a different keyword."
|
||||
)
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""gen-ui-agent — minimal agent with explicit state + state-editing tool.
|
||||
|
||||
The agent plans a task as 3 steps and walks each pending -> in_progress
|
||||
-> completed, calling `set_steps` after every transition. The frontend
|
||||
subscribes to `state.steps` via `useAgent` and renders a live progress
|
||||
card.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware.types import AgentState, OmitFromInput
|
||||
from langchain.chat_models import init_chat_model
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import InjectedToolCallId, tool
|
||||
from langgraph.types import Command
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
|
||||
class Step(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
status: Literal["pending", "in_progress", "completed"]
|
||||
|
||||
|
||||
def _last_steps(_prev: list[Step] | None, new: list[Step] | None) -> list[Step]:
|
||||
"""Reducer: last write wins (accepts parallel tool calls in one superstep)."""
|
||||
return new if new is not None else (_prev or [])
|
||||
|
||||
|
||||
class GenUiAgentState(AgentState):
|
||||
"""Extends the base agent state with a typed `steps` field."""
|
||||
|
||||
steps: Annotated[NotRequired[list[Step]], _last_steps, OmitFromInput]
|
||||
|
||||
|
||||
@tool
|
||||
def set_steps(
|
||||
steps: list[Step], tool_call_id: Annotated[str, InjectedToolCallId]
|
||||
) -> Command[Any]:
|
||||
"""Publish the current plan + step statuses. Call this every time a step
|
||||
transitions (including the first enumeration of steps)."""
|
||||
return Command(
|
||||
update={
|
||||
"steps": steps,
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
f"Published {len(steps)} step(s).",
|
||||
name="set_steps",
|
||||
id=str(uuid.uuid4()),
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are an agentic planner. For each user request, follow this exact "
|
||||
"sequence:\n"
|
||||
"1. Plan exactly 3 concrete steps and call `set_steps` ONCE with all "
|
||||
'three steps at status="pending".\n'
|
||||
'2. Step 1: call `set_steps` with step 1 at status="in_progress", '
|
||||
'then call `set_steps` again with step 1 at status="completed".\n'
|
||||
'3. Step 2: call `set_steps` with step 2 at status="in_progress", '
|
||||
'then call `set_steps` again with step 2 at status="completed".\n'
|
||||
'4. Step 3: call `set_steps` with step 3 at status="in_progress", '
|
||||
'then call `set_steps` again with step 3 at status="completed".\n'
|
||||
"5. Send ONE final conversational assistant message summarizing the "
|
||||
"plan, then stop. Do not call any more tools after step 3 is "
|
||||
"completed.\n"
|
||||
"\n"
|
||||
"Rules: never call set_steps in parallel — always wait for one call to "
|
||||
"return before the next. After all three steps are completed you MUST "
|
||||
"send a final assistant message and terminate."
|
||||
)
|
||||
|
||||
|
||||
# Uses `langchain.agents.create_agent` (not `deepagents.create_deep_agent`)
|
||||
# because `create_deep_agent`'s planner+sub-agent middleware ate enough
|
||||
# supersteps on this single-tool ReAct loop to repeatedly trip
|
||||
# LangGraph's default recursion limit. The ReAct loop here is two
|
||||
# supersteps per LLM/tool cycle (model node + tool node); the prompt
|
||||
# drives ~7 set_steps cycles + 1 final model turn, so nominal cost is
|
||||
# ~15 supersteps. `recursion_limit=50` gives ~3× headroom for retries
|
||||
# inside the LLM loop.
|
||||
graph = create_agent(
|
||||
model=init_chat_model("openai:gpt-4o-mini", temperature=0, use_responses_api=False),
|
||||
tools=[set_steps],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
state_schema=GenUiAgentState,
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
).with_config({"recursion_limit": 50})
|
||||
@@ -0,0 +1,34 @@
|
||||
"""LangGraph agent backing the Tool-Based Generative UI demo.
|
||||
|
||||
The frontend registers `render_bar_chart` and `render_pie_chart` tools via
|
||||
`useComponent`. CopilotKit's LangGraph middleware injects those tools into
|
||||
the model request at runtime so the agent can call them.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
SYSTEM_PROMPT = """You are a data visualization assistant.
|
||||
|
||||
When the user asks for a chart, call `render_bar_chart` or `render_pie_chart`
|
||||
with a concise title, short description, and a `data` array of
|
||||
`{label, value}` items. Pick bar for comparisons over a small set of
|
||||
categories; pick pie for composition / share-of-whole.
|
||||
|
||||
If the user names a chart subject but does NOT supply concrete numbers
|
||||
(e.g. "show me a pie chart of website traffic by source"), do NOT ask
|
||||
them for data. Invent plausible illustrative sample values yourself,
|
||||
call the appropriate `render_*` tool immediately, and briefly note in
|
||||
the follow-up that the values are illustrative samples. Always render
|
||||
the chart on the first turn -- never reply with a clarifying question
|
||||
asking for the data.
|
||||
|
||||
Keep chat responses brief -- let the chart do the talking."""
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""LangGraph agent backing the Headless Chat (Complete) demo.
|
||||
|
||||
The cell exists to prove that every CopilotKit rendering surface works
|
||||
when the chat UI is composed manually (no <CopilotChatMessageView /> or
|
||||
<CopilotChatAssistantMessage />). To exercise those surfaces we give
|
||||
this agent:
|
||||
|
||||
- two mock backend tools (get_weather, get_stock_price) — render via
|
||||
app-registered `useRenderTool` renderers on the frontend,
|
||||
- access to a frontend-registered `useComponent` tool
|
||||
(`highlight_note`) — the agent "calls" it and the UI flows through
|
||||
the same `useRenderToolCall` path,
|
||||
- MCP Apps wired through the runtime — the agent can invoke Excalidraw
|
||||
MCP tools and the middleware emits activity events that
|
||||
`useRenderActivityMessage` picks up.
|
||||
|
||||
The system prompt nudges the model toward the right surface per user
|
||||
question and falls back to plain text otherwise.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful, concise assistant wired into a headless chat "
|
||||
"surface that demonstrates CopilotKit's full rendering stack. Pick the "
|
||||
"right surface for each user question and fall back to plain text when "
|
||||
"none of the tools fit.\n\n"
|
||||
"Routing rules:\n"
|
||||
" - If the user asks about weather for a place, call `get_weather` "
|
||||
"with the location.\n"
|
||||
" - If the user asks about a stock or ticker (AAPL, TSLA, MSFT, ...), "
|
||||
"call `get_stock_price` with the ticker.\n"
|
||||
" - If the user asks for a chart, graph, or visualization of revenue, "
|
||||
"sales, or other metrics over time, call `get_revenue_chart`.\n"
|
||||
" - If the user asks you to highlight, flag, or mark a short note or "
|
||||
"phrase, call the frontend `highlight_note` tool with the text and a "
|
||||
"color (yellow, pink, green, or blue). Do NOT ask the user for the "
|
||||
"color — pick a sensible one if they didn't say.\n"
|
||||
" - If the user asks to draw, sketch, or diagram something, use the "
|
||||
"Excalidraw MCP tools that are available to you.\n"
|
||||
" - Otherwise, reply in plain text.\n\n"
|
||||
"After a tool returns, write one short sentence summarizing the "
|
||||
"result. Never fabricate data a tool could provide."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: str) -> dict:
|
||||
"""Get the current weather for a given location.
|
||||
|
||||
Returns a mock payload with city, temperature in Fahrenheit, humidity,
|
||||
wind speed, and conditions. Use this whenever the user asks about
|
||||
weather anywhere.
|
||||
"""
|
||||
return {
|
||||
"city": location,
|
||||
"temperature": 68,
|
||||
"humidity": 55,
|
||||
"wind_speed": 10,
|
||||
"conditions": "Sunny",
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def get_stock_price(ticker: str) -> dict:
|
||||
"""Get a mock current price for a stock ticker.
|
||||
|
||||
Returns a payload with the ticker symbol (uppercased), price in USD,
|
||||
and percentage change for the day. Use this whenever the user asks
|
||||
about a stock price.
|
||||
"""
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": 189.42,
|
||||
"change_pct": 1.27,
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def get_revenue_chart() -> dict:
|
||||
"""Get a mock six-month revenue series for a chart visualization.
|
||||
|
||||
Returns a title, subtitle, and an array of {label, value} points. Use
|
||||
this whenever the user asks for a chart, graph, or visualization of
|
||||
revenue, sales, or other quarterly/monthly metrics.
|
||||
"""
|
||||
return {
|
||||
"title": "Quarterly revenue",
|
||||
"subtitle": "Last six months · USD thousands",
|
||||
"data": [
|
||||
{"label": "Jan", "value": 38},
|
||||
{"label": "Feb", "value": 47},
|
||||
{"label": "Mar", "value": 52},
|
||||
{"label": "Apr", "value": 49},
|
||||
{"label": "May", "value": 63},
|
||||
{"label": "Jun", "value": 71},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[get_weather, get_stock_price, get_revenue_chart],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""LangGraph agent backing the In-App HITL (frontend-tool + popup) demo.
|
||||
|
||||
The agent is a support assistant that processes customer-care requests
|
||||
(refunds, account changes, escalations). Any action that materially
|
||||
affects a customer MUST be confirmed by the human operator via the
|
||||
frontend-provided `request_user_approval` tool.
|
||||
|
||||
The tool is defined on the frontend via `useFrontendTool` with an async
|
||||
handler that opens a modal dialog OUTSIDE the chat surface. The handler
|
||||
awaits the user's decision and resolves with
|
||||
`{"approved": bool, "reason": str}`. This agent treats that result as
|
||||
authoritative: if `approved` is `True`, continue; otherwise, stop and
|
||||
explain the decision back to the user.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a support operations copilot working alongside a human operator "
|
||||
"inside an internal support console. The operator can see a list of open "
|
||||
"support tickets on the left side of their screen and is chatting with "
|
||||
"you on the right.\n"
|
||||
"\n"
|
||||
"Whenever the operator asks you to take an action that affects a "
|
||||
"customer — for example: issuing a refund, updating a customer's plan, "
|
||||
"cancelling a subscription, escalating a ticket, or sending an apology "
|
||||
"credit — you MUST first call the frontend-provided "
|
||||
"`request_user_approval` tool to obtain the operator's explicit consent.\n"
|
||||
"\n"
|
||||
"How to use `request_user_approval`:\n"
|
||||
"- `message`: a short, plain-English summary of the exact action you "
|
||||
" are about to take, including concrete numbers (e.g. '$50 refund to "
|
||||
" customer #12345').\n"
|
||||
"- `context`: optional extra context the operator might want to review "
|
||||
" (the ticket ID, the policy rule you're applying, etc.). Keep it to "
|
||||
" one or two short sentences.\n"
|
||||
"\n"
|
||||
"The tool returns an object of the shape "
|
||||
'`{"approved": boolean, "reason": string | null}`.\n'
|
||||
"- If `approved` is `true`: confirm in one short sentence that you are "
|
||||
" processing the action. You do not actually need to call any other "
|
||||
" tool — this is a demo. Just acknowledge.\n"
|
||||
"- If `approved` is `false`: acknowledge the rejection in one short "
|
||||
" sentence and, if `reason` is non-empty, reflect the operator's "
|
||||
" reason back to them. Do NOT retry the action.\n"
|
||||
"\n"
|
||||
"Keep all chat replies to one or two short sentences. Never make up "
|
||||
"customer data — always use whatever the operator told you in the "
|
||||
"prompt."
|
||||
)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""LangGraph agent backing the In-Chat HITL (useHumanInTheLoop) demo.
|
||||
|
||||
The `book_call` tool is defined on the frontend via `useHumanInTheLoop`,
|
||||
so there is no backend tool here. CopilotKitMiddleware is attached so the
|
||||
frontend suggestions and the time-picker render hook are picked up.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=(
|
||||
"You help users book an onboarding call with the sales team. "
|
||||
"When they ask to book a call, call the frontend-provided "
|
||||
"`book_call` tool with a short topic and the user's name. "
|
||||
"Keep any chat reply to one short sentence."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""LangGraph agent for the Human-in-the-Loop (Interrupt-based) booking demo.
|
||||
|
||||
Defines a backend tool `schedule_meeting(topic, attendee)` that uses
|
||||
LangGraph's `interrupt()` primitive to pause the run and surface a
|
||||
structured booking payload to the frontend. The frontend `useInterrupt`
|
||||
renderer shows a time picker inline in the chat and resolves with
|
||||
`{chosen_time, chosen_label}` or `{cancelled: true}`, which this tool
|
||||
turns into a human-readable result the agent uses to confirm the booking.
|
||||
"""
|
||||
|
||||
# @region[backend-interrupt-tool]
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Any, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import interrupt
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a scheduling assistant. Whenever the user asks you to book a "
|
||||
"call / schedule a meeting, you MUST call the `schedule_meeting` tool. "
|
||||
"Pass a short `topic` describing the purpose and `attendee` describing "
|
||||
"who the meeting is with. After the tool returns, confirm briefly "
|
||||
"whether the meeting was scheduled and at what time, or that the user "
|
||||
"cancelled."
|
||||
)
|
||||
|
||||
# Demo-only fixed timezone. A real app would use the user's calendar +
|
||||
# locale (e.g. zoneinfo.ZoneInfo(user.timezone) and Google Calendar /
|
||||
# Outlook availability); we hardcode Pacific so screenshots are stable.
|
||||
_DEMO_TZ = ZoneInfo("America/Los_Angeles")
|
||||
|
||||
|
||||
def _candidate_slots() -> List[dict]:
|
||||
"""Upcoming candidate slots, relative to "now" so the picker never
|
||||
shows stale dates."""
|
||||
now = datetime.now(_DEMO_TZ)
|
||||
tomorrow = (now + timedelta(days=1)).date()
|
||||
# Skip a week when the result would collide with `tomorrow` — i.e.
|
||||
# today is Mon (0 days away, picker would show two slots both
|
||||
# labelled "Monday") or Sun (1 day away, picker would show
|
||||
# "Tomorrow" and "Monday" both pointing at the same date).
|
||||
days_to_monday = (7 - now.weekday()) % 7
|
||||
if days_to_monday <= 1:
|
||||
days_to_monday += 7
|
||||
next_monday = (now + timedelta(days=days_to_monday)).date()
|
||||
candidates = [
|
||||
("Tomorrow 10:00 AM", tomorrow, time(10, 0)),
|
||||
("Tomorrow 2:00 PM", tomorrow, time(14, 0)),
|
||||
("Monday 9:00 AM", next_monday, time(9, 0)),
|
||||
("Monday 3:30 PM", next_monday, time(15, 30)),
|
||||
]
|
||||
return [
|
||||
{"label": label, "iso": datetime.combine(d, t, _DEMO_TZ).isoformat()}
|
||||
for label, d, t in candidates
|
||||
]
|
||||
|
||||
|
||||
@tool
|
||||
def schedule_meeting(topic: str, attendee: Optional[str] = None) -> str:
|
||||
"""Ask the user to pick a time slot for a call, via an in-chat picker.
|
||||
|
||||
Args:
|
||||
topic: Short human-readable description of the call's purpose.
|
||||
attendee: Who the call is with (optional).
|
||||
|
||||
Returns:
|
||||
Human-readable result string describing the chosen slot or
|
||||
indicating the user cancelled.
|
||||
"""
|
||||
# `interrupt()` pauses the LangGraph run and forwards a structured
|
||||
# payload to the client. The frontend v2 `useInterrupt` hook renders
|
||||
# the picker inline in the chat, then calls `resolve(...)` with the
|
||||
# user's selection — that value comes back here as `response`.
|
||||
response: Any = interrupt(
|
||||
{
|
||||
"topic": topic,
|
||||
"attendee": attendee,
|
||||
"slots": _candidate_slots(),
|
||||
}
|
||||
)
|
||||
|
||||
if isinstance(response, dict):
|
||||
if response.get("cancelled"):
|
||||
return f"User cancelled. Meeting NOT scheduled: {topic}"
|
||||
chosen_label = response.get("chosen_label") or response.get("chosen_time")
|
||||
if chosen_label:
|
||||
return f"Meeting scheduled for {chosen_label}: {topic}"
|
||||
|
||||
return f"User did not pick a time. Meeting NOT scheduled: {topic}"
|
||||
|
||||
|
||||
# @endregion[backend-interrupt-tool]
|
||||
|
||||
|
||||
model = ChatOpenAI(model="gpt-5.4")
|
||||
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[schedule_meeting],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Default LangGraph agent — neutral "helpful, concise assistant".
|
||||
|
||||
This is the fallthrough graph for demos that don't require anything more
|
||||
specialized. Cells that need tailored behavior (chart viz, weather-only,
|
||||
etc.) should have their own dedicated graph under `src/agents/` and
|
||||
explicit wiring in the CopilotKit route.
|
||||
"""
|
||||
|
||||
# CVDIAG runtime bootstrap (L1-H, folded into L1-I for LGP). MUST be the first
|
||||
# non-stdlib import: importing this module configures the root logger so the
|
||||
# agents._* CVDIAG loggers actually emit, resolves the verbosity tier (§6
|
||||
# fail-closed DEBUG guard), and builds the threaded PocketBase writer — once, at
|
||||
# process start. main.py is langgraph's default graph entrypoint (sample_agent)
|
||||
# and is verified present by entrypoint.sh, so it is the reliable single
|
||||
# bootstrap chokepoint for the LGP process.
|
||||
import _shared.cvdiag_bootstrap # noqa: F401 (import side effects = the bootstrap)
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt="You are a helpful, concise assistant.",
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
LangGraph agent for the CopilotKit MCP Apps demo.
|
||||
|
||||
This agent has no bespoke tools — the CopilotKit runtime is wired with
|
||||
``mcpApps: { servers: [...] }`` pointing at the public Excalidraw MCP
|
||||
server (see ``src/app/api/copilotkit-mcp-apps/route.ts``). The runtime
|
||||
auto-applies the MCP Apps middleware, which exposes the remote MCP
|
||||
server's tools to this agent at request time and emits the activity
|
||||
events that CopilotKit's built-in ``MCPAppsActivityRenderer`` renders in
|
||||
the chat as a sandboxed iframe.
|
||||
|
||||
Reference:
|
||||
https://docs.copilotkit.ai/integrations/langgraph/generative-ui/mcp-apps
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You draw simple diagrams in Excalidraw via the MCP tool.
|
||||
|
||||
SPEED MATTERS. Produce a correct-enough diagram fast; do not optimize
|
||||
for polish. Target: one tool call, done in seconds.
|
||||
|
||||
When the user asks for a diagram:
|
||||
1. Call `create_view` ONCE with 3-5 elements total: shapes + arrows +
|
||||
an optional title text.
|
||||
2. Use straightforward shapes (rectangle, ellipse, diamond) with plain
|
||||
`label` fields (`{"text": "...", "fontSize": 18}`) on them.
|
||||
3. Connect with arrows. Endpoints can be element centers or simple
|
||||
coordinates — you don't need edge anchors / fixedPoint bindings.
|
||||
4. Include ONE `cameraUpdate` at the END of the elements array that
|
||||
frames the whole diagram. Use an approved 4:3 size (600x450 or
|
||||
800x600). No opening camera needed.
|
||||
5. Reply with ONE short sentence describing what you drew.
|
||||
|
||||
Every element needs a unique string `id` (e.g. `"b1"`, `"a1"`,
|
||||
`"title"`). Standard sizes: rectangles 160x70, ellipses/diamonds
|
||||
120x80, 40-80px gap between shapes.
|
||||
|
||||
Do NOT:
|
||||
- Call `read_me`. You already know the basic shape API.
|
||||
- Make multiple `create_view` calls.
|
||||
- Iterate or refine. Ship on the first shot.
|
||||
- Add decorative colors / fills / zone backgrounds unless the user
|
||||
explicitly asks for them.
|
||||
- Add labels on arrows unless crucial.
|
||||
|
||||
If the user asks for something specific (colors, more elements,
|
||||
particular layout), follow their lead — but still in ONE call.
|
||||
"""
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
# gpt-4o-mini for speed — Excalidraw element emission is simple
|
||||
# JSON and we're biasing hard toward sub-30s generation. A faster
|
||||
# model produces shorter, quicker outputs with acceptable layouts.
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Multimodal LangGraph agent — accepts image + document (PDF) attachments.
|
||||
|
||||
Wave 2b design: a *dedicated* vision-capable graph scoped to the
|
||||
`/demos/multimodal` cell. Other demos continue to use their own (cheaper,
|
||||
text-only) models — this keeps vision cost isolated to the one demo that
|
||||
exercises it.
|
||||
|
||||
Wire format the agent sees
|
||||
==========================
|
||||
Attachments arrive here after travelling through:
|
||||
|
||||
CopilotChat → AG-UI message content parts → @ag-ui/langgraph runtime
|
||||
(ag-ui → LangChain converter)
|
||||
→ this agent (LangChain HumanMessage content parts)
|
||||
|
||||
The ag-ui-langgraph converter only understands the legacy
|
||||
``{ type: "binary", mimeType, data | url }`` AG-UI part shape — the page
|
||||
at ``src/app/demos/multimodal/page.tsx`` installs an
|
||||
``onRunInitialized`` shim that rewrites the modern
|
||||
``{ type: "image" | "document", source: {...} }`` shape CopilotChat emits
|
||||
to the legacy shape before it hits the runtime. Once the converter has
|
||||
run, every attachment shows up in this agent as a LangChain
|
||||
``image_url`` content part::
|
||||
|
||||
{"type": "image_url", "image_url": {"url": "data:<mime>;base64,<payload>"}}
|
||||
|
||||
regardless of whether the upstream modality was ``image`` or ``document``.
|
||||
|
||||
We therefore route on ``mimeType``, not the part ``type``:
|
||||
``image/*`` parts are forwarded to GPT-4o unchanged (vision-native);
|
||||
``application/pdf`` parts are flattened to inline text via ``pypdf`` so
|
||||
the model can read them without needing file-part support.
|
||||
|
||||
References:
|
||||
- src/agents/main.py, src/agents/agentic_chat.py (baseline pattern)
|
||||
- packages/runtime/src/agent/converters/tanstack.ts (the modern content-
|
||||
part shape — useful context when the runtime gets upgraded and this
|
||||
agent can drop the pypdf flatten)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from pypdf import PdfReader
|
||||
|
||||
|
||||
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, base64-payload).
|
||||
|
||||
Returns ("", url) if the input is not a base64 data URL — callers can
|
||||
fall back to treating the url as a fetchable reference.
|
||||
"""
|
||||
if not url.startswith("data:"):
|
||||
return "", url
|
||||
header, _, payload = url.partition(",")
|
||||
# Header looks like "data:application/pdf;base64" — take the piece
|
||||
# between the colon and the first semicolon.
|
||||
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
|
||||
any failure so one malformed attachment doesn't tank the user turn —
|
||||
callers must treat the extracted text as best-effort."""
|
||||
try:
|
||||
raw = base64.b64decode(b64, validate=False)
|
||||
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
|
||||
# One log line so a malformed attachment stays triageable in
|
||||
# Railway logs without restoring the per-stage noise the
|
||||
# cleanup removed.
|
||||
print(f"[multimodal_agent] PDF extract failed: {exc!r}")
|
||||
return ""
|
||||
|
||||
|
||||
def _classify_attachment_part(part: Any) -> tuple[str, str, str] | None:
|
||||
"""Inspect a content part and return (kind, mime, base64_payload).
|
||||
|
||||
``kind`` is one of ``"image"``, ``"pdf"``, ``"other"``. Returns
|
||||
``None`` if the part is not an attachment we recognise (plain text,
|
||||
unrelated dict, string, etc.).
|
||||
|
||||
Handles the shapes we actually see in practice:
|
||||
|
||||
- ``{"type": "image_url", "image_url": {"url": "data:..."}}``
|
||||
(what the ag-ui-langgraph converter emits for every attachment
|
||||
after the page rewrites to legacy ``binary``).
|
||||
- ``{"type": "image_url", "image_url": "data:..."}``
|
||||
(older LangChain/OpenAI shape where ``image_url`` is a raw string).
|
||||
- ``{"type": "document", "source": {"type": "data",
|
||||
"value": "<base64>", "mimeType": "application/pdf"}}``
|
||||
(modern AG-UI shape — preserved for forward-compat if the runtime
|
||||
ever starts forwarding modern parts directly).
|
||||
"""
|
||||
if not isinstance(part, dict):
|
||||
return None
|
||||
part_type = part.get("type")
|
||||
|
||||
if part_type == "image_url":
|
||||
image_url = part.get("image_url")
|
||||
url: str | None = None
|
||||
if isinstance(image_url, str):
|
||||
url = image_url
|
||||
elif isinstance(image_url, dict):
|
||||
raw_url = image_url.get("url")
|
||||
if isinstance(raw_url, str):
|
||||
url = raw_url
|
||||
if not url:
|
||||
return None
|
||||
mime, payload = _extract_data_url_parts(url)
|
||||
if not payload or not mime:
|
||||
return None
|
||||
if mime.startswith("image/"):
|
||||
return ("image", mime, payload)
|
||||
if "pdf" in mime.lower():
|
||||
return ("pdf", mime, payload)
|
||||
return ("other", mime, payload)
|
||||
|
||||
if part_type == "document":
|
||||
source = part.get("source")
|
||||
if not isinstance(source, dict) or source.get("type") != "data":
|
||||
return None
|
||||
value = source.get("value")
|
||||
mime = source.get("mimeType", "")
|
||||
if not isinstance(value, str) or not isinstance(mime, str):
|
||||
return None
|
||||
if "pdf" in mime.lower():
|
||||
return ("pdf", mime, value)
|
||||
return ("other", mime, value)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _preprocess_part(part: Any) -> Any:
|
||||
"""Flatten PDF attachments to text; pass everything else through.
|
||||
|
||||
Images stay as-is so GPT-4o consumes them natively via its vision
|
||||
adapter. PDFs (which gpt-4o cannot read directly) become a text part
|
||||
prefixed with ``[Attached document]`` and the extracted body. If
|
||||
extraction fails we emit a structured placeholder so the model can
|
||||
tell the user the document was unreadable instead of pretending no
|
||||
attachment was sent.
|
||||
"""
|
||||
classified = _classify_attachment_part(part)
|
||||
if classified is None:
|
||||
return part
|
||||
kind, _mime, payload = classified
|
||||
if kind != "pdf":
|
||||
return part
|
||||
text = _extract_pdf_text(payload)
|
||||
if not text:
|
||||
return {
|
||||
"type": "text",
|
||||
"text": "[Attached document: PDF could not be read.]",
|
||||
}
|
||||
return {"type": "text", "text": f"[Attached document]\n{text}"}
|
||||
|
||||
|
||||
def _rewrite_messages(messages: list[Any]) -> list[Any]:
|
||||
"""Rewrite user messages so non-image attachments become text parts.
|
||||
|
||||
Operates on the messages list stored in agent state. Returns a *new*
|
||||
list; the input list is not mutated.
|
||||
"""
|
||||
rewritten: list[Any] = []
|
||||
for message in messages:
|
||||
# Only touch HumanMessage — assistant/tool messages stay as-is.
|
||||
if not isinstance(message, HumanMessage):
|
||||
rewritten.append(message)
|
||||
continue
|
||||
content = message.content
|
||||
if not isinstance(content, list):
|
||||
rewritten.append(message)
|
||||
continue
|
||||
new_parts = [_preprocess_part(part) for part in content]
|
||||
rewritten.append(HumanMessage(content=new_parts, id=message.id))
|
||||
return rewritten
|
||||
|
||||
|
||||
class _PdfFlattenMiddleware(AgentMiddleware):
|
||||
"""Flatten PDF content parts to text for the model call only.
|
||||
|
||||
Uses ``wrap_model_call`` instead of ``before_model`` so the PDF→text
|
||||
rewrite is scoped to the outgoing model request and never persists
|
||||
back into agent state. This matters because the agent state is
|
||||
streamed verbatim to the chat UI: if we mutated state with a
|
||||
``{"type": "text", "text": "[Attached document]\\n<pdf body>"}``
|
||||
part, the chat would render that flattened text inline in the user
|
||||
message bubble (in addition to the PDF chip preview the modern
|
||||
``document`` part already drives), turning a clean attachment chip
|
||||
into a wall of raw PDF text.
|
||||
|
||||
With ``wrap_model_call`` we copy the request, rewrite messages on
|
||||
the copy, hand the copy to the model, and return the model's
|
||||
response unchanged. The handler closure keeps state untouched.
|
||||
"""
|
||||
|
||||
def wrap_model_call(self, request, handler): # type: ignore[override]
|
||||
messages = list(request.messages) if request.messages else []
|
||||
rewritten = _rewrite_messages(messages)
|
||||
if rewritten == messages:
|
||||
return handler(request)
|
||||
return handler(request.override(messages=rewritten))
|
||||
|
||||
async def awrap_model_call(self, request, handler): # type: ignore[override]
|
||||
messages = list(request.messages) if request.messages else []
|
||||
rewritten = _rewrite_messages(messages)
|
||||
if rewritten == messages:
|
||||
return await handler(request)
|
||||
return await handler(request.override(messages=rewritten))
|
||||
|
||||
|
||||
# Vision-capable model. gpt-4o consumes `image_url` content parts natively.
|
||||
_MODEL = ChatOpenAI(model="gpt-5.4", temperature=0.2)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=_MODEL,
|
||||
tools=[],
|
||||
middleware=[_PdfFlattenMiddleware(), CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
|
||||
# Re-export under both names — `graph` matches the langgraph.json convention
|
||||
# used by the rest of the package; `multimodal_agent` is a friendlier alias
|
||||
# for any future non-langgraph.json import paths.
|
||||
multimodal_agent = graph
|
||||
|
||||
__all__ = ["graph", "multimodal_agent"]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""LangGraph agent for the Open-Ended Generative UI (Advanced) demo.
|
||||
|
||||
This is the "advanced" variant of the Open Generative UI demo. The key
|
||||
distinguishing feature: the agent-authored, sandboxed UI can invoke
|
||||
frontend-registered **sandbox functions** — functions the app defines on
|
||||
the host page (see `src/app/demos/open-gen-ui-advanced/sandbox-functions.ts`)
|
||||
and makes callable from inside the iframe via
|
||||
`await Websandbox.connection.remote.<name>(args)`.
|
||||
|
||||
How it works end-to-end:
|
||||
- The frontend passes `openGenerativeUI={{ sandboxFunctions }}` to the
|
||||
`CopilotKitProvider`. The provider injects a JSON descriptor of those
|
||||
functions into the agent context.
|
||||
- `CopilotKitMiddleware` here picks up both the frontend-registered
|
||||
`generateSandboxedUi` tool (auto-registered by the provider when OGUI
|
||||
is enabled on the runtime) AND the sandbox-function descriptors (via
|
||||
`copilotkit.context`), and merges them into what the LLM sees.
|
||||
- The LLM then generates HTML + JS that calls
|
||||
`Websandbox.connection.remote.<name>(...)` in response to user
|
||||
interactions.
|
||||
- The runtime's `OpenGenerativeUIMiddleware` converts the streaming
|
||||
`generateSandboxedUi` tool call into `open-generative-ui` activity
|
||||
events that the built-in renderer mounts inside a sandboxed iframe.
|
||||
- The renderer wires each `sandboxFunctions` entry as a `localApi`
|
||||
method on the websandbox connection so in-iframe code can call it.
|
||||
|
||||
The "minimal" sibling (`open_gen_ui_agent.py`) uses the same OGUI
|
||||
pipeline without sandbox functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are a UI-generating assistant for the Open Generative UI (Advanced) demo.
|
||||
|
||||
On every user turn you MUST call the `generateSandboxedUi` frontend tool
|
||||
exactly once. The generated UI must be INTERACTIVE and must invoke the
|
||||
available host-side sandbox functions described in your agent context
|
||||
(delivered via `copilotkit.context`) in response to user interactions.
|
||||
|
||||
Sandbox-function calling contract (inside the generated iframe):
|
||||
- Call a host function with:
|
||||
await Websandbox.connection.remote.<functionName>(args)
|
||||
The call returns a Promise; await it.
|
||||
- Each handler returns a plain object. Read the return shape from the
|
||||
function's description in your context and use the EXACT field names
|
||||
it returns (e.g. if the description says the handler returns
|
||||
`{ ok, value }`, read `res.value` — not `res.result`).
|
||||
- Descriptions, names, and JSON-schema parameter shapes for every
|
||||
available sandbox function are listed in your context. Read them
|
||||
carefully and wire at least one interactive UI element to call one.
|
||||
|
||||
Sandbox iframe restrictions (CRITICAL):
|
||||
- The iframe runs with `sandbox="allow-scripts"` ONLY. Forms are NOT
|
||||
allowed. You MUST NOT use `<form>` elements or `<button type="submit">`.
|
||||
Clicking a submit button inside a sandboxed form is blocked by the
|
||||
browser BEFORE any onsubmit handler runs, so the sandbox-function call
|
||||
never fires.
|
||||
- Use plain `<button type="button">` elements and wire them with
|
||||
`addEventListener('click', ...)` or an inline click handler. Do the same
|
||||
for "Enter" keypresses on inputs: attach a `keydown` listener that
|
||||
checks `e.key === 'Enter'` and calls your handler directly — do NOT
|
||||
wrap inputs in a `<form>`.
|
||||
|
||||
Generation guidance:
|
||||
- Emit `initialHeight` and `placeholderMessages` first, then CSS, then
|
||||
HTML, then `jsFunctions` / `jsExpressions` if helpful.
|
||||
- Always include a visible result element (e.g. an output div) that you
|
||||
UPDATE after the sandbox function resolves, so the user can *see* the
|
||||
round-trip: "Button clicked -> remote call -> visible result".
|
||||
- Use CDN scripts (Chart.js, D3, etc.) via <script> tags in the HTML head
|
||||
when you need libraries.
|
||||
- Do NOT use fetch/XHR, localStorage, or document.cookie — the sandbox has
|
||||
no same-origin access. ONLY use `Websandbox.connection.remote.*` for
|
||||
host-page interactions.
|
||||
- Keep your own chat message brief (1 sentence max); the rendered UI is
|
||||
the real output.
|
||||
"""
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4", model_kwargs={"parallel_tool_calls": False}),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Minimal LangGraph agent for the Open-Ended Generative UI demo.
|
||||
|
||||
The simplest possible example that exercises the open-ended generative UI
|
||||
pipeline. All the interesting work happens outside the agent:
|
||||
|
||||
- `CopilotKitMiddleware` merges the frontend-registered `generateSandboxedUi`
|
||||
tool (auto-registered by `CopilotKitProvider` when the runtime has
|
||||
`openGenerativeUI` enabled) into the agent's tool list. The LLM then sees
|
||||
the tool via the normal AG-UI flow.
|
||||
- When the LLM calls `generateSandboxedUi`, the runtime's
|
||||
`OpenGenerativeUIMiddleware` (enabled via `openGenerativeUI` on the
|
||||
runtime — see `src/app/api/copilotkit-ogui/route.ts`) converts that
|
||||
streaming tool call into `open-generative-ui` activity events that the
|
||||
built-in renderer mounts inside a sandboxed iframe.
|
||||
|
||||
This is the minimal variant: no sandbox functions, no app-side tools. The
|
||||
agent simply asks the LLM to design and emit a single-shot sandboxed UI.
|
||||
The "advanced" sibling (`open_gen_ui_advanced_agent.py`) builds on this
|
||||
with sandbox-to-host function calling via `openGenerativeUI.sandboxFunctions`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are a UI-generating assistant for an Open Generative UI demo
|
||||
focused on intricate, educational visualisations (3D axes / rotations,
|
||||
neural-network activations, sorting-algorithm walkthroughs, Fourier
|
||||
series, wave interference, planetary orbits, etc.).
|
||||
|
||||
On every user turn you MUST call the `generateSandboxedUi` frontend tool
|
||||
exactly once. Design a visually polished, self-contained HTML + CSS +
|
||||
SVG widget that *teaches* the requested concept.
|
||||
|
||||
The frontend injects a detailed "design skill" as agent context
|
||||
describing the palette, typography, labelling, and motion conventions
|
||||
expected — follow it closely. Key invariants:
|
||||
- Use inline SVG (or <canvas>) for geometric content, not stacks of <div>s.
|
||||
- Every axis is labelled; every colour-coded series has a legend.
|
||||
- Prefer CSS @keyframes / transitions over setInterval; loop cyclical
|
||||
concepts with animation-iteration-count: infinite.
|
||||
- Motion must teach — animate the actual step of the concept, not decoration.
|
||||
- No fetch / XHR / localStorage — the sandbox has no same-origin access.
|
||||
|
||||
Output order:
|
||||
- `initialHeight` (typically 480-560 for visualisations) first.
|
||||
- A short `placeholderMessages` array (2-3 lines describing the build).
|
||||
- `css` (complete).
|
||||
- `html` (streams live — keep it tidy). CDN <script> tags for Chart.js /
|
||||
D3 / etc. go inside the html.
|
||||
|
||||
Keep your own chat message brief (1 sentence) — the real output is the
|
||||
rendered visualisation.
|
||||
"""
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4", model_kwargs={"parallel_tool_calls": False}),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""LangGraph agent backing the Shared State (Agent Read-Only) demo.
|
||||
|
||||
Demonstrates the `useAgentContext` hook from @copilotkit/react-core/v2:
|
||||
the frontend provides READ-ONLY context *to* the agent. This is the
|
||||
reverse direction of writable-shared-state — the UI cannot be edited by
|
||||
the agent, but the agent reads this context on every turn via
|
||||
`CopilotKitMiddleware`, which routes the context entries into the
|
||||
model's message history.
|
||||
|
||||
No custom state, no tools: this is the minimal shape of the
|
||||
useAgentContext pattern. The agent just reads whatever context the
|
||||
frontend registered and answers accordingly.
|
||||
"""
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
# @region[agent-context-setup]
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=(
|
||||
"You are a helpful, concise assistant. The frontend may provide "
|
||||
"read-only context about the user (e.g. name, timezone, recent "
|
||||
"activity) via the `useAgentContext` hook. Always consult that "
|
||||
"context when it is relevant — address the user by name if known, "
|
||||
"respect their timezone when mentioning times, and reference "
|
||||
"recent activity when it helps you answer. Keep responses short."
|
||||
),
|
||||
)
|
||||
# @endregion[agent-context-setup]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Reasoning agent — emits AG-UI REASONING_MESSAGE_* events.
|
||||
|
||||
Shared by reasoning-custom (custom amber ReasoningBlock) and
|
||||
reasoning-default (CopilotKit's built-in reasoning slot).
|
||||
|
||||
Why a reasoning model + Responses API:
|
||||
The OpenAI Responses API streams `response.reasoning_summary_text.delta`
|
||||
items only for native reasoning models (gpt-5, o3, o4-mini, etc.).
|
||||
CopilotKit's bridge translates those into AG-UI REASONING_MESSAGE_*
|
||||
events with `role: "reasoning"`, which the frontend renders via the
|
||||
`reasoningMessage` slot. gpt-4o / gpt-4o-mini do not emit reasoning
|
||||
items, so a non-reasoning model would never light up the slot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
from langchain.chat_models import init_chat_model
|
||||
|
||||
from src.agents._header_forwarding_middleware import HeaderForwardingMiddleware
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant. For each user question, first think "
|
||||
"step-by-step about the approach, then give a concise answer."
|
||||
)
|
||||
|
||||
REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-5.4")
|
||||
|
||||
# No full CopilotKitMiddleware — this demo exercises only reasoning-token
|
||||
# streaming through the OpenAI Responses API and doesn't consume frontend
|
||||
# tools or app context. We still attach the minimal HeaderForwardingMiddleware
|
||||
# so the inbound ``x-aimock-context`` (and other ``x-*``) headers reach the
|
||||
# outgoing /v1/responses call; without it the LangGraph run swallows them
|
||||
# inside ``configurable`` and aimock 404s with no fixture match. The minimal
|
||||
# middleware does ONLY header propagation — no App-Context injection, no
|
||||
# tool-merging, no state-surfacing.
|
||||
graph = create_deep_agent(
|
||||
model=init_chat_model(
|
||||
f"openai:{REASONING_MODEL}",
|
||||
use_responses_api=True,
|
||||
reasoning={"effort": "medium", "summary": "detailed"},
|
||||
),
|
||||
tools=[],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
middleware=[HeaderForwardingMiddleware()],
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""LangGraph agent for the A2UI Error Recovery demo (OSS-158 / OSS-375).
|
||||
|
||||
Same dynamic-schema A2UI setup as `a2ui_dynamic.py` (declarative-gen-ui), but
|
||||
with the toolkit's validate->retry recovery loop made *visible*. The two
|
||||
aimock pills drive the inner `render_a2ui` sub-agent two ways:
|
||||
|
||||
- HEAL pill: the model emits FREE-FORM / sloppy A2UI args (components and data
|
||||
as JSON strings rather than structured arrays) — the toolkit heals them via
|
||||
`parse_and_fix` into a valid surface in a single pass, which paints. (A
|
||||
single deterministic response: no per-attempt fixture switching needed.)
|
||||
- EXHAUST pill: every attempt is structurally invalid (the root references a
|
||||
missing child), so the validate->retry loop hits the cap and the tool
|
||||
returns the `a2ui_recovery_exhausted` hard-fail envelope, which the renderer
|
||||
(`@ag-ui/a2ui-middleware`) surfaces as a tasteful `failed` state (no broken
|
||||
surface).
|
||||
|
||||
Backend-owned wiring: unlike the declarative-gen-ui demo (which relies on the
|
||||
CopilotKit runtime auto-injecting `generate_a2ui`), this agent OWNS the tool via
|
||||
`ag_ui_langgraph.get_a2ui_tools`, whose body runs the `render_a2ui` sub-agent +
|
||||
the toolkit recovery loop IN-GRAPH. The dedicated route sets
|
||||
`injectA2UITool: false` so the runtime does not inject a second copy. Only this
|
||||
backend-owned path surfaces the recovery loop + `a2ui_recovery_exhausted`
|
||||
hard-fail explicitly (the runtime auto-injection path has no equivalent loop).
|
||||
|
||||
Mirrors `showcase/integrations/google-adk/src/agents/recovery_agent.py` (the
|
||||
ADK sibling, which uses the singular `get_a2ui_tool`). Catalog is reused from
|
||||
declarative-gen-ui ("declarative-gen-ui-catalog") so no new components are
|
||||
introduced; the Vantage Threads sales dataset + composition rules arrive from
|
||||
the frontend via App Context (declarative-gen-ui/sales-context.ts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
from langchain.agents import create_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from ag_ui_langgraph import get_a2ui_tools
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log_attempt(record: dict) -> None:
|
||||
"""Dev observability: log each recovery attempt (incl. rejected ones)."""
|
||||
logger.info(
|
||||
"[a2ui recovery] attempt %s: %s %s",
|
||||
record.get("attempt"),
|
||||
"valid" if record.get("ok") else "invalid",
|
||||
record.get("errors"),
|
||||
)
|
||||
|
||||
|
||||
# Keep this aligned with the ADK `_INSTRUCTION` and the declarative-gen-ui
|
||||
# SYSTEM_PROMPT: a sales analyst that answers every question by drawing a
|
||||
# surface. `generate_a2ui` (owned by `get_a2ui_tools` below) handles the
|
||||
# rendering — and its automatic recovery — internally.
|
||||
SYSTEM_PROMPT = (
|
||||
"You are the embedded sales analyst for Vantage Threads, the fictional "
|
||||
"B2B apparel company described in your App Context. Answer every business "
|
||||
"question by calling `generate_a2ui` to draw a rich visual surface, and "
|
||||
"keep the chat reply to one short sentence. Ground every number in the "
|
||||
"sales dataset from your App Context. `generate_a2ui` handles the "
|
||||
"rendering — and its automatic recovery — for you."
|
||||
)
|
||||
|
||||
_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o")
|
||||
|
||||
# Backend-owned A2UI with the recovery loop made explicit. `maxAttempts` is set
|
||||
# so the renderer's "Retrying… (N/M)" label matches the adapter's cap. Recovery
|
||||
# + the recovery-exhausted hard-fail are toolkit defaults; pinned here for the
|
||||
# demo. Catalog/data arrive from the frontend via context (same as declarative).
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model=_MODEL),
|
||||
tools=[
|
||||
get_a2ui_tools(
|
||||
{
|
||||
"model": ChatOpenAI(model=_MODEL),
|
||||
"default_catalog_id": "declarative-gen-ui-catalog",
|
||||
"recovery": {"maxAttempts": 3},
|
||||
"on_a2ui_attempt": _log_attempt,
|
||||
}
|
||||
)
|
||||
],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""LangGraph agent backing the Shared State (Read + Write) demo.
|
||||
|
||||
Demonstrates the full bidirectional shared-state pattern between UI and
|
||||
agent:
|
||||
|
||||
- **UI -> agent (write)**: The UI owns a `preferences` object (the user's
|
||||
profile) that it writes into agent state via `agent.setState(...)`. A
|
||||
middleware reads those preferences every turn and injects them into
|
||||
the system prompt, so the LLM adapts accordingly.
|
||||
- **agent -> UI (read)**: The agent can call `set_notes` to update a
|
||||
`notes` slot in shared state. The UI reflects every update in real
|
||||
time via `useAgent(...)`.
|
||||
|
||||
Together this shows the canonical LangGraph-Python bidirectional shared
|
||||
state: frontend writes, backend reads AND writes, frontend re-renders.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any, Awaitable, Callable, TypedDict
|
||||
|
||||
from langchain.agents import AgentState as BaseAgentState, create_agent
|
||||
from langchain.agents.middleware import (
|
||||
AgentMiddleware,
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
)
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_core.messages import SystemMessage, ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import Command
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
|
||||
class Preferences(TypedDict, total=False):
|
||||
name: str
|
||||
tone: str # "formal" | "casual" | "playful"
|
||||
language: str # "English", "Spanish", ...
|
||||
interests: list[str]
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
"""Bidirectional shared state between UI and agent.
|
||||
|
||||
- `preferences` is written by the UI (via agent.setState).
|
||||
- `notes` is written by the agent (via the `set_notes` tool) and
|
||||
read by the UI.
|
||||
"""
|
||||
|
||||
preferences: Preferences
|
||||
notes: list[str]
|
||||
|
||||
|
||||
@tool
|
||||
def set_notes(notes: list[str], runtime: ToolRuntime) -> Command:
|
||||
"""Replace the notes array in shared state with the full updated list.
|
||||
|
||||
Use this tool whenever the user asks you to "remember" something, or
|
||||
when you have an observation about the user worth surfacing in the
|
||||
UI's notes panel. Always pass the FULL notes list (existing notes +
|
||||
any new ones), not a diff. Keep each note short (< 120 chars).
|
||||
"""
|
||||
return Command(
|
||||
update={
|
||||
"notes": notes,
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content="Notes updated.",
|
||||
name="set_notes",
|
||||
id=str(uuid.uuid4()),
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PreferencesInjectorMiddleware(AgentMiddleware[AgentState, Any]):
|
||||
"""Injects the UI-supplied `preferences` into the system prompt.
|
||||
|
||||
Every turn, we read the latest `preferences` from agent state and
|
||||
prepend a SystemMessage that tells the LLM about them. This is how
|
||||
UI-written state becomes visible to the agent.
|
||||
"""
|
||||
|
||||
state_schema = AgentState
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "PreferencesInjectorMiddleware"
|
||||
|
||||
def _build_prefs_message(self, prefs: Preferences) -> SystemMessage | None:
|
||||
if not prefs:
|
||||
return None
|
||||
lines = ["The user has shared these preferences with you:"]
|
||||
if prefs.get("name"):
|
||||
lines.append(f"- Name: {prefs['name']}")
|
||||
if prefs.get("tone"):
|
||||
lines.append(f"- Preferred tone: {prefs['tone']}")
|
||||
if prefs.get("language"):
|
||||
lines.append(f"- Preferred language: {prefs['language']}")
|
||||
interests = prefs.get("interests") or []
|
||||
if interests:
|
||||
lines.append(f"- Interests: {', '.join(interests)}")
|
||||
lines.append(
|
||||
"Tailor every response to these preferences. Address the user "
|
||||
"by name when appropriate."
|
||||
)
|
||||
return SystemMessage(content="\n".join(lines))
|
||||
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse:
|
||||
prefs = request.state.get("preferences") or {}
|
||||
prefs_message = self._build_prefs_message(prefs)
|
||||
if prefs_message is None:
|
||||
return handler(request)
|
||||
return handler(request.override(messages=[prefs_message, *request.messages]))
|
||||
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse:
|
||||
prefs = request.state.get("preferences") or {}
|
||||
prefs_message = self._build_prefs_message(prefs)
|
||||
if prefs_message is None:
|
||||
return await handler(request)
|
||||
return await handler(
|
||||
request.override(messages=[prefs_message, *request.messages])
|
||||
)
|
||||
|
||||
|
||||
# @region[shared-state-setup]
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[set_notes],
|
||||
middleware=[CopilotKitMiddleware(), PreferencesInjectorMiddleware()],
|
||||
state_schema=AgentState,
|
||||
system_prompt=(
|
||||
"You are a helpful, concise assistant. "
|
||||
"The user's preferences are supplied via shared state and will be "
|
||||
"added as a system message at the start of every turn. Always "
|
||||
"respect them. "
|
||||
"When the user asks you to remember something, or when you observe "
|
||||
"something worth surfacing in the UI, call `set_notes` with the "
|
||||
"FULL updated list of short note strings (existing notes + new)."
|
||||
),
|
||||
)
|
||||
# @endregion[shared-state-setup]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""LangGraph agent backing the State Streaming demo.
|
||||
|
||||
Demonstrates per-token state-delta streaming. The agent writes a long
|
||||
`document` string into shared agent state via a `write_document` tool;
|
||||
`StateStreamingMiddleware(StateItem(...))` tells CopilotKit to forward
|
||||
*every token* of the tool's `content` argument directly into the
|
||||
`document` state key as it is generated. The UI (useAgent) sees
|
||||
`state.document` grow token-by-token, without waiting for the tool call
|
||||
to finish.
|
||||
|
||||
This is the canonical per-token state-streaming pattern:
|
||||
docs.copilotkit.ai/integrations/langgraph/shared-state/predictive-state-updates
|
||||
"""
|
||||
|
||||
# @region[state-streaming-middleware]
|
||||
import uuid
|
||||
|
||||
from langchain.agents import AgentState as BaseAgentState, create_agent
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import Command
|
||||
|
||||
from copilotkit import (
|
||||
CopilotKitMiddleware,
|
||||
StateItem,
|
||||
StateStreamingMiddleware,
|
||||
)
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
"""Shared state. `document` is streamed token-by-token."""
|
||||
|
||||
document: str
|
||||
|
||||
|
||||
@tool
|
||||
def write_document(document: str, runtime: ToolRuntime) -> Command:
|
||||
"""Write a document for the user.
|
||||
|
||||
Always call this tool when the user asks you to write or draft
|
||||
something of any length (an essay, poem, email, summary, etc.).
|
||||
The `document` argument is streamed *per token* into shared agent
|
||||
state under the `document` key, so the UI can render it as it is
|
||||
generated.
|
||||
"""
|
||||
return Command(
|
||||
update={
|
||||
"document": document,
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content="Document written to shared state.",
|
||||
name="write_document",
|
||||
id=str(uuid.uuid4()),
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[write_document],
|
||||
middleware=[
|
||||
CopilotKitMiddleware(),
|
||||
# Forward every token of write_document's `document` argument
|
||||
# straight into state["document"] while the tool call is still
|
||||
# streaming. Without this, `document` would only update once
|
||||
# the tool call completes.
|
||||
#
|
||||
# NOTE: the frontend `usePredictStateSubscription` hook indexes
|
||||
# the (partial-JSON-parsed) tool args by `state_key`, so the
|
||||
# tool's argument name MUST match `state_key` ("document") for
|
||||
# per-token deltas to land in `state.document`.
|
||||
StateStreamingMiddleware(
|
||||
StateItem(
|
||||
state_key="document",
|
||||
tool="write_document",
|
||||
tool_argument="document",
|
||||
)
|
||||
),
|
||||
],
|
||||
state_schema=AgentState,
|
||||
system_prompt=(
|
||||
"You are a collaborative writing assistant. Whenever the user asks "
|
||||
"you to write, draft, or revise any piece of text, ALWAYS call the "
|
||||
"`write_document` tool with the full content as a single string in "
|
||||
"the `document` argument. Never paste the document into a chat "
|
||||
"message directly — the document belongs in shared state and the "
|
||||
"UI renders it live as you type."
|
||||
),
|
||||
)
|
||||
# @endregion[state-streaming-middleware]
|
||||
@@ -0,0 +1,299 @@
|
||||
"""LangGraph agent backing the Sub-Agents demo.
|
||||
|
||||
Demonstrates multi-agent delegation with a visible delegation log.
|
||||
|
||||
A top-level "supervisor" LLM orchestrates three specialized sub-agents,
|
||||
exposed as tools:
|
||||
|
||||
- `research_agent` — gathers facts
|
||||
- `writing_agent` — drafts prose
|
||||
- `critique_agent` — reviews drafts
|
||||
|
||||
Each sub-agent is a full `create_agent(...)` under the hood. Every
|
||||
delegation appends an entry to the `delegations` slot in shared agent
|
||||
state so the UI can render a live "delegation log" as the supervisor
|
||||
fans work out and collects results. This is the canonical LangGraph
|
||||
sub-agents-as-tools pattern, adapted to surface delegation events to
|
||||
the frontend via CopilotKit's shared-state channel.
|
||||
"""
|
||||
|
||||
# @region[supervisor-delegation-tools]
|
||||
# @region[subagent-setup]
|
||||
import operator
|
||||
import uuid
|
||||
from typing import Annotated, Literal, TypedDict
|
||||
|
||||
from langchain.agents import AgentState as BaseAgentState, create_agent
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.types import Command
|
||||
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
from src.agents._header_forwarding_middleware import HeaderForwardingMiddleware
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Delegation(TypedDict):
|
||||
id: str
|
||||
sub_agent: Literal["research_agent", "writing_agent", "critique_agent"]
|
||||
task: str
|
||||
status: Literal["completed"]
|
||||
result: str
|
||||
|
||||
|
||||
# Cap the supervisor → critique sub-agent loop at a single iteration.
|
||||
# Without this, the supervisor LLM occasionally re-calls `critique_agent`
|
||||
# repeatedly on the same draft (visible as stacking 🧐 cards in the
|
||||
# chat). The critic only adds value once per draft, so we hard-stop
|
||||
# after `_MAX_CRITIQUE_ITERATIONS` invocations and return a no-op
|
||||
# result.
|
||||
_MAX_CRITIQUE_ITERATIONS = 1
|
||||
|
||||
|
||||
class AgentState(BaseAgentState):
|
||||
"""Shared state. `delegations` is rendered as a live log in the UI.
|
||||
|
||||
`delegations` uses an `operator.add` reducer so that concurrent
|
||||
sub-agent emissions in the same supervisor step accumulate into a
|
||||
single list instead of conflicting (LangGraph would otherwise raise
|
||||
`INVALID_CONCURRENT_GRAPH_UPDATE` — "Can receive only one value per
|
||||
step. Use an Annotated key to handle multiple values.").
|
||||
"""
|
||||
|
||||
delegations: Annotated[list[Delegation], operator.add]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-agents (real LLM agents under the hood)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Each sub-agent is a full-fledged `create_agent(...)` with its own
|
||||
# system prompt. They don't share memory or tools with the supervisor —
|
||||
# the supervisor only sees their return value.
|
||||
_sub_model = ChatOpenAI(model="gpt-5.4")
|
||||
|
||||
# Each sub-agent gets the minimal HeaderForwardingMiddleware so the
|
||||
# inbound x-aimock-context (and other x-*) headers from the supervisor's
|
||||
# inbound HTTP request propagate to the sub-agent's outbound LLM call.
|
||||
# We intentionally do NOT attach the full CopilotKitMiddleware here —
|
||||
# the supervisor handles App-Context / frontend-tool injection for the
|
||||
# whole run, and adding it on sub-agents would double-inject prompt
|
||||
# state. Header forwarding alone is enough to keep aimock matching.
|
||||
_research_agent = create_agent(
|
||||
model=_sub_model,
|
||||
tools=[],
|
||||
system_prompt=(
|
||||
"You are a research sub-agent. Given a topic, produce a concise "
|
||||
"bulleted list of 3-5 key facts. No preamble, no closing."
|
||||
),
|
||||
middleware=[HeaderForwardingMiddleware()],
|
||||
)
|
||||
|
||||
_writing_agent = create_agent(
|
||||
model=_sub_model,
|
||||
tools=[],
|
||||
system_prompt=(
|
||||
"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."
|
||||
),
|
||||
middleware=[HeaderForwardingMiddleware()],
|
||||
)
|
||||
|
||||
_critique_agent = create_agent(
|
||||
model=_sub_model,
|
||||
tools=[],
|
||||
system_prompt=(
|
||||
"You are an editorial critique sub-agent. Given a draft, give "
|
||||
"2-3 crisp, actionable critiques. No preamble."
|
||||
),
|
||||
middleware=[HeaderForwardingMiddleware()],
|
||||
)
|
||||
# @endregion[subagent-setup]
|
||||
|
||||
|
||||
# Sentinel surfaced when a sub-agent run produces no usable text. Kept
|
||||
# as a module-level constant so the harness probe (and any UI fallback)
|
||||
# can match the exact phrase. The leading/trailing angle brackets keep
|
||||
# it out of plausible LLM phrasing.
|
||||
SUB_AGENT_EMPTY_SENTINEL = "<sub-agent produced no output>"
|
||||
|
||||
|
||||
def _invoke_sub_agent(agent, task: str) -> str:
|
||||
"""Run a sub-agent on `task` and return its final prose message."""
|
||||
result = agent.invoke({"messages": [HumanMessage(content=task)]})
|
||||
messages = result.get("messages", [])
|
||||
# Walk newest -> oldest so we pick the answer for THIS task, not a stale
|
||||
# intro. Skip empty AIMessages that only carry tool_calls.
|
||||
for msg in reversed(messages):
|
||||
if isinstance(msg, AIMessage):
|
||||
content = msg.content
|
||||
if isinstance(content, str) and content.strip():
|
||||
return content
|
||||
# Some providers stream content as a list of content blocks
|
||||
# (e.g. {"type": "text", "text": "..."}); concatenate the text.
|
||||
# The `isinstance(block.get("text"), str)` guard rejects
|
||||
# `{"type": "text", "text": null}` payloads — a known provider
|
||||
# quirk — that would otherwise crash `"".join(...)` with
|
||||
# `TypeError: sequence item N: expected str instance, NoneType found`.
|
||||
if isinstance(content, list):
|
||||
parts = [
|
||||
block["text"]
|
||||
for block in content
|
||||
if isinstance(block, dict)
|
||||
and block.get("type") == "text"
|
||||
and isinstance(block.get("text"), str)
|
||||
]
|
||||
joined = "".join(parts).strip()
|
||||
if joined:
|
||||
return joined
|
||||
return SUB_AGENT_EMPTY_SENTINEL
|
||||
|
||||
|
||||
def _delegation_update(
|
||||
sub_agent: str,
|
||||
task: str,
|
||||
result: str,
|
||||
tool_call_id: str,
|
||||
) -> Command:
|
||||
"""Append a completed delegation entry to shared state.
|
||||
|
||||
Returns just the new entry (a one-element list). The reducer on
|
||||
`AgentState.delegations` is `operator.add`, which concatenates the
|
||||
new list with the prior state — so we must NOT echo back the
|
||||
existing delegations here, or they would be duplicated each step.
|
||||
"""
|
||||
entry: Delegation = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"sub_agent": sub_agent, # type: ignore[typeddict-item]
|
||||
"task": task,
|
||||
"status": "completed",
|
||||
"result": result,
|
||||
}
|
||||
return Command(
|
||||
update={
|
||||
"delegations": [entry],
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content=result,
|
||||
name=sub_agent,
|
||||
id=str(uuid.uuid4()),
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor tools (each tool delegates to one sub-agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Each @tool wraps a sub-agent invocation. The supervisor LLM "calls"
|
||||
# these tools 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 a ToolMessage the supervisor can
|
||||
# read on its next step.
|
||||
@tool
|
||||
def research_agent(task: str, runtime: ToolRuntime) -> Command:
|
||||
"""Delegate a research task to the research sub-agent.
|
||||
|
||||
Use for: gathering facts, background, definitions, statistics.
|
||||
Returns a bulleted list of key facts.
|
||||
"""
|
||||
result = _invoke_sub_agent(_research_agent, task)
|
||||
return _delegation_update("research_agent", task, result, runtime.tool_call_id)
|
||||
|
||||
|
||||
@tool
|
||||
def writing_agent(task: str, runtime: ToolRuntime) -> Command:
|
||||
"""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`.
|
||||
"""
|
||||
result = _invoke_sub_agent(_writing_agent, task)
|
||||
return _delegation_update("writing_agent", task, result, runtime.tool_call_id)
|
||||
|
||||
|
||||
@tool
|
||||
def critique_agent(task: str, runtime: ToolRuntime) -> Command:
|
||||
"""Delegate a critique task to the critique sub-agent.
|
||||
|
||||
Use for: reviewing a draft and suggesting concrete improvements.
|
||||
|
||||
Capped at `_MAX_CRITIQUE_ITERATIONS` invocations per supervisor run
|
||||
— the supervisor LLM occasionally re-calls the critic in a loop and
|
||||
each rerun produces near-identical output, so additional calls are
|
||||
short-circuited with a no-op result that nudges the supervisor to
|
||||
finish.
|
||||
"""
|
||||
state: AgentState = runtime.state # type: ignore[assignment]
|
||||
delegations = state.get("delegations") or []
|
||||
prior_critiques = sum(
|
||||
1 for d in delegations if d.get("sub_agent") == "critique_agent"
|
||||
)
|
||||
if prior_critiques >= _MAX_CRITIQUE_ITERATIONS:
|
||||
# Short-circuit without appending another delegation entry — the
|
||||
# UI renders one card per delegation and we want exactly one
|
||||
# critic card per supervisor run, even if the LLM ignores the
|
||||
# system prompt and re-issues the call.
|
||||
skip_message = (
|
||||
"Critique already produced for this run. "
|
||||
"Stop calling critique_agent and return your final answer "
|
||||
"to the user now."
|
||||
)
|
||||
return Command(
|
||||
update={
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
content=skip_message,
|
||||
name="critique_agent",
|
||||
id=str(uuid.uuid4()),
|
||||
tool_call_id=runtime.tool_call_id,
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
result = _invoke_sub_agent(_critique_agent, task)
|
||||
return _delegation_update("critique_agent", task, result, runtime.tool_call_id)
|
||||
|
||||
|
||||
# @endregion[supervisor-delegation-tools]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor (the graph we export)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
graph = create_agent(
|
||||
model=ChatOpenAI(model="gpt-5.4"),
|
||||
tools=[research_agent, writing_agent, critique_agent],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
state_schema=AgentState,
|
||||
system_prompt=(
|
||||
"You are a supervisor agent that coordinates three specialized "
|
||||
"sub-agents to produce high-quality deliverables.\n\n"
|
||||
"Available sub-agents (call them as tools):\n"
|
||||
" - research_agent: gathers facts on a topic.\n"
|
||||
" - writing_agent: turns facts + a brief into a polished draft.\n"
|
||||
" - critique_agent: reviews a draft and suggests improvements.\n\n"
|
||||
"For every non-trivial user request, delegate in sequence: "
|
||||
"research_agent -> writing_agent -> critique_agent. "
|
||||
"IMPORTANT: call EACH sub-agent EXACTLY ONCE per user request. "
|
||||
"After critique_agent returns, do NOT call any sub-agent "
|
||||
"again — return a concise final answer to the user that "
|
||||
"incorporates the critique. Pass the relevant facts/draft "
|
||||
"through the `task` argument of each tool. Keep your own "
|
||||
"messages short — explain the plan once, delegate, then return "
|
||||
"a concise summary once done. The UI shows the user a live log "
|
||||
"of every sub-agent delegation."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Regression test for A2UI tool naming.
|
||||
|
||||
History: beautiful_chat.py and a2ui_dynamic.py used to hand-roll their own
|
||||
dynamic-A2UI tool with an internal secondary-LLM helper named
|
||||
`_design_a2ui_surface`. The rename away from `render_a2ui` was forced because
|
||||
`@ag-ui/a2ui-middleware`'s default `a2uiToolNames` is `["render_a2ui"]` — any
|
||||
tool by that name has its streaming args parsed into A2UI surface ops by the
|
||||
middleware, bypassing the tool's own body.
|
||||
|
||||
Both agents now use the canonical path instead: the runtime injects
|
||||
`generate_a2ui` (`a2ui.injectA2UITool: true`) and the `get_a2ui_tools` factory's
|
||||
inner `render_a2ui` tool is intentionally intercepted by the middleware — that
|
||||
interception IS the supported render mechanism, and catalog force-pinning /
|
||||
malformed-root handling now live inside the factory (ag_ui_langgraph), not here.
|
||||
So the old hand-rolled internal tool no longer exists to regression-test.
|
||||
|
||||
What still matters: the FIXED-schema demo (a2ui_fixed.py) owns an OUTER tool the
|
||||
primary agent calls directly. If it were ever named into the middleware's
|
||||
intercept list, the fixed-schema surface would render from raw streaming args
|
||||
instead of the tool's validated output — the same class of bug. Guard that name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# `display_flight` is the OUTER tool the primary agent calls in the fixed-schema
|
||||
# demo. Its name must stay out of the middleware's intercept list.
|
||||
from src.agents.a2ui_fixed import display_flight as a2ui_fixed_display_flight
|
||||
|
||||
|
||||
# A2UI middleware's default intercept list (mirrors `RENDER_A2UI_TOOL_NAME` in
|
||||
# `@ag-ui/a2ui-middleware`). Any tool here gets its streaming args parsed as
|
||||
# A2UI surface ops, bypassing the tool's own body.
|
||||
A2UI_MIDDLEWARE_INTERCEPTED_NAMES = {"render_a2ui"}
|
||||
|
||||
|
||||
def test_a2ui_fixed_display_flight_name_unchanged():
|
||||
"""`display_flight` is the OUTER tool the primary agent calls. Renaming it
|
||||
to anything in the middleware's intercept list would break the fixed-schema
|
||||
demo by rendering from raw streaming args instead of the tool's output."""
|
||||
assert a2ui_fixed_display_flight.name not in A2UI_MIDDLEWARE_INTERCEPTED_NAMES, (
|
||||
f"a2ui_fixed.display_flight is named {a2ui_fixed_display_flight.name!r}, "
|
||||
f"which collides with the A2UI middleware's intercept list."
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Unit tests for agent_config_agent's prompt builder + defensive defaults."""
|
||||
|
||||
from src.agents.agent_config_agent import (
|
||||
DEFAULT_EXPERTISE,
|
||||
DEFAULT_RESPONSE_LENGTH,
|
||||
DEFAULT_TONE,
|
||||
build_system_prompt,
|
||||
read_properties,
|
||||
)
|
||||
|
||||
|
||||
def test_read_properties_returns_defaults_when_missing():
|
||||
result = read_properties(None)
|
||||
assert result == {
|
||||
"tone": DEFAULT_TONE,
|
||||
"expertise": DEFAULT_EXPERTISE,
|
||||
"response_length": DEFAULT_RESPONSE_LENGTH,
|
||||
}
|
||||
|
||||
|
||||
def test_read_properties_returns_defaults_when_configurable_missing():
|
||||
result = read_properties({})
|
||||
assert result == {
|
||||
"tone": DEFAULT_TONE,
|
||||
"expertise": DEFAULT_EXPERTISE,
|
||||
"response_length": DEFAULT_RESPONSE_LENGTH,
|
||||
}
|
||||
|
||||
|
||||
def test_read_properties_returns_defaults_when_properties_missing():
|
||||
result = read_properties({"configurable": {}})
|
||||
assert result == {
|
||||
"tone": DEFAULT_TONE,
|
||||
"expertise": DEFAULT_EXPERTISE,
|
||||
"response_length": DEFAULT_RESPONSE_LENGTH,
|
||||
}
|
||||
|
||||
|
||||
def test_read_properties_accepts_valid_values():
|
||||
result = read_properties(
|
||||
{
|
||||
"configurable": {
|
||||
"properties": {
|
||||
"tone": "enthusiastic",
|
||||
"expertise": "expert",
|
||||
"responseLength": "detailed",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
assert result == {
|
||||
"tone": "enthusiastic",
|
||||
"expertise": "expert",
|
||||
"response_length": "detailed",
|
||||
}
|
||||
|
||||
|
||||
def test_read_properties_rejects_invalid_tone_to_default():
|
||||
result = read_properties({"configurable": {"properties": {"tone": "sinister"}}})
|
||||
assert result["tone"] == DEFAULT_TONE
|
||||
|
||||
|
||||
def test_read_properties_rejects_invalid_expertise_to_default():
|
||||
result = read_properties({"configurable": {"properties": {"expertise": "ninja"}}})
|
||||
assert result["expertise"] == DEFAULT_EXPERTISE
|
||||
|
||||
|
||||
def test_read_properties_rejects_invalid_length_to_default():
|
||||
result = read_properties(
|
||||
{"configurable": {"properties": {"responseLength": "epic"}}}
|
||||
)
|
||||
assert result["response_length"] == DEFAULT_RESPONSE_LENGTH
|
||||
|
||||
|
||||
def test_read_properties_mixes_valid_and_invalid():
|
||||
result = read_properties(
|
||||
{
|
||||
"configurable": {
|
||||
"properties": {
|
||||
"tone": "casual",
|
||||
"expertise": "unknown",
|
||||
"responseLength": "detailed",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
assert result == {
|
||||
"tone": "casual",
|
||||
"expertise": DEFAULT_EXPERTISE,
|
||||
"response_length": "detailed",
|
||||
}
|
||||
|
||||
|
||||
def test_build_system_prompt_mentions_each_axis():
|
||||
prompt = build_system_prompt("casual", "expert", "detailed")
|
||||
assert "Tone:" in prompt
|
||||
assert "Expertise level:" in prompt
|
||||
assert "Response length:" in prompt
|
||||
assert "friendly" in prompt.lower()
|
||||
assert "technical fluency" in prompt.lower()
|
||||
assert "multiple paragraphs" in prompt.lower()
|
||||
|
||||
|
||||
def test_build_system_prompt_professional_beginner_concise():
|
||||
prompt = build_system_prompt("professional", "beginner", "concise")
|
||||
assert "neutral, precise language" in prompt.lower()
|
||||
assert "assume no prior knowledge" in prompt.lower()
|
||||
assert "1-3 sentences" in prompt.lower()
|
||||
|
||||
|
||||
def test_build_system_prompt_enthusiastic_intermediate_concise():
|
||||
prompt = build_system_prompt("enthusiastic", "intermediate", "concise")
|
||||
assert "upbeat" in prompt.lower()
|
||||
assert "specialized terms" in prompt.lower()
|
||||
assert "1-3 sentences" in prompt.lower()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
LangGraph agent for the CopilotKit Tool Rendering demos.
|
||||
|
||||
Backs the three tool-rendering cells:
|
||||
- tool-rendering-default-catchall (no frontend renderers)
|
||||
- tool-rendering-custom-catchall (wildcard renderer on frontend)
|
||||
- tool-rendering (per-tool + catch-all on frontend)
|
||||
|
||||
All three share this backend — they differ only in how the frontend
|
||||
renders the same tool calls. The `tool-rendering-reasoning-chain` cell
|
||||
has its own agent (`tool_rendering_reasoning_chain_agent.py`) because
|
||||
it routes through the OpenAI Responses API for reasoning streaming.
|
||||
"""
|
||||
|
||||
# @region[weather-tool-backend]
|
||||
from random import choice, randint
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from copilotkit import CopilotKitMiddleware
|
||||
|
||||
# Multi-tool-per-question prompt.
|
||||
#
|
||||
# This backend serves the tool-rendering demos, whose JOB is to show the
|
||||
# rendering patterns (per-tool, catch-all, default fallback). The agent
|
||||
# may call multiple tools per turn when the user asks for them. The
|
||||
# `roll_d20` tool accepts a deterministic `value` parameter so the
|
||||
# aimock fixtures can script the exact dice sequence the e2e tests
|
||||
# assert against.
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a travel & lifestyle concierge. Use the mock tools for "
|
||||
"weather, flights, stock prices, or d20 rolls when the user asks; "
|
||||
"otherwise reply in plain text. For flights, default origin to 'SFO' "
|
||||
"if the user only names a destination. Call multiple tools in one "
|
||||
"turn if asked. After tools return, summarize in one short sentence. "
|
||||
"Never fabricate data a tool could provide."
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: str) -> dict:
|
||||
"""Get the current weather for a given location."""
|
||||
return {
|
||||
"city": location,
|
||||
"temperature": 68,
|
||||
"humidity": 55,
|
||||
"wind_speed": 10,
|
||||
"conditions": "Sunny",
|
||||
}
|
||||
|
||||
|
||||
# @endregion[weather-tool-backend]
|
||||
|
||||
|
||||
@tool
|
||||
def search_flights(origin: str, destination: str) -> dict:
|
||||
"""Search mock flights from an origin airport to a destination airport."""
|
||||
return {
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"flights": [
|
||||
{
|
||||
"airline": "United",
|
||||
"flight": "UA231",
|
||||
"depart": "08:15",
|
||||
"arrive": "16:45",
|
||||
"price_usd": 348,
|
||||
},
|
||||
{
|
||||
"airline": "Delta",
|
||||
"flight": "DL412",
|
||||
"depart": "11:20",
|
||||
"arrive": "19:55",
|
||||
"price_usd": 312,
|
||||
},
|
||||
{
|
||||
"airline": "JetBlue",
|
||||
"flight": "B6722",
|
||||
"depart": "17:05",
|
||||
"arrive": "01:30",
|
||||
"price_usd": 289,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def get_stock_price(
|
||||
ticker: str,
|
||||
price_usd: float | None = None,
|
||||
change_pct: float | None = None,
|
||||
) -> dict:
|
||||
"""Get a mock current price for a stock ticker.
|
||||
|
||||
The optional `price_usd` and `change_pct` arguments let the LLM (or
|
||||
aimock fixture) script a deterministic ticker quote for testing —
|
||||
when supplied, the tool echoes them back verbatim. When omitted (or
|
||||
`None`), the tool returns mock random values. Mirrors the
|
||||
deterministic-`value` pattern on `roll_d20`.
|
||||
"""
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": (
|
||||
round(float(price_usd), 2)
|
||||
if price_usd is not None
|
||||
else round(100 + randint(0, 400) + randint(0, 99) / 100, 2)
|
||||
),
|
||||
"change_pct": (
|
||||
round(float(change_pct), 2)
|
||||
if change_pct is not None
|
||||
else round(choice([-1, 1]) * (randint(0, 300) / 100), 2)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def roll_d20(value: int = 0) -> dict:
|
||||
"""Roll a 20-sided die.
|
||||
|
||||
The `value` argument lets the LLM (or aimock fixture) script a
|
||||
deterministic roll for testing — the tool simply echoes it back as
|
||||
the result. When called without `value` (or with 0), the tool
|
||||
returns a random natural d20 roll.
|
||||
"""
|
||||
rolled = value if isinstance(value, int) and 1 <= value <= 20 else randint(1, 20)
|
||||
return {"sides": 20, "value": rolled, "result": rolled}
|
||||
|
||||
|
||||
model = ChatOpenAI(model="gpt-5.4")
|
||||
|
||||
graph = create_agent(
|
||||
model=model,
|
||||
tools=[get_weather, search_flights, get_stock_price, roll_d20],
|
||||
middleware=[CopilotKitMiddleware()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""Tool Rendering (Reasoning Chain) — minimal deep agent with tools.
|
||||
|
||||
Routes through a reasoning-capable OpenAI model via the Responses API
|
||||
so the chain of thought streams as AG-UI REASONING_MESSAGE_* events
|
||||
alongside the tool calls. See `reasoning_agent.py` for the rationale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from random import choice, randint
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
from langchain.chat_models import init_chat_model
|
||||
from langchain.tools import tool
|
||||
|
||||
from src.agents._header_forwarding_middleware import HeaderForwardingMiddleware
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: str) -> dict:
|
||||
"""Get the current weather for a given location."""
|
||||
return {
|
||||
"city": location,
|
||||
"temperature": 68,
|
||||
"humidity": 55,
|
||||
"wind_speed": 10,
|
||||
"conditions": "Sunny",
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def search_flights(origin: str, destination: str) -> dict:
|
||||
"""Search mock flights from an origin airport to a destination airport."""
|
||||
return {
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"flights": [
|
||||
{
|
||||
"airline": "United",
|
||||
"flight": "UA231",
|
||||
"depart": "08:15",
|
||||
"arrive": "16:45",
|
||||
"price_usd": 348,
|
||||
},
|
||||
{
|
||||
"airline": "Delta",
|
||||
"flight": "DL412",
|
||||
"depart": "11:20",
|
||||
"arrive": "19:55",
|
||||
"price_usd": 312,
|
||||
},
|
||||
{
|
||||
"airline": "JetBlue",
|
||||
"flight": "B6722",
|
||||
"depart": "17:05",
|
||||
"arrive": "01:30",
|
||||
"price_usd": 289,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def get_stock_price(
|
||||
ticker: str,
|
||||
price_usd: float | None = None,
|
||||
change_pct: float | None = None,
|
||||
) -> dict:
|
||||
"""Get a mock current price for a stock ticker.
|
||||
|
||||
The optional `price_usd` and `change_pct` arguments let the LLM (or
|
||||
aimock fixture) script a deterministic ticker quote for testing —
|
||||
when supplied, the tool echoes them back verbatim. Mirrors the
|
||||
basic tool-rendering agent's signature so the aimock fixtures shared
|
||||
across both demos can script chained AAPL/MSFT comparisons.
|
||||
"""
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": (
|
||||
round(float(price_usd), 2)
|
||||
if price_usd is not None
|
||||
else round(100 + randint(0, 400) + randint(0, 99) / 100, 2)
|
||||
),
|
||||
"change_pct": (
|
||||
round(float(change_pct), 2)
|
||||
if change_pct is not None
|
||||
else round(choice([-1, 1]) * (randint(0, 300) / 100), 2)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def roll_dice(sides: int = 6) -> dict:
|
||||
"""Roll a single die with the given number of sides."""
|
||||
return {"sides": sides, "result": randint(1, max(2, sides))}
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful travel & lifestyle concierge with mock tools for "
|
||||
"weather, flights, stock prices, and dice rolls — they all return "
|
||||
"fake data, so call them liberally.\n\n"
|
||||
"Your habit is to CHAIN tools when one answer naturally invites "
|
||||
"another. For a single user question, call at least TWO tools in "
|
||||
"succession when the topic allows, then compose your final reply. "
|
||||
"Default chains:\n"
|
||||
" - 'What's the weather in <city>?' -> call get_weather(<city>), "
|
||||
"then call search_flights(origin='SFO', destination=<city>) so the "
|
||||
"user also sees how to get there.\n"
|
||||
" - 'How is <ticker> doing?' -> call get_stock_price(<ticker>), "
|
||||
"then call get_stock_price on a comparable ticker (e.g. 'MSFT' or "
|
||||
"'GOOGL') so the user can compare.\n"
|
||||
" - 'Roll a 20-sided die' -> call roll_dice(sides=20), then call "
|
||||
"roll_dice again with a different number of sides so the user sees "
|
||||
"a contrast.\n"
|
||||
" - 'Find flights from <a> to <b>' -> call search_flights(a, b), "
|
||||
"then call get_weather(<b>) for the destination.\n\n"
|
||||
"Only skip chaining when the user has clearly asked for a single, "
|
||||
"atomic answer and more tool calls would feel intrusive. Never "
|
||||
"fabricate data that a tool could provide."
|
||||
)
|
||||
|
||||
REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-5.4")
|
||||
|
||||
# No full CopilotKitMiddleware — this demo combines reasoning-token streaming
|
||||
# with backend tool rendering, but doesn't consume any frontend tools or app
|
||||
# context. We still attach the minimal HeaderForwardingMiddleware so inbound
|
||||
# ``x-aimock-context`` (and other ``x-*``) headers reach the outgoing
|
||||
# /v1/responses call; without it the LangGraph run swallows them inside
|
||||
# ``configurable`` and aimock 404s with no fixture match. The minimal
|
||||
# middleware does ONLY header propagation — no App-Context injection, no
|
||||
# tool-merging, no state-surfacing.
|
||||
graph = create_deep_agent(
|
||||
model=init_chat_model(
|
||||
f"openai:{REASONING_MODEL}",
|
||||
use_responses_api=True,
|
||||
# `summary: "detailed"` forces reasoning-summary emission on every
|
||||
# response. The previous `"auto"` lets the model decide, and with
|
||||
# tools present the model often skips reasoning summaries entirely
|
||||
# (the chain-of-thought goes straight to a tool call without the
|
||||
# summary step). That breaks the `<ReasoningBlock>` mount because
|
||||
# no reasoning-role message lands. Match the working
|
||||
# `reasoning_agent.py` config: medium effort + detailed summary.
|
||||
reasoning={"effort": "medium", "summary": "detailed"},
|
||||
),
|
||||
tools=[get_weather, search_flights, get_stock_price, roll_dice],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
middleware=[HeaderForwardingMiddleware()],
|
||||
)
|
||||
Reference in New Issue
Block a user