chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""_shared — single-source CVDIAG bootstrap shared across all 12 Python
|
||||
integration backends.
|
||||
|
||||
The canonical copy lives at ``showcase/integrations/_shared/``; each Python
|
||||
integration carries a ``_shared`` symlink → ``../_shared`` that the harness
|
||||
build tooling (``stageSharedModules()`` / ``stage_shared()``) dereferences into
|
||||
a real directory inside that integration's Docker build context, landing at
|
||||
``/app/_shared/`` (``/app`` is on PYTHONPATH for all 12). Plan unit: L0-C.
|
||||
"""
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""codegen_cvdiag_schema.py — generate ``cvdiag_schema.py`` (Pydantic v2 models)
|
||||
from the canonical JSON Schema ``showcase/harness/src/cvdiag/schema.json``.
|
||||
|
||||
The Python data-plane models are CODE-GENERATED (never hand-written) so they
|
||||
stay byte-for-byte in lockstep with the cross-language schema that L0-A owns.
|
||||
``schema.json`` is the single intermediate representation consumed here, by the
|
||||
.NET binding (L0-D), the Java binding (L0-E), and the TS binding (L0-F).
|
||||
|
||||
Run from the repo root::
|
||||
|
||||
python3 showcase/integrations/_shared/codegen_cvdiag_schema.py # write
|
||||
python3 showcase/integrations/_shared/codegen_cvdiag_schema.py --check # CI drift check
|
||||
|
||||
Plan unit: L0-C.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── Path resolution ──────────────────────────────────────────────────────────
|
||||
# This file lives at showcase/integrations/_shared/codegen_cvdiag_schema.py;
|
||||
# the schema lives at showcase/harness/src/cvdiag/schema.json. Resolve both
|
||||
# relative to this file so codegen works from any CWD.
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
_REPO_SHOWCASE = _HERE.parent.parent # → showcase/
|
||||
_SCHEMA_JSON = _REPO_SHOWCASE / "harness" / "src" / "cvdiag" / "schema.json"
|
||||
_OUTPUT = _HERE / "cvdiag_schema.py"
|
||||
|
||||
|
||||
def _enum_member_name(value: str) -> str:
|
||||
"""Map a dotted boundary/enum literal to a PascalCase enum member name.
|
||||
|
||||
``probe.dom.firsttoken`` → ``PROBE_DOM_FIRSTTOKEN``; ``ok`` → ``OK``.
|
||||
Uses SCREAMING_SNAKE so the member set is stable + collision-free.
|
||||
"""
|
||||
return value.replace(".", "_").replace("-", "_").upper()
|
||||
|
||||
|
||||
def _metadata_class_name(boundary: str) -> str:
|
||||
"""``probe.dom.firsttoken`` → ``MetadataProbeDomFirsttoken``."""
|
||||
parts = boundary.replace("-", "_").split(".")
|
||||
camel = "".join(p[:1].upper() + p[1:] for p in parts)
|
||||
return f"Metadata{camel}"
|
||||
|
||||
|
||||
def build_module(schema: dict) -> str:
|
||||
"""Render the full ``cvdiag_schema.py`` source from the schema IR."""
|
||||
defs = schema["$defs"]
|
||||
layers: list[str] = defs["layers"]
|
||||
outcomes: list[str] = defs["outcomes"]
|
||||
boundaries: list[str] = defs["boundaries"]
|
||||
edge_keys: list[str] = defs["edge_header_keys"]
|
||||
boundary_meta: dict[str, list[str]] = defs["boundary_metadata_keys"]
|
||||
test_id_pattern: str = schema["properties"]["test_id"]["pattern"]
|
||||
span_id_pattern: str = schema["properties"]["span_id"]["pattern"]
|
||||
slug_pattern: str = schema["properties"]["slug"]["pattern"]
|
||||
schema_version: int = schema["schema_version"]
|
||||
|
||||
out: list[str] = []
|
||||
a = out.append
|
||||
|
||||
a('"""cvdiag_schema.py — GENERATED Pydantic v2 models for the CVDIAG envelope.')
|
||||
a("")
|
||||
a("DO NOT EDIT BY HAND. This file is code-generated from")
|
||||
a("``showcase/harness/src/cvdiag/schema.json`` by")
|
||||
a("``showcase/integrations/_shared/codegen_cvdiag_schema.py``. Re-run that")
|
||||
a("script (and commit the result) whenever the schema changes; CI runs the")
|
||||
a("generator with ``--check`` to fail on drift. Plan unit: L0-C.")
|
||||
a('"""')
|
||||
a("")
|
||||
a("from __future__ import annotations")
|
||||
a("")
|
||||
a("from enum import Enum")
|
||||
a("from typing import Any, Optional")
|
||||
a("")
|
||||
a("from pydantic import BaseModel, ConfigDict, Field, model_validator")
|
||||
a("")
|
||||
a(f"SCHEMA_VERSION = {schema_version}")
|
||||
a("")
|
||||
a("# UUIDv7 (RFC 9562) pattern for ``test_id`` — version nibble 7, variant 10.")
|
||||
a(f"TEST_ID_PATTERN = r{test_id_pattern!r}")
|
||||
a(f"SPAN_ID_PATTERN = r{span_id_pattern!r}")
|
||||
a(f"SLUG_PATTERN = r{slug_pattern!r}")
|
||||
a("")
|
||||
a("")
|
||||
|
||||
# ── Enums ────────────────────────────────────────────────────────────────
|
||||
a("class CvdiagLayer(str, Enum):")
|
||||
a(' """Owning layer of a CVDIAG envelope (spec §5)."""')
|
||||
a("")
|
||||
for lyr in layers:
|
||||
a(f' {_enum_member_name(lyr)} = "{lyr}"')
|
||||
a("")
|
||||
a("")
|
||||
|
||||
a("class CvdiagOutcome(str, Enum):")
|
||||
a(' """Terminal outcome of a boundary observation (spec §5)."""')
|
||||
a("")
|
||||
for oc in outcomes:
|
||||
a(f' {_enum_member_name(oc)} = "{oc}"')
|
||||
a("")
|
||||
a("")
|
||||
|
||||
a("class CvdiagBoundary(str, Enum):")
|
||||
a(' """The closed set of 29 data-plane + 4 accounting boundaries (spec §5)."""')
|
||||
a("")
|
||||
for b in boundaries:
|
||||
a(f' {_enum_member_name(b)} = "{b}"')
|
||||
a("")
|
||||
a("")
|
||||
|
||||
# ── EdgeHeaders ────────────────────────────────────────────────────────────
|
||||
a("class EdgeHeaders(BaseModel):")
|
||||
a(' """The closed 9-key edge-header bag (spec §5). Absent → ``None``.')
|
||||
a("")
|
||||
a(" ``model_config`` forbids extra keys so a forbidden/unknown edge header")
|
||||
a(" can never round-trip through this model.")
|
||||
a(' """')
|
||||
a("")
|
||||
a(' model_config = ConfigDict(extra="forbid")')
|
||||
a("")
|
||||
for key in edge_keys:
|
||||
# Header names contain hyphens → use an alias and a sanitized field name.
|
||||
field = key.replace("-", "_")
|
||||
a(f' {field}: Optional[str] = Field(default=None, alias="{key}")')
|
||||
a("")
|
||||
a("")
|
||||
|
||||
# ── Per-boundary metadata models (data-plane boundaries only) ──────────────
|
||||
a("# ── Per-boundary metadata models (one per data-plane boundary, spec §5) ──")
|
||||
a("# Each forbids extra keys so an unknown metadata key is surfaced (caller")
|
||||
a("# stamps ``_metadata_dropped`` on the envelope).")
|
||||
a("")
|
||||
metadata_class_map: list[tuple[str, str]] = []
|
||||
for boundary in boundaries:
|
||||
keys = boundary_meta.get(boundary)
|
||||
if keys is None:
|
||||
# Accounting (cvdiag.*) boundaries carry a free-form metadata bag.
|
||||
continue
|
||||
cls = _metadata_class_name(boundary)
|
||||
metadata_class_map.append((boundary, cls))
|
||||
a("")
|
||||
a(f"class {cls}(BaseModel):")
|
||||
a(f' """Metadata for boundary ``{boundary}`` (closed key set)."""')
|
||||
a("")
|
||||
a(' model_config = ConfigDict(extra="forbid")')
|
||||
a("")
|
||||
for k in keys:
|
||||
a(f" {k}: Optional[Any] = None")
|
||||
a("")
|
||||
a("")
|
||||
|
||||
# Map from boundary literal → metadata model class (for callers/tests).
|
||||
a("#: boundary literal → its closed metadata model (data-plane only).")
|
||||
a("BOUNDARY_METADATA_MODEL: dict[str, type[BaseModel]] = {")
|
||||
for boundary, cls in metadata_class_map:
|
||||
a(f' "{boundary}": {cls},')
|
||||
a("}")
|
||||
a("")
|
||||
a("")
|
||||
|
||||
# ── Envelope ───────────────────────────────────────────────────────────────
|
||||
a("class CvdiagEnvelope(BaseModel):")
|
||||
a(' """The CVDIAG flap-observability envelope (spec §5).')
|
||||
a("")
|
||||
a(" Unknown TOP-LEVEL keys are dropped (closed-world) and the drop is")
|
||||
a(" recorded via ``_metadata_dropped``; the ``metadata`` bag itself is")
|
||||
a(" free-form here (per-boundary closed validation is applied separately")
|
||||
a(" via ``BOUNDARY_METADATA_MODEL`` so a metadata-only unknown key does not")
|
||||
a(" reject the whole envelope, it just stamps ``_metadata_dropped``).")
|
||||
a(' """')
|
||||
a("")
|
||||
a(' model_config = ConfigDict(populate_by_name=True, extra="ignore")')
|
||||
a("")
|
||||
a(f" schema_version: int = SCHEMA_VERSION")
|
||||
a(" test_id: str = Field(pattern=TEST_ID_PATTERN)")
|
||||
a(" trace_id: str")
|
||||
a(" span_id: str = Field(pattern=SPAN_ID_PATTERN)")
|
||||
a(" parent_span_id: Optional[str] = None")
|
||||
a(" layer: CvdiagLayer")
|
||||
a(" boundary: CvdiagBoundary")
|
||||
a(" slug: str = Field(pattern=SLUG_PATTERN)")
|
||||
a(" demo: str")
|
||||
a(" ts: str")
|
||||
a(" mono_ns: int")
|
||||
a(" duration_ms: Optional[int] = None")
|
||||
a(" outcome: CvdiagOutcome")
|
||||
a(" edge_headers: EdgeHeaders")
|
||||
a(" metadata: dict[str, Any] = Field(default_factory=dict)")
|
||||
a(' metadata_dropped: bool = Field(default=False, alias="_metadata_dropped")')
|
||||
a(' truncated: bool = Field(default=False, alias="_truncated")')
|
||||
a("")
|
||||
a(' @model_validator(mode="before")')
|
||||
a(" @classmethod")
|
||||
a(" def _stamp_dropped_unknown_keys(cls, data: Any) -> Any:")
|
||||
a(' """Stamp ``_metadata_dropped`` when unknown top-level OR metadata keys')
|
||||
a(" are present, then strip the unknown top-level keys (closed-world).")
|
||||
a(' """')
|
||||
a(" if not isinstance(data, dict):")
|
||||
a(" return data")
|
||||
a(" known = set(cls.model_fields.keys())")
|
||||
a(" aliases = {")
|
||||
a(" f.alias for f in cls.model_fields.values() if f.alias is not None")
|
||||
a(" }")
|
||||
a(" allowed = known | aliases")
|
||||
a(" dropped = False")
|
||||
a(" cleaned: dict[str, Any] = {}")
|
||||
a(" for key, value in data.items():")
|
||||
a(" if key in allowed:")
|
||||
a(" cleaned[key] = value")
|
||||
a(" else:")
|
||||
a(" dropped = True # unknown top-level key → drop + stamp")
|
||||
a(" # Unknown metadata keys (against the per-boundary closed model).")
|
||||
a(' boundary = cleaned.get("boundary")')
|
||||
a(' meta = cleaned.get("metadata")')
|
||||
a(" if isinstance(boundary, str) and isinstance(meta, dict):")
|
||||
a(" model = BOUNDARY_METADATA_MODEL.get(boundary)")
|
||||
a(" if model is not None:")
|
||||
a(" allowed_meta = set(model.model_fields.keys())")
|
||||
a(" if any(mk not in allowed_meta for mk in meta):")
|
||||
a(" dropped = True")
|
||||
a(" if dropped:")
|
||||
a(' cleaned["_metadata_dropped"] = True')
|
||||
a(" return cleaned")
|
||||
a("")
|
||||
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def load_schema() -> dict:
|
||||
return json.loads(_SCHEMA_JSON.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def render() -> str:
|
||||
return build_module(load_schema())
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
rendered = render()
|
||||
if "--check" in argv:
|
||||
try:
|
||||
on_disk = _OUTPUT.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
"CVDIAG codegen: cvdiag_schema.py is MISSING — run the generator and commit.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if on_disk == rendered:
|
||||
print("CVDIAG codegen: cvdiag_schema.py is in sync with schema.json")
|
||||
return 0
|
||||
print(
|
||||
"CVDIAG codegen: cvdiag_schema.py is STALE — run the generator and commit.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
_OUTPUT.write_text(rendered, encoding="utf-8")
|
||||
print(f"CVDIAG codegen: wrote {_OUTPUT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,273 @@
|
||||
"""cvdiag_bootstrap.py — single-source CVDIAG runtime bootstrap for every Python
|
||||
integration backend.
|
||||
|
||||
Importing this module (``import _shared.cvdiag_bootstrap``) at the top of an
|
||||
integration entrypoint does three things, once, at import time:
|
||||
|
||||
1. **Captures the ``agents.*`` loggers** by attaching a SCOPED stream handler
|
||||
to the ``agents`` logger so the ``agents._header_forwarding`` (and sibling
|
||||
``agents.*``) loggers actually EMIT. This fixes the silent-drop bug: those
|
||||
loggers call ``logger.info(...)`` but, with no handler attached anywhere up
|
||||
the hierarchy, the records were being discarded. We attach a dedicated
|
||||
handler to the ``agents`` logger (NOT ``basicConfig(force=True)`` on root)
|
||||
so the CVDIAG lines reach stdout where the harness greps for them WITHOUT
|
||||
tearing down the HOST application's own root-logger configuration — the
|
||||
module is fully inert (no global logging mutation) when cvdiag is disabled,
|
||||
matching the canary-safe contract the TS emitter upholds.
|
||||
|
||||
2. **Resolves the verbosity tier** (default | verbose | debug) and applies the
|
||||
§6 fail-closed guard: ``CVDIAG_DEBUG`` is REFUSED (raises at import time)
|
||||
when the deployment environment resolves to ``production`` or cannot be
|
||||
resolved at all (unknown env is treated as production).
|
||||
|
||||
3. **Exposes ``emit_cvdiag(envelope)``** — validates the envelope against the
|
||||
generated Pydantic model, writes a single ``CVDIAG`` JSON line to stdout,
|
||||
and best-effort hands the row to the threaded PocketBase writer.
|
||||
|
||||
Pure instrumentation: ``emit_cvdiag`` never throws into the caller. The ONE
|
||||
permitted raise is the fail-closed DEBUG guard during ``setup()`` (a startup
|
||||
assertion, mirroring the TS emitter's constructor guard).
|
||||
|
||||
Plan unit: L0-C.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from _shared.cvdiag_pb_writer import CvdiagPbWriter
|
||||
from _shared.cvdiag_schema import CvdiagEnvelope
|
||||
|
||||
logger = logging.getLogger("agents._cvdiag_bootstrap")
|
||||
|
||||
# ── Tier resolution ──────────────────────────────────────────────────────────
|
||||
|
||||
# Production-detection env precedence (spec §6):
|
||||
# SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → PYTHON_ENV.
|
||||
_ENV_PRECEDENCE = ("SHOWCASE_ENV", "RAILWAY_ENVIRONMENT_NAME", "PYTHON_ENV")
|
||||
|
||||
# Module-level singletons, populated by setup().
|
||||
_TIER: str = "default"
|
||||
_PB_WRITER: Optional[CvdiagPbWriter] = None
|
||||
# Idempotency guard: a successful (or degraded) setup() flips this so any
|
||||
# repeated invocation is a no-op — repeated calls must NOT orphan a second
|
||||
# flush daemon / PB writer queue.
|
||||
_SETUP_DONE = False
|
||||
# True iff cvdiag instrumentation is active. Flipped OFF (fail-closed) when a
|
||||
# misconfiguration is detected so the backend keeps running with instrumentation
|
||||
# disabled rather than crashing at import.
|
||||
_ENABLED = False
|
||||
_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
|
||||
# The scoped handler we attach to the ``agents`` logger when ENABLED. Tracked so
|
||||
# the capture install is idempotent and ``reset_for_test`` can detach it,
|
||||
# leaving no residual host-logging mutation between tests.
|
||||
_AGENTS_LOG_NAME = "agents"
|
||||
_CAPTURE_HANDLER: Optional[logging.Handler] = None
|
||||
|
||||
|
||||
def _install_agents_log_capture() -> None:
|
||||
"""Attach a scoped stream handler to the ``agents`` logger (idempotent).
|
||||
|
||||
This is the silent-drop fix WITHOUT the global blast radius of
|
||||
``basicConfig(force=True)``: we never touch the root logger's handlers, so
|
||||
the host application's own logging configuration is preserved. The handler
|
||||
is attached only when cvdiag is ENABLED; a disabled / degraded backend
|
||||
leaves host logging byte-for-byte untouched.
|
||||
"""
|
||||
global _CAPTURE_HANDLER
|
||||
if _CAPTURE_HANDLER is not None:
|
||||
return
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter(_LOG_FORMAT))
|
||||
agents_logger = logging.getLogger(_AGENTS_LOG_NAME)
|
||||
agents_logger.addHandler(handler)
|
||||
# Ensure ``agents.*`` records at INFO survive the level filter even if the
|
||||
# host left the (effective) level above INFO; scoped to the agents subtree.
|
||||
if agents_logger.level == logging.NOTSET or agents_logger.level > logging.INFO:
|
||||
agents_logger.setLevel(logging.INFO)
|
||||
_CAPTURE_HANDLER = handler
|
||||
|
||||
|
||||
def resolve_env_label(env: Optional[dict[str, str]] = None) -> Optional[str]:
|
||||
"""Resolve the deployment-environment label (lowercased) or ``None``.
|
||||
|
||||
Precedence: ``SHOWCASE_ENV`` → ``RAILWAY_ENVIRONMENT_NAME`` → ``PYTHON_ENV``.
|
||||
"""
|
||||
src = env if env is not None else os.environ
|
||||
for key in _ENV_PRECEDENCE:
|
||||
raw = src.get(key)
|
||||
if raw is not None and raw != "":
|
||||
return str(raw).lower()
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_tier(env: dict[str, str]) -> str:
|
||||
"""Resolve the verbosity tier, applying the §6 fail-closed DEBUG guard.
|
||||
|
||||
Raises ``RuntimeError`` (fail-closed) when DEBUG is requested but the
|
||||
deployment environment is ``production`` or unresolved.
|
||||
"""
|
||||
wants_debug = env.get("CVDIAG_DEBUG") == "1"
|
||||
wants_verbose = env.get("CVDIAG_VERBOSE") == "1"
|
||||
if wants_debug:
|
||||
label = resolve_env_label(env)
|
||||
if label is None:
|
||||
raise RuntimeError(
|
||||
"CVDIAG_DEBUG refused: deployment environment is unresolved "
|
||||
"(SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → PYTHON_ENV all "
|
||||
"unset); fail-closed treats unknown env as production."
|
||||
)
|
||||
if label == "production":
|
||||
raise RuntimeError(
|
||||
"CVDIAG_DEBUG refused: deployment environment is production."
|
||||
)
|
||||
return "debug"
|
||||
if wants_verbose:
|
||||
return "verbose"
|
||||
return "default"
|
||||
|
||||
|
||||
def setup(env: Optional[dict[str, str]] = None) -> None:
|
||||
"""Idempotent bootstrap: resolve tier, build the PB writer, capture agents logs.
|
||||
|
||||
Runs once at import time. Three safety contracts:
|
||||
|
||||
* **Idempotent** — a second invocation after a completed setup() is a
|
||||
no-op (the ``_SETUP_DONE`` guard); repeated calls must never orphan a
|
||||
second flush daemon / PB writer queue.
|
||||
* **Inert when disabled** — a disabled / degraded setup() performs NO
|
||||
logging mutation: the scoped ``agents`` capture handler is installed
|
||||
only on the ENABLED path, and the root logger is never touched. Merely
|
||||
importing this module when cvdiag is off leaves the host application's
|
||||
logging configuration byte-for-byte intact (canary-safe).
|
||||
* **Degrade-not-crash** — a misconfiguration (e.g. the §6 fail-closed
|
||||
DEBUG guard) DISABLES cvdiag instrumentation and logs a warning; it
|
||||
must NEVER propagate and abort the host backend's module import. The
|
||||
fail-closed *intent* is preserved (instrumentation stays OFF on a
|
||||
forbidden DEBUG request) but the backend keeps running. This mirrors
|
||||
the TS emitter: it throws at construction, but the wrapper catches it
|
||||
so the host app survives.
|
||||
"""
|
||||
global _TIER, _PB_WRITER, _SETUP_DONE, _ENABLED
|
||||
|
||||
# (0) Idempotency guard — repeated setup() is a no-op (FIX-3).
|
||||
if _SETUP_DONE:
|
||||
return
|
||||
|
||||
src = env if env is not None else dict(os.environ)
|
||||
|
||||
# (1) Resolve tier. ``_resolve_tier`` raises (fail-closed) on a forbidden
|
||||
# DEBUG request — catch it here so a misconfig DEGRADES (instrumentation
|
||||
# OFF) rather than crashing the backend import (FIX-2).
|
||||
try:
|
||||
_TIER = _resolve_tier(src)
|
||||
except RuntimeError as err:
|
||||
_TIER = "default"
|
||||
_ENABLED = False
|
||||
_PB_WRITER = None
|
||||
_SETUP_DONE = True
|
||||
logger.warning(
|
||||
"CVDIAG bootstrap degraded component=_shared reason=%s "
|
||||
"(instrumentation disabled; backend continues)",
|
||||
err,
|
||||
)
|
||||
return
|
||||
|
||||
# (2) Build the threaded PB writer (no-op when CVDIAG_PB_URL unset).
|
||||
_PB_WRITER = CvdiagPbWriter(
|
||||
pb_url=src.get("CVDIAG_PB_URL"),
|
||||
writer_key=src.get("CVDIAG_WRITER_KEY"),
|
||||
)
|
||||
|
||||
_ENABLED = True
|
||||
_SETUP_DONE = True
|
||||
|
||||
# (3) Only NOW — once instrumentation is confirmed ENABLED — install the
|
||||
# scoped ``agents`` logger capture. A disabled / degraded setup (the early
|
||||
# returns above) reaches neither this nor any other logging mutation, so
|
||||
# importing the bootstrap is fully inert when cvdiag is disabled — it never
|
||||
# touches the host application's root-logger handlers.
|
||||
_install_agents_log_capture()
|
||||
logger.info(
|
||||
"CVDIAG bootstrap component=_shared tier=%s pb_enabled=%s",
|
||||
_TIER,
|
||||
str(_PB_WRITER.enabled).lower(),
|
||||
)
|
||||
|
||||
|
||||
def current_tier() -> str:
|
||||
"""Return the resolved tier (``default`` | ``verbose`` | ``debug``)."""
|
||||
return _TIER
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""True iff cvdiag instrumentation is active (False after a degraded setup)."""
|
||||
return _ENABLED
|
||||
|
||||
|
||||
def reset_for_test() -> None:
|
||||
"""Reset module state so a test can re-run ``setup()`` from scratch.
|
||||
|
||||
Test-only helper: clears the idempotency guard and singletons. The flush
|
||||
daemon is a short-lived best-effort daemon thread, so we simply drop the
|
||||
reference (the thread exits with the process); we do not join it.
|
||||
|
||||
Also detaches the scoped ``agents`` capture handler so each test starts from
|
||||
an unmutated logging tree (otherwise an enabled setup() would leave a
|
||||
handler attached across tests).
|
||||
"""
|
||||
global _TIER, _PB_WRITER, _SETUP_DONE, _ENABLED, _CAPTURE_HANDLER
|
||||
_TIER = "default"
|
||||
_PB_WRITER = None
|
||||
_SETUP_DONE = False
|
||||
_ENABLED = False
|
||||
if _CAPTURE_HANDLER is not None:
|
||||
logging.getLogger(_AGENTS_LOG_NAME).removeHandler(_CAPTURE_HANDLER)
|
||||
_CAPTURE_HANDLER = None
|
||||
|
||||
|
||||
def emit_cvdiag(envelope: Union[CvdiagEnvelope, dict[str, Any]]) -> None:
|
||||
"""Emit one CVDIAG envelope: validate → JSON line to stdout → best-effort PB.
|
||||
|
||||
Pure instrumentation — catches every error and degrades to a single
|
||||
``CVDIAG emit-failed`` log line; never raises into the caller.
|
||||
|
||||
The shared emit gate is the single chokepoint every integration's backend
|
||||
emitter routes through. It honors the ``_ENABLED`` flag (``is_enabled()``)
|
||||
so a DEGRADED setup() (the §6 fail-closed DEBUG misconfig) actually
|
||||
SUPPRESSES emission — the degrade must win over a live
|
||||
``CVDIAG_BACKEND_EMITTER=1`` toggle, otherwise the fail-closed intent is
|
||||
silently defeated and a degraded backend keeps writing envelopes.
|
||||
"""
|
||||
# Degrade gate: a disabled (degraded) backend emits nothing, regardless of
|
||||
# the per-integration CVDIAG_BACKEND_EMITTER toggle.
|
||||
if not is_enabled():
|
||||
return
|
||||
try:
|
||||
model = (
|
||||
envelope
|
||||
if isinstance(envelope, CvdiagEnvelope)
|
||||
else CvdiagEnvelope.model_validate(envelope)
|
||||
)
|
||||
payload = model.model_dump(by_alias=True, exclude_none=False)
|
||||
# One JSON line to stdout, ``CVDIAG`` tagged so the harness greps it.
|
||||
sys.stdout.write("CVDIAG " + _dump_json(payload) + "\n")
|
||||
sys.stdout.flush()
|
||||
if _PB_WRITER is not None:
|
||||
_PB_WRITER.enqueue(payload)
|
||||
except Exception as err: # noqa: BLE001 - instrumentation must not throw
|
||||
logger.warning("CVDIAG emit-failed error=%s", err)
|
||||
|
||||
|
||||
def _dump_json(payload: dict[str, Any]) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(payload, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
# Run the bootstrap at import time (the whole point — importing this module
|
||||
# wires logging + tier + PB writer for the integration entrypoint).
|
||||
setup()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""cvdiag_pb_writer.py — best-effort, background (threaded) PocketBase flush for
|
||||
CVDIAG envelopes emitted from the Python integration backends.
|
||||
|
||||
Contract (spec §7 — pure instrumentation, never blocks the observed boundary):
|
||||
- ``enqueue(envelope)`` returns immediately; it only appends to an in-memory
|
||||
queue. A single daemon worker thread drains the queue on a ≤1s window and
|
||||
POSTs to the PocketBase ``cvdiag_events`` collection (CREATE-only).
|
||||
- A PB write failure is swallowed and logged once as
|
||||
``CVDIAG pb-write-failed`` — it must NEVER propagate into the caller.
|
||||
- When ``CVDIAG_PB_URL`` is unset the writer is a no-op sink (enqueue still
|
||||
returns immediately; nothing is flushed). This keeps local/unit runs free
|
||||
of network side effects.
|
||||
|
||||
Authentication (see the 1779990200_create_cvdiag_events.js migration):
|
||||
The ``cvdiag_events`` createRule requires the caller to authenticate as a
|
||||
``cvdiag_api_keys`` auth record whose ``role`` is ``"writer"`` —
|
||||
|
||||
@request.auth.collectionName = "cvdiag_api_keys" && @request.auth.role = "writer"
|
||||
|
||||
PocketBase has NO notion of a bespoke header, so a header-only request is
|
||||
UNAUTHENTICATED and the CREATE 4xxs (the createRule evaluates false). The
|
||||
writer therefore POSTs ``/api/collections/cvdiag_api_keys/auth-with-password``
|
||||
with the fixed writer identity (``cvdiag-writer@keys.local`` — overridable via
|
||||
``CVDIAG_WRITER_IDENTITY``) and ``CVDIAG_WRITER_KEY`` as the PASSWORD, caches
|
||||
the returned token, and sends ``Authorization: Bearer <token>`` on the CREATE.
|
||||
A 401 (token expiry / bad creds) clears the cached token and triggers a single
|
||||
re-auth + retry. Auth failure stays best-effort: it degrades to a no-op + the
|
||||
one-shot ``CVDIAG pb-write-failed`` warn — it NEVER crashes the daemon.
|
||||
|
||||
Plan unit: L0-C.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger("agents._cvdiag_pb_writer")
|
||||
|
||||
# Flush window: drain at least this often (spec §7 R5-F12: ≤1s window).
|
||||
FLUSH_WINDOW_S = 1.0
|
||||
# Bounded queue — drop-oldest on overflow so a stuck flush can't grow unbounded.
|
||||
QUEUE_CAP = 5000
|
||||
# Per-flush HTTP timeout so a hung PB never wedges the worker thread.
|
||||
HTTP_TIMEOUT_S = 5.0
|
||||
# Auth collection + fixed default identity of the seeded writer record. The
|
||||
# migration seeds email ``cvdiag-writer@keys.local`` with role ``writer``;
|
||||
# CVDIAG_WRITER_KEY is that record's PASSWORD. The identity is overridable for
|
||||
# environments that rotate the writer email, but defaults to the seeded value.
|
||||
WRITER_AUTH_COLLECTION = "cvdiag_api_keys"
|
||||
DEFAULT_WRITER_IDENTITY = "cvdiag-writer@keys.local"
|
||||
|
||||
|
||||
class CvdiagPbWriter:
|
||||
"""Threaded, best-effort PocketBase writer. Construct once at import time.
|
||||
|
||||
The worker thread is a daemon so it never keeps the process alive on exit.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pb_url: Optional[str] = None,
|
||||
writer_key: Optional[str] = None,
|
||||
*,
|
||||
writer_identity: Optional[str] = None,
|
||||
flush_window_s: float = FLUSH_WINDOW_S,
|
||||
) -> None:
|
||||
self._pb_url = pb_url if pb_url is not None else os.environ.get("CVDIAG_PB_URL")
|
||||
self._writer_key = (
|
||||
writer_key
|
||||
if writer_key is not None
|
||||
else os.environ.get("CVDIAG_WRITER_KEY")
|
||||
)
|
||||
self._writer_identity = (
|
||||
writer_identity
|
||||
if writer_identity is not None
|
||||
else os.environ.get("CVDIAG_WRITER_IDENTITY", DEFAULT_WRITER_IDENTITY)
|
||||
)
|
||||
self._flush_window_s = flush_window_s
|
||||
self._queue: "queue.Queue[dict[str, Any]]" = queue.Queue(maxsize=QUEUE_CAP)
|
||||
self._logged_failure = False
|
||||
self._started = False
|
||||
self._lock = threading.Lock()
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
# Cached auth-with-password token. Only the single daemon worker thread
|
||||
# touches this (auth + CREATE both run inside ``_run``), so no lock is
|
||||
# needed. ``None`` means "not authenticated yet / cleared after a 401".
|
||||
self._auth_token: Optional[str] = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""True iff a PB target URL is configured (otherwise this is a no-op)."""
|
||||
return bool(self._pb_url)
|
||||
|
||||
def _ensure_worker(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
with self._lock:
|
||||
if self._started:
|
||||
return
|
||||
self._worker = threading.Thread(
|
||||
target=self._run,
|
||||
name="cvdiag-pb-writer",
|
||||
daemon=True,
|
||||
)
|
||||
self._worker.start()
|
||||
self._started = True
|
||||
|
||||
def enqueue(self, envelope: dict[str, Any]) -> None:
|
||||
"""Queue one envelope for background flush. Never blocks; never raises.
|
||||
|
||||
On a full queue we drop the OLDEST entry (instrumentation must shed
|
||||
load rather than block the boundary it observes).
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
try:
|
||||
self._ensure_worker()
|
||||
try:
|
||||
self._queue.put_nowait(envelope)
|
||||
except queue.Full:
|
||||
# Drop-oldest, then retry once. Best-effort; never block.
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
self._queue.put_nowait(envelope)
|
||||
except queue.Full:
|
||||
pass
|
||||
except Exception as err: # pragma: no cover - defensive belt
|
||||
self._log_failure(err)
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
envelope = self._queue.get(timeout=self._flush_window_s)
|
||||
except queue.Empty:
|
||||
continue
|
||||
batch = [envelope]
|
||||
# Coalesce anything else already queued into this flush.
|
||||
while True:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
for env in batch:
|
||||
# Never-propagate: isolate each record so no single envelope
|
||||
# can unwind ``_run`` and PERMANENTLY kill the flush daemon.
|
||||
try:
|
||||
self._post(env)
|
||||
except Exception as err: # noqa: BLE001 - daemon must survive
|
||||
self._log_failure(err)
|
||||
|
||||
def _authenticate(self) -> Optional[str]:
|
||||
"""Auth-with-password as the writer role; return + cache the token.
|
||||
|
||||
Returns the cached token if present, else POSTs the writer identity +
|
||||
``CVDIAG_WRITER_KEY`` (the writer record PASSWORD) to the
|
||||
``cvdiag_api_keys`` auth-with-password endpoint and caches the token.
|
||||
Returns ``None`` on any failure (bad creds, unreachable PB, malformed
|
||||
response) — the caller degrades to a no-op. NEVER raises.
|
||||
"""
|
||||
if self._auth_token:
|
||||
return self._auth_token
|
||||
url = self._pb_url
|
||||
if not url or not self._writer_key:
|
||||
return None
|
||||
endpoint = (
|
||||
url.rstrip("/")
|
||||
+ f"/api/collections/{WRITER_AUTH_COLLECTION}/auth-with-password"
|
||||
)
|
||||
body = json.dumps(
|
||||
{"identity": self._writer_identity, "password": self._writer_key}
|
||||
).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
endpoint,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
token = payload.get("token")
|
||||
if not token:
|
||||
return None
|
||||
self._auth_token = token
|
||||
return token
|
||||
|
||||
def _post(self, envelope: dict[str, Any]) -> None:
|
||||
url = self._pb_url
|
||||
if not url:
|
||||
return
|
||||
endpoint = url.rstrip("/") + "/api/collections/cvdiag_events/records"
|
||||
# Never-propagate: a single bad record (e.g. a non-JSON-serializable
|
||||
# envelope that makes ``json.dumps`` raise ``TypeError``) or an auth
|
||||
# failure must be logged/dropped, NOT allowed to escape and kill the
|
||||
# drain daemon. This mirrors the TS pb-writer ``writeBatch`` contract —
|
||||
# one bad row / a failed auth degrades to a warn; the worker survives.
|
||||
try:
|
||||
body = json.dumps(envelope).encode("utf-8")
|
||||
# Authenticate as the writer-role record (createRule requires it).
|
||||
# On a 401 (token expiry / stale token) clear the cache and re-auth
|
||||
# once before giving up — but never loop.
|
||||
self._create_with_auth(endpoint, body, allow_reauth=True)
|
||||
except Exception as err: # noqa: BLE001 - instrumentation must never throw
|
||||
self._log_failure(err)
|
||||
|
||||
def _create_with_auth(
|
||||
self, endpoint: str, body: bytes, *, allow_reauth: bool
|
||||
) -> None:
|
||||
"""POST the CREATE with a Bearer token; re-auth once on a 401."""
|
||||
token = self._authenticate()
|
||||
if not token:
|
||||
# Auth failed (bad/missing writer key, unreachable PB). Degrade to a
|
||||
# no-op + the one-shot warn — never crash the daemon.
|
||||
self._log_failure(RuntimeError("CVDIAG writer auth failed"))
|
||||
return
|
||||
req = urllib.request.Request(
|
||||
endpoint,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S).close()
|
||||
except urllib.error.HTTPError as err:
|
||||
# 401 → token expired / revoked. Clear the cache and re-auth ONCE.
|
||||
if err.code == 401 and allow_reauth:
|
||||
self._auth_token = None
|
||||
self._create_with_auth(endpoint, body, allow_reauth=False)
|
||||
return
|
||||
raise
|
||||
|
||||
def _log_failure(self, err: Exception) -> None:
|
||||
# Log the first failure at WARNING; subsequent ones at DEBUG to avoid
|
||||
# spamming the log on a sustained PB outage.
|
||||
if not self._logged_failure:
|
||||
self._logged_failure = True
|
||||
logger.warning("CVDIAG pb-write-failed error=%s", err)
|
||||
else:
|
||||
logger.debug("CVDIAG pb-write-failed error=%s", err)
|
||||
@@ -0,0 +1,482 @@
|
||||
"""cvdiag_schema.py — GENERATED Pydantic v2 models for the CVDIAG envelope.
|
||||
|
||||
DO NOT EDIT BY HAND. This file is code-generated from
|
||||
``showcase/harness/src/cvdiag/schema.json`` by
|
||||
``showcase/integrations/_shared/codegen_cvdiag_schema.py``. Re-run that
|
||||
script (and commit the result) whenever the schema changes; CI runs the
|
||||
generator with ``--check`` to fail on drift. Plan unit: L0-C.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# UUIDv7 (RFC 9562) pattern for ``test_id`` — version nibble 7, variant 10.
|
||||
TEST_ID_PATTERN = (
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
)
|
||||
SPAN_ID_PATTERN = r"^[0-9a-f]{16}$"
|
||||
SLUG_PATTERN = r"^[a-z][a-z0-9-]{0,63}$"
|
||||
|
||||
|
||||
class CvdiagLayer(str, Enum):
|
||||
"""Owning layer of a CVDIAG envelope (spec §5)."""
|
||||
|
||||
PROBE = "probe"
|
||||
BACKEND = "backend"
|
||||
AIMOCK = "aimock"
|
||||
|
||||
|
||||
class CvdiagOutcome(str, Enum):
|
||||
"""Terminal outcome of a boundary observation (spec §5)."""
|
||||
|
||||
OK = "ok"
|
||||
ERR = "err"
|
||||
TIMEOUT = "timeout"
|
||||
INFO = "info"
|
||||
|
||||
|
||||
class CvdiagBoundary(str, Enum):
|
||||
"""The closed set of 29 data-plane + 4 accounting boundaries (spec §5)."""
|
||||
|
||||
PROBE_START = "probe.start"
|
||||
PROBE_NAVIGATE_COMPLETE = "probe.navigate.complete"
|
||||
PROBE_MESSAGE_SEND = "probe.message.send"
|
||||
PROBE_DOM_CONTAINER_MOUNT = "probe.dom.container.mount"
|
||||
PROBE_DOM_FIRSTTOKEN = "probe.dom.firsttoken"
|
||||
PROBE_DOM_ALTERNATE_CONTENT = "probe.dom.alternate_content"
|
||||
PROBE_SSE_EVENT = "probe.sse.event"
|
||||
PROBE_SSE_ABORTED = "probe.sse.aborted"
|
||||
PROBE_NETWORK_ERROR = "probe.network.error"
|
||||
PROBE_NETWORK_RESPONSE = "probe.network.response"
|
||||
PROBE_CONSOLE_ERROR = "probe.console.error"
|
||||
PROBE_EXIT = "probe.exit"
|
||||
BACKEND_REQUEST_INGRESS = "backend.request.ingress"
|
||||
BACKEND_AGENT_ENTER = "backend.agent.enter"
|
||||
BACKEND_LLM_CALL_START = "backend.llm.call.start"
|
||||
BACKEND_LLM_CALL_HEARTBEAT = "backend.llm.call.heartbeat"
|
||||
BACKEND_LLM_CALL_RESPONSE = "backend.llm.call.response"
|
||||
BACKEND_SSE_FIRST_BYTE = "backend.sse.first_byte"
|
||||
BACKEND_SSE_EVENT = "backend.sse.event"
|
||||
BACKEND_SSE_ABORTED = "backend.sse.aborted"
|
||||
BACKEND_AGENT_EXIT = "backend.agent.exit"
|
||||
BACKEND_RESPONSE_COMPLETE = "backend.response.complete"
|
||||
BACKEND_ERROR_CAUGHT = "backend.error.caught"
|
||||
AIMOCK_REQUEST_INGRESS = "aimock.request.ingress"
|
||||
AIMOCK_MATCH_DECISION = "aimock.match.decision"
|
||||
AIMOCK_RESPONSE_START = "aimock.response.start"
|
||||
AIMOCK_SSE_CHUNK = "aimock.sse.chunk"
|
||||
AIMOCK_RESPONSE_ABORTED = "aimock.response.aborted"
|
||||
AIMOCK_RESPONSE_COMPLETE = "aimock.response.complete"
|
||||
CVDIAG_PURGE_AUDIT = "cvdiag.purge_audit"
|
||||
CVDIAG_COLLISION_DETECTED = "cvdiag.collision_detected"
|
||||
CVDIAG_QUEUE_DROPPED = "cvdiag.queue_dropped"
|
||||
CVDIAG_METADATA_DROPPED = "cvdiag.metadata_dropped"
|
||||
|
||||
|
||||
class EdgeHeaders(BaseModel):
|
||||
"""The closed 9-key edge-header bag (spec §5). Absent → ``None``.
|
||||
|
||||
``model_config`` forbids extra keys so a forbidden/unknown edge header
|
||||
can never round-trip through this model.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
cf_ray: Optional[str] = Field(default=None, alias="cf-ray")
|
||||
cf_mitigated: Optional[str] = Field(default=None, alias="cf-mitigated")
|
||||
cf_cache_status: Optional[str] = Field(default=None, alias="cf-cache-status")
|
||||
x_railway_edge: Optional[str] = Field(default=None, alias="x-railway-edge")
|
||||
x_railway_request_id: Optional[str] = Field(
|
||||
default=None, alias="x-railway-request-id"
|
||||
)
|
||||
x_hikari_trace: Optional[str] = Field(default=None, alias="x-hikari-trace")
|
||||
retry_after: Optional[str] = Field(default=None, alias="retry-after")
|
||||
via: Optional[str] = Field(default=None, alias="via")
|
||||
server: Optional[str] = Field(default=None, alias="server")
|
||||
|
||||
|
||||
# ── Per-boundary metadata models (one per data-plane boundary, spec §5) ──
|
||||
# Each forbids extra keys so an unknown metadata key is surfaced (caller
|
||||
# stamps ``_metadata_dropped`` on the envelope).
|
||||
|
||||
|
||||
class MetadataProbeStart(BaseModel):
|
||||
"""Metadata for boundary ``probe.start`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
url: Optional[Any] = None
|
||||
viewport: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeNavigateComplete(BaseModel):
|
||||
"""Metadata for boundary ``probe.navigate.complete`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
url: Optional[Any] = None
|
||||
nav_ms: Optional[Any] = None
|
||||
http_status: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeMessageSend(BaseModel):
|
||||
"""Metadata for boundary ``probe.message.send`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
message_index: Optional[Any] = None
|
||||
char_count: Optional[Any] = None
|
||||
demo: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeDomContainerMount(BaseModel):
|
||||
"""Metadata for boundary ``probe.dom.container.mount`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
delta_ms_from_start: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeDomFirsttoken(BaseModel):
|
||||
"""Metadata for boundary ``probe.dom.firsttoken`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
delta_ms_from_start: Optional[Any] = None
|
||||
text_length: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeDomAlternate_content(BaseModel):
|
||||
"""Metadata for boundary ``probe.dom.alternate_content`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
child_type_histogram: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeSseEvent(BaseModel):
|
||||
"""Metadata for boundary ``probe.sse.event`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
event_type: Optional[Any] = None
|
||||
payload_size_bytes: Optional[Any] = None
|
||||
sequence_num: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeSseAborted(BaseModel):
|
||||
"""Metadata for boundary ``probe.sse.aborted`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
termination_kind: Optional[Any] = None
|
||||
bytes_before_abort: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeNetworkError(BaseModel):
|
||||
"""Metadata for boundary ``probe.network.error`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
url: Optional[Any] = None
|
||||
error_class: Optional[Any] = None
|
||||
response_status: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeNetworkResponse(BaseModel):
|
||||
"""Metadata for boundary ``probe.network.response`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
url: Optional[Any] = None
|
||||
status: Optional[Any] = None
|
||||
content_length: Optional[Any] = None
|
||||
duration_ms: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeConsoleError(BaseModel):
|
||||
"""Metadata for boundary ``probe.console.error`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
level: Optional[Any] = None
|
||||
message_scrubbed: Optional[Any] = None
|
||||
source_file: Optional[Any] = None
|
||||
line_col: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataProbeExit(BaseModel):
|
||||
"""Metadata for boundary ``probe.exit`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
terminal_outcome: Optional[Any] = None
|
||||
total_duration_ms: Optional[Any] = None
|
||||
sse_event_count: Optional[Any] = None
|
||||
first_token_delta_ms: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendRequestIngress(BaseModel):
|
||||
"""Metadata for boundary ``backend.request.ingress`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
method: Optional[Any] = None
|
||||
path: Optional[Any] = None
|
||||
content_length: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendAgentEnter(BaseModel):
|
||||
"""Metadata for boundary ``backend.agent.enter`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
agent_name: Optional[Any] = None
|
||||
model_id: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendLlmCallStart(BaseModel):
|
||||
"""Metadata for boundary ``backend.llm.call.start`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: Optional[Any] = None
|
||||
model: Optional[Any] = None
|
||||
prompt_token_count_estimate: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendLlmCallHeartbeat(BaseModel):
|
||||
"""Metadata for boundary ``backend.llm.call.heartbeat`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
elapsed_ms_since_start: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendLlmCallResponse(BaseModel):
|
||||
"""Metadata for boundary ``backend.llm.call.response`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: Optional[Any] = None
|
||||
model: Optional[Any] = None
|
||||
response_token_count: Optional[Any] = None
|
||||
latency_ms: Optional[Any] = None
|
||||
error_class: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendSseFirst_byte(BaseModel):
|
||||
"""Metadata for boundary ``backend.sse.first_byte`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
delta_ms_from_ingress: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendSseEvent(BaseModel):
|
||||
"""Metadata for boundary ``backend.sse.event`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
event_type: Optional[Any] = None
|
||||
payload_size_bytes: Optional[Any] = None
|
||||
sequence_num: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendSseAborted(BaseModel):
|
||||
"""Metadata for boundary ``backend.sse.aborted`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
termination_kind: Optional[Any] = None
|
||||
bytes_before_abort: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendAgentExit(BaseModel):
|
||||
"""Metadata for boundary ``backend.agent.exit`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
terminal_outcome: Optional[Any] = None
|
||||
total_duration_ms: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendResponseComplete(BaseModel):
|
||||
"""Metadata for boundary ``backend.response.complete`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
http_status: Optional[Any] = None
|
||||
content_length: Optional[Any] = None
|
||||
total_duration_ms: Optional[Any] = None
|
||||
sse_event_count: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataBackendErrorCaught(BaseModel):
|
||||
"""Metadata for boundary ``backend.error.caught`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
exception_type: Optional[Any] = None
|
||||
message_scrubbed: Optional[Any] = None
|
||||
stack_brief: Optional[Any] = None
|
||||
truncated: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataAimockRequestIngress(BaseModel):
|
||||
"""Metadata for boundary ``aimock.request.ingress`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
path: Optional[Any] = None
|
||||
content_length: Optional[Any] = None
|
||||
match_keys: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataAimockMatchDecision(BaseModel):
|
||||
"""Metadata for boundary ``aimock.match.decision`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
fixture_id: Optional[Any] = None
|
||||
match_score: Optional[Any] = None
|
||||
reject_reasons: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataAimockResponseStart(BaseModel):
|
||||
"""Metadata for boundary ``aimock.response.start`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
delta_ms_from_ingress: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataAimockSseChunk(BaseModel):
|
||||
"""Metadata for boundary ``aimock.sse.chunk`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
chunk_size_bytes: Optional[Any] = None
|
||||
sequence_num: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataAimockResponseAborted(BaseModel):
|
||||
"""Metadata for boundary ``aimock.response.aborted`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
termination_kind: Optional[Any] = None
|
||||
bytes_before_abort: Optional[Any] = None
|
||||
|
||||
|
||||
class MetadataAimockResponseComplete(BaseModel):
|
||||
"""Metadata for boundary ``aimock.response.complete`` (closed key set)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
http_status: Optional[Any] = None
|
||||
total_bytes: Optional[Any] = None
|
||||
total_duration_ms: Optional[Any] = None
|
||||
chunk_count: Optional[Any] = None
|
||||
|
||||
|
||||
#: boundary literal → its closed metadata model (data-plane only).
|
||||
BOUNDARY_METADATA_MODEL: dict[str, type[BaseModel]] = {
|
||||
"probe.start": MetadataProbeStart,
|
||||
"probe.navigate.complete": MetadataProbeNavigateComplete,
|
||||
"probe.message.send": MetadataProbeMessageSend,
|
||||
"probe.dom.container.mount": MetadataProbeDomContainerMount,
|
||||
"probe.dom.firsttoken": MetadataProbeDomFirsttoken,
|
||||
"probe.dom.alternate_content": MetadataProbeDomAlternate_content,
|
||||
"probe.sse.event": MetadataProbeSseEvent,
|
||||
"probe.sse.aborted": MetadataProbeSseAborted,
|
||||
"probe.network.error": MetadataProbeNetworkError,
|
||||
"probe.network.response": MetadataProbeNetworkResponse,
|
||||
"probe.console.error": MetadataProbeConsoleError,
|
||||
"probe.exit": MetadataProbeExit,
|
||||
"backend.request.ingress": MetadataBackendRequestIngress,
|
||||
"backend.agent.enter": MetadataBackendAgentEnter,
|
||||
"backend.llm.call.start": MetadataBackendLlmCallStart,
|
||||
"backend.llm.call.heartbeat": MetadataBackendLlmCallHeartbeat,
|
||||
"backend.llm.call.response": MetadataBackendLlmCallResponse,
|
||||
"backend.sse.first_byte": MetadataBackendSseFirst_byte,
|
||||
"backend.sse.event": MetadataBackendSseEvent,
|
||||
"backend.sse.aborted": MetadataBackendSseAborted,
|
||||
"backend.agent.exit": MetadataBackendAgentExit,
|
||||
"backend.response.complete": MetadataBackendResponseComplete,
|
||||
"backend.error.caught": MetadataBackendErrorCaught,
|
||||
"aimock.request.ingress": MetadataAimockRequestIngress,
|
||||
"aimock.match.decision": MetadataAimockMatchDecision,
|
||||
"aimock.response.start": MetadataAimockResponseStart,
|
||||
"aimock.sse.chunk": MetadataAimockSseChunk,
|
||||
"aimock.response.aborted": MetadataAimockResponseAborted,
|
||||
"aimock.response.complete": MetadataAimockResponseComplete,
|
||||
}
|
||||
|
||||
|
||||
class CvdiagEnvelope(BaseModel):
|
||||
"""The CVDIAG flap-observability envelope (spec §5).
|
||||
|
||||
Unknown TOP-LEVEL keys are dropped (closed-world) and the drop is
|
||||
recorded via ``_metadata_dropped``; the ``metadata`` bag itself is
|
||||
free-form here (per-boundary closed validation is applied separately
|
||||
via ``BOUNDARY_METADATA_MODEL`` so a metadata-only unknown key does not
|
||||
reject the whole envelope, it just stamps ``_metadata_dropped``).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
||||
|
||||
schema_version: int = SCHEMA_VERSION
|
||||
test_id: str = Field(pattern=TEST_ID_PATTERN)
|
||||
trace_id: str
|
||||
span_id: str = Field(pattern=SPAN_ID_PATTERN)
|
||||
parent_span_id: Optional[str] = None
|
||||
layer: CvdiagLayer
|
||||
boundary: CvdiagBoundary
|
||||
slug: str = Field(pattern=SLUG_PATTERN)
|
||||
demo: str
|
||||
ts: str
|
||||
mono_ns: int
|
||||
duration_ms: Optional[int] = None
|
||||
outcome: CvdiagOutcome
|
||||
edge_headers: EdgeHeaders
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
metadata_dropped: bool = Field(default=False, alias="_metadata_dropped")
|
||||
truncated: bool = Field(default=False, alias="_truncated")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _stamp_dropped_unknown_keys(cls, data: Any) -> Any:
|
||||
"""Stamp ``_metadata_dropped`` when unknown top-level OR metadata keys
|
||||
are present, then strip the unknown top-level keys (closed-world).
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
known = set(cls.model_fields.keys())
|
||||
aliases = {f.alias for f in cls.model_fields.values() if f.alias is not None}
|
||||
allowed = known | aliases
|
||||
dropped = False
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if key in allowed:
|
||||
cleaned[key] = value
|
||||
else:
|
||||
dropped = True # unknown top-level key → drop + stamp
|
||||
# Unknown metadata keys (against the per-boundary closed model).
|
||||
boundary = cleaned.get("boundary")
|
||||
meta = cleaned.get("metadata")
|
||||
if isinstance(boundary, str) and isinstance(meta, dict):
|
||||
model = BOUNDARY_METADATA_MODEL.get(boundary)
|
||||
if model is not None:
|
||||
allowed_meta = set(model.model_fields.keys())
|
||||
if any(mk not in allowed_meta for mk in meta):
|
||||
dropped = True
|
||||
if dropped:
|
||||
cleaned["_metadata_dropped"] = True
|
||||
return cleaned
|
||||
@@ -0,0 +1,483 @@
|
||||
// CvdiagEmitter.cs — the shared .NET CVDIAG emitter (plan unit L0-D; spec §6/§7).
|
||||
//
|
||||
// Mirrors the canonical TS `emit.ts` contract for the .NET integrations:
|
||||
// • Tier resolution from the deployment environment, with the .NET-specific
|
||||
// precedence SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → ASPNETCORE_ENVIRONMENT
|
||||
// (the last is the .NET analogue of TS's NODE_ENV).
|
||||
// • Fail-closed DEBUG: the constructor throws (startup assertion — the ONE
|
||||
// place the emitter is permitted to throw) when DEBUG is requested in a
|
||||
// production env, when no env resolves (unknown == production), or when no
|
||||
// CVDIAG_DEBUG_ALLOW_LIST is provided.
|
||||
// • §6 tier matrix: a boundary is emitted only if included at the current
|
||||
// tier; `cvdiag.*` accounting boundaries are always emitted.
|
||||
// • DEBUG auto-off: after 10 minutes OR 10k events, DEBUG disarms and falls
|
||||
// back to default-tier inclusion.
|
||||
// • Closed-world metadata filter: unknown metadata keys for the boundary are
|
||||
// dropped and `_metadata_dropped` is stamped.
|
||||
// • Edge-header allow/deny filter (9 allow / 12 deny, exact-match, deny wins,
|
||||
// case-insensitive, no cf-ip* wildcard).
|
||||
// • Per-event byte cap by tier; over-budget envelopes are trimmed +
|
||||
// `_truncated` stamped.
|
||||
// • EmitEvent → single-line JSON to stdout, PLUS a background fire-and-forget
|
||||
// PocketBase write (HttpClient, ≤1s timeout) that never blocks or throws
|
||||
// into the observed boundary.
|
||||
//
|
||||
// Pure instrumentation: outside the constructor's startup guard, a CVDIAG
|
||||
// failure must NEVER throw into the caller (spec §7). All hot-path errors
|
||||
// degrade to a stderr CVDIAG-tagged warning.
|
||||
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Copilotkit.Showcase.Cvdiag;
|
||||
|
||||
/// <summary>Resolved verbosity tier (cumulative; spec §6).</summary>
|
||||
public enum CvdiagTier { Default, Verbose, Debug }
|
||||
|
||||
/// <summary>Construction options for <see cref="CvdiagEmitter"/>.</summary>
|
||||
public sealed class CvdiagEmitterOptions
|
||||
{
|
||||
/// <summary>Force DEBUG tier (subject to the fail-closed prod guard).</summary>
|
||||
public bool Debug { get; init; }
|
||||
/// <summary>Force VERBOSE tier (DEBUG wins if both set).</summary>
|
||||
public bool Verbose { get; init; }
|
||||
/// <summary>Environment bag; defaults to the process environment.</summary>
|
||||
public IReadOnlyDictionary<string, string?>? Env { get; init; }
|
||||
/// <summary>Owning layer for default envelope fields.</summary>
|
||||
public CvdiagLayer Layer { get; init; } = CvdiagLayer.Backend;
|
||||
/// <summary>PocketBase ingest URL; when null, no background write is attempted.</summary>
|
||||
public string? PbWriteUrl { get; init; }
|
||||
/// <summary>Injected HttpClient (tests); defaults to a shared 1s-timeout client.</summary>
|
||||
public HttpClient? HttpClient { get; init; }
|
||||
/// <summary>Emit the single-line JSON to stdout (default true).</summary>
|
||||
public bool WriteToStdout { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>Arguments for a single <see cref="CvdiagEmitter.Emit"/> call.</summary>
|
||||
public sealed class CvdiagEmitArgs
|
||||
{
|
||||
public required CvdiagLayer Layer { get; init; }
|
||||
public required CvdiagBoundary Boundary { get; init; }
|
||||
public required string Slug { get; init; }
|
||||
public required string Demo { get; init; }
|
||||
public required CvdiagOutcome Outcome { get; init; }
|
||||
public EdgeHeaders? EdgeHeaders { get; init; }
|
||||
public Dictionary<string, object?>? Metadata { get; init; }
|
||||
public long? DurationMs { get; init; }
|
||||
public string? ParentSpanId { get; init; }
|
||||
/// <summary>Override test_id (e.g. probe.start mints one and threads it).</summary>
|
||||
public string? TestId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CvdiagEmitter
|
||||
{
|
||||
// §6 hard bounds + §7 byte caps (mirror emit.ts constants).
|
||||
private const long DebugMaxWallclockMs = 10 * 60 * 1000;
|
||||
private const int DebugMaxEvents = 10_000;
|
||||
private static readonly IReadOnlyDictionary<CvdiagTier, int> ByteCapByTier =
|
||||
new Dictionary<CvdiagTier, int>
|
||||
{
|
||||
[CvdiagTier.Default] = 2 * 1024,
|
||||
[CvdiagTier.Verbose] = 4 * 1024,
|
||||
[CvdiagTier.Debug] = 16 * 1024,
|
||||
};
|
||||
|
||||
private const string AccountingPrefix = "cvdiag.";
|
||||
|
||||
// The 12-name DENY list (spec §5). Exact-match, lowercase, deny wins; the
|
||||
// cf-ip* family is blocked by these explicit entries, NOT a wildcard.
|
||||
private static readonly HashSet<string> DenyList = new(StringComparer.Ordinal)
|
||||
{
|
||||
"cf-ipcountry", "cf-connecting-ip", "cf-ipcity", "cf-iplatitude",
|
||||
"cf-iplongitude", "cf-iptimezone", "cf-visitor", "cf-worker",
|
||||
"true-client-ip", "x-forwarded-for", "x-real-ip", "forwarded",
|
||||
};
|
||||
private static readonly HashSet<string> AllowList =
|
||||
new(CvdiagSchema.EdgeHeaderKeys, StringComparer.Ordinal);
|
||||
|
||||
// §6 tier matrix: per data-plane boundary, included-at-tier flags.
|
||||
private static readonly IReadOnlyDictionary<string, (bool Default, bool Verbose, bool Debug)> TierMatrix =
|
||||
new Dictionary<string, (bool, bool, bool)>
|
||||
{
|
||||
["probe.start"] = (false, true, true),
|
||||
["probe.navigate.complete"] = (false, true, true),
|
||||
["probe.message.send"] = (true, true, true),
|
||||
["probe.dom.container.mount"] = (true, true, true),
|
||||
["probe.dom.firsttoken"] = (true, true, true),
|
||||
["probe.dom.alternate_content"] = (true, true, true),
|
||||
["probe.sse.event"] = (false, true, true),
|
||||
["probe.sse.aborted"] = (true, true, true),
|
||||
["probe.network.error"] = (true, true, true),
|
||||
["probe.network.response"] = (true, true, true),
|
||||
["probe.console.error"] = (true, true, true),
|
||||
["probe.exit"] = (true, true, true),
|
||||
["backend.request.ingress"] = (false, true, true),
|
||||
["backend.agent.enter"] = (true, true, true),
|
||||
["backend.llm.call.start"] = (false, true, true),
|
||||
["backend.llm.call.heartbeat"] = (false, true, true),
|
||||
["backend.llm.call.response"] = (false, true, true),
|
||||
["backend.sse.first_byte"] = (false, true, true),
|
||||
["backend.sse.event"] = (false, false, true),
|
||||
["backend.sse.aborted"] = (true, true, true),
|
||||
["backend.agent.exit"] = (true, true, true),
|
||||
["backend.response.complete"] = (true, true, true),
|
||||
["backend.error.caught"] = (true, true, true),
|
||||
["aimock.request.ingress"] = (false, true, true),
|
||||
["aimock.match.decision"] = (false, true, true),
|
||||
["aimock.response.start"] = (false, true, true),
|
||||
["aimock.sse.chunk"] = (false, false, true),
|
||||
["aimock.response.aborted"] = (true, true, true),
|
||||
["aimock.response.complete"] = (true, true, true),
|
||||
};
|
||||
|
||||
private static readonly HttpClient SharedHttpClient =
|
||||
new() { Timeout = TimeSpan.FromSeconds(1) };
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string?> _env;
|
||||
private readonly CvdiagLayer _defaultLayer;
|
||||
private readonly string? _pbWriteUrl;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _writeToStdout;
|
||||
private readonly long _debugDeadlineMs;
|
||||
private readonly Stopwatch _mono = Stopwatch.StartNew();
|
||||
|
||||
private long _debugEventCount;
|
||||
private bool _debugDisarmed;
|
||||
|
||||
public CvdiagTier Tier { get; }
|
||||
|
||||
public CvdiagEmitter(CvdiagEmitterOptions? options = null)
|
||||
{
|
||||
options ??= new CvdiagEmitterOptions();
|
||||
_env = options.Env ?? ReadProcessEnv();
|
||||
_defaultLayer = options.Layer;
|
||||
_pbWriteUrl = options.PbWriteUrl;
|
||||
_httpClient = options.HttpClient ?? SharedHttpClient;
|
||||
_writeToStdout = options.WriteToStdout;
|
||||
|
||||
var wantsDebug = options.Debug || _env.GetValueOrDefault("CVDIAG_DEBUG") == "1";
|
||||
var wantsVerbose = options.Verbose || _env.GetValueOrDefault("CVDIAG_VERBOSE") == "1";
|
||||
|
||||
if (wantsDebug)
|
||||
{
|
||||
AssertDebugAllowed();
|
||||
Tier = CvdiagTier.Debug;
|
||||
_debugDeadlineMs = NowMs() + DebugMaxWallclockMs;
|
||||
}
|
||||
else
|
||||
{
|
||||
Tier = wantsVerbose ? CvdiagTier.Verbose : CvdiagTier.Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the deployment-environment label (spec §6 production detection):
|
||||
/// SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → ASPNETCORE_ENVIRONMENT.
|
||||
/// Returns the lowercase label, or null if none resolves.
|
||||
/// </summary>
|
||||
public static string? ResolveEnvLabel(IReadOnlyDictionary<string, string?> env)
|
||||
{
|
||||
var raw = env.GetValueOrDefault("SHOWCASE_ENV")
|
||||
?? env.GetValueOrDefault("RAILWAY_ENVIRONMENT_NAME")
|
||||
?? env.GetValueOrDefault("ASPNETCORE_ENVIRONMENT");
|
||||
return string.IsNullOrEmpty(raw) ? null : raw.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DEBUG startup assertion (spec §6 hard bounds). Throws (fail-closed) when
|
||||
/// the env is production, unresolved (unknown == production), or no
|
||||
/// allow-list is provided. The ONE place the emitter may throw.
|
||||
/// </summary>
|
||||
private void AssertDebugAllowed()
|
||||
{
|
||||
var label = ResolveEnvLabel(_env);
|
||||
if (label is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"CVDIAG_DEBUG refused: deployment environment is unresolved " +
|
||||
"(SHOWCASE_ENV → RAILWAY_ENVIRONMENT_NAME → ASPNETCORE_ENVIRONMENT all unset); " +
|
||||
"fail-closed treats unknown env as production.");
|
||||
}
|
||||
if (label == "production")
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"CVDIAG_DEBUG refused: deployment environment is production.");
|
||||
}
|
||||
var allowList = _env.GetValueOrDefault("CVDIAG_DEBUG_ALLOW_LIST");
|
||||
if (string.IsNullOrWhiteSpace(allowList))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"CVDIAG_DEBUG refused: CVDIAG_DEBUG_ALLOW_LIST is required " +
|
||||
"(comma-separated slug list) before DEBUG may start.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True iff the boundary is included at the current tier.</summary>
|
||||
public bool ShouldEmit(CvdiagBoundary boundary)
|
||||
{
|
||||
var wire = CvdiagSchema.WireValue(boundary);
|
||||
if (wire.StartsWith(AccountingPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return true; // accounting events always emit
|
||||
}
|
||||
if (Tier == CvdiagTier.Debug && IsDebugExpired())
|
||||
{
|
||||
return TierMatrix.TryGetValue(wire, out var fb) && fb.Default;
|
||||
}
|
||||
if (!TierMatrix.TryGetValue(wire, out var row)) return false;
|
||||
return Tier switch
|
||||
{
|
||||
CvdiagTier.Default => row.Default,
|
||||
CvdiagTier.Verbose => row.Verbose,
|
||||
CvdiagTier.Debug => row.Debug,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsDebugExpired()
|
||||
{
|
||||
if (_debugDisarmed) return true;
|
||||
if (NowMs() >= _debugDeadlineMs || _debugEventCount >= DebugMaxEvents)
|
||||
{
|
||||
_debugDisarmed = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit one event: tier-filter, mint ids, closed-world metadata filter,
|
||||
/// byte-cap, then EmitEvent (stdout + background PB write). Returns the
|
||||
/// built envelope, or null when filtered out / on failure. Never throws.
|
||||
/// </summary>
|
||||
public CvdiagEnvelope? Emit(CvdiagEmitArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ShouldEmit(args.Boundary)) return null;
|
||||
if (Tier == CvdiagTier.Debug) _debugEventCount++;
|
||||
|
||||
var wire = CvdiagSchema.WireValue(args.Boundary);
|
||||
var isDataPlane = !wire.StartsWith(AccountingPrefix, StringComparison.Ordinal);
|
||||
|
||||
var metadata = new Dictionary<string, object?>();
|
||||
var metadataDropped = false;
|
||||
if (isDataPlane)
|
||||
{
|
||||
(metadata, metadataDropped) = FilterMetadata(wire, args.Metadata);
|
||||
}
|
||||
else if (args.Metadata is not null)
|
||||
{
|
||||
// Accounting events ride their payload verbatim (trusted internal).
|
||||
metadata = new Dictionary<string, object?>(args.Metadata);
|
||||
}
|
||||
|
||||
var testId = args.TestId ?? MintTestId();
|
||||
var envelope = new CvdiagEnvelope
|
||||
{
|
||||
SchemaVersion = CvdiagSchema.SchemaVersion,
|
||||
TestId = testId,
|
||||
TraceId = testId,
|
||||
SpanId = MintSpanId(),
|
||||
ParentSpanId = args.ParentSpanId,
|
||||
Layer = args.Layer,
|
||||
Boundary = args.Boundary,
|
||||
Slug = args.Slug,
|
||||
Demo = args.Demo,
|
||||
Ts = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
|
||||
MonoNs = MonoNs(),
|
||||
DurationMs = args.DurationMs,
|
||||
Outcome = args.Outcome,
|
||||
EdgeHeaders = args.EdgeHeaders ?? new EdgeHeaders(),
|
||||
Metadata = metadata,
|
||||
MetadataDropped = metadataDropped,
|
||||
};
|
||||
|
||||
ApplyByteCap(envelope);
|
||||
EmitEvent(envelope);
|
||||
return envelope;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"CVDIAG emit failed boundary={args.Boundary} error={ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EmitEvent: write the single-line JSON to stdout AND kick off a
|
||||
/// fire-and-forget background PB write (≤1s). Never blocks the caller;
|
||||
/// background failures are swallowed to a stderr warning.
|
||||
/// </summary>
|
||||
public void EmitEvent(CvdiagEnvelope envelope)
|
||||
{
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = JsonSerializer.Serialize(envelope, CvdiagJsonContext.Default.CvdiagEnvelope);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"CVDIAG serialize failed error={ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_writeToStdout) Console.Out.WriteLine(json);
|
||||
|
||||
if (string.IsNullOrEmpty(_pbWriteUrl)) return;
|
||||
// Fire-and-forget: do not await, do not throw into the boundary.
|
||||
_ = BackgroundWriteAsync(json);
|
||||
}
|
||||
|
||||
private async Task BackgroundWriteAsync(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
|
||||
await _httpClient.PostAsync(_pbWriteUrl, content, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"CVDIAG pb-write failed error={ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closed-world metadata filter: keep only the boundary's schema keys; drop
|
||||
/// the rest and flag <c>_metadata_dropped</c> (spec §6). If the boundary has
|
||||
/// no key set (shouldn't happen for data-plane), keep nothing and flag drop.
|
||||
/// </summary>
|
||||
private static (Dictionary<string, object?> Metadata, bool Dropped) FilterMetadata(
|
||||
string wireBoundary, Dictionary<string, object?>? raw)
|
||||
{
|
||||
var result = new Dictionary<string, object?>();
|
||||
if (raw is null || raw.Count == 0) return (result, false);
|
||||
if (!CvdiagSchema.BoundaryMetadataKeys.TryGetValue(wireBoundary, out var allowed))
|
||||
{
|
||||
return (result, true);
|
||||
}
|
||||
var allowedSet = new HashSet<string>(allowed, StringComparer.Ordinal);
|
||||
var dropped = false;
|
||||
foreach (var (key, value) in raw)
|
||||
{
|
||||
if (allowedSet.Contains(key)) result[key] = value;
|
||||
else dropped = true;
|
||||
}
|
||||
return (result, dropped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter a raw header bag to the closed 9-key <see cref="EdgeHeaders"/>:
|
||||
/// deny-list rejected first (deny wins, case-insensitive); non-allow keys
|
||||
/// dropped; every result carries all 9 keys (absent → null). No cf-ip*
|
||||
/// wildcard — only exact deny-list entries.
|
||||
/// </summary>
|
||||
public static EdgeHeaders FilterEdgeHeaders(IReadOnlyDictionary<string, string?> raw)
|
||||
{
|
||||
var kept = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (var (rawKey, rawValue) in raw)
|
||||
{
|
||||
var key = rawKey.ToLowerInvariant();
|
||||
if (DenyList.Contains(key)) continue; // deny wins
|
||||
if (!AllowList.Contains(key)) continue; // closed-world
|
||||
kept[key] = rawValue;
|
||||
}
|
||||
string? Get(string k) => kept.TryGetValue(k, out var v) ? v : null;
|
||||
return new EdgeHeaders
|
||||
{
|
||||
CfRay = Get("cf-ray"),
|
||||
CfMitigated = Get("cf-mitigated"),
|
||||
CfCacheStatus = Get("cf-cache-status"),
|
||||
XRailwayEdge = Get("x-railway-edge"),
|
||||
XRailwayRequestId = Get("x-railway-request-id"),
|
||||
XHikariTrace = Get("x-hikari-trace"),
|
||||
RetryAfter = Get("retry-after"),
|
||||
Via = Get("via"),
|
||||
Server = Get("server"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trim over-budget envelopes to the tier byte cap and stamp
|
||||
/// <c>_truncated</c> (spec §7). Metadata string values are trimmed first.
|
||||
/// </summary>
|
||||
private void ApplyByteCap(CvdiagEnvelope envelope)
|
||||
{
|
||||
var cap = ByteCapByTier[Tier];
|
||||
if (SerializedSize(envelope) <= cap) return;
|
||||
envelope.Truncated = true;
|
||||
foreach (var key in new List<string>(envelope.Metadata.Keys))
|
||||
{
|
||||
if (SerializedSize(envelope) <= cap) break;
|
||||
var value = envelope.Metadata[key];
|
||||
if (value is string s && s.Length > 64)
|
||||
{
|
||||
envelope.Metadata[key] = s[..61] + "...";
|
||||
}
|
||||
else if (value is not null && value is not (int or long or double or bool))
|
||||
{
|
||||
envelope.Metadata[key] = "[truncated]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int SerializedSize(CvdiagEnvelope envelope)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(envelope, CvdiagJsonContext.Default.CvdiagEnvelope);
|
||||
return Encoding.UTF8.GetByteCount(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mint a UUIDv7 (RFC 9562): 48-bit Unix-ms timestamp, version nibble 7,
|
||||
/// variant bits 10. Lowercase hyphenated. Matches the TS mintTestId().
|
||||
/// </summary>
|
||||
public static string MintTestId(long? nowMs = null)
|
||||
{
|
||||
var bytes = new byte[16];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
var ts = (ulong)(nowMs ?? NowMs());
|
||||
bytes[0] = (byte)((ts >> 40) & 0xff);
|
||||
bytes[1] = (byte)((ts >> 32) & 0xff);
|
||||
bytes[2] = (byte)((ts >> 24) & 0xff);
|
||||
bytes[3] = (byte)((ts >> 16) & 0xff);
|
||||
bytes[4] = (byte)((ts >> 8) & 0xff);
|
||||
bytes[5] = (byte)(ts & 0xff);
|
||||
bytes[6] = (byte)((bytes[6] & 0x0f) | 0x70); // version 7
|
||||
bytes[8] = (byte)((bytes[8] & 0x3f) | 0x80); // variant 10
|
||||
var hex = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
return $"{hex[..8]}-{hex[8..12]}-{hex[12..16]}-{hex[16..20]}-{hex[20..32]}";
|
||||
}
|
||||
|
||||
/// <summary>Mint a 16-hex span id (8 random bytes).</summary>
|
||||
public static string MintSpanId()
|
||||
{
|
||||
var bytes = new byte[8];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private long MonoNs() => (long)(_mono.Elapsed.TotalMilliseconds * 1e6);
|
||||
|
||||
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
private static IReadOnlyDictionary<string, string?> ReadProcessEnv()
|
||||
{
|
||||
var dict = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
foreach (System.Collections.DictionaryEntry e in Environment.GetEnvironmentVariables())
|
||||
{
|
||||
if (e.Key is string k) dict[k] = e.Value as string;
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// <auto-generated>
|
||||
// CvdiagSchema.cs — C# binding for the CVDIAG flap-observability envelope.
|
||||
// CODEGEN'd from the single IR `showcase/harness/src/cvdiag/schema.json`
|
||||
// ($id https://copilotkit.ai/schemas/cvdiag/v1.json, schema_version 1).
|
||||
// Plan unit L0-D; spec §5. Mirrors the canonical TS `schema.ts` / Pydantic /
|
||||
// Java bindings so all five languages share one wire contract.
|
||||
//
|
||||
// Source of truth: schema.json. To regenerate, re-run the .NET codegen step
|
||||
// (see L1-F build-context note in the slot report) — DO NOT hand-edit the
|
||||
// enum members / record fields below; edit schema.ts (→ schema.json) and
|
||||
// re-emit. The 3 enums, the EdgeHeaders record, the CvdiagEnvelope record,
|
||||
// the 29 per-boundary metadata records, and the closed key tables below are
|
||||
// derived 1:1 from schema.json `$defs`.
|
||||
//
|
||||
// System.Text.Json source-generation: `CvdiagJsonContext` carries the
|
||||
// [JsonSerializable] attributes so serialization is AOT/trim-safe and never
|
||||
// uses reflection-based serializers at runtime.
|
||||
// </auto-generated>
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Copilotkit.Showcase.Cvdiag;
|
||||
|
||||
/// <summary>Emitting layer (schema.json `$defs.layers`).</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CvdiagLayer>))]
|
||||
public enum CvdiagLayer
|
||||
{
|
||||
[JsonStringEnumMemberName("probe")] Probe,
|
||||
[JsonStringEnumMemberName("backend")] Backend,
|
||||
[JsonStringEnumMemberName("aimock")] Aimock,
|
||||
}
|
||||
|
||||
/// <summary>Event outcome (schema.json `$defs.outcomes`).</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CvdiagOutcome>))]
|
||||
public enum CvdiagOutcome
|
||||
{
|
||||
[JsonStringEnumMemberName("ok")] Ok,
|
||||
[JsonStringEnumMemberName("err")] Err,
|
||||
[JsonStringEnumMemberName("timeout")] Timeout,
|
||||
[JsonStringEnumMemberName("info")] Info,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closed boundary enum (schema.json `$defs.boundaries`, 33 members). The
|
||||
/// dotted wire values (e.g. "backend.response.complete") are carried by the
|
||||
/// [JsonStringEnumMemberName] attributes; the C# member names are the
|
||||
/// PascalCase'd, dot-stripped equivalents.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CvdiagBoundary>))]
|
||||
public enum CvdiagBoundary
|
||||
{
|
||||
[JsonStringEnumMemberName("probe.start")] ProbeStart,
|
||||
[JsonStringEnumMemberName("probe.navigate.complete")] ProbeNavigateComplete,
|
||||
[JsonStringEnumMemberName("probe.message.send")] ProbeMessageSend,
|
||||
[JsonStringEnumMemberName("probe.dom.container.mount")] ProbeDomContainerMount,
|
||||
[JsonStringEnumMemberName("probe.dom.firsttoken")] ProbeDomFirsttoken,
|
||||
[JsonStringEnumMemberName("probe.dom.alternate_content")] ProbeDomAlternateContent,
|
||||
[JsonStringEnumMemberName("probe.sse.event")] ProbeSseEvent,
|
||||
[JsonStringEnumMemberName("probe.sse.aborted")] ProbeSseAborted,
|
||||
[JsonStringEnumMemberName("probe.network.error")] ProbeNetworkError,
|
||||
[JsonStringEnumMemberName("probe.network.response")] ProbeNetworkResponse,
|
||||
[JsonStringEnumMemberName("probe.console.error")] ProbeConsoleError,
|
||||
[JsonStringEnumMemberName("probe.exit")] ProbeExit,
|
||||
[JsonStringEnumMemberName("backend.request.ingress")] BackendRequestIngress,
|
||||
[JsonStringEnumMemberName("backend.agent.enter")] BackendAgentEnter,
|
||||
[JsonStringEnumMemberName("backend.llm.call.start")] BackendLlmCallStart,
|
||||
[JsonStringEnumMemberName("backend.llm.call.heartbeat")] BackendLlmCallHeartbeat,
|
||||
[JsonStringEnumMemberName("backend.llm.call.response")] BackendLlmCallResponse,
|
||||
[JsonStringEnumMemberName("backend.sse.first_byte")] BackendSseFirstByte,
|
||||
[JsonStringEnumMemberName("backend.sse.event")] BackendSseEvent,
|
||||
[JsonStringEnumMemberName("backend.sse.aborted")] BackendSseAborted,
|
||||
[JsonStringEnumMemberName("backend.agent.exit")] BackendAgentExit,
|
||||
[JsonStringEnumMemberName("backend.response.complete")] BackendResponseComplete,
|
||||
[JsonStringEnumMemberName("backend.error.caught")] BackendErrorCaught,
|
||||
[JsonStringEnumMemberName("aimock.request.ingress")] AimockRequestIngress,
|
||||
[JsonStringEnumMemberName("aimock.match.decision")] AimockMatchDecision,
|
||||
[JsonStringEnumMemberName("aimock.response.start")] AimockResponseStart,
|
||||
[JsonStringEnumMemberName("aimock.sse.chunk")] AimockSseChunk,
|
||||
[JsonStringEnumMemberName("aimock.response.aborted")] AimockResponseAborted,
|
||||
[JsonStringEnumMemberName("aimock.response.complete")] AimockResponseComplete,
|
||||
[JsonStringEnumMemberName("cvdiag.purge_audit")] CvdiagPurgeAudit,
|
||||
[JsonStringEnumMemberName("cvdiag.collision_detected")] CvdiagCollisionDetected,
|
||||
[JsonStringEnumMemberName("cvdiag.queue_dropped")] CvdiagQueueDropped,
|
||||
[JsonStringEnumMemberName("cvdiag.metadata_dropped")] CvdiagMetadataDropped,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The closed 9-key edge-header bag (schema.json `edge_headers`). Every field
|
||||
/// is always present on the wire; an absent header serializes as null. The
|
||||
/// hyphenated wire names are carried by [JsonPropertyName].
|
||||
/// </summary>
|
||||
public sealed record EdgeHeaders
|
||||
{
|
||||
[JsonPropertyName("cf-ray")] public string? CfRay { get; init; }
|
||||
[JsonPropertyName("cf-mitigated")] public string? CfMitigated { get; init; }
|
||||
[JsonPropertyName("cf-cache-status")] public string? CfCacheStatus { get; init; }
|
||||
[JsonPropertyName("x-railway-edge")] public string? XRailwayEdge { get; init; }
|
||||
[JsonPropertyName("x-railway-request-id")] public string? XRailwayRequestId { get; init; }
|
||||
[JsonPropertyName("x-hikari-trace")] public string? XHikariTrace { get; init; }
|
||||
[JsonPropertyName("retry-after")] public string? RetryAfter { get; init; }
|
||||
[JsonPropertyName("via")] public string? Via { get; init; }
|
||||
[JsonPropertyName("server")] public string? Server { get; init; }
|
||||
|
||||
/// <summary>Always 9 — the closed allow-list cardinality.</summary>
|
||||
public int KeyCount() => 9;
|
||||
|
||||
/// <summary>All 9 field values (for deny-leakage assertions/inspection).</summary>
|
||||
public IReadOnlyList<string?> AllValues() => new[]
|
||||
{
|
||||
CfRay, CfMitigated, CfCacheStatus, XRailwayEdge, XRailwayRequestId,
|
||||
XHikariTrace, RetryAfter, Via, Server,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The CVDIAG envelope (schema.json root). Field/property names map to the
|
||||
/// snake_case wire keys via [JsonPropertyName]; the two optional `_`-prefixed
|
||||
/// stamp flags are written only when true (DefaultIgnoreCondition handles the
|
||||
/// false/null case via the source-gen context options).
|
||||
/// </summary>
|
||||
public sealed record CvdiagEnvelope
|
||||
{
|
||||
[JsonPropertyName("schema_version")] public int SchemaVersion { get; init; } = CvdiagSchema.SchemaVersion;
|
||||
[JsonPropertyName("test_id")] public string TestId { get; init; } = "";
|
||||
[JsonPropertyName("trace_id")] public string TraceId { get; init; } = "";
|
||||
[JsonPropertyName("span_id")] public string SpanId { get; init; } = "";
|
||||
[JsonPropertyName("parent_span_id")] public string? ParentSpanId { get; init; }
|
||||
[JsonPropertyName("layer")] public CvdiagLayer Layer { get; init; }
|
||||
[JsonPropertyName("boundary")] public CvdiagBoundary Boundary { get; init; }
|
||||
[JsonPropertyName("slug")] public string Slug { get; init; } = "";
|
||||
[JsonPropertyName("demo")] public string Demo { get; init; } = "";
|
||||
[JsonPropertyName("ts")] public string Ts { get; init; } = "";
|
||||
[JsonPropertyName("mono_ns")] public long MonoNs { get; init; }
|
||||
[JsonPropertyName("duration_ms")] public long? DurationMs { get; init; }
|
||||
[JsonPropertyName("outcome")] public CvdiagOutcome Outcome { get; init; }
|
||||
[JsonPropertyName("edge_headers")] public EdgeHeaders EdgeHeaders { get; init; } = new();
|
||||
[JsonPropertyName("metadata")] public Dictionary<string, object?> Metadata { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("_metadata_dropped")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool MetadataDropped { get; set; }
|
||||
|
||||
[JsonPropertyName("_truncated")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool Truncated { get; set; }
|
||||
}
|
||||
|
||||
// ── Per-boundary metadata records (29; schema.json `$defs.boundary_metadata_keys`) ──
|
||||
//
|
||||
// One record per metadata-bearing boundary. These are convenience/typed views
|
||||
// over the boundary's closed key set; the on-wire `metadata` bag stays an open
|
||||
// JSON object (Dictionary<string, object?>) so a single envelope type carries
|
||||
// every boundary. Emitters MAY construct these and pass their property bag to
|
||||
// CvdiagEmitArgs.Metadata. Field names mirror the schema.json metadata keys.
|
||||
|
||||
public sealed record ProbeStartMeta { public string? Url { get; init; } public string? Viewport { get; init; } }
|
||||
public sealed record ProbeNavigateCompleteMeta { public string? Url { get; init; } public long? NavMs { get; init; } public int? HttpStatus { get; init; } }
|
||||
public sealed record ProbeMessageSendMeta { public int? MessageIndex { get; init; } public int? CharCount { get; init; } public string? Demo { get; init; } }
|
||||
public sealed record ProbeDomContainerMountMeta { public long? DeltaMsFromStart { get; init; } }
|
||||
public sealed record ProbeDomFirsttokenMeta { public long? DeltaMsFromStart { get; init; } public int? TextLength { get; init; } }
|
||||
public sealed record ProbeDomAlternateContentMeta { public Dictionary<string, int>? ChildTypeHistogram { get; init; } }
|
||||
public sealed record ProbeSseEventMeta { public string? EventType { get; init; } public int? PayloadSizeBytes { get; init; } public int? SequenceNum { get; init; } }
|
||||
public sealed record ProbeSseAbortedMeta { public string? TerminationKind { get; init; } public int? BytesBeforeAbort { get; init; } }
|
||||
public sealed record ProbeNetworkErrorMeta { public string? Url { get; init; } public string? ErrorClass { get; init; } public int? ResponseStatus { get; init; } }
|
||||
public sealed record ProbeNetworkResponseMeta { public string? Url { get; init; } public int? Status { get; init; } public int? ContentLength { get; init; } public long? DurationMs { get; init; } }
|
||||
public sealed record ProbeConsoleErrorMeta { public string? Level { get; init; } public string? MessageScrubbed { get; init; } public string? SourceFile { get; init; } public string? LineCol { get; init; } }
|
||||
public sealed record ProbeExitMeta { public string? TerminalOutcome { get; init; } public long? TotalDurationMs { get; init; } public int? SseEventCount { get; init; } public long? FirstTokenDeltaMs { get; init; } }
|
||||
public sealed record BackendRequestIngressMeta { public string? Method { get; init; } public string? Path { get; init; } public int? ContentLength { get; init; } }
|
||||
public sealed record BackendAgentEnterMeta { public string? AgentName { get; init; } public string? ModelId { get; init; } }
|
||||
public sealed record BackendLlmCallStartMeta { public string? Provider { get; init; } public string? Model { get; init; } public int? PromptTokenCountEstimate { get; init; } }
|
||||
public sealed record BackendLlmCallHeartbeatMeta { public long? ElapsedMsSinceStart { get; init; } }
|
||||
public sealed record BackendLlmCallResponseMeta { public string? Provider { get; init; } public string? Model { get; init; } public int? ResponseTokenCount { get; init; } public long? LatencyMs { get; init; } public string? ErrorClass { get; init; } }
|
||||
public sealed record BackendSseFirstByteMeta { public long? DeltaMsFromIngress { get; init; } }
|
||||
public sealed record BackendSseEventMeta { public string? EventType { get; init; } public int? PayloadSizeBytes { get; init; } public int? SequenceNum { get; init; } }
|
||||
public sealed record BackendSseAbortedMeta { public string? TerminationKind { get; init; } public int? BytesBeforeAbort { get; init; } }
|
||||
public sealed record BackendAgentExitMeta { public string? TerminalOutcome { get; init; } public long? TotalDurationMs { get; init; } }
|
||||
public sealed record BackendResponseCompleteMeta { public int? HttpStatus { get; init; } public int? ContentLength { get; init; } public long? TotalDurationMs { get; init; } public int? SseEventCount { get; init; } }
|
||||
public sealed record BackendErrorCaughtMeta { public string? ExceptionType { get; init; } public string? MessageScrubbed { get; init; } public string? StackBrief { get; init; } public bool? Truncated { get; init; } }
|
||||
public sealed record AimockRequestIngressMeta { public string? Path { get; init; } public int? ContentLength { get; init; } public string? MatchKeys { get; init; } }
|
||||
public sealed record AimockMatchDecisionMeta { public string? FixtureId { get; init; } public double? MatchScore { get; init; } public IReadOnlyList<string>? RejectReasons { get; init; } }
|
||||
public sealed record AimockResponseStartMeta { public long? DeltaMsFromIngress { get; init; } }
|
||||
public sealed record AimockSseChunkMeta { public int? ChunkSizeBytes { get; init; } public int? SequenceNum { get; init; } }
|
||||
public sealed record AimockResponseAbortedMeta { public string? TerminationKind { get; init; } public int? BytesBeforeAbort { get; init; } }
|
||||
public sealed record AimockResponseCompleteMeta { public int? HttpStatus { get; init; } public int? TotalBytes { get; init; } public long? TotalDurationMs { get; init; } public int? ChunkCount { get; init; } }
|
||||
|
||||
/// <summary>
|
||||
/// Static schema tables derived 1:1 from schema.json `$defs`. These back the
|
||||
/// closed-world metadata filter in <see cref="CvdiagEmitter"/> and the
|
||||
/// codegen-coverage assertions.
|
||||
/// </summary>
|
||||
public static class CvdiagSchema
|
||||
{
|
||||
/// <summary>schema.json `schema_version` (const 1).</summary>
|
||||
public const int SchemaVersion = 1;
|
||||
|
||||
/// <summary>schema.json `$defs.edge_header_keys` (9 keys, wire order).</summary>
|
||||
public static readonly IReadOnlyList<string> EdgeHeaderKeys = new[]
|
||||
{
|
||||
"cf-ray", "cf-mitigated", "cf-cache-status", "x-railway-edge",
|
||||
"x-railway-request-id", "x-hikari-trace", "retry-after", "via", "server",
|
||||
};
|
||||
|
||||
/// <summary>schema.json `$defs.boundaries` (all 33 wire values).</summary>
|
||||
public static readonly IReadOnlyList<string> AllBoundaries = new[]
|
||||
{
|
||||
"probe.start", "probe.navigate.complete", "probe.message.send",
|
||||
"probe.dom.container.mount", "probe.dom.firsttoken",
|
||||
"probe.dom.alternate_content", "probe.sse.event", "probe.sse.aborted",
|
||||
"probe.network.error", "probe.network.response", "probe.console.error",
|
||||
"probe.exit", "backend.request.ingress", "backend.agent.enter",
|
||||
"backend.llm.call.start", "backend.llm.call.heartbeat",
|
||||
"backend.llm.call.response", "backend.sse.first_byte", "backend.sse.event",
|
||||
"backend.sse.aborted", "backend.agent.exit", "backend.response.complete",
|
||||
"backend.error.caught", "aimock.request.ingress", "aimock.match.decision",
|
||||
"aimock.response.start", "aimock.sse.chunk", "aimock.response.aborted",
|
||||
"aimock.response.complete", "cvdiag.purge_audit",
|
||||
"cvdiag.collision_detected", "cvdiag.queue_dropped",
|
||||
"cvdiag.metadata_dropped",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// schema.json `$defs.boundary_metadata_keys` — the closed metadata key
|
||||
/// set per data-plane boundary (29 entries; the 4 `cvdiag.*` accounting
|
||||
/// boundaries carry no closed key set and ride their payload verbatim).
|
||||
/// Keyed by the dotted wire boundary value.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, IReadOnlyList<string>> BoundaryMetadataKeys =
|
||||
new Dictionary<string, IReadOnlyList<string>>
|
||||
{
|
||||
["probe.start"] = new[] { "url", "viewport" },
|
||||
["probe.navigate.complete"] = new[] { "url", "nav_ms", "http_status" },
|
||||
["probe.message.send"] = new[] { "message_index", "char_count", "demo" },
|
||||
["probe.dom.container.mount"] = new[] { "delta_ms_from_start" },
|
||||
["probe.dom.firsttoken"] = new[] { "delta_ms_from_start", "text_length" },
|
||||
["probe.dom.alternate_content"] = new[] { "child_type_histogram" },
|
||||
["probe.sse.event"] = new[] { "event_type", "payload_size_bytes", "sequence_num" },
|
||||
["probe.sse.aborted"] = new[] { "termination_kind", "bytes_before_abort" },
|
||||
["probe.network.error"] = new[] { "url", "error_class", "response_status" },
|
||||
["probe.network.response"] = new[] { "url", "status", "content_length", "duration_ms" },
|
||||
["probe.console.error"] = new[] { "level", "message_scrubbed", "source_file", "line_col" },
|
||||
["probe.exit"] = new[] { "terminal_outcome", "total_duration_ms", "sse_event_count", "first_token_delta_ms" },
|
||||
["backend.request.ingress"] = new[] { "method", "path", "content_length" },
|
||||
["backend.agent.enter"] = new[] { "agent_name", "model_id" },
|
||||
["backend.llm.call.start"] = new[] { "provider", "model", "prompt_token_count_estimate" },
|
||||
["backend.llm.call.heartbeat"] = new[] { "elapsed_ms_since_start" },
|
||||
["backend.llm.call.response"] = new[] { "provider", "model", "response_token_count", "latency_ms", "error_class" },
|
||||
["backend.sse.first_byte"] = new[] { "delta_ms_from_ingress" },
|
||||
["backend.sse.event"] = new[] { "event_type", "payload_size_bytes", "sequence_num" },
|
||||
["backend.sse.aborted"] = new[] { "termination_kind", "bytes_before_abort" },
|
||||
["backend.agent.exit"] = new[] { "terminal_outcome", "total_duration_ms" },
|
||||
["backend.response.complete"] = new[] { "http_status", "content_length", "total_duration_ms", "sse_event_count" },
|
||||
["backend.error.caught"] = new[] { "exception_type", "message_scrubbed", "stack_brief", "truncated" },
|
||||
["aimock.request.ingress"] = new[] { "path", "content_length", "match_keys" },
|
||||
["aimock.match.decision"] = new[] { "fixture_id", "match_score", "reject_reasons" },
|
||||
["aimock.response.start"] = new[] { "delta_ms_from_ingress" },
|
||||
["aimock.sse.chunk"] = new[] { "chunk_size_bytes", "sequence_num" },
|
||||
["aimock.response.aborted"] = new[] { "termination_kind", "bytes_before_abort" },
|
||||
["aimock.response.complete"] = new[] { "http_status", "total_bytes", "total_duration_ms", "chunk_count" },
|
||||
};
|
||||
|
||||
/// <summary>The dotted wire value for a boundary enum member.</summary>
|
||||
public static string WireValue(CvdiagBoundary boundary) => boundary switch
|
||||
{
|
||||
CvdiagBoundary.ProbeStart => "probe.start",
|
||||
CvdiagBoundary.ProbeNavigateComplete => "probe.navigate.complete",
|
||||
CvdiagBoundary.ProbeMessageSend => "probe.message.send",
|
||||
CvdiagBoundary.ProbeDomContainerMount => "probe.dom.container.mount",
|
||||
CvdiagBoundary.ProbeDomFirsttoken => "probe.dom.firsttoken",
|
||||
CvdiagBoundary.ProbeDomAlternateContent => "probe.dom.alternate_content",
|
||||
CvdiagBoundary.ProbeSseEvent => "probe.sse.event",
|
||||
CvdiagBoundary.ProbeSseAborted => "probe.sse.aborted",
|
||||
CvdiagBoundary.ProbeNetworkError => "probe.network.error",
|
||||
CvdiagBoundary.ProbeNetworkResponse => "probe.network.response",
|
||||
CvdiagBoundary.ProbeConsoleError => "probe.console.error",
|
||||
CvdiagBoundary.ProbeExit => "probe.exit",
|
||||
CvdiagBoundary.BackendRequestIngress => "backend.request.ingress",
|
||||
CvdiagBoundary.BackendAgentEnter => "backend.agent.enter",
|
||||
CvdiagBoundary.BackendLlmCallStart => "backend.llm.call.start",
|
||||
CvdiagBoundary.BackendLlmCallHeartbeat => "backend.llm.call.heartbeat",
|
||||
CvdiagBoundary.BackendLlmCallResponse => "backend.llm.call.response",
|
||||
CvdiagBoundary.BackendSseFirstByte => "backend.sse.first_byte",
|
||||
CvdiagBoundary.BackendSseEvent => "backend.sse.event",
|
||||
CvdiagBoundary.BackendSseAborted => "backend.sse.aborted",
|
||||
CvdiagBoundary.BackendAgentExit => "backend.agent.exit",
|
||||
CvdiagBoundary.BackendResponseComplete => "backend.response.complete",
|
||||
CvdiagBoundary.BackendErrorCaught => "backend.error.caught",
|
||||
CvdiagBoundary.AimockRequestIngress => "aimock.request.ingress",
|
||||
CvdiagBoundary.AimockMatchDecision => "aimock.match.decision",
|
||||
CvdiagBoundary.AimockResponseStart => "aimock.response.start",
|
||||
CvdiagBoundary.AimockSseChunk => "aimock.sse.chunk",
|
||||
CvdiagBoundary.AimockResponseAborted => "aimock.response.aborted",
|
||||
CvdiagBoundary.AimockResponseComplete => "aimock.response.complete",
|
||||
CvdiagBoundary.CvdiagPurgeAudit => "cvdiag.purge_audit",
|
||||
CvdiagBoundary.CvdiagCollisionDetected => "cvdiag.collision_detected",
|
||||
CvdiagBoundary.CvdiagQueueDropped => "cvdiag.queue_dropped",
|
||||
CvdiagBoundary.CvdiagMetadataDropped => "cvdiag.metadata_dropped",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(boundary), boundary, null),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// System.Text.Json source-generation context. Carries the [JsonSerializable]
|
||||
/// roots so serialization is reflection-free (AOT/trim-safe). The snake_case
|
||||
/// policy is NOT applied globally — every field already declares its exact
|
||||
/// wire name via [JsonPropertyName], so we keep the default naming and rely on
|
||||
/// the explicit attributes (avoids double-transforming hyphenated edge keys).
|
||||
///
|
||||
/// IMPORTANT: we do NOT set a context-wide DefaultIgnoreCondition. The schema
|
||||
/// REQUIRES the nullable fields (`parent_span_id`, `duration_ms`) to appear on
|
||||
/// the wire as `null`, so a global WhenWritingDefault/WhenWritingNull would
|
||||
/// drop them and break the closed-world `required` contract. The only fields
|
||||
/// that should be omitted when default are the two `_`-prefixed stamp flags,
|
||||
/// which carry their own per-property [JsonIgnore(WhenWritingDefault)].
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(WriteIndented = false)]
|
||||
[JsonSerializable(typeof(CvdiagEnvelope))]
|
||||
[JsonSerializable(typeof(EdgeHeaders))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
public partial class CvdiagJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Standalone xUnit test project for the shared .NET CVDIAG binding (plan unit
|
||||
L0-D). The `_shared/dotnet/` directory is a SOURCE-INCLUDE shared library
|
||||
(no production .csproj of its own — see the L1-F reference note in the slot
|
||||
report), so this test project compiles the two binding sources directly via
|
||||
<Compile Include> rather than a <ProjectReference>. This keeps the binding
|
||||
buildable/testable in isolation without a host integration.
|
||||
|
||||
System.Text.Json source-generation requires C# 9+ partial-class contexts and
|
||||
the JsonStringEnumMemberName attribute (net8.0+); we target net9.0 to match
|
||||
the .NET integrations (ProverbsAgent.csproj is net9.0).
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>Copilotkit.Showcase.Cvdiag.Tests</RootNamespace>
|
||||
<AssemblyName>Cvdiag.Schema.Tests</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Compile the shared binding sources directly (source-include model). -->
|
||||
<ItemGroup>
|
||||
<Compile Include="..\CvdiagSchema.cs" />
|
||||
<Compile Include="..\CvdiagEmitter.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,262 @@
|
||||
// CvdiagSchema.test.cs — xUnit red-green proof for the .NET CVDIAG codegen
|
||||
// binding (plan unit L0-D, spec §5/§6). Mirrors the TS `schema.test.ts` /
|
||||
// `emit.ts` contract:
|
||||
//
|
||||
// 1. schema round-trip — a fully-populated CvdiagEnvelope serializes to JSON
|
||||
// and deserializes back identically (System.Text.Json source-gen).
|
||||
// 2. forbidden-header rejection — CvdiagEmitter.FilterEdgeHeaders() drops a
|
||||
// DENY-list header (cf-ipcountry) and the cf-ip* family even if it is
|
||||
// injected alongside an allow-list header; every result carries all 9
|
||||
// allow-list keys (absent → null).
|
||||
// 3. _metadata_dropped stamp — an envelope carrying an unknown metadata key
|
||||
// is closed-world filtered and the `_metadata_dropped` flag is set.
|
||||
// 4. production-env DEBUG refusal — CvdiagEmitter fail-closes (throws) when
|
||||
// DEBUG is requested in a production environment, when no env resolves,
|
||||
// and when the allow-list is absent.
|
||||
//
|
||||
// RED: with no CvdiagSchema.cs / CvdiagEmitter.cs these types do not exist and
|
||||
// the suite does not compile. GREEN once both are implemented.
|
||||
//
|
||||
// Run (from repo root, requires the .NET 9 SDK):
|
||||
// dotnet test showcase/integrations/_shared/dotnet/tests/CvdiagSchema.Tests.csproj
|
||||
|
||||
using System.Text.Json;
|
||||
using Copilotkit.Showcase.Cvdiag;
|
||||
using Xunit;
|
||||
|
||||
namespace Copilotkit.Showcase.Cvdiag.Tests;
|
||||
|
||||
public class CvdiagSchemaTests
|
||||
{
|
||||
private static EdgeHeaders EmptyEdgeHeaders() => new();
|
||||
|
||||
private static CvdiagEnvelope SampleEnvelope() => new()
|
||||
{
|
||||
SchemaVersion = CvdiagSchema.SchemaVersion,
|
||||
TestId = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f",
|
||||
TraceId = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f",
|
||||
SpanId = "00f067aa0ba902b7",
|
||||
ParentSpanId = null,
|
||||
Layer = CvdiagLayer.Backend,
|
||||
Boundary = CvdiagBoundary.BackendResponseComplete,
|
||||
Slug = "gen-ui-chat",
|
||||
Demo = "default",
|
||||
Ts = "2026-06-18T12:00:00.000Z",
|
||||
MonoNs = 123456789,
|
||||
DurationMs = 42,
|
||||
Outcome = CvdiagOutcome.Ok,
|
||||
EdgeHeaders = EmptyEdgeHeaders(),
|
||||
Metadata = new Dictionary<string, object?> { ["http_status"] = 200 },
|
||||
};
|
||||
|
||||
// (1) Round-trip: serialize → deserialize → equal field-by-field.
|
||||
[Fact]
|
||||
public void Envelope_RoundTrips_ThroughSystemTextJson()
|
||||
{
|
||||
var original = SampleEnvelope();
|
||||
var json = JsonSerializer.Serialize(original, CvdiagJsonContext.Default.CvdiagEnvelope);
|
||||
var back = JsonSerializer.Deserialize(json, CvdiagJsonContext.Default.CvdiagEnvelope)!;
|
||||
|
||||
Assert.Equal(original.SchemaVersion, back.SchemaVersion);
|
||||
Assert.Equal(original.TestId, back.TestId);
|
||||
Assert.Equal(original.SpanId, back.SpanId);
|
||||
Assert.Equal(original.Layer, back.Layer);
|
||||
Assert.Equal(original.Boundary, back.Boundary);
|
||||
Assert.Equal(original.Slug, back.Slug);
|
||||
Assert.Equal(original.Outcome, back.Outcome);
|
||||
Assert.Equal(original.MonoNs, back.MonoNs);
|
||||
Assert.Equal(original.DurationMs, back.DurationMs);
|
||||
}
|
||||
|
||||
// (1b) Wire format: enum + key names match the schema.json contract exactly
|
||||
// (snake_case keys, dotted/lowercase enum string values).
|
||||
[Fact]
|
||||
public void Envelope_SerializesWithSchemaWireNames()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(SampleEnvelope(), CvdiagJsonContext.Default.CvdiagEnvelope);
|
||||
Assert.Contains("\"schema_version\":1", json);
|
||||
Assert.Contains("\"test_id\":", json);
|
||||
Assert.Contains("\"parent_span_id\":null", json);
|
||||
Assert.Contains("\"edge_headers\":", json);
|
||||
Assert.Contains("\"layer\":\"backend\"", json);
|
||||
Assert.Contains("\"boundary\":\"backend.response.complete\"", json);
|
||||
Assert.Contains("\"outcome\":\"ok\"", json);
|
||||
// cf-ray etc. serialize with their hyphenated wire names.
|
||||
Assert.Contains("\"cf-ray\":null", json);
|
||||
Assert.Contains("\"x-railway-request-id\":null", json);
|
||||
}
|
||||
|
||||
// (2) Forbidden-header rejection: cf-ipcountry (DENY) is dropped even when
|
||||
// injected next to an allow-list header; the cf-ip* family is never
|
||||
// captured; all 9 allow-list keys are present (absent → null).
|
||||
[Fact]
|
||||
public void FilterEdgeHeaders_RejectsDenyListAndCfIpFamily()
|
||||
{
|
||||
var raw = new Dictionary<string, string?>
|
||||
{
|
||||
["cf-ray"] = "8abc-DFW", // allow → kept
|
||||
["cf-ipcountry"] = "US", // deny → dropped
|
||||
["cf-connecting-ip"] = "1.2.3.4", // deny → dropped
|
||||
["true-client-ip"] = "1.2.3.4", // deny → dropped
|
||||
["x-forwarded-for"] = "1.2.3.4", // deny → dropped
|
||||
["CF-IPCity"] = "Dallas", // deny (case-insensitive)
|
||||
["server"] = "railway-edge", // allow → kept
|
||||
};
|
||||
|
||||
var filtered = CvdiagEmitter.FilterEdgeHeaders(raw);
|
||||
|
||||
Assert.Equal("8abc-DFW", filtered.CfRay);
|
||||
Assert.Equal("railway-edge", filtered.Server);
|
||||
// DENY-list values never surface on any of the 9 fields.
|
||||
Assert.DoesNotContain("US", filtered.AllValues());
|
||||
Assert.DoesNotContain("1.2.3.4", filtered.AllValues());
|
||||
Assert.DoesNotContain("Dallas", filtered.AllValues());
|
||||
// All 9 allow-list keys present; unset → null.
|
||||
Assert.Null(filtered.CfMitigated);
|
||||
Assert.Null(filtered.XRailwayRequestId);
|
||||
Assert.Null(filtered.RetryAfter);
|
||||
Assert.Equal(9, filtered.KeyCount());
|
||||
}
|
||||
|
||||
// (2b) Deny wins even if a deny key somehow appears in the allow path.
|
||||
[Fact]
|
||||
public void FilterEdgeHeaders_DenyWinsOverAllow()
|
||||
{
|
||||
var raw = new Dictionary<string, string?> { ["forwarded"] = "for=1.2.3.4" };
|
||||
var filtered = CvdiagEmitter.FilterEdgeHeaders(raw);
|
||||
Assert.DoesNotContain("for=1.2.3.4", filtered.AllValues());
|
||||
}
|
||||
|
||||
// (3) _metadata_dropped stamp: an unknown metadata key for the boundary is
|
||||
// dropped (closed-world) and the flag is stamped.
|
||||
[Fact]
|
||||
public void Emit_StampsMetadataDropped_OnUnknownKey()
|
||||
{
|
||||
var emitter = CvdiagEmitterTestFactory.Verbose();
|
||||
var env = emitter.Emit(new CvdiagEmitArgs
|
||||
{
|
||||
Layer = CvdiagLayer.Backend,
|
||||
Boundary = CvdiagBoundary.BackendResponseComplete,
|
||||
Slug = "gen-ui-chat",
|
||||
Demo = "default",
|
||||
Outcome = CvdiagOutcome.Ok,
|
||||
Metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["http_status"] = 200, // allowed for this boundary
|
||||
["totally_unknown_key"] = 1, // not in the closed key set → dropped
|
||||
},
|
||||
});
|
||||
|
||||
Assert.NotNull(env);
|
||||
Assert.True(env!.MetadataDropped);
|
||||
Assert.True(env.Metadata.ContainsKey("http_status"));
|
||||
Assert.False(env.Metadata.ContainsKey("totally_unknown_key"));
|
||||
}
|
||||
|
||||
// (3b) Clean metadata leaves the flag unset.
|
||||
[Fact]
|
||||
public void Emit_DoesNotStampMetadataDropped_OnCleanMetadata()
|
||||
{
|
||||
var emitter = CvdiagEmitterTestFactory.Verbose();
|
||||
var env = emitter.Emit(new CvdiagEmitArgs
|
||||
{
|
||||
Layer = CvdiagLayer.Backend,
|
||||
Boundary = CvdiagBoundary.BackendAgentEnter,
|
||||
Slug = "gen-ui-chat",
|
||||
Demo = "default",
|
||||
Outcome = CvdiagOutcome.Ok,
|
||||
Metadata = new Dictionary<string, object?>
|
||||
{
|
||||
["agent_name"] = "proverbs",
|
||||
["model_id"] = "gpt-4o-mini",
|
||||
},
|
||||
});
|
||||
|
||||
Assert.NotNull(env);
|
||||
Assert.False(env!.MetadataDropped);
|
||||
}
|
||||
|
||||
// (4) Production-env DEBUG refusal: SHOWCASE_ENV=production + DEBUG throws.
|
||||
[Fact]
|
||||
public void DebugInProduction_FailsClosed()
|
||||
{
|
||||
var env = new Dictionary<string, string?>
|
||||
{
|
||||
["SHOWCASE_ENV"] = "production",
|
||||
["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat",
|
||||
};
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env }));
|
||||
}
|
||||
|
||||
// (4b) Unresolved env (no SHOWCASE_ENV/RAILWAY/ASPNETCORE) treated as prod.
|
||||
[Fact]
|
||||
public void DebugWithUnresolvedEnv_FailsClosed()
|
||||
{
|
||||
var env = new Dictionary<string, string?> { ["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat" };
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env }));
|
||||
}
|
||||
|
||||
// (4c) DEBUG in a non-prod env without an allow-list still refuses.
|
||||
[Fact]
|
||||
public void DebugWithoutAllowList_FailsClosed()
|
||||
{
|
||||
var env = new Dictionary<string, string?> { ["SHOWCASE_ENV"] = "staging" };
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env }));
|
||||
}
|
||||
|
||||
// (4d) DEBUG in a non-prod env WITH an allow-list is permitted (no throw).
|
||||
[Fact]
|
||||
public void DebugInStagingWithAllowList_IsAllowed()
|
||||
{
|
||||
var env = new Dictionary<string, string?>
|
||||
{
|
||||
["SHOWCASE_ENV"] = "staging",
|
||||
["CVDIAG_DEBUG_ALLOW_LIST"] = "gen-ui-chat",
|
||||
};
|
||||
var emitter = new CvdiagEmitter(new CvdiagEmitterOptions { Debug = true, Env = env });
|
||||
Assert.Equal(CvdiagTier.Debug, emitter.Tier);
|
||||
}
|
||||
|
||||
// (4e) Tier env precedence: ASPNETCORE_ENVIRONMENT is the last fallback
|
||||
// (the .NET analogue of NODE_ENV).
|
||||
[Fact]
|
||||
public void ResolveEnvLabel_FallsBackToAspNetCoreEnvironment()
|
||||
{
|
||||
var env = new Dictionary<string, string?> { ["ASPNETCORE_ENVIRONMENT"] = "Production" };
|
||||
Assert.Equal("production", CvdiagEmitter.ResolveEnvLabel(env));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEnvLabel_ShowcaseEnvWins()
|
||||
{
|
||||
var env = new Dictionary<string, string?>
|
||||
{
|
||||
["SHOWCASE_ENV"] = "staging",
|
||||
["RAILWAY_ENVIRONMENT_NAME"] = "production",
|
||||
["ASPNETCORE_ENVIRONMENT"] = "Production",
|
||||
};
|
||||
Assert.Equal("staging", CvdiagEmitter.ResolveEnvLabel(env));
|
||||
}
|
||||
|
||||
// Codegen coverage: all 29 metadata-bearing boundaries have a closed key set.
|
||||
[Fact]
|
||||
public void AllBoundaries_HaveClosedMetadataKeySets()
|
||||
{
|
||||
Assert.Equal(33, CvdiagSchema.AllBoundaries.Count);
|
||||
Assert.Equal(29, CvdiagSchema.BoundaryMetadataKeys.Count);
|
||||
Assert.Equal(9, CvdiagSchema.EdgeHeaderKeys.Count);
|
||||
}
|
||||
}
|
||||
|
||||
// Small factory: a VERBOSE-tier emitter with no PB writer for metadata tests.
|
||||
internal static class CvdiagEmitterTestFactory
|
||||
{
|
||||
public static CvdiagEmitter Verbose() => new(new CvdiagEmitterOptions
|
||||
{
|
||||
Verbose = true,
|
||||
Env = new Dictionary<string, string?> { ["SHOWCASE_ENV"] = "staging" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"""conftest.py — put ``showcase/integrations`` on ``sys.path`` so the test suite
|
||||
can ``import _shared.*`` exactly as the runtime does (``/app`` on PYTHONPATH,
|
||||
``/app/_shared`` the package).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# showcase/integrations/_shared/tests/conftest.py → showcase/integrations
|
||||
_INTEGRATIONS_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(_INTEGRATIONS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_INTEGRATIONS_DIR))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""test_cvdiag_emit_gate_degrade.py — M5 CR R3 functional regression for C1: the
|
||||
fail-closed DEBUG degrade must actually SUPPRESS emission.
|
||||
|
||||
Background: a prior fix made ``cvdiag_bootstrap.setup()`` DEGRADE
|
||||
(``_ENABLED=False``, instrumentation disabled) instead of crashing the backend on
|
||||
a fail-closed DEBUG misconfig. But the emit path never consulted that flag —
|
||||
``emit_cvdiag`` wrote the ``CVDIAG`` envelope to stdout regardless of
|
||||
``is_enabled()``. A degraded backend therefore STILL emitted default-tier
|
||||
boundaries whenever the per-integration gate (``CVDIAG_BACKEND_EMITTER=1``) was
|
||||
on. ``is_enabled()`` was a dead flag.
|
||||
|
||||
RED (pre-fix): a degraded ``setup()`` (``_ENABLED=False``) followed by
|
||||
``emit_cvdiag(...)`` still writes a ``CVDIAG `` line to stdout.
|
||||
GREEN (post-fix): the degraded emit writes NOTHING; the normal (enabled) path
|
||||
still emits, so the gate isn't over-tightened.
|
||||
|
||||
Imports the package as ``_shared.*`` to mirror the runtime layout; conftest.py
|
||||
puts ``showcase/integrations`` on sys.path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from _shared import cvdiag_bootstrap
|
||||
|
||||
# A valid UUIDv7 test_id (version nibble 7, variant 8/9/a/b).
|
||||
_VALID_TEST_ID = "018f8b2a-7c3e-7a1b-9f4d-0123456789ab"
|
||||
_VALID_SPAN_ID = "0123456789abcdef"
|
||||
|
||||
|
||||
def _base_envelope(**overrides):
|
||||
"""A schema-valid default-tier boundary envelope (backend.agent.enter)."""
|
||||
env = {
|
||||
"schema_version": 1,
|
||||
"test_id": _VALID_TEST_ID,
|
||||
"trace_id": _VALID_TEST_ID,
|
||||
"span_id": _VALID_SPAN_ID,
|
||||
"parent_span_id": None,
|
||||
"layer": "backend",
|
||||
"boundary": "backend.agent.enter",
|
||||
"slug": "langgraph-python",
|
||||
"demo": "chat",
|
||||
"ts": "2026-06-18T12:00:00Z",
|
||||
"mono_ns": 123456789,
|
||||
"duration_ms": None,
|
||||
"outcome": "ok",
|
||||
"edge_headers": {
|
||||
"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,
|
||||
},
|
||||
"metadata": {"agent_name": "weather", "model_id": "claude"},
|
||||
}
|
||||
env.update(overrides)
|
||||
return env
|
||||
|
||||
|
||||
def test_degraded_setup_suppresses_emission(capsys):
|
||||
"""A degraded backend (``_ENABLED=False``) must emit NOTHING.
|
||||
|
||||
Reproduces the prod-reachable path ``CVDIAG_BACKEND_EMITTER=1`` +
|
||||
``CVDIAG_DEBUG=1`` (unresolved env → fail-closed degrade): the per-integration
|
||||
gate is armed, but the shared bootstrap degraded instrumentation OFF. The
|
||||
shared ``emit_cvdiag`` is the single chokepoint every integration routes
|
||||
through, so it must honor ``is_enabled()``.
|
||||
|
||||
RED (pre-fix): ``emit_cvdiag`` ignores ``_ENABLED`` and still writes the
|
||||
``CVDIAG `` line.
|
||||
"""
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
# Degrade: fail-closed DEBUG with an unresolved env disables instrumentation.
|
||||
cvdiag_bootstrap.setup({"CVDIAG_BACKEND_EMITTER": "1", "CVDIAG_DEBUG": "1"})
|
||||
assert cvdiag_bootstrap.is_enabled() is False, "precondition: setup degraded"
|
||||
|
||||
cvdiag_bootstrap.emit_cvdiag(_base_envelope())
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "CVDIAG " not in out, (
|
||||
"degraded backend must suppress emission; got stdout:\n" + out
|
||||
)
|
||||
|
||||
|
||||
def test_enabled_setup_still_emits(capsys):
|
||||
"""Sanity / over-tightening guard: the normal enabled path still emits.
|
||||
|
||||
GREEN must not silence a healthy backend — a non-degraded ``setup()`` keeps
|
||||
``emit_cvdiag`` writing the ``CVDIAG `` envelope line.
|
||||
"""
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"CVDIAG_BACKEND_EMITTER": "1", "SHOWCASE_ENV": "staging"})
|
||||
assert cvdiag_bootstrap.is_enabled() is True, "precondition: setup enabled"
|
||||
|
||||
cvdiag_bootstrap.emit_cvdiag(_base_envelope())
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "CVDIAG " in out, "enabled backend must still emit; got stdout:\n" + out
|
||||
@@ -0,0 +1,133 @@
|
||||
"""test_cvdiag_inert_when_disabled.py — M5 CR R4 functional regression for N3:
|
||||
importing the cvdiag bootstrap must be FULLY INERT when cvdiag is disabled.
|
||||
|
||||
Background (N3): ``cvdiag_bootstrap.setup()`` ran
|
||||
``logging.basicConfig(level=…, format=…, force=True)`` unconditionally at import
|
||||
time (the module calls ``setup()`` at the bottom). ``force=True`` REMOVES and
|
||||
CLOSES every pre-existing root-logger handler, so merely importing the module —
|
||||
which every Python integration backend does whenever it imports
|
||||
``_cvdiag_backend`` — ripped out the HOST application's logging configuration,
|
||||
even with the backend emitter OFF (``CVDIAG_BACKEND_EMITTER`` unset, the
|
||||
canary-safe default). That violates the "byte-for-byte inert when disabled /
|
||||
canary-safe" contract the backend emitters advertise; the canonical TypeScript
|
||||
emitter performs NO equivalent global mutation.
|
||||
|
||||
RED (pre-fix): a pre-configured host root-logger handler is TORN DOWN by the
|
||||
import-time ``basicConfig(force=True)``.
|
||||
GREEN (post-fix): importing the bootstrap with cvdiag disabled is fully inert —
|
||||
the host root handler survives, nothing is written to stdout, and no rogue
|
||||
non-daemon threads are spawned.
|
||||
|
||||
This locks the CLASS of "fully inert when disabled" so future CR rounds stop
|
||||
re-discovering individual instances of host-state mutation at import.
|
||||
|
||||
The assertions run in a FRESH SUBPROCESS so the import-time side effects of
|
||||
``cvdiag_bootstrap`` are actually observable (a same-process import is cached and
|
||||
``basicConfig`` is a no-op the second time).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# showcase/integrations/_shared/tests/ → showcase/integrations
|
||||
_INTEGRATIONS_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Child program: pre-configure a host root handler, import the bootstrap with
|
||||
# cvdiag DISABLED, then assert the import was fully inert. Printed sentinels are
|
||||
# parsed by the parent so a failure produces a readable diff.
|
||||
_CHILD = r"""
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
|
||||
# (host) Pre-configure the application's own root-logger handler BEFORE importing
|
||||
# cvdiag — this is the state a real host app has when the backend boots.
|
||||
_host_handler = logging.StreamHandler(sys.stderr)
|
||||
_host_handler.set_name("HOST_ROOT_HANDLER")
|
||||
_root = logging.getLogger()
|
||||
_root.addHandler(_host_handler)
|
||||
_root.setLevel(logging.INFO)
|
||||
|
||||
_threads_before = {t.ident for t in threading.enumerate()}
|
||||
|
||||
import io
|
||||
_cap = io.StringIO()
|
||||
_real_stdout = sys.stdout
|
||||
sys.stdout = _cap
|
||||
try:
|
||||
# cvdiag DISABLED: backend emitter OFF (canary-safe default), no debug.
|
||||
import _shared.cvdiag_bootstrap # noqa: F401 (import-time setup() runs here)
|
||||
finally:
|
||||
sys.stdout = _real_stdout
|
||||
|
||||
_stdout_text = _cap.getvalue()
|
||||
|
||||
# (1) The host's pre-existing root handler MUST survive the import. This is the
|
||||
# RED trigger on the force=True code (basicConfig(force=True) removed it).
|
||||
_host_present = any(
|
||||
getattr(h, "get_name", lambda: None)() == "HOST_ROOT_HANDLER"
|
||||
for h in logging.getLogger().handlers
|
||||
)
|
||||
|
||||
# (2) Zero CVDIAG lines written to stdout at import (disabled → nothing emitted).
|
||||
_no_cvdiag = "CVDIAG " not in _stdout_text
|
||||
|
||||
# (3) No rogue non-daemon threads spawned by the import.
|
||||
_new_threads = [
|
||||
t for t in threading.enumerate()
|
||||
if t.ident not in _threads_before and not t.daemon
|
||||
]
|
||||
_no_nondaemon_threads = len(_new_threads) == 0
|
||||
|
||||
print("HOST_PRESENT=" + ("1" if _host_present else "0"))
|
||||
print("NO_CVDIAG=" + ("1" if _no_cvdiag else "0"))
|
||||
print("NO_NONDAEMON_THREADS=" + ("1" if _no_nondaemon_threads else "0"))
|
||||
print("NEW_NONDAEMON_THREADS=" + ",".join(t.name for t in _new_threads))
|
||||
"""
|
||||
|
||||
|
||||
def _run_child() -> dict[str, str]:
|
||||
env_clean = {
|
||||
# Inherit a minimal env but ensure cvdiag is fully disabled.
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"PYTHONPATH": str(_INTEGRATIONS_DIR),
|
||||
# Explicitly NOT setting CVDIAG_BACKEND_EMITTER / CVDIAG_DEBUG /
|
||||
# CVDIAG_VERBOSE — disabled, canary-safe default.
|
||||
}
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", _CHILD],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env_clean,
|
||||
cwd=str(_INTEGRATIONS_DIR),
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, (
|
||||
"child process failed:\nSTDOUT:\n" + proc.stdout + "\nSTDERR:\n" + proc.stderr
|
||||
)
|
||||
result: dict[str, str] = {}
|
||||
for line in proc.stdout.splitlines():
|
||||
if "=" in line:
|
||||
key, _, val = line.partition("=")
|
||||
result[key] = val
|
||||
return result
|
||||
|
||||
|
||||
def test_import_is_fully_inert():
|
||||
"""Importing cvdiag_bootstrap (disabled) must not mutate host logging state."""
|
||||
r = _run_child()
|
||||
|
||||
assert r.get("HOST_PRESENT") == "1", (
|
||||
"host root-logger handler was torn down by the import-time "
|
||||
"basicConfig(force=True); the disabled cvdiag bootstrap must be inert"
|
||||
)
|
||||
assert r.get("NO_CVDIAG") == "1", (
|
||||
"a disabled cvdiag import wrote a CVDIAG line to stdout"
|
||||
)
|
||||
assert r.get("NO_NONDAEMON_THREADS") == "1", (
|
||||
"a disabled cvdiag import spawned non-daemon thread(s): "
|
||||
+ r.get("NEW_NONDAEMON_THREADS", "")
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""test_cvdiag_schema.py — L0-C unit suite for the Python ``_shared`` CVDIAG
|
||||
bootstrap module (6 tests, spec §5/§6).
|
||||
|
||||
Run from the repo root::
|
||||
|
||||
python3 -m pytest showcase/integrations/_shared/tests/
|
||||
|
||||
These tests import the package as ``_shared.*`` to mirror the runtime layout
|
||||
(``/app`` on PYTHONPATH, ``/app/_shared`` the package). ``conftest.py`` puts
|
||||
``showcase/integrations`` on ``sys.path`` so ``import _shared`` resolves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from _shared import cvdiag_schema as schema
|
||||
|
||||
# A valid UUIDv7 test_id (version nibble 7, variant 8/9/a/b).
|
||||
_VALID_TEST_ID = "018f8b2a-7c3e-7a1b-9f4d-0123456789ab"
|
||||
_VALID_SPAN_ID = "0123456789abcdef"
|
||||
|
||||
|
||||
def _base_envelope(**overrides):
|
||||
env = {
|
||||
"schema_version": 1,
|
||||
"test_id": _VALID_TEST_ID,
|
||||
"trace_id": _VALID_TEST_ID,
|
||||
"span_id": _VALID_SPAN_ID,
|
||||
"parent_span_id": None,
|
||||
"layer": "backend",
|
||||
"boundary": "backend.agent.enter",
|
||||
"slug": "langgraph-python",
|
||||
"demo": "chat",
|
||||
"ts": "2026-06-18T12:00:00Z",
|
||||
"mono_ns": 123456789,
|
||||
"duration_ms": None,
|
||||
"outcome": "ok",
|
||||
"edge_headers": {
|
||||
"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,
|
||||
},
|
||||
"metadata": {"agent_name": "weather", "model_id": "claude"},
|
||||
}
|
||||
env.update(overrides)
|
||||
return env
|
||||
|
||||
|
||||
def test_valid_envelope_round_trips():
|
||||
"""A well-formed envelope validates and round-trips by alias."""
|
||||
model = schema.CvdiagEnvelope.model_validate(_base_envelope())
|
||||
assert model.boundary is schema.CvdiagBoundary.BACKEND_AGENT_ENTER
|
||||
assert model.layer is schema.CvdiagLayer.BACKEND
|
||||
assert model.outcome is schema.CvdiagOutcome.OK
|
||||
assert model.metadata_dropped is False
|
||||
dumped = model.model_dump(by_alias=True)
|
||||
# The edge-header alias keys survive the round-trip.
|
||||
assert dumped["edge_headers"]["cf-ray"] is None
|
||||
assert dumped["_metadata_dropped"] is False
|
||||
|
||||
|
||||
def test_unknown_metadata_key_stamps_metadata_dropped():
|
||||
"""An unknown metadata key sets ``_metadata_dropped`` on the envelope."""
|
||||
env = _base_envelope(
|
||||
metadata={"agent_name": "weather", "bogus_key": "x"},
|
||||
)
|
||||
model = schema.CvdiagEnvelope.model_validate(env)
|
||||
assert model.metadata_dropped is True
|
||||
# Also true for an unknown TOP-LEVEL key.
|
||||
env2 = _base_envelope()
|
||||
env2["totally_unknown"] = "y"
|
||||
model2 = schema.CvdiagEnvelope.model_validate(env2)
|
||||
assert model2.metadata_dropped is True
|
||||
|
||||
|
||||
def test_forbidden_edge_header_dropped():
|
||||
"""A deny-list edge header (cf-connecting-ip) is rejected by EdgeHeaders.
|
||||
|
||||
``EdgeHeaders`` forbids extra keys, so a forbidden header can never round
|
||||
-trip through the closed model.
|
||||
"""
|
||||
bad = {
|
||||
"cf-ray": "abc",
|
||||
"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,
|
||||
"cf-connecting-ip": "1.2.3.4", # forbidden PII header
|
||||
}
|
||||
with pytest.raises(Exception):
|
||||
schema.EdgeHeaders.model_validate(bad)
|
||||
|
||||
|
||||
def test_uuidv7_test_id_validation():
|
||||
"""UUIDv7 test_id passes; a UUIDv4 / malformed test_id is rejected."""
|
||||
# Pass.
|
||||
schema.CvdiagEnvelope.model_validate(_base_envelope(test_id=_VALID_TEST_ID))
|
||||
# UUIDv4 (version nibble 4) → reject.
|
||||
uuid_v4 = "018f8b2a-7c3e-4a1b-9f4d-0123456789ab"
|
||||
with pytest.raises(Exception):
|
||||
schema.CvdiagEnvelope.model_validate(_base_envelope(test_id=uuid_v4))
|
||||
# Malformed → reject.
|
||||
with pytest.raises(Exception):
|
||||
schema.CvdiagEnvelope.model_validate(_base_envelope(test_id="not-a-uuid"))
|
||||
|
||||
|
||||
def test_debug_in_production_degrades_at_setup():
|
||||
"""``CVDIAG_DEBUG=1`` + ``SHOWCASE_ENV=production`` fails closed at setup.
|
||||
|
||||
Fail-closed intent: instrumentation is DISABLED (tier stays ``default``,
|
||||
``is_enabled()`` False). Degrade-not-crash: ``setup()`` must NOT raise —
|
||||
a misconfig may not abort the backend's module import.
|
||||
"""
|
||||
from _shared import cvdiag_bootstrap
|
||||
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"CVDIAG_DEBUG": "1", "SHOWCASE_ENV": "production"})
|
||||
assert cvdiag_bootstrap.current_tier() == "default"
|
||||
assert cvdiag_bootstrap.is_enabled() is False
|
||||
|
||||
# Unresolved env is ALSO treated as production (fail-closed → degraded).
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"CVDIAG_DEBUG": "1"})
|
||||
assert cvdiag_bootstrap.current_tier() == "default"
|
||||
assert cvdiag_bootstrap.is_enabled() is False
|
||||
|
||||
# A non-production env with DEBUG is allowed (instrumentation enabled).
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"CVDIAG_DEBUG": "1", "SHOWCASE_ENV": "staging"})
|
||||
assert cvdiag_bootstrap.current_tier() == "debug"
|
||||
assert cvdiag_bootstrap.is_enabled() is True
|
||||
|
||||
|
||||
def test_basicconfig_captures_agents_logger_output(capsys):
|
||||
"""``setup()`` installs a handler so ``agents.*`` loggers actually emit.
|
||||
|
||||
This is the silent-drop regression guard: before bootstrap configures the
|
||||
root logger, an ``agents._header_forwarding`` ``logger.info`` is dropped;
|
||||
after ``setup()`` it reaches the stream.
|
||||
"""
|
||||
from _shared import cvdiag_bootstrap
|
||||
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"SHOWCASE_ENV": "staging"})
|
||||
fwd_logger = logging.getLogger("agents._header_forwarding")
|
||||
fwd_logger.info("CVDIAG component=test boundary=probe.start status=ok")
|
||||
captured = capsys.readouterr()
|
||||
combined = captured.out + captured.err
|
||||
assert "CVDIAG component=test boundary=probe.start" in combined
|
||||
@@ -0,0 +1,164 @@
|
||||
"""test_cvdiag_writer_bootstrap.py — functional regression suite for the three
|
||||
M5 CR R1 fixes in the Python ``_shared`` CVDIAG core:
|
||||
|
||||
FIX-1 cvdiag_pb_writer drain loop never-propagate: a non-JSON-serializable
|
||||
envelope must NOT kill the flush daemon (mirrors TS pb-writer
|
||||
writeBatch — one bad row degrades, the batch/daemon survives).
|
||||
FIX-2 cvdiag_bootstrap.setup() degrade-not-crash: a fail-closed DEBUG
|
||||
misconfig must DISABLE instrumentation, not raise out of import.
|
||||
FIX-3 cvdiag_bootstrap.setup() idempotency: a repeated call is a no-op and
|
||||
never orphans a second daemon thread / PB writer.
|
||||
|
||||
Imports the package as ``_shared.*`` to mirror the runtime layout; conftest.py
|
||||
puts ``showcase/integrations`` on sys.path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from _shared import cvdiag_bootstrap
|
||||
from _shared.cvdiag_pb_writer import CvdiagPbWriter
|
||||
|
||||
|
||||
class _Unserializable:
|
||||
"""An object json.dumps cannot encode (raises TypeError in _post)."""
|
||||
|
||||
|
||||
def _drain_thread_alive(writer: CvdiagPbWriter) -> bool:
|
||||
worker = writer._worker
|
||||
return worker is not None and worker.is_alive()
|
||||
|
||||
|
||||
# ── FIX-1: drain loop never-propagate ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_drain_survives_non_json_serializable_envelope(monkeypatch):
|
||||
"""A non-JSON-serializable record must not kill the daemon; a later valid
|
||||
record still flushes.
|
||||
|
||||
RED (pre-fix): ``_post`` lets ``TypeError`` from ``json.dumps`` escape the
|
||||
``except (URLError, OSError, ValueError)`` clause; the exception unwinds
|
||||
``_run`` and the daemon thread dies — the later valid envelope is never
|
||||
POSTed.
|
||||
"""
|
||||
writer = CvdiagPbWriter(pb_url="http://pb.invalid", flush_window_s=0.05)
|
||||
|
||||
posted: list[dict] = []
|
||||
|
||||
# Stub the HTTP layer: record what reaches the wire after json.dumps.
|
||||
def _fake_post(envelope):
|
||||
# Re-run the real serialization seam so a bad envelope still throws
|
||||
# inside the drain loop, but a good one is "delivered" without network.
|
||||
import json as _json
|
||||
|
||||
_json.dumps(envelope) # raises TypeError on the bad envelope
|
||||
posted.append(envelope)
|
||||
|
||||
monkeypatch.setattr(writer, "_post", _fake_post)
|
||||
|
||||
# Bad record first — pre-fix this kills the drain thread.
|
||||
writer.enqueue({"bad": _Unserializable()})
|
||||
# Give the worker a couple of flush windows to process the bad record.
|
||||
time.sleep(0.2)
|
||||
|
||||
# Then a perfectly valid record.
|
||||
writer.enqueue({"ok": True, "n": 1})
|
||||
time.sleep(0.2)
|
||||
|
||||
assert _drain_thread_alive(writer), "drain daemon must survive a bad record"
|
||||
assert {"ok": True, "n": 1} in posted, "valid record must still flush"
|
||||
|
||||
|
||||
def test_post_swallows_typeerror_on_unserializable(monkeypatch):
|
||||
"""``_post`` itself must not raise on a non-serializable envelope.
|
||||
|
||||
RED (pre-fix): the ``TypeError`` from ``json.dumps`` is uncaught and
|
||||
propagates out of ``_post`` (only URLError/OSError/ValueError are caught).
|
||||
"""
|
||||
writer = CvdiagPbWriter(pb_url="http://pb.invalid")
|
||||
# Should NOT raise — _post is the never-throw persistence seam.
|
||||
writer._post({"bad": _Unserializable()})
|
||||
|
||||
|
||||
# ── FIX-2: bootstrap degrade-not-crash ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_setup_degrades_on_failclosed_debug_misconfig():
|
||||
"""A fail-closed DEBUG misconfig DISABLES instrumentation instead of raising.
|
||||
|
||||
RED (pre-fix): ``setup({"CVDIAG_DEBUG": "1"})`` (unresolved env → treated as
|
||||
production) raises ``RuntimeError`` out of setup(), which at import time
|
||||
would abort the whole backend module import.
|
||||
"""
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
# Must NOT raise.
|
||||
cvdiag_bootstrap.setup({"CVDIAG_DEBUG": "1"})
|
||||
# Fail-closed intent preserved: instrumentation is OFF (tier not debug).
|
||||
assert cvdiag_bootstrap.current_tier() == "default"
|
||||
assert cvdiag_bootstrap.is_enabled() is False
|
||||
|
||||
# Explicit production env with DEBUG also degrades, never raises.
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"CVDIAG_DEBUG": "1", "SHOWCASE_ENV": "production"})
|
||||
assert cvdiag_bootstrap.current_tier() == "default"
|
||||
assert cvdiag_bootstrap.is_enabled() is False
|
||||
|
||||
|
||||
def test_setup_allows_debug_in_nonproduction():
|
||||
"""A non-production DEBUG request still enables debug tier (intent intact)."""
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
cvdiag_bootstrap.setup({"CVDIAG_DEBUG": "1", "SHOWCASE_ENV": "staging"})
|
||||
assert cvdiag_bootstrap.current_tier() == "debug"
|
||||
assert cvdiag_bootstrap.is_enabled() is True
|
||||
|
||||
|
||||
# ── FIX-3: bootstrap idempotency ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_setup_is_idempotent_no_orphan_daemon():
|
||||
"""A second setup() is a no-op: no second PB writer / daemon thread.
|
||||
|
||||
RED (pre-fix): no ``_SETUP_DONE`` guard in setup(), so a second call rebuilds
|
||||
``_PB_WRITER`` (orphaning the first writer's queue) and a subsequent enqueue
|
||||
spins a second ``cvdiag-pb-writer`` daemon thread.
|
||||
"""
|
||||
cvdiag_bootstrap.reset_for_test()
|
||||
|
||||
# Count daemon threads BEFORE this test so the assertion is robust to
|
||||
# daemons left alive by earlier tests in the suite (short-lived best-effort
|
||||
# daemons are not joined on reset).
|
||||
def _pb_daemon_count() -> int:
|
||||
return sum(1 for t in threading.enumerate() if t.name == "cvdiag-pb-writer")
|
||||
|
||||
before = _pb_daemon_count()
|
||||
|
||||
cvdiag_bootstrap.setup(
|
||||
{"SHOWCASE_ENV": "staging", "CVDIAG_PB_URL": "http://pb.invalid"}
|
||||
)
|
||||
first_writer = cvdiag_bootstrap._PB_WRITER
|
||||
assert first_writer is not None
|
||||
first_writer.enqueue({"n": 1})
|
||||
time.sleep(0.05)
|
||||
after_first = _pb_daemon_count()
|
||||
assert after_first == before + 1, (
|
||||
"first setup()+enqueue must spin exactly one daemon"
|
||||
)
|
||||
|
||||
cvdiag_bootstrap.setup(
|
||||
{"SHOWCASE_ENV": "staging", "CVDIAG_PB_URL": "http://pb.invalid"}
|
||||
)
|
||||
second_writer = cvdiag_bootstrap._PB_WRITER
|
||||
|
||||
assert second_writer is first_writer, (
|
||||
"second setup() must not rebuild the PB writer"
|
||||
)
|
||||
|
||||
second_writer.enqueue({"n": 2})
|
||||
time.sleep(0.05)
|
||||
after_second = _pb_daemon_count()
|
||||
# The second setup()+enqueue must NOT spin an additional daemon.
|
||||
assert after_second == after_first, (
|
||||
f"second setup() orphaned a daemon: {before}->{after_first}->{after_second}"
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""test_cvdiag_writer_live_pb.py — live-PocketBase auth proof for the Python
|
||||
CVDIAG PB writer.
|
||||
|
||||
Boots an ACTUAL PocketBase 0.22 server using the EXACT production migrations
|
||||
(showcase/pocketbase/pb_migrations), which create ``cvdiag_events`` + the
|
||||
``cvdiag_api_keys`` auth collection + the seeded role-keyed records (writer /
|
||||
purge / migration). It then drives the real ``CvdiagPbWriter`` against that
|
||||
server and asserts the CREATE-only ACL is satisfied **as the writer role**.
|
||||
|
||||
WHY WRITER-ROLE (not superuser): the PB superuser bypasses ALL collection
|
||||
rules, so a writer that never authenticates would still "succeed" if probed as
|
||||
a superuser — that is exactly how the original defect (the inert
|
||||
``X-Cvdiag-Writer-Key`` header) escaped review. The createRule
|
||||
|
||||
@request.auth.collectionName = "cvdiag_api_keys" && @request.auth.role = "writer"
|
||||
|
||||
is only satisfiable by authenticating as the seeded writer record and sending
|
||||
``Authorization: Bearer <token>``. We therefore drive the writer with the
|
||||
writer record's PASSWORD as ``CVDIAG_WRITER_KEY`` and verify the row lands by
|
||||
querying ``cvdiag_events`` AS THE SUPERUSER (read-only, rule-bypassing) — the
|
||||
write path under test is never superuser.
|
||||
|
||||
Requires a ``pocketbase`` binary: set ``POCKETBASE_BIN`` to its path, put it on
|
||||
PATH, or drop it at ``/tmp/pb022/pocketbase``. The suite SKIPS (does not fail)
|
||||
when no binary is available so CI without the binary stays green.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from _shared.cvdiag_pb_writer import CvdiagPbWriter
|
||||
|
||||
# These mirror the seed records created by the cvdiag_api_keys migration
|
||||
# (showcase/pocketbase/pb_migrations/1779990200_create_cvdiag_events.js).
|
||||
WRITER_EMAIL = "cvdiag-writer@keys.local"
|
||||
WRITER_PASS = "cvdiagwriterpass123"
|
||||
|
||||
# A superuser used ONLY to read rows back (rule-bypassing read); never the
|
||||
# write path under test.
|
||||
ADMIN_EMAIL = "cvdiag-py-acl@test.local"
|
||||
ADMIN_PASS = "cvdiagpyaclpass123"
|
||||
|
||||
# showcase/integrations/_shared/tests/ → repo root → showcase/pocketbase
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
_MIGRATIONS_DIR = _REPO_ROOT / "showcase" / "pocketbase" / "pb_migrations"
|
||||
|
||||
|
||||
def _resolve_pb_binary() -> Optional[str]:
|
||||
explicit = os.environ.get("POCKETBASE_BIN")
|
||||
if explicit and Path(explicit).is_file():
|
||||
return explicit
|
||||
on_path = shutil.which("pocketbase")
|
||||
if on_path:
|
||||
return on_path
|
||||
if Path("/tmp/pb022/pocketbase").is_file():
|
||||
return "/tmp/pb022/pocketbase"
|
||||
return None
|
||||
|
||||
|
||||
_PB_BIN = _resolve_pb_binary()
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
_PB_BIN is None,
|
||||
reason="no pocketbase binary (set POCKETBASE_BIN, PATH, or /tmp/pb022/pocketbase)",
|
||||
)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _wait_for_health(base: str, timeout_s: float = 15.0) -> None:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(f"{base}/api/health", timeout=1.0) as r:
|
||||
if r.status == 200:
|
||||
return
|
||||
except (urllib.error.URLError, OSError):
|
||||
pass
|
||||
time.sleep(0.2)
|
||||
raise RuntimeError("PocketBase did not become healthy in time")
|
||||
|
||||
|
||||
def _admin_token(base: str) -> str:
|
||||
body = json.dumps({"identity": ADMIN_EMAIL, "password": ADMIN_PASS}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{base}/api/admins/auth-with-password",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5.0) as r:
|
||||
return json.loads(r.read())["token"]
|
||||
|
||||
|
||||
def _count_events(base: str, test_id: str) -> int:
|
||||
"""Count cvdiag_events rows for one test_id, reading AS SUPERUSER."""
|
||||
tok = _admin_token(base)
|
||||
flt = urllib.parse.quote(f'test_id="{test_id}"')
|
||||
req = urllib.request.Request(
|
||||
f"{base}/api/collections/cvdiag_events/records?filter={flt}",
|
||||
headers={"Authorization": tok},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5.0) as r:
|
||||
return json.loads(r.read())["totalItems"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def live_pb():
|
||||
"""Boot a real PB with the production migrations; yield its base URL."""
|
||||
# skipif(_PB_BIN is None) guarantees a binary at runtime; narrow to a
|
||||
# concrete str so subprocess calls don't take a `str | None` arg.
|
||||
pb_bin = _PB_BIN
|
||||
assert pb_bin is not None
|
||||
data_dir = tempfile.mkdtemp(prefix="pb-cvdiag-py-")
|
||||
try:
|
||||
mig = subprocess.run(
|
||||
[
|
||||
pb_bin,
|
||||
"migrate",
|
||||
"up",
|
||||
f"--dir={data_dir}",
|
||||
f"--migrationsDir={_MIGRATIONS_DIR}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if mig.returncode != 0:
|
||||
raise RuntimeError(f"pb migrate up failed: {mig.stderr or mig.stdout}")
|
||||
admin = subprocess.run(
|
||||
[pb_bin, "admin", "create", ADMIN_EMAIL, ADMIN_PASS, f"--dir={data_dir}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if admin.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"pb admin create failed: {admin.stderr or admin.stdout}"
|
||||
)
|
||||
port = _free_port()
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
pb_bin,
|
||||
"serve",
|
||||
f"--http=127.0.0.1:{port}",
|
||||
f"--dir={data_dir}",
|
||||
f"--migrationsDir={_MIGRATIONS_DIR}",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
_wait_for_health(base)
|
||||
yield base
|
||||
finally:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
finally:
|
||||
shutil.rmtree(data_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _flush_one(writer: CvdiagPbWriter, envelope: dict) -> None:
|
||||
"""Enqueue one envelope and let the daemon drain it (flush window 0.05s)."""
|
||||
writer.enqueue(envelope)
|
||||
# Give the worker a few flush windows to drain + POST + (re-)auth.
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def _event(test_id: str) -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"test_id": test_id,
|
||||
"trace_id": test_id,
|
||||
"span_id": "0000000000000001",
|
||||
"parent_span_id": None,
|
||||
"layer": "backend",
|
||||
"boundary": "backend.handle",
|
||||
"slug": "langgraph-python",
|
||||
"demo": "chat",
|
||||
"ts": "2026-06-18T00:00:00.000Z",
|
||||
"mono_ns": 1,
|
||||
"duration_ms": None,
|
||||
"outcome": "ok",
|
||||
"edge_headers": {},
|
||||
"metadata": {"src": "live-pb-test"},
|
||||
}
|
||||
|
||||
|
||||
def test_writer_authenticates_as_writer_role_and_persists(live_pb):
|
||||
"""GREEN: the writer auth-with-passwords as the writer role → CREATE 201 →
|
||||
the row is present in cvdiag_events (read back as superuser).
|
||||
|
||||
This is the fix for the inert ``X-Cvdiag-Writer-Key`` header: without
|
||||
auth-with-password → Bearer the CREATE 403s under the writer createRule.
|
||||
"""
|
||||
test_id = "0190a0c0-0000-7000-8000-00000000a001"
|
||||
# Pre-condition: no row yet.
|
||||
assert _count_events(live_pb, test_id) == 0
|
||||
|
||||
writer = CvdiagPbWriter(
|
||||
pb_url=live_pb,
|
||||
writer_key=WRITER_PASS, # CVDIAG_WRITER_KEY == the writer record password
|
||||
flush_window_s=0.05,
|
||||
)
|
||||
_flush_one(writer, _event(test_id))
|
||||
|
||||
# The CREATE went through the CREATE-only writer rule → row present.
|
||||
assert _count_events(live_pb, test_id) == 1
|
||||
|
||||
|
||||
def test_writer_with_wrong_password_degrades_to_noop(live_pb):
|
||||
"""A WRONG password must degrade to a no-op (auth fails, CREATE 401/403)
|
||||
WITHOUT crashing the daemon — never-throw preserved.
|
||||
"""
|
||||
test_id = "0190a0c0-0000-7000-8000-00000000a002"
|
||||
assert _count_events(live_pb, test_id) == 0
|
||||
|
||||
writer = CvdiagPbWriter(
|
||||
pb_url=live_pb,
|
||||
writer_key="totally-wrong-password",
|
||||
flush_window_s=0.05,
|
||||
)
|
||||
_flush_one(writer, _event(test_id))
|
||||
|
||||
# No row landed (auth failed) AND the daemon survived (no crash).
|
||||
assert _count_events(live_pb, test_id) == 0
|
||||
worker = writer._worker
|
||||
assert worker is not None and worker.is_alive(), (
|
||||
"daemon must survive an auth failure (never-throw)"
|
||||
)
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* cvdiag-emitter.test.ts — vitest suite for the shared TS integration emitter
|
||||
* binding (plan unit L0-F). Asserts the four invariants the §6 PII/tier
|
||||
* contract requires of EVERY language emitter:
|
||||
* - schema conformance (re-exported envelope keys + UUIDv7 minters),
|
||||
* - tier gating (default vs verbose vs debug boundary inclusion),
|
||||
* - PII scrub (Bearer / sk- secrets removed from captured values),
|
||||
* - forbidden-header rejection (cf-ipcountry never captured),
|
||||
* - DEBUG-in-production refusal (fail-closed startup guard).
|
||||
*
|
||||
* These re-exercise the L0-A invariants THROUGH the binding so a regression in
|
||||
* the re-export wiring (wrong relative path, dropped symbol) fails here, not
|
||||
* silently in a downstream TS integration.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CvdiagEmitter,
|
||||
ENVELOPE_KEYS,
|
||||
EDGE_HEADER_DENYLIST,
|
||||
SCHEMA_VERSION,
|
||||
TEST_ID_REGEX,
|
||||
filterEdgeHeaders,
|
||||
isValidTestId,
|
||||
mintSpanId,
|
||||
mintTestId,
|
||||
scrubSecrets,
|
||||
validateEnvelope,
|
||||
} from "./cvdiag-emitter.js";
|
||||
import type { CvdiagEnvelope } from "./cvdiag-emitter.js";
|
||||
|
||||
describe("L0-F binding: re-exports resolve from the canonical schema", () => {
|
||||
it("re-exports SCHEMA_VERSION === 1", () => {
|
||||
expect(SCHEMA_VERSION).toBe(1);
|
||||
});
|
||||
|
||||
it("re-exports the closed envelope key set and validator", () => {
|
||||
expect(ENVELOPE_KEYS).toContain("test_id");
|
||||
expect(ENVELOPE_KEYS).toContain("edge_headers");
|
||||
// A foreign top-level key is rejected (closed-world).
|
||||
const bad = validateEnvelope({ test_id: "x", attacker_key: 1 });
|
||||
expect(bad.ok).toBe(false);
|
||||
expect(bad.unknownKeys).toContain("attacker_key");
|
||||
});
|
||||
|
||||
it("re-exports the UUIDv7 minters + validator", () => {
|
||||
const id = mintTestId();
|
||||
expect(TEST_ID_REGEX.test(id)).toBe(true);
|
||||
expect(isValidTestId(id)).toBe(true);
|
||||
// A v4 UUID (version nibble 4) must be rejected.
|
||||
expect(isValidTestId("00000000-0000-4000-8000-000000000000")).toBe(false);
|
||||
// span_id is 16 lowercase hex chars.
|
||||
expect(mintSpanId()).toMatch(/^[0-9a-f]{16}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("L0-F binding: schema conformance of an emitted envelope", () => {
|
||||
it("emits a closed-world envelope at the verbose tier", () => {
|
||||
const emitter = new CvdiagEmitter({
|
||||
verbose: true,
|
||||
env: {},
|
||||
layer: "backend",
|
||||
});
|
||||
const env = emitter.emit({
|
||||
layer: "backend",
|
||||
boundary: "backend.agent.enter",
|
||||
slug: "langgraph-typescript",
|
||||
demo: "agentic_chat",
|
||||
outcome: "ok",
|
||||
metadata: { agent_name: "main", model_id: "gpt-4o" },
|
||||
}) as CvdiagEnvelope;
|
||||
expect(env).not.toBeNull();
|
||||
expect(env.schema_version).toBe(SCHEMA_VERSION);
|
||||
expect(isValidTestId(env.test_id)).toBe(true);
|
||||
expect(env.trace_id).toBe(env.test_id);
|
||||
expect(env.boundary).toBe("backend.agent.enter");
|
||||
// Every emitted key must be in the closed envelope key set.
|
||||
expect(validateEnvelope(env as unknown as Record<string, unknown>).ok).toBe(
|
||||
true,
|
||||
);
|
||||
// All 9 edge-header keys present (absent → null).
|
||||
expect(Object.keys(env.edge_headers).sort()).toEqual(
|
||||
[
|
||||
"cf-cache-status",
|
||||
"cf-mitigated",
|
||||
"cf-ray",
|
||||
"retry-after",
|
||||
"server",
|
||||
"via",
|
||||
"x-hikari-trace",
|
||||
"x-railway-edge",
|
||||
"x-railway-request-id",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops unknown metadata keys and stamps _metadata_dropped", () => {
|
||||
const emitter = new CvdiagEmitter({
|
||||
verbose: true,
|
||||
env: {},
|
||||
layer: "backend",
|
||||
});
|
||||
const env = emitter.emit({
|
||||
layer: "backend",
|
||||
boundary: "backend.agent.enter",
|
||||
slug: "mastra",
|
||||
demo: "agentic_chat",
|
||||
outcome: "ok",
|
||||
metadata: { agent_name: "main", model_id: "gpt-4o", attacker: "x" },
|
||||
}) as CvdiagEnvelope;
|
||||
expect(env._metadata_dropped).toBe(true);
|
||||
expect(env.metadata).not.toHaveProperty("attacker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("L0-F binding: tier gating", () => {
|
||||
it("default tier excludes a verbose-only boundary", () => {
|
||||
const emitter = new CvdiagEmitter({ env: {}, layer: "backend" });
|
||||
expect(emitter.tier).toBe("default");
|
||||
// backend.request.ingress is verbose+debug only (default:false).
|
||||
expect(emitter.shouldEmit("backend.request.ingress")).toBe(false);
|
||||
// backend.agent.enter is default:true.
|
||||
expect(emitter.shouldEmit("backend.agent.enter")).toBe(true);
|
||||
});
|
||||
|
||||
it("verbose tier includes verbose-only boundaries", () => {
|
||||
const emitter = new CvdiagEmitter({
|
||||
verbose: true,
|
||||
env: {},
|
||||
layer: "backend",
|
||||
});
|
||||
expect(emitter.tier).toBe("verbose");
|
||||
expect(emitter.shouldEmit("backend.request.ingress")).toBe(true);
|
||||
});
|
||||
|
||||
it("accounting boundaries always emit regardless of tier", () => {
|
||||
const emitter = new CvdiagEmitter({ env: {}, layer: "backend" });
|
||||
expect(emitter.shouldEmit("cvdiag.queue_dropped")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("L0-F binding: PII scrub (re-exported from edge-headers)", () => {
|
||||
it("scrubs Bearer tokens", () => {
|
||||
expect(scrubSecrets("auth: Bearer abc123def456")).toBe("auth: [REDACTED]");
|
||||
});
|
||||
|
||||
it("scrubs sk- provider keys", () => {
|
||||
expect(scrubSecrets("key sk-ABCDEFGHIJKLMNOP1234")).toBe("key [REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("L0-F binding: forbidden edge-header rejection", () => {
|
||||
it("never captures cf-ipcountry even when present", () => {
|
||||
const filtered = filterEdgeHeaders({
|
||||
"cf-ray": "abc-iad",
|
||||
"cf-ipcountry": "US",
|
||||
"true-client-ip": "1.2.3.4",
|
||||
});
|
||||
expect(filtered["cf-ray"]).toBe("abc-iad");
|
||||
expect(filtered).not.toHaveProperty("cf-ipcountry");
|
||||
expect(filtered).not.toHaveProperty("true-client-ip");
|
||||
});
|
||||
|
||||
it("the deny list contains the cf-ip* family by exact match", () => {
|
||||
expect(EDGE_HEADER_DENYLIST).toContain("cf-ipcountry");
|
||||
expect(EDGE_HEADER_DENYLIST).toContain("cf-connecting-ip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("L0-F binding: DEBUG fail-closed in production", () => {
|
||||
it("refuses DEBUG when env resolves to production", () => {
|
||||
expect(
|
||||
() =>
|
||||
new CvdiagEmitter({
|
||||
debug: true,
|
||||
env: {
|
||||
SHOWCASE_ENV: "production",
|
||||
CVDIAG_DEBUG_ALLOW_LIST: "langgraph-typescript",
|
||||
},
|
||||
}),
|
||||
).toThrow(/production/);
|
||||
});
|
||||
|
||||
it("refuses DEBUG when no env label resolves (unknown == prod)", () => {
|
||||
expect(
|
||||
() =>
|
||||
new CvdiagEmitter({
|
||||
debug: true,
|
||||
env: { CVDIAG_DEBUG_ALLOW_LIST: "langgraph-typescript" },
|
||||
}),
|
||||
).toThrow(/unresolved|production/);
|
||||
});
|
||||
|
||||
it("allows DEBUG in a non-prod env with an allow-list", () => {
|
||||
const emitter = new CvdiagEmitter({
|
||||
debug: true,
|
||||
env: {
|
||||
SHOWCASE_ENV: "staging",
|
||||
CVDIAG_DEBUG_ALLOW_LIST: "langgraph-typescript",
|
||||
},
|
||||
});
|
||||
expect(emitter.tier).toBe("debug");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* cvdiag-emitter.ts — the SHARED TypeScript CVDIAG emitter binding that the
|
||||
* four TS-backed integrations import (langgraph-typescript, claude-sdk-typescript,
|
||||
* mastra, built-in-agent). Plan unit: L0-F. Spec: 2026-06-18-flap-observability
|
||||
* §5 (schema) + §6 (tiers / PII).
|
||||
*
|
||||
* ──────────────────────────────────────────────────────────────────────────
|
||||
* SINGLE SOURCE OF TRUTH — this file RE-EXPORTS, it does NOT redefine.
|
||||
*
|
||||
* The canonical CVDIAG schema, edge-header allow/deny filter, PII scrub, and
|
||||
* the `CvdiagEmitter` (tier resolution, fail-closed DEBUG guard, byte caps,
|
||||
* bounded queue, UUIDv7 span/test minters) all live in L0-A under
|
||||
* `showcase/harness/src/cvdiag/`. This binding is a thin barrel that pulls
|
||||
* those symbols forward so the TS integrations have ONE import surface and so
|
||||
* a schema change in L0-A propagates here automatically (no duplicate enum to
|
||||
* drift). Re-exporting (not duplicating) is the whole point of the unit: the
|
||||
* §5 "single source of truth" / "CI lint fails on drift" policy is only
|
||||
* enforceable if every emitter shares the L0-A definitions.
|
||||
*
|
||||
* The relative path `../../../harness/src/cvdiag/` resolves as:
|
||||
* showcase/integrations/_shared/ts/ → ../../../harness/src/cvdiag/
|
||||
* ( _shared/ts → _shared → integrations → showcase )/harness/src/cvdiag
|
||||
* i.e. from this file up three levels to `showcase/`, then into `harness/`.
|
||||
* The `.js` extensions match L0-A's ESM (`"type": "module"`, bundler module
|
||||
* resolution) — at runtime under tsx / a bundler the `.js` specifier resolves
|
||||
* to the `.ts` source, exactly as the harness's own `index.ts` barrel does.
|
||||
*
|
||||
* ──────────────────────────────────────────────────────────────────────────
|
||||
* CROSS-CONTEXT RESOLUTION FOR L1-E (how a standalone TS integration's Docker
|
||||
* build sees these files):
|
||||
*
|
||||
* Each TS integration (langgraph-typescript, claude-sdk-typescript, mastra)
|
||||
* already vendors a sibling-directory `shared-tools/` into its build context
|
||||
* via a `COPY shared-tools/ ./shared-tools/` line in its Dockerfile (the
|
||||
* build context is the integration dir, so a sibling source tree is copied
|
||||
* in as a top-level dir, NOT imported across the repo). L1-E MUST use the
|
||||
* SAME mechanism for CVDIAG:
|
||||
*
|
||||
* 1. Stage `_shared/ts/cvdiag-emitter.ts` (this file, with the relative
|
||||
* re-exports flattened to point at a co-located copy of the L0-A
|
||||
* sources) AND the three L0-A sources it pulls from
|
||||
* (`schema.ts`, `edge-headers.ts`, `emit.ts`) into the integration's
|
||||
* build context — e.g. under `shared-tools/cvdiag/` or a dedicated
|
||||
* `_cvdiag/` dir — exactly as `shared-tools/` is copied today.
|
||||
* 2. Add `COPY shared-tools/cvdiag/ ./shared-tools/cvdiag/` (or the chosen
|
||||
* path) to each integration Dockerfile, mirroring the existing
|
||||
* `COPY shared-tools/ ./shared-tools/` line.
|
||||
* 3. The integration's CVDIAG wiring imports
|
||||
* `from "../../shared-tools/cvdiag/cvdiag-emitter"` (path per chosen
|
||||
* layout) rather than reaching across the monorepo — standalone npm
|
||||
* projects have no path alias back to `showcase/harness`.
|
||||
*
|
||||
* This is the TS analogue of L0-C's Python `_shared/cvdiag_bootstrap`: a
|
||||
* COPY-into-context staging step, NOT a workspace/path-alias import. Within
|
||||
* THIS slot (the harness/worktree) the relative `../../../harness/...`
|
||||
* re-export resolves directly so the vitest suite runs against the real L0-A
|
||||
* sources; L1-E performs the COPY-staging flatten when packaging each
|
||||
* integration. A `bin/showcase cvdiag stage-ts` helper (or the existing
|
||||
* build wrapper) is the natural home for the copy, so the staging is a
|
||||
* build step and not hand-maintained per integration.
|
||||
*/
|
||||
|
||||
// ── Schema (types, enums, validators, UUIDv7 regex) ─────────────────────────
|
||||
export {
|
||||
SCHEMA_VERSION,
|
||||
CVDIAG_LAYERS,
|
||||
CVDIAG_OUTCOMES,
|
||||
PROBE_BOUNDARIES,
|
||||
BACKEND_BOUNDARIES,
|
||||
AIMOCK_BOUNDARIES,
|
||||
CVDIAG_DATA_PLANE_BOUNDARIES,
|
||||
CVDIAG_ACCOUNTING_BOUNDARIES,
|
||||
CVDIAG_BOUNDARIES,
|
||||
EDGE_HEADER_KEYS,
|
||||
TERMINATION_KINDS,
|
||||
TEST_ID_REGEX,
|
||||
ENVELOPE_KEYS,
|
||||
BOUNDARY_METADATA_KEYS,
|
||||
isValidTestId,
|
||||
validateEnvelope,
|
||||
validateMetadata,
|
||||
} from "../../../harness/src/cvdiag/schema.js";
|
||||
|
||||
export type {
|
||||
CvdiagLayer,
|
||||
CvdiagOutcome,
|
||||
CvdiagDataPlaneBoundary,
|
||||
CvdiagAccountingBoundary,
|
||||
CvdiagBoundary,
|
||||
EdgeHeaders,
|
||||
EdgeHeaderKey,
|
||||
TerminationKind,
|
||||
CvdiagEnvelope,
|
||||
EnvelopeValidationResult,
|
||||
MetadataValidationResult,
|
||||
} from "../../../harness/src/cvdiag/schema.js";
|
||||
|
||||
// ── Edge-header allow/deny filter + PII scrub ───────────────────────────────
|
||||
export {
|
||||
EDGE_HEADER_ALLOWLIST,
|
||||
EDGE_HEADER_DENYLIST,
|
||||
BEARER_TOKEN_REGEX,
|
||||
SK_KEY_REGEX,
|
||||
URL_USERINFO_REGEX,
|
||||
SCRUB_REPLACEMENT,
|
||||
scrubSecrets,
|
||||
filterEdgeHeaders,
|
||||
} from "../../../harness/src/cvdiag/edge-headers.js";
|
||||
|
||||
// ── Emitter (tier resolution, fail-closed DEBUG, byte caps, span/id minters) ─
|
||||
export {
|
||||
CvdiagEmitter,
|
||||
BYTE_CAP_BY_TIER,
|
||||
QUEUE_CAP,
|
||||
DEBUG_MAX_WALLCLOCK_MS,
|
||||
DEBUG_MAX_EVENTS,
|
||||
FLUSH_WINDOW_MS,
|
||||
resolveEnvLabel,
|
||||
mintTestId,
|
||||
mintSpanId,
|
||||
} from "../../../harness/src/cvdiag/emit.js";
|
||||
|
||||
export type {
|
||||
CvdiagTier,
|
||||
CvdiagPbWriter,
|
||||
CvdiagEnv,
|
||||
CvdiagEmitterOptions,
|
||||
CvdiagEmitArgs,
|
||||
} from "../../../harness/src/cvdiag/emit.js";
|
||||
|
||||
// ── Concrete writer-role PB writer (plain fetch; auth-with-password→Bearer) ──
|
||||
export {
|
||||
CvdiagFetchPbWriter,
|
||||
createCvdiagFetchPbWriterFromEnv,
|
||||
} from "../../../harness/src/cvdiag/pb-writer-fetch.js";
|
||||
|
||||
export type {
|
||||
FetchLike,
|
||||
CvdiagFetchPbWriterOptions,
|
||||
} from "../../../harness/src/cvdiag/pb-writer-fetch.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["*.test.ts"],
|
||||
environment: "node",
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
**/node_modules
|
||||
.next
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
**/vitest.config.ts
|
||||
**/test-setup.ts
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
@@ -0,0 +1,9 @@
|
||||
# API Keys (shared across integrations)
|
||||
OPENAI_API_KEY=replace-with-your-key
|
||||
ANTHROPIC_API_KEY=replace-with-your-key
|
||||
|
||||
# Agent backend URL (for the CopilotKit runtime proxy)
|
||||
AGENT_URL=http://localhost:8000
|
||||
|
||||
# Showcase
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
.env
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.venv/
|
||||
dist/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Shared modules (copied by CI)
|
||||
shared_frontend/
|
||||
@@ -0,0 +1,82 @@
|
||||
# Stage 1: Build Next.js frontend
|
||||
# Cache bust: 2026-04-07 smoke route
|
||||
FROM node:22-slim AS frontend
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Python venv with agent deps
|
||||
#
|
||||
# True multi-stage split — the builder carries full `python:3.12` (compilers,
|
||||
# build-essential, dev headers needed to compile wheels that don't ship
|
||||
# pre-built for ``-slim``) and produces a self-contained ``/opt/venv`` that
|
||||
# the runner COPYs in as a single opaque tree. The runner never runs
|
||||
# ``pip install`` itself, so no compiler toolchain bloats the final image.
|
||||
FROM python:3.12.13 AS agent-builder
|
||||
WORKDIR /agent
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
COPY requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Stage 3: Production image with Node.js + Python (runtime only — no pip,
|
||||
# no build tools). Node.js is installed via NodeSource because the package
|
||||
# runs BOTH Next.js (frontend) and the Python agent inside this single image;
|
||||
# entrypoint.sh orchestrates both processes.
|
||||
FROM python:3.12.13-slim AS runner
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create unprivileged runtime user BEFORE any COPY so --chown resolves
|
||||
# by name and so recursive chown over /app is never needed (fast builds).
|
||||
# Mirrors the starter Dockerfile pattern for parity — Railway / any
|
||||
# platform that enforces non-root by policy needs this from the package
|
||||
# image too, not just the generated starter.
|
||||
RUN (groupadd --system --gid 1001 app 2>/dev/null || true) \
|
||||
&& (useradd --system --uid 1001 --gid 1001 --no-create-home app 2>/dev/null || true) \
|
||||
&& mkdir -p /home/app && chown app:app /home/app
|
||||
|
||||
# Python venv (prebuilt in agent-builder stage — no pip in the runner).
|
||||
COPY --chown=app:app --from=agent-builder /opt/venv /opt/venv
|
||||
ENV PATH=/opt/venv/bin:$PATH
|
||||
|
||||
# Next.js build artifacts
|
||||
COPY --chown=app:app --from=frontend /app/.next ./.next
|
||||
COPY --chown=app:app --from=frontend /app/node_modules ./node_modules
|
||||
COPY --chown=app:app --from=frontend /app/package.json ./
|
||||
COPY --chown=app:app --from=frontend /app/public ./public
|
||||
|
||||
# Agent code
|
||||
COPY --chown=app:app src/agent_server.py ./
|
||||
COPY --chown=app:app src/agents/ ./agents/
|
||||
|
||||
# Shared Python tools (symlinked locally, copied into build context by CI)
|
||||
COPY --chown=app:app tools/ /app/tools/
|
||||
|
||||
# Shared CVDIAG bootstrap module (single source: showcase/integrations/_shared;
|
||||
# symlinked in source, dereferenced into this build context by the harness
|
||||
# stage_shared step). Lands at /app/_shared; /app is already on PYTHONPATH.
|
||||
COPY --chown=app:app _shared/ ./_shared/
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# Entrypoint
|
||||
COPY --chown=app:app entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 10000
|
||||
# Intentionally NOT setting `ENV NODE_ENV=production` at the image level.
|
||||
# NODE_ENV=production at the image level would leak into every child process
|
||||
# (Python agent, shell scripts, healthchecks) — most of which don't use it
|
||||
# the way Next.js does. entrypoint.sh scopes NODE_ENV=production to the
|
||||
# Next.js invocation only so non-Next children see the host's environment.
|
||||
CMD ["./entrypoint.sh"]
|
||||
@@ -0,0 +1,248 @@
|
||||
# AG2 Parity Notes
|
||||
|
||||
Status of AG2 showcase demos relative to the langgraph-python canonical set.
|
||||
|
||||
## Ported
|
||||
|
||||
### Batch 1 — Frontend variants over the shared ConversableAgent
|
||||
|
||||
These demos reuse the existing `src/agents/agent.py` (one `ConversableAgent`
|
||||
wrapped with `AGUIStream`). The runtime route registers each agent name,
|
||||
all pointing to the same HTTP backend.
|
||||
|
||||
- `prebuilt-sidebar` — `<CopilotSidebar />` docked layout
|
||||
- `prebuilt-popup` — `<CopilotPopup />` floating launcher
|
||||
- `chat-slots` — slot-overridden `<CopilotChat />` (welcomeScreen, disclaimer, assistantMessage)
|
||||
- `chat-customization-css` — scoped CSS theming of built-in classes
|
||||
- `headless-simple` — bespoke chat built on `useAgent` / `useComponent`
|
||||
- `readonly-state-agent-context` — `useAgentContext` read-only context
|
||||
- `reasoning-default` — built-in `CopilotChatReasoningMessage` (no custom slot)
|
||||
- `tool-rendering-default-catchall` — `useDefaultRenderTool()` (built-in card)
|
||||
- `tool-rendering-custom-catchall` — single branded wildcard renderer
|
||||
- `frontend-tools` — `useFrontendTool` with sync handler (change_background)
|
||||
- `frontend-tools-async` — `useFrontendTool` with async handler (notes-card)
|
||||
- `hitl-in-app` — async `useFrontendTool` + app-level modal (approval-dialog)
|
||||
|
||||
### Previously ported (kept)
|
||||
|
||||
- `agentic-chat`, `hitl-in-chat`, `tool-rendering`, `gen-ui-tool-based`,
|
||||
`gen-ui-agent`, `shared-state-streaming`
|
||||
|
||||
### Batch 3 — Headless complete + manifest-only entries
|
||||
|
||||
- `cli-start` — informational manifest entry (copy-paste starter command).
|
||||
- `gen-ui-tool-based` — already shipped; manifest entry added.
|
||||
- `headless-complete` — TRULY headless chat re-composed from low-level
|
||||
hooks (`useRenderToolCall`, `useRenderActivityMessage`,
|
||||
`useRenderCustomMessages`). Backend: dedicated AG2
|
||||
`ConversableAgent` (`agents/headless_complete.py`) mounted at
|
||||
`/headless-complete/` with `get_weather` + `get_stock_price` tools;
|
||||
`highlight_note` is registered on the frontend via `useComponent`.
|
||||
|
||||
### Batch 4 — A2UI / OGUI / MCP + reasoning ports (this batch)
|
||||
|
||||
Each demo gets its own AG2 sub-app mounted at a named path, plus
|
||||
(where required) its own dedicated `/api/copilotkit-*` runtime route so
|
||||
the runtime middleware config doesn't leak into other cells.
|
||||
|
||||
- `declarative-gen-ui` — A2UI Dynamic Schema. Backend
|
||||
(`src/agents/a2ui_dynamic.py`) owns the `generate_a2ui` tool, which
|
||||
invokes a secondary OpenAI client bound to `render_a2ui` and returns
|
||||
an `a2ui_operations` container. Runtime route at
|
||||
`api/copilotkit-declarative-gen-ui/route.ts` with
|
||||
`a2ui.injectA2UITool: false`.
|
||||
- `a2ui-fixed-schema` — A2UI Fixed Schema. Backend
|
||||
(`src/agents/a2ui_fixed.py`) ships `flight_schema.json` and exposes a
|
||||
`display_flight(origin, destination, airline, price)` tool that emits
|
||||
`a2ui_operations` directly. Runtime route at
|
||||
`api/copilotkit-a2ui-fixed-schema/route.ts` with
|
||||
`a2ui.injectA2UITool: false`.
|
||||
- `mcp-apps` — Backend (`src/agents/mcp_apps_agent.py`) is a no-tools
|
||||
ConversableAgent; the runtime route at
|
||||
`api/copilotkit-mcp-apps/route.ts` configures
|
||||
`mcpApps.servers` pointing at the public Excalidraw MCP server, and
|
||||
the runtime middleware injects MCP tools at request time.
|
||||
- `open-gen-ui`, `open-gen-ui-advanced` — Backends are no-tools
|
||||
ConversableAgents (`src/agents/open_gen_ui_agent.py` and
|
||||
`src/agents/open_gen_ui_advanced_agent.py`). Shared runtime route at
|
||||
`api/copilotkit-ogui/route.ts` enables
|
||||
`openGenerativeUI: { agents: [...] }` so the runtime middleware
|
||||
converts streamed `generateSandboxedUi` tool calls into
|
||||
`open-generative-ui` activity events.
|
||||
- `reasoning-custom`, `tool-rendering-reasoning-chain` — Frontend
|
||||
ports of the LangGraph reasoning cells. The custom `reasoningMessage`
|
||||
slot is wired exactly as in the canonical reference. The tool chain
|
||||
(`tool-rendering-reasoning-chain` backend at
|
||||
`src/agents/tool_rendering_reasoning_chain.py`, mounted at
|
||||
`/tool-rendering-reasoning-chain/`) still exercises end-to-end.
|
||||
**Reasoning channel does NOT light up — confirmed framework-bridge
|
||||
limitation, not a fixture bug.** See the dedicated section below.
|
||||
|
||||
### Batch 2 — Dedicated AG2 sub-apps
|
||||
|
||||
These demos own their own `ConversableAgent(s)` plus FastAPI sub-app
|
||||
mounted at a named path (`agent_server.py` mounts each one before the
|
||||
catch-all `/`). The Next.js runtime points an `HttpAgent` at the
|
||||
matching path so each demo gets its own ContextVariables-backed state
|
||||
slot, isolated from the shared default agent.
|
||||
|
||||
- `shared-state-read-write` — bidirectional shared state via AG2
|
||||
`ContextVariables` + `ReplyResult`. Agent calls `get_current_preferences`
|
||||
to read UI-written prefs and `set_notes` to write back.
|
||||
- `subagents` — supervisor `ConversableAgent` that delegates to three
|
||||
sub-`ConversableAgent`s (research/writing/critique) exposed as tools;
|
||||
each delegation appends to `delegations` in shared state for the live
|
||||
log UI.
|
||||
|
||||
## Deferred (require per-demo agent specialization)
|
||||
|
||||
AG2's AG-UI integration mounts a single `AGUIStream` over one
|
||||
`ConversableAgent` at the FastAPI root. Achieving per-demo specialized
|
||||
behavior (tailored system prompts, dedicated tool sets, backend-owned
|
||||
A2UI tools, MCP integration, vision input, structured-output BYOC, etc.)
|
||||
requires adding additional Python agent modules AND either (a) mounting
|
||||
each as its own ASGI app at a distinct path and pointing a dedicated
|
||||
`HttpAgent({ url })` at it from a per-demo Next.js runtime route, or
|
||||
(b) adopting AG2's `GroupChat` to host multiple specialized agents
|
||||
behind a single stream with router logic. Both approaches are feasible
|
||||
but represent a distinct engineering investment and are not a pure port
|
||||
of the langgraph-python cell.
|
||||
|
||||
The following demos fall into that bucket and are **deferred**, not
|
||||
strictly "missing primitive" skips:
|
||||
|
||||
- `agent-config` — needs the agent to re-materialize system prompt from
|
||||
forwardedProps on every turn (AG2 ConversableAgent supports this but a
|
||||
dedicated runtime wiring is required).
|
||||
- `auth` — pure runtime `onRequest` hook demo; dedicated `/api/copilotkit-auth`
|
||||
route; agent stays unchanged. Straightforward but requires a new route.
|
||||
- `byoc-hashbrown`, `byoc-json-render` — streaming structured-output BYOC
|
||||
with Zod-validated catalogs; each has its own runtime route, catalog,
|
||||
renderer, and supporting components.
|
||||
- `multimodal` — vision-capable AG2 agent + dedicated `/api/copilotkit-multimodal`.
|
||||
- `voice` — frontend voice STT; needs dedicated `/api/copilotkit-voice` and
|
||||
the lazy-init agent shape from langgraph-python.
|
||||
|
||||
## Shipped — wave 2 follow-up
|
||||
|
||||
- `beautiful-chat` — simplified port: combines A2UI Dynamic + Open
|
||||
Generative UI on a dedicated runtime (`/api/copilotkit-beautiful-chat`).
|
||||
MCP Apps is intentionally out-of-scope (covered separately by
|
||||
`/demos/mcp-apps`); the canonical reference's app-mode toggle / todos
|
||||
canvas is also not ported. Frontend reuses the catalog from
|
||||
`/demos/declarative-gen-ui` to avoid duplication.
|
||||
- `hitl-in-chat-booking` — manifest alias to the existing `hitl-in-chat`
|
||||
cell. The langgraph reference itself aliases the booking variant to
|
||||
the same `/demos/hitl-in-chat` route; AG2's `useHumanInTheLoop`
|
||||
surface (TimePickerCard) is functionally equivalent for the booking
|
||||
flow. NOT a missing-primitive case — the earlier "skipped" entry was
|
||||
incorrect (it conflated `hitl-in-chat-booking` with the
|
||||
`useInterrupt`-driven flow, which it isn't).
|
||||
|
||||
## Skipped (missing primitive)
|
||||
|
||||
- `gen-ui-interrupt` — requires a LangGraph-style `interrupt()` that
|
||||
round-trips a resumable graph pause through the event stream. AG2's
|
||||
`human_input_mode` is a synchronous request/reply; it does not resume
|
||||
the same run from a persisted checkpoint. Marked as
|
||||
`not_supported_features` in `manifest.yaml`; the route renders a stub
|
||||
page pointing at `hitl-in-chat` / `hitl-in-app`.
|
||||
- `interrupt-headless` — same underlying primitive as `gen-ui-interrupt`.
|
||||
Marked `not_supported_features`; stub page points at `hitl-in-app` /
|
||||
`frontend-tools-async`.
|
||||
|
||||
## Reasoning channel — framework-bridge limitation (verified)
|
||||
|
||||
Applies to `reasoning-custom`, `tool-rendering-reasoning-chain`,
|
||||
and `reasoning-default`. The custom/built-in `reasoningMessage`
|
||||
slot is wired correctly, but the AG-UI reasoning channel never lights up
|
||||
because **AG2's `AGUIStream` bridge cannot emit `REASONING_MESSAGE_*`
|
||||
events** — it has no reasoning data to emit. This is the same class of
|
||||
gap as pydantic-ai, not a fixture or wiring bug. Do NOT attempt to fix
|
||||
it by hacking the aimock fixtures.
|
||||
|
||||
Verified against `ag2==0.13.3` / `autogen 0.13.3` (the version pinned by
|
||||
`requirements.txt`, `ag2[openai,ag-ui]>=0.9.0`).
|
||||
|
||||
### What AGUIStream actually emits
|
||||
|
||||
`autogen.ag_ui.adapter` (the `AGUIStream` / `run_stream` implementation)
|
||||
imports and emits only this fixed set of AG-UI event types:
|
||||
|
||||
- `RUN_STARTED`, `RUN_FINISHED`, `RUN_ERROR`
|
||||
- `STATE_SNAPSHOT`
|
||||
- `TEXT_MESSAGE_START` / `_CONTENT` / `_END` / `_CHUNK`
|
||||
- `TOOL_CALL_START` / `_ARGS` / `_CHUNK` / `_END` / `_RESULT`
|
||||
|
||||
There is **no** `REASONING_MESSAGE_*` import and **no** `THINKING_*`
|
||||
import anywhere in the adapter. So the question "does it emit
|
||||
`REASONING_MESSAGE_*`, `THINKING_*`, or nothing?" resolves to **nothing**
|
||||
— the reasoning channel is entirely absent from the bridge. (Note: even
|
||||
if it emitted `THINKING_*`, that would be a dead end — `@ag-ui/client`
|
||||
0.0.52 drops `THINKING_*`; only `REASONING_MESSAGE_*` with
|
||||
`role:"reasoning"` reaches the UI.)
|
||||
|
||||
### Why a custom-synth interceptor is NOT feasible
|
||||
|
||||
The agno / claude-sdk-python pattern (synthesize `REASONING_MESSAGE_*`
|
||||
from the model's native reasoning channel — agno reads
|
||||
`RunContentEvent.reasoning_content`; claude-sdk-python reads Anthropic's
|
||||
Messages-API `thinking_delta`, never chat-completions
|
||||
`delta.reasoning_content`) cannot be applied here, because the reasoning
|
||||
data never survives into any layer the bridge can see:
|
||||
|
||||
1. `AGUIStream` exposes an `event_interceptors` hook, but interceptors
|
||||
receive `ServiceResponse` objects (`autogen.agentchat.remote.protocol`).
|
||||
`ServiceResponse` has exactly four fields — `message`, `context`,
|
||||
`input_required`, `streaming_text` — and **no reasoning field**.
|
||||
2. Upstream of that, `AgentService` (`agent_service.py`) builds its
|
||||
streaming text from an `AsyncIOQueueStream` whose `send()` only
|
||||
captures `StreamEvent.content.content` (visible text). The final
|
||||
reply comes from `a_generate_oai_reply`, which returns a plain OAI
|
||||
message (content + tool_calls).
|
||||
3. Upstream of _that_, autogen's OpenAI chat-completions client
|
||||
(`autogen/oai/client.py`) reads only `choice.delta.content` and
|
||||
`choice.delta.tool_calls` from each streaming chunk.
|
||||
`choice.delta.reasoning_content` is **never read** in the
|
||||
chat-completions path — it is silently dropped at ingestion. (Only the
|
||||
separate `responses_v2` / Responses-API client surfaces reasoning via
|
||||
`response.reasoning`, and that path does not flow through `AGUIStream`
|
||||
either.)
|
||||
|
||||
Empirical confirmation: an OpenAI-compatible endpoint that streams
|
||||
`delta.reasoning_content` (exactly the channel aimock's `reasoning`
|
||||
fixture field drives) + `delta.content`, driven through a real
|
||||
`ConversableAgent` + `AGUIStream`, produces:
|
||||
|
||||
```
|
||||
RUN_STARTED: 1
|
||||
TEXT_MESSAGE_START: 1
|
||||
TEXT_MESSAGE_CONTENT: 3
|
||||
TEXT_MESSAGE_END: 1
|
||||
RUN_FINISHED: 1
|
||||
REASONING_MESSAGE_START: 0 ← reasoning channel never fires
|
||||
```
|
||||
|
||||
and the assembled reply is just the visible string — the
|
||||
`reasoning_content` is gone. There is therefore no reasoning data for a
|
||||
custom interceptor to synthesize from; manufacturing reasoning text would
|
||||
be a demo fabrication, which we explicitly do not do.
|
||||
|
||||
### What a real fix requires (upstream, in AG2)
|
||||
|
||||
A genuine fix must add reasoning support inside autogen itself, end to
|
||||
end:
|
||||
|
||||
1. `autogen/oai/client.py` streaming consumer must read
|
||||
`choice.delta.reasoning_content` and accumulate it alongside content.
|
||||
2. A reasoning carrier must be threaded through `StreamEvent` →
|
||||
`AsyncIOQueueStream` → `AgentService`, and `ServiceResponse` must gain
|
||||
a reasoning field (or a dedicated streaming reasoning chunk type).
|
||||
3. `autogen/ag_ui/adapter.py::run_stream` must import and emit
|
||||
`REASONING_MESSAGE_START` / `_CONTENT` / `_END` (role `"reasoning"`)
|
||||
when reasoning deltas arrive — analogous to its existing
|
||||
`TEXT_MESSAGE_*` handling.
|
||||
|
||||
Until AG2 ships that, the showcase reasoning slot for AG2 demos will
|
||||
render empty/skeletal. The cells remain valuable for exercising the slot
|
||||
plumbing and (for `tool-rendering-reasoning-chain`) the multi-tool chain.
|
||||
+1
@@ -0,0 +1 @@
|
||||
../_shared
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"framework": "ag2",
|
||||
"features": {
|
||||
"agentic-chat": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/ag2/prebuilt-components",
|
||||
"shell_docs_path": "/prebuilt-components"
|
||||
},
|
||||
"hitl-in-chat": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/ag2/human-in-the-loop",
|
||||
"shell_docs_path": "/generative-ui/your-components/interactive"
|
||||
},
|
||||
"tool-rendering": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/ag2/generative-ui/tool-rendering",
|
||||
"shell_docs_path": "/generative-ui/tool-rendering"
|
||||
},
|
||||
"gen-ui-tool-based": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/ag2/generative-ui/your-components/display-only",
|
||||
"shell_docs_path": "/generative-ui/your-components/display-only"
|
||||
},
|
||||
"gen-ui-agent": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/ag2/generative-ui/state-rendering",
|
||||
"shell_docs_path": "/generative-ui/state-rendering"
|
||||
},
|
||||
"shared-state-read-write": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/ag2/shared-state",
|
||||
"shell_docs_path": "/shared-state"
|
||||
},
|
||||
"shared-state-streaming": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/ag2/shared-state/write",
|
||||
"shell_docs_path": "/shared-state"
|
||||
},
|
||||
"subagents": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/ag2",
|
||||
"shell_docs_path": "/multi-agent/subagents"
|
||||
},
|
||||
"auth": {
|
||||
"og_docs_url": "https://docs.copilotkit.ai/ag2/auth",
|
||||
"shell_docs_path": "/auth"
|
||||
}
|
||||
},
|
||||
"missing": [
|
||||
{
|
||||
"feature": "subagents",
|
||||
"reason": "No /ag2/multi-agent/subagents page on docs.copilotkit.ai (SPA fallback). OG falls back to the ag2 framework root; shell points at the unified /multi-agent/subagents content."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cleanup() {
|
||||
kill $AGENT_PID $NEXTJS_PID $WATCHDOG_PID 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Disable Python stdout buffering so the FastAPI/uvicorn agent flushes
|
||||
# tracebacks and log lines immediately. Without this a silent crash during
|
||||
# module import can sit in Python's userspace buffer until the process
|
||||
# exits, by which point the container is already gone.
|
||||
export PYTHONUNBUFFERED=1
|
||||
|
||||
echo "========================================="
|
||||
echo "[entrypoint] Starting showcase package: ag2"
|
||||
echo "[entrypoint] Time: $(date -u)"
|
||||
echo "[entrypoint] PORT=${PORT:-not set}"
|
||||
echo "[entrypoint] NODE_ENV=${NODE_ENV:-not set}"
|
||||
echo "========================================="
|
||||
|
||||
if [ -z "$OPENAI_API_KEY" ]; then
|
||||
echo "[entrypoint] WARNING: OPENAI_API_KEY is not set! Agent will fail."
|
||||
else
|
||||
echo "[entrypoint] OPENAI_API_KEY: set (${#OPENAI_API_KEY} chars)"
|
||||
fi
|
||||
|
||||
# Start agent backend on :8000 with log prefixing so its output is
|
||||
# distinguishable from Next.js in the Railway log stream.
|
||||
#
|
||||
# Belt-and-suspenders log flushing: `PYTHONUNBUFFERED=1` above exports the env
|
||||
# var, but a child process could in principle un-export or override it. The
|
||||
# `-u` flag to the Python interpreter forces unbuffered stdout/stderr at the
|
||||
# interpreter level and is not overridable by user code. Combined with the
|
||||
# `fflush()` inside the awk pipe below, this guarantees uvicorn request lines
|
||||
# and tracebacks reach Railway's log stream line-at-a-time rather than
|
||||
# block-buffered in pipe buffers.
|
||||
echo "[entrypoint] Starting Python agent on port 8000..."
|
||||
python -u -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 &> >(awk '{print "[agent] " $0; fflush()}') &
|
||||
AGENT_PID=$!
|
||||
sleep 2
|
||||
if kill -0 $AGENT_PID 2>/dev/null; then
|
||||
echo "[entrypoint] Agent started (PID: $AGENT_PID)"
|
||||
else
|
||||
echo "[entrypoint] ERROR: Agent failed to start — exiting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "========================================="
|
||||
echo "[entrypoint] Starting Next.js frontend on port ${PORT:-10000}..."
|
||||
echo "========================================="
|
||||
|
||||
PORT=${PORT:-10000}
|
||||
# Scope NODE_ENV=production to the Next.js invocation ONLY, not the whole
|
||||
# container environment. `ENV NODE_ENV=production` at the image level would
|
||||
# leak into every child process (Python agent, shell, healthchecks). `env`
|
||||
# prefix binds the value to this single exec.
|
||||
env NODE_ENV=production npx next start --port $PORT &> >(awk '{print "[nextjs] " $0; fflush()}') &
|
||||
NEXTJS_PID=$!
|
||||
|
||||
echo "[entrypoint] Next.js started (PID: $NEXTJS_PID)"
|
||||
|
||||
# Watchdog: Railway deploys of showcase packages have been observed to hit a
|
||||
# silent agent hang — the Python process stays alive (so `wait -n` never
|
||||
# fires and the container never restarts) but stops responding on :8000.
|
||||
# Poll the agent's /health endpoint every 30s; after 3 consecutive failures
|
||||
# (90s of unreachable agent), kill the agent process so `wait -n` returns
|
||||
# and Railway restarts the container. We kill the agent (not the whole
|
||||
# script) first so `set -e` + `wait -n; exit $?` handles the restart
|
||||
# through the normal path rather than a forced `exit` that would bypass
|
||||
# logging. Generalized from showcase/integrations/crewai-crews/entrypoint.sh
|
||||
# (PRs #4114 + #4115).
|
||||
(
|
||||
FAILS=0
|
||||
while sleep 30; do
|
||||
if ! kill -0 $AGENT_PID 2>/dev/null; then
|
||||
# Agent already dead — wait -n in the main shell will handle it.
|
||||
break
|
||||
fi
|
||||
if curl -fsS --max-time 5 http://127.0.0.1:8000/health > /dev/null 2>&1; then
|
||||
FAILS=0
|
||||
else
|
||||
FAILS=$((FAILS + 1))
|
||||
echo "[watchdog] Agent health probe failed (count=$FAILS)"
|
||||
if [ $FAILS -ge 3 ]; then
|
||||
echo "[watchdog] Agent unresponsive for ~90s — killing PID $AGENT_PID to trigger container restart"
|
||||
kill -9 $AGENT_PID 2>/dev/null || true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
) &
|
||||
WATCHDOG_PID=$!
|
||||
|
||||
echo "[entrypoint] Watchdog started (PID: $WATCHDOG_PID)"
|
||||
echo "[entrypoint] All processes running. Waiting..."
|
||||
|
||||
wait -n $AGENT_PID $NEXTJS_PID
|
||||
EXIT_CODE=$?
|
||||
if ! kill -0 $AGENT_PID 2>/dev/null; then
|
||||
echo "[entrypoint] Agent (PID: $AGENT_PID) exited with code $EXIT_CODE"
|
||||
elif ! kill -0 $NEXTJS_PID 2>/dev/null; then
|
||||
echo "[entrypoint] Next.js (PID: $NEXTJS_PID) exited with code $EXIT_CODE"
|
||||
else
|
||||
echo "[entrypoint] A process exited with code $EXIT_CODE"
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
@@ -0,0 +1,557 @@
|
||||
name: AG2
|
||||
slug: ag2
|
||||
category: emerging
|
||||
language: python
|
||||
logo: /logos/ag2.svg
|
||||
description: >-
|
||||
CopilotKit integration with AG2. Supports the full range of CopilotKit
|
||||
features including generative UI, shared state, human-in-the-loop, sub-agents,
|
||||
and streaming.
|
||||
partner_docs: null
|
||||
repo: https://github.com/CopilotKit/CopilotKit/tree/main/showcase/integrations/ag2
|
||||
copilotkit_version: 2.0.0
|
||||
deployed: true
|
||||
docs_mode: authored
|
||||
sort_order: 100
|
||||
generative_ui:
|
||||
- constrained-explicit
|
||||
- a2ui-fixed-schema
|
||||
- a2ui-dynamic-schema
|
||||
not_supported_features:
|
||||
- shared-state-streaming
|
||||
- gen-ui-interrupt
|
||||
- interrupt-headless
|
||||
# AG2's ConversableAgent/AGUIStream does not emit AG-UI REASONING_MESSAGE_*
|
||||
# events (documented in src/agents/tool_rendering_reasoning_chain.py) — the
|
||||
# reasoning-block can never mount, so any reasoning-bearing pill is
|
||||
# upstream-incapable until autogen maps reasoning deltas to AG-UI events.
|
||||
# The reasoning-default-render / agentic-chat-reasoning demos remain wired
|
||||
# on disk but cannot stream reasoning text; tool-rendering-reasoning-chain's
|
||||
# tool loop works, but its reasoning slot is degraded for the same reason.
|
||||
- reasoning-default-render
|
||||
- agentic-chat-reasoning
|
||||
- tool-rendering-reasoning-chain
|
||||
interaction_modalities:
|
||||
- sidebar
|
||||
- embedded
|
||||
- chat
|
||||
features:
|
||||
- cli-start
|
||||
- agentic-chat
|
||||
- hitl-in-chat
|
||||
- hitl-in-chat-booking
|
||||
- hitl-in-app
|
||||
- hitl
|
||||
- tool-rendering
|
||||
- tool-rendering-default-catchall
|
||||
- tool-rendering-custom-catchall
|
||||
- gen-ui-agent
|
||||
- gen-ui-tool-based
|
||||
- declarative-gen-ui
|
||||
- a2ui-fixed-schema
|
||||
- mcp-apps
|
||||
- open-gen-ui
|
||||
- open-gen-ui-advanced
|
||||
- prebuilt-sidebar
|
||||
- prebuilt-popup
|
||||
- chat-slots
|
||||
- chat-customization-css
|
||||
- headless-simple
|
||||
- headless-complete
|
||||
- readonly-state-agent-context
|
||||
- frontend-tools
|
||||
- frontend-tools-async
|
||||
- shared-state-read-write
|
||||
- subagents
|
||||
- auth
|
||||
- agent-config
|
||||
- voice
|
||||
- multimodal
|
||||
- beautiful-chat
|
||||
agent_config_pattern: shared-state
|
||||
|
||||
a2ui_pattern: schema-loading
|
||||
auth_pattern: ag2-context-variables
|
||||
demos:
|
||||
- id: cli-start
|
||||
name: CLI Start Command
|
||||
description: Copy-paste command to clone the canonical starter
|
||||
tags:
|
||||
- chat-ui
|
||||
command: "npx copilotkit@latest init --framework ag2"
|
||||
- id: agentic-chat
|
||||
name: Agentic Chat
|
||||
description: Natural conversation with frontend tool execution
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/agentic-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/agentic-chat/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: hitl-in-chat
|
||||
name: In-Chat HITL (useHumanInTheLoop)
|
||||
description:
|
||||
Interactive approval/decision surface rendered inline in the chat
|
||||
via the high-level `useHumanInTheLoop` hook
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/hitl-in-chat/page.tsx
|
||||
- src/app/demos/hitl-in-chat/time-picker-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: hitl-in-chat-booking
|
||||
name: In-Chat HITL (Booking)
|
||||
description: Time-picker card rendered inline via useHumanInTheLoop for a
|
||||
booking flow — same surface as hitl-in-chat with a booking-focused framing
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/hitl-in-chat/page.tsx
|
||||
- src/app/demos/hitl-in-chat/time-picker-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: hitl-in-app
|
||||
name: In-App Human in the Loop (Frontend Tools + async HITL)
|
||||
description:
|
||||
Agent requests approval via useFrontendTool with an async handler;
|
||||
the approval UI pops up as an app-level modal OUTSIDE the chat
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl-in-app
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/hitl-in-app/page.tsx
|
||||
- src/app/demos/hitl-in-app/approval-dialog.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: hitl
|
||||
name: In-Chat Human in the Loop (Original)
|
||||
description: User approves agent actions before execution
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/hitl
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/hitl/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering
|
||||
name: Tool Rendering
|
||||
description: Backend agent tools rendered as UI components
|
||||
tags:
|
||||
- agent-capabilities
|
||||
route: /demos/tool-rendering
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- tools/get_weather.py
|
||||
- tools/query_data.py
|
||||
- tools/schedule_meeting.py
|
||||
- tools/search_flights.py
|
||||
- src/app/demos/tool-rendering/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering-default-catchall
|
||||
name: Tool Rendering (Default Catch-all)
|
||||
description: Out-of-the-box tool rendering — backend defines the tools; the
|
||||
frontend adds zero custom renderers and relies on CopilotKit's built-in
|
||||
default UI
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/tool-rendering-default-catchall
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/tool-rendering-default-catchall/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: tool-rendering-custom-catchall
|
||||
name: Tool Rendering (Custom Catch-all)
|
||||
description: Single branded wildcard renderer via useDefaultRenderTool — the
|
||||
same app-designed card paints every tool call
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/tool-rendering-custom-catchall
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/tool-rendering-custom-catchall/page.tsx
|
||||
- src/app/demos/tool-rendering-custom-catchall/custom-catchall-renderer.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: gen-ui-agent
|
||||
name: Agentic Generative UI
|
||||
description: Long-running agent tasks with generated UI
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/gen-ui-agent
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/gen-ui-agent/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: gen-ui-tool-based
|
||||
name: Tool-Based Generative UI
|
||||
description: Agent uses tools to trigger UI generation
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/gen-ui-tool-based
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/gen-ui-tool-based/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: shared-state-streaming
|
||||
name: State Streaming
|
||||
description: Per-token state delta streaming from agent to UI
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/shared-state-streaming
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/shared-state-streaming/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: prebuilt-sidebar
|
||||
name: "Pre-Built: Sidebar"
|
||||
description: Docked sidebar chat via <CopilotSidebar />
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/prebuilt-sidebar
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/prebuilt-sidebar/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: prebuilt-popup
|
||||
name: "Pre-Built: Popup"
|
||||
description: Floating popup chat via <CopilotPopup />
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/prebuilt-popup
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/prebuilt-popup/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: chat-slots
|
||||
name: Chat Customization (Slots)
|
||||
description: Customize CopilotChat via its slot system
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/chat-slots
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/chat-slots/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: chat-customization-css
|
||||
name: Chat Customization (CSS)
|
||||
description: Default CopilotChat re-themed via CopilotKitCSSProperties
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/chat-customization-css
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/chat-customization-css/page.tsx
|
||||
- src/app/demos/chat-customization-css/theme.css
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: headless-simple
|
||||
name: Headless Chat (Simple)
|
||||
description: Minimal custom chat surface built on useAgent
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/headless-simple
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/headless-simple/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: headless-complete
|
||||
name: Headless Chat (Complete)
|
||||
description: Full chat implementation built from scratch on useAgent
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/headless-complete
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/headless_complete.py
|
||||
- src/app/demos/headless-complete/page.tsx
|
||||
- src/app/api/copilotkit-mcp-apps/route.ts
|
||||
- src/app/demos/headless-complete/chat/chat.tsx
|
||||
- src/app/demos/headless-complete/hooks/use-tool-renderers.tsx
|
||||
- src/app/demos/headless-complete/hooks/use-frontend-components.ts
|
||||
- src/app/demos/headless-complete/hooks/use-headless-suggestions.ts
|
||||
- src/app/demos/headless-complete/attachments/use-attachments-config.ts
|
||||
- src/app/demos/headless-complete/tools/weather-card.tsx
|
||||
- src/app/demos/headless-complete/tools/stock-card.tsx
|
||||
- src/app/demos/headless-complete/tools/chart-card.tsx
|
||||
- src/app/demos/headless-complete/tools/highlight-note.tsx
|
||||
- id: readonly-state-agent-context
|
||||
name: Readonly State (Agent Context)
|
||||
description: Frontend provides read-only context to the agent via useAgentContext
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/readonly-state-agent-context
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/readonly-state-agent-context/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: frontend-tools
|
||||
name: Frontend Tools (In-App Actions)
|
||||
description: Agent invokes client-side handlers registered with useFrontendTool
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/frontend-tools
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/frontend-tools/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: frontend-tools-async
|
||||
name: Frontend Tools (Async)
|
||||
description:
|
||||
useFrontendTool with an async handler — agent awaits a client-side
|
||||
async operation and uses the returned result
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/frontend-tools-async
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/frontend-tools-async/page.tsx
|
||||
- src/app/demos/frontend-tools-async/notes-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: shared-state-read-write
|
||||
name: Shared State (Read + Write)
|
||||
description: Bidirectional agent state — UI writes preferences, agent writes
|
||||
notes back via AG2 ContextVariables
|
||||
tags:
|
||||
- agent-state
|
||||
route: /demos/shared-state-read-write
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/shared_state_read_write.py
|
||||
- src/app/demos/shared-state-read-write/page.tsx
|
||||
- src/app/demos/shared-state-read-write/preferences-card.tsx
|
||||
- src/app/demos/shared-state-read-write/notes-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: subagents
|
||||
name: Sub-Agents
|
||||
description: Supervisor delegates tasks to research, writing, and critique
|
||||
sub-agents with a live delegation log
|
||||
tags:
|
||||
- multi-agent
|
||||
route: /demos/subagents
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/subagents.py
|
||||
- src/app/demos/subagents/page.tsx
|
||||
- src/app/demos/subagents/delegation-log.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: gen-ui-interrupt
|
||||
name: Gen UI Interrupt (Frontend Tool + async Promise)
|
||||
description:
|
||||
In-chat time-picker card via useFrontendTool with an async handler
|
||||
that blocks until the user picks a slot — AG2 adaptation of the LangGraph
|
||||
interrupt() primitive
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/gen-ui-interrupt
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/interrupt_agent.py
|
||||
- src/app/demos/gen-ui-interrupt/page.tsx
|
||||
- src/app/demos/gen-ui-interrupt/_components/time-picker-card.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: interrupt-headless
|
||||
name: Headless Interrupt (Frontend Tool + async Promise)
|
||||
description:
|
||||
Time-picker popup rendered outside the chat in the app surface via
|
||||
useFrontendTool with an async handler — AG2 adaptation of the LangGraph
|
||||
headless interrupt pattern
|
||||
tags:
|
||||
- interactivity
|
||||
route: /demos/interrupt-headless
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/interrupt_agent.py
|
||||
- src/app/demos/interrupt-headless/page.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: declarative-gen-ui
|
||||
name: Declarative Generative UI (A2UI Dynamic Schema)
|
||||
description:
|
||||
Agent dynamically composes UI from a registered catalog of branded
|
||||
components via the A2UI middleware
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/declarative-gen-ui
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/a2ui_dynamic.py
|
||||
- src/app/demos/declarative-gen-ui/page.tsx
|
||||
- src/app/demos/declarative-gen-ui/a2ui/catalog.ts
|
||||
- src/app/demos/declarative-gen-ui/a2ui/definitions.ts
|
||||
- src/app/demos/declarative-gen-ui/a2ui/renderers.tsx
|
||||
- src/app/api/copilotkit-declarative-gen-ui/route.ts
|
||||
- id: a2ui-fixed-schema
|
||||
name: A2UI (Fixed Schema)
|
||||
description:
|
||||
Agent streams data into a frontend-authored fixed component schema;
|
||||
backend ships JSON, agent fills in values
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/a2ui-fixed-schema
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/a2ui_fixed.py
|
||||
- src/agents/a2ui_schemas/flight_schema.json
|
||||
- src/app/demos/a2ui-fixed-schema/page.tsx
|
||||
- src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts
|
||||
- src/app/demos/a2ui-fixed-schema/a2ui/definitions.ts
|
||||
- src/app/demos/a2ui-fixed-schema/a2ui/renderers.tsx
|
||||
- src/app/api/copilotkit-a2ui-fixed-schema/route.ts
|
||||
- id: mcp-apps
|
||||
name: MCP Apps
|
||||
description: Runtime mcpApps middleware injects an MCP server's tools and
|
||||
renders associated UI resources inline
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/mcp-apps
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/mcp_apps_agent.py
|
||||
- src/app/demos/mcp-apps/page.tsx
|
||||
- src/app/api/copilotkit-mcp-apps/route.ts
|
||||
- id: open-gen-ui
|
||||
name: Open Generative UI (Minimal)
|
||||
description:
|
||||
Agent streams a single generateSandboxedUi tool call; the runtime
|
||||
renders agent-authored HTML/CSS in a sandboxed iframe
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/open-gen-ui
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/open_gen_ui_agent.py
|
||||
- src/app/demos/open-gen-ui/page.tsx
|
||||
- src/app/api/copilotkit-ogui/route.ts
|
||||
- id: open-gen-ui-advanced
|
||||
name: Open Generative UI (Advanced)
|
||||
description: Sandboxed iframe UIs invoke host-page sandbox functions via
|
||||
Websandbox.connection.remote
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/open-gen-ui-advanced
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/open_gen_ui_advanced_agent.py
|
||||
- src/app/demos/open-gen-ui-advanced/page.tsx
|
||||
- src/app/demos/open-gen-ui-advanced/sandbox-functions.ts
|
||||
- src/app/demos/open-gen-ui-advanced/suggestions.ts
|
||||
- src/app/api/copilotkit-ogui/route.ts
|
||||
- id: tool-rendering-reasoning-chain
|
||||
name: Tool Rendering (Reasoning Chain)
|
||||
description:
|
||||
Sequential tool calls combined with a custom reasoningMessage slot
|
||||
— weather, flights, custom catch-all
|
||||
tags:
|
||||
- generative-ui
|
||||
route: /demos/tool-rendering-reasoning-chain
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/tool_rendering_reasoning_chain.py
|
||||
- src/app/demos/tool-rendering-reasoning-chain/page.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/weather-card.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/flight-list-card.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/custom-catchall-renderer.tsx
|
||||
- src/app/demos/tool-rendering-reasoning-chain/reasoning-block.tsx
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: auth
|
||||
name: Authentication
|
||||
description:
|
||||
Bearer-token gate via runtime onRequest hook with unauthenticated /
|
||||
authenticated states
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/auth
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/app/demos/auth/page.tsx
|
||||
- src/app/demos/auth/auth-banner.tsx
|
||||
- src/app/demos/auth/use-demo-auth.ts
|
||||
- src/app/demos/auth/demo-token.ts
|
||||
- src/app/api/copilotkit-auth/[[...slug]]/route.ts
|
||||
- id: agent-config
|
||||
name: Agent Config Object
|
||||
description:
|
||||
Forward a typed config object (tone / expertise / response length)
|
||||
from the provider to the agent's dynamic system prompt
|
||||
tags:
|
||||
- platform
|
||||
route: /demos/agent-config
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent_config_agent.py
|
||||
- src/app/demos/agent-config/page.tsx
|
||||
- src/app/demos/agent-config/config-card.tsx
|
||||
- src/app/demos/agent-config/use-agent-config.ts
|
||||
- src/app/demos/agent-config/config-types.ts
|
||||
- src/app/api/copilotkit/route.ts
|
||||
- id: voice
|
||||
name: Voice Input
|
||||
description: Speech-to-text via @copilotkit/voice with a bundled sample audio button
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/voice
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/agent.py
|
||||
- src/app/demos/voice/page.tsx
|
||||
- src/app/demos/voice/sample-audio-button.tsx
|
||||
- src/app/api/copilotkit-voice/[[...slug]]/route.ts
|
||||
- id: multimodal
|
||||
name: Multimodal Attachments
|
||||
description:
|
||||
Image and PDF uploads via CopilotChat attachments, processed by a
|
||||
vision-capable agent
|
||||
tags:
|
||||
- chat-ui
|
||||
route: /demos/multimodal
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/multimodal_agent.py
|
||||
- src/app/demos/multimodal/page.tsx
|
||||
- src/app/demos/multimodal/sample-attachment-buttons.tsx
|
||||
- src/app/api/copilotkit-multimodal/route.ts
|
||||
- id: beautiful-chat
|
||||
name: Beautiful Chat
|
||||
description: Simplified flagship cell that combines A2UI Dynamic + Open
|
||||
Generative UI on a single dedicated runtime — agent picks branded catalog
|
||||
components for structured visuals and free-form sandboxed UI for
|
||||
everything else
|
||||
tags:
|
||||
- chat-ui
|
||||
- generative-ui
|
||||
route: /demos/beautiful-chat
|
||||
animated_preview_url:
|
||||
highlight:
|
||||
- src/agents/beautiful_chat.py
|
||||
- src/app/demos/beautiful-chat/page.tsx
|
||||
- src/app/demos/declarative-gen-ui/a2ui/catalog.ts
|
||||
- src/app/api/copilotkit-beautiful-chat/route.ts
|
||||
- src/app/demos/beautiful-chat/components/example-layout/index.tsx
|
||||
- src/app/demos/beautiful-chat/components/example-canvas/index.tsx
|
||||
- src/app/demos/beautiful-chat/components/generative-ui/charts/bar-chart.tsx
|
||||
- src/app/demos/beautiful-chat/components/generative-ui/charts/pie-chart.tsx
|
||||
- src/app/demos/beautiful-chat/hooks/use-example-suggestions.tsx
|
||||
- src/app/demos/beautiful-chat/hooks/use-generative-ui-examples.tsx
|
||||
managed_platform:
|
||||
name: Agent OS
|
||||
url: https://ag2.ai/product
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Allow iframe embedding from the showcase shell
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "ALLOWALL",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: "frame-ancestors *;",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
+13211
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@copilotkit/showcase-ag2",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"next dev --turbopack\" \"PYTHONPATH=. python -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 --reload\"",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-ui/client": "0.0.57",
|
||||
"@copilotkit/a2ui-renderer": "1.61.2",
|
||||
"@copilotkit/react-core": "1.61.2",
|
||||
"@copilotkit/runtime": "1.61.2",
|
||||
"@copilotkit/shared": "1.61.2",
|
||||
"@copilotkit/voice": "1.61.2",
|
||||
"@hashbrownai/core": "0.5.0-beta.4",
|
||||
"@hashbrownai/react": "0.5.0-beta.4",
|
||||
"@json-render/core": "0.18.0",
|
||||
"@json-render/react": "0.18.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^0.2.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "^15.5.15",
|
||||
"openai": "^5.9.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^2.15.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"concurrently": "^9.1.0",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@copilotkit/web-inspector": {
|
||||
"@copilotkit/core": "1.61.2"
|
||||
}
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@copilotkit/web-inspector>@copilotkit/core": "1.61.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
extraHTTPHeaders: {
|
||||
"X-AIMock-Context": "ag2",
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: process.env.CI
|
||||
? undefined
|
||||
: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: true,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL || "",
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY || "",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,18 @@
|
||||
# Voice demo audio
|
||||
|
||||
Drop a small (<100KB) WAV file named `sample.wav` in this directory. The voice
|
||||
demo (`/demos/voice`) fetches it client-side and POSTs to the transcription
|
||||
endpoint so the flow works without mic permissions.
|
||||
|
||||
Expected content: an audio clip speaking the phrase
|
||||
**"What is the weather in Tokyo?"** — the demo caption advertises that phrase
|
||||
to the user, and the bundled QA checklist + E2E spec assert the transcribed
|
||||
text contains "weather" and/or "Tokyo".
|
||||
|
||||
Generate locally, for example:
|
||||
|
||||
- macOS: `say -o sample.aiff "What is the weather in Tokyo?" && ffmpeg -i sample.aiff -ar 16000 -ac 1 sample.wav`
|
||||
- Linux: `espeak-ng -w sample.wav "What is the weather in Tokyo?"`
|
||||
- Windows: PowerShell `System.Speech.Synthesis.SpeechSynthesizer` → `SetOutputToWaveFile`
|
||||
|
||||
Target: 16kHz mono, 3-5s duration, <100KB.
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bd4aa7b049f1c3e324dfd15af4068d7f8fbf2eae1dd044df270dddc5f38a5c57
|
||||
size 87078
|
||||
@@ -0,0 +1,22 @@
|
||||
# Demo Files — Multimodal Demo
|
||||
|
||||
This directory bundles sample files referenced by the `/demos/multimodal` page.
|
||||
|
||||
Required files (must be committed as binaries; see `.gitattributes` at repo root):
|
||||
|
||||
- `sample.png` — a small (< 50 KB) PNG that the "Try with sample image" button
|
||||
injects into the chat. A CopilotKit logo or other recognizable brand mark
|
||||
works well so the vision-capable agent has something to describe.
|
||||
- `sample.pdf` — a small (< 50 KB) one-page PDF that the "Try with sample PDF"
|
||||
button injects. The content must mention "CopilotKit" so E2E soft
|
||||
assertions hold (e.g. a one-page export of the CopilotKit quickstart).
|
||||
|
||||
The page at `src/app/demos/multimodal/page.tsx` fetches these via the public
|
||||
path (`/demo-files/sample.png`, `/demo-files/sample.pdf`), wraps the fetched
|
||||
blob in a `File`, and routes it through the same `AttachmentsConfig.onUpload`
|
||||
callback the paperclip button uses — so the sample path exercises the exact
|
||||
same queueing code as a real user upload.
|
||||
|
||||
If these files are missing, the demo page still renders but the sample
|
||||
buttons will surface a fetch error. The paperclip / drag-and-drop paths
|
||||
continue to work without them.
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3da2afae36a1a81fd2c02f15e54bfc38b6c22e41655c31a5b54ff1e0e3daab41
|
||||
size 2486
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:01aa5681de99461247543e9215c1e4da3242e26b2bee11593fcdbe209672d973
|
||||
size 10083
|
||||
@@ -0,0 +1,62 @@
|
||||
# QA: Agentic Chat — AG2
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the agentic-chat demo page
|
||||
- [ ] Verify the chat interface loads with a text input placeholder "Type a message"
|
||||
- [ ] Verify the background container (`data-testid="background-container"`) is visible
|
||||
- [ ] Verify the default background color is the theme default (rgb(250, 250, 249))
|
||||
- [ ] Send a basic message (e.g. "Hello")
|
||||
- [ ] Verify the agent responds with a text message
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Change background" suggestion button is visible
|
||||
- [ ] Verify "Generate sonnet" suggestion button is visible
|
||||
- [ ] Click the "Change background" suggestion
|
||||
- [ ] Verify the suggestion either populates the input or sends the message
|
||||
|
||||
#### Background Change (useFrontendTool)
|
||||
|
||||
- [ ] Ask "Change the background to a sunset gradient"
|
||||
- [ ] Verify the background container style changes from the default
|
||||
- [ ] Verify the change_background tool returns a success status
|
||||
|
||||
#### Weather Render Tool (useRenderTool)
|
||||
|
||||
- [ ] Type "What's the weather in Tokyo?"
|
||||
- [ ] Verify loading state shows "Loading weather..." (`data-testid="weather-info-loading"`)
|
||||
- [ ] Verify WeatherCard renders (`data-testid="weather-info"`) with:
|
||||
- [ ] City name displayed
|
||||
- [ ] Temperature in degrees C
|
||||
- [ ] Humidity percentage
|
||||
- [ ] Wind speed in mph
|
||||
- [ ] Conditions text
|
||||
|
||||
#### Agent Context
|
||||
|
||||
- [ ] Verify the agent knows the user's name is "Bob" (provided via useAgentContext)
|
||||
- [ ] Ask "What is my name?" and verify the agent responds with "Bob"
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
- [ ] Send a very long message and verify no UI breakage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- Background changes are instant after tool execution
|
||||
- Weather card renders with all data fields populated
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,11 @@
|
||||
# QA: Chat Customization (CSS) — AG2
|
||||
|
||||
- [ ] Navigate to /demos/chat-customization-css
|
||||
- [ ] Verify scoped theme applied — `.chat-css-demo-scope` wrapper has hot-pink accents
|
||||
- [ ] Send a message — verify user bubble is hot-pink gradient, assistant bubble is amber monospace
|
||||
- [ ] Input border is dashed pink
|
||||
- [ ] No theme leak to other pages
|
||||
|
||||
## Expected Results
|
||||
|
||||
- All CSS overrides visible, scoped to this demo
|
||||
@@ -0,0 +1,12 @@
|
||||
# QA: Chat Slots — AG2
|
||||
|
||||
- [ ] Navigate to /demos/chat-slots
|
||||
- [ ] Verify custom welcome screen visible (`data-testid="custom-welcome-screen"`)
|
||||
- [ ] Verify "Custom Slot" badge
|
||||
- [ ] Send a message (or click a suggestion)
|
||||
- [ ] Verify custom assistant message wrapper (`data-testid="custom-assistant-message"`) renders
|
||||
- [ ] Verify custom disclaimer (`data-testid="custom-disclaimer"`) renders
|
||||
|
||||
## Expected Results
|
||||
|
||||
- All three slot overrides render and are visible
|
||||
@@ -0,0 +1,11 @@
|
||||
# QA: Frontend Tools (Async) — AG2
|
||||
|
||||
- [ ] Navigate to /demos/frontend-tools-async
|
||||
- [ ] Click "Find project-planning notes"
|
||||
- [ ] Verify NotesCard loading state ("Querying local notes DB...")
|
||||
- [ ] After ~500ms, verify matches render (notes-list)
|
||||
- [ ] Verify agent summarizes the notes in the next assistant bubble
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Async frontend-tool handler waits and returns data to the agent
|
||||
@@ -0,0 +1,10 @@
|
||||
# QA: Frontend Tools — AG2
|
||||
|
||||
- [ ] Navigate to /demos/frontend-tools
|
||||
- [ ] Click "Change background" suggestion
|
||||
- [ ] Verify background updates via client-side handler
|
||||
- [ ] Verify success message in chat
|
||||
|
||||
## Expected Results
|
||||
|
||||
- useFrontendTool handler runs on the client; agent uses the return value
|
||||
@@ -0,0 +1,62 @@
|
||||
# QA: Agentic Generative UI — AG2
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the gen-ui-agent demo page
|
||||
- [ ] Verify the chat interface loads in a centered full-height layout
|
||||
- [ ] Verify the chat input placeholder "Type a message" is visible
|
||||
- [ ] Send a basic message
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Simple plan" suggestion button is visible (plan to go to Mars in 5 steps)
|
||||
- [ ] Verify "Complex plan" suggestion button is visible (plan to make pizza in 10 steps)
|
||||
|
||||
#### Task Progress Tracker (useAgent with state streaming)
|
||||
|
||||
- [ ] Click "Simple plan" suggestion or type "Build a plan to go to Mars in 5 steps"
|
||||
- [ ] Verify the TaskProgress component renders (`data-testid="task-progress"`)
|
||||
- [ ] Verify the progress bar appears with a gradient fill
|
||||
- [ ] Verify step items appear with descriptions (`data-testid="task-step-text"`)
|
||||
- [ ] Verify the "N/N Complete" counter updates as steps complete
|
||||
- [ ] Verify completed steps show:
|
||||
- Green background gradient
|
||||
- Check icon
|
||||
- Green text color
|
||||
- [ ] Verify the current pending step shows:
|
||||
- Blue/purple background gradient
|
||||
- Spinner icon with "Processing..." text
|
||||
- Pulsing animation
|
||||
- [ ] Verify future pending steps show:
|
||||
- Gray background
|
||||
- Clock icon
|
||||
- Muted text color
|
||||
|
||||
#### Complex Plan
|
||||
|
||||
- [ ] Type "Plan to make pizza in 10 steps"
|
||||
- [ ] Verify 10 steps appear in the progress tracker
|
||||
- [ ] Verify progress bar width increases as steps complete
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- Task progress tracker shows live step completion
|
||||
- Progress bar animates smoothly
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,59 @@
|
||||
# QA: Tool-Based Generative UI — AG2
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the gen-ui-tool-based demo page
|
||||
- [ ] Verify the CopilotSidebar opens by default with title "Haiku Generator"
|
||||
- [ ] Verify a placeholder haiku card is displayed on the main area
|
||||
- [ ] Send a basic message via the sidebar
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Nature Haiku" suggestion button is visible
|
||||
- [ ] Verify "Ocean Haiku" suggestion button is visible
|
||||
- [ ] Verify "Spring Haiku" suggestion button is visible
|
||||
|
||||
#### Haiku Generation (useFrontendTool)
|
||||
|
||||
- [ ] Click the "Nature Haiku" suggestion or type "Write me a haiku about nature"
|
||||
- [ ] Verify a HaikuCard renders (`data-testid="haiku-card"`) with:
|
||||
- [ ] Three Japanese text lines (`data-testid="haiku-japanese-line"`)
|
||||
- [ ] Three English translation lines (`data-testid="haiku-english-line"`)
|
||||
- [ ] A background gradient style applied to the card
|
||||
- [ ] Verify the Japanese text contains actual Japanese characters (not Latin)
|
||||
- [ ] Verify the English lines are a readable English translation
|
||||
|
||||
#### Image Display
|
||||
|
||||
- [ ] After haiku generation, verify an image renders (`data-testid="haiku-image"`) if the agent provides an image_name
|
||||
- [ ] Verify the image src points to /images/ with a valid filename from the predefined list
|
||||
|
||||
#### Multiple Haikus
|
||||
|
||||
- [ ] Generate a second haiku (e.g. "Ocean Haiku")
|
||||
- [ ] Verify the new haiku card appears at the top
|
||||
- [ ] Verify the previous haiku card is still visible below
|
||||
- [ ] Verify the initial placeholder haiku is removed
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Sidebar loads within 3 seconds
|
||||
- Agent responds and generates haiku within 10 seconds
|
||||
- Haiku cards display with Japanese and English text
|
||||
- Generated haikus stack with newest on top
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,13 @@
|
||||
# QA: Headless Chat (Simple) — AG2
|
||||
|
||||
- [ ] Navigate to /demos/headless-simple
|
||||
- [ ] Verify "Headless Chat (Simple)" heading
|
||||
- [ ] Type "show a card about cats"
|
||||
- [ ] Click Send
|
||||
- [ ] Verify user bubble appears
|
||||
- [ ] Verify assistant replies, `ShowCard` renders (title + body)
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Custom chat UI works (not pre-built CopilotChat)
|
||||
- useComponent registration wired via copilotkit.runAgent
|
||||
@@ -0,0 +1,12 @@
|
||||
# QA: In-App HITL — AG2
|
||||
|
||||
- [ ] Navigate to /demos/hitl-in-app
|
||||
- [ ] Verify tickets panel on left, chat on right
|
||||
- [ ] Click suggestion "Approve refund for #12345"
|
||||
- [ ] Verify approval dialog modal appears (`data-testid="approval-dialog"`) OUTSIDE the chat
|
||||
- [ ] Click Approve
|
||||
- [ ] Verify dialog closes and agent acknowledges approval in chat
|
||||
|
||||
## Expected Results
|
||||
|
||||
- App-level modal routes through async useFrontendTool Promise
|
||||
@@ -0,0 +1,57 @@
|
||||
# QA: Human in the Loop — AG2
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the HITL demo page (`/demos/hitl`) > _Note: the URL path `/demos/hitl` is intentionally shorter than the feature id `hitl-in-chat`._
|
||||
- [ ] Verify the chat interface loads in a centered max-w-4xl container
|
||||
- [ ] Verify the chat input placeholder "Type a message" is visible
|
||||
- [ ] Send a basic message
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Simple plan" suggestion button is visible
|
||||
- [ ] Verify "Complex plan" suggestion button is visible
|
||||
- [ ] Click the "Simple plan" suggestion
|
||||
- [ ] Verify it triggers a message about planning a trip to Mars in 5 steps
|
||||
|
||||
#### Step Selection and Approval
|
||||
|
||||
The HITL demo renders a single StepSelector card regardless of whether
|
||||
the underlying flow uses an interrupt hook or a frontend HITL tool. The
|
||||
framework-specific hook name is an implementation detail covered in each
|
||||
package's demo source; QA below validates the user-visible card behavior.
|
||||
|
||||
- [ ] Send "Plan a trip to Mars in 5 steps"
|
||||
- [ ] Verify the StepSelector card appears (`data-testid="select-steps"`)
|
||||
- [ ] Verify step items are displayed with checkboxes (`data-testid="step-item"`)
|
||||
- [ ] Verify step text is visible (`data-testid="step-text"`)
|
||||
- [ ] Verify the selected count display shows "N/N selected"
|
||||
- [ ] Toggle a step checkbox off and verify the count decreases
|
||||
- [ ] Toggle it back on and verify the count increases
|
||||
- [ ] Click "Perform Steps (N)" / "Confirm (N)" button
|
||||
- [ ] Verify the agent continues processing after confirmation
|
||||
- [ ] In a new conversation, trigger the same flow and click "Reject" (where present)
|
||||
- [ ] Verify the card reflects the decision (Accepted / Rejected) and buttons disable
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- Step selector renders with toggleable checkboxes
|
||||
- Accept/Reject flow completes without errors
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,12 @@
|
||||
# QA: Pre-Built Popup — AG2
|
||||
|
||||
- [ ] Navigate to /demos/prebuilt-popup
|
||||
- [ ] Verify floating launcher visible
|
||||
- [ ] Popup opens by default
|
||||
- [ ] Send "Say hi from the popup"
|
||||
- [ ] Verify agent responds
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Popup opens as overlay
|
||||
- Agent replies inside the popup window
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA: Pre-Built Sidebar — AG2
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
- [ ] Navigate to /demos/prebuilt-sidebar
|
||||
- [ ] Verify main content renders ("Sidebar demo — click the launcher")
|
||||
- [ ] Verify sidebar launcher button visible
|
||||
- [ ] Verify sidebar is open by default
|
||||
- [ ] Send "Say hi"
|
||||
- [ ] Verify agent responds
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Sidebar toggles via launcher
|
||||
- Agent replies inside the sidebar
|
||||
@@ -0,0 +1,12 @@
|
||||
# QA: Readonly State (Agent Context) — AG2
|
||||
|
||||
- [ ] Navigate to /demos/readonly-state-agent-context
|
||||
- [ ] Verify context card (`data-testid="context-card"`)
|
||||
- [ ] Change name in `data-testid="ctx-name"` input
|
||||
- [ ] Toggle an activity checkbox
|
||||
- [ ] Send "Who am I?"
|
||||
- [ ] Verify agent reflects the context (name, timezone, activities)
|
||||
|
||||
## Expected Results
|
||||
|
||||
- useAgentContext values visibly propagate into agent's replies
|
||||
@@ -0,0 +1,10 @@
|
||||
# QA: Reasoning (Default Render) — AG2
|
||||
|
||||
- [ ] Navigate to /demos/reasoning-default-render
|
||||
- [ ] Send "Think step-by-step: what is 17 \* 23?"
|
||||
- [ ] Verify a reasoning card appears above the final answer (default CopilotChatReasoningMessage)
|
||||
- [ ] Card is collapsible
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Built-in reasoning UI renders without a custom slot
|
||||
@@ -0,0 +1,69 @@
|
||||
# QA: Shared State (Read + Write) — AG2
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible at `/demos/shared-state-read-write`
|
||||
- Agent backend is healthy (check `/api/copilotkit` GET → `agent_status: reachable`)
|
||||
- Backend has `OPENAI_API_KEY` set
|
||||
- The `shared-state-read-write` agent is mounted at `/shared-state-read-write` on
|
||||
the FastAPI server (see `src/agent_server.py`)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Page renders with the two cards and chat
|
||||
|
||||
- [ ] Navigate to `/demos/shared-state-read-write`
|
||||
- [ ] Sidebar shows the **Preferences** card (`data-testid="preferences-card"`)
|
||||
- [ ] Sidebar shows the **Agent notes** card (`data-testid="notes-card"`) with the
|
||||
empty state copy "No notes yet. Ask the agent to remember something."
|
||||
- [ ] Right-hand pane shows `<CopilotChat />` with the placeholder
|
||||
"Chat with the agent..."
|
||||
- [ ] The "Shared state" panel inside the preferences card shows JSON with
|
||||
`name: ""`, `tone: "casual"`, `language: "English"`, `interests: []`
|
||||
|
||||
### 2. UI -> agent (write)
|
||||
|
||||
- [ ] Type a name (e.g. `Atai`) into the **Name** input. The "Shared state"
|
||||
JSON inside the card updates immediately to reflect the new name.
|
||||
- [ ] Change **Tone** to `playful`. JSON updates.
|
||||
- [ ] Change **Language** to `Spanish`. JSON updates.
|
||||
- [ ] Toggle 2-3 interests (e.g. `Cooking`, `Tech`). JSON updates and the
|
||||
buttons show the selected style.
|
||||
- [ ] In the chat, send: **"Greet me in one sentence."**
|
||||
- [ ] The agent's reply addresses the user by name in a playful tone, in
|
||||
Spanish (i.e. it actually used the preferences). It must NOT just
|
||||
echo the JSON.
|
||||
|
||||
### 3. agent -> UI (read)
|
||||
|
||||
- [ ] In the chat, send: **"Remember that I prefer morning meetings and that
|
||||
I don't eat dairy."**
|
||||
- [ ] The **Agent notes** card transitions from the empty state to a
|
||||
bulleted list with at least 2 entries (`data-testid="note-item"`),
|
||||
reflecting the two facts above. The list updates in real time
|
||||
while/after the agent finishes its turn.
|
||||
- [ ] In the chat, send: **"Also remember I work in Pacific time."**
|
||||
- [ ] The notes list now has at least 3 entries (the agent passed the FULL
|
||||
list to `set_notes`, not just the new one).
|
||||
|
||||
### 4. Round-trip + Clear
|
||||
|
||||
- [ ] Click the **Clear** button on the notes card.
|
||||
- [ ] The notes card returns to the empty state immediately.
|
||||
- [ ] In the chat, send: **"What do you remember about me?"**
|
||||
- [ ] The agent reports it has no remembered notes (because the UI cleared
|
||||
them via `agent.setState`), confirming the UI's write-back was
|
||||
visible to the agent on its next turn.
|
||||
|
||||
### 5. Error handling
|
||||
|
||||
- [ ] Send an empty message → handled gracefully (no crash, no broken UI).
|
||||
- [ ] No console errors during normal usage.
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads in < 3 seconds.
|
||||
- Preferences edits propagate to agent state instantly.
|
||||
- Agent replies adapt visibly to preferences (name, tone, language).
|
||||
- Notes card reflects every `set_notes` call from the agent.
|
||||
- Clearing notes from the UI is reflected on the agent's next turn.
|
||||
@@ -0,0 +1,75 @@
|
||||
# QA: Shared State (Reading) — AG2
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the shared-state-read demo page
|
||||
- [ ] Verify the recipe card form loads (`data-testid="recipe-card"`)
|
||||
- [ ] Verify the CopilotSidebar opens by default with title "AI Recipe Assistant"
|
||||
- [ ] Send a message via the sidebar
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Initial Recipe State
|
||||
|
||||
- [ ] Verify the recipe title input shows "Make Your Recipe"
|
||||
- [ ] Verify the cooking time dropdown defaults to "45 min"
|
||||
- [ ] Verify the skill level dropdown defaults to "Intermediate"
|
||||
- [ ] Verify the default ingredients are displayed:
|
||||
- [ ] Carrots (3 large, grated) with carrot emoji
|
||||
- [ ] All-Purpose Flour (2 cups) with wheat emoji
|
||||
- [ ] Verify the default instruction is displayed: "Preheat oven to 350 F"
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Create Italian recipe" suggestion is visible
|
||||
- [ ] Verify "Make it healthier" suggestion is visible
|
||||
- [ ] Verify "Suggest variations" suggestion is visible
|
||||
|
||||
#### Recipe Editing (Local State)
|
||||
|
||||
- [ ] Edit the recipe title and verify it updates
|
||||
- [ ] Change the skill level dropdown and verify it updates
|
||||
- [ ] Change the cooking time dropdown and verify it updates
|
||||
- [ ] Toggle a dietary preference checkbox (e.g. "Vegetarian") and verify it's checked
|
||||
- [ ] Click "+ Add Ingredient" (`data-testid="add-ingredient-button"`) and verify a new empty row appears
|
||||
- [ ] Edit an ingredient name and amount
|
||||
- [ ] Remove an ingredient by clicking the "x" button
|
||||
- [ ] Click "+ Add Step" and verify a new instruction row appears
|
||||
- [ ] Edit an instruction and verify it saves
|
||||
- [ ] Remove an instruction by clicking the "x" button
|
||||
|
||||
#### AI-Powered Recipe Updates (useAgent with shared state)
|
||||
|
||||
- [ ] Click "Create Italian recipe" suggestion
|
||||
- [ ] Verify the agent updates the recipe title, ingredients, and instructions
|
||||
- [ ] Verify the ping indicator appears on changed sections
|
||||
- [ ] Verify the "Improve with AI" button (`data-testid="improve-button"`) changes to "Please Wait..." while loading
|
||||
- [ ] Click "Improve with AI" and verify the recipe is enhanced
|
||||
|
||||
#### Agent Reads Frontend State
|
||||
|
||||
- [ ] Edit the recipe (change title, add ingredients)
|
||||
- [ ] Ask the agent "What recipe am I making?"
|
||||
- [ ] Verify the agent's response references the current recipe state
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
- [ ] Verify the "Improve with AI" button is disabled while loading
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Recipe card and sidebar load within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- Recipe state syncs bidirectionally between UI and agent
|
||||
- Ping indicators highlight changed sections
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,41 @@
|
||||
# QA: State Streaming — AG2
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the shared-state-streaming demo page
|
||||
- [ ] Verify the chat interface loads with title "State Streaming"
|
||||
- [ ] Verify the chat input placeholder "Type a message..." is visible
|
||||
- [ ] Send a basic message (e.g. "Hello! What can you do?")
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Get started" suggestion button is visible
|
||||
|
||||
#### Note: Stub Demo
|
||||
|
||||
> **Status: Stub** — This demo is currently a stub (TODO: implement)
|
||||
|
||||
- [ ] Verify the basic CopilotChat loads and accepts messages
|
||||
- [ ] Verify the agent responds to messages
|
||||
- [ ] No custom UI components are expected beyond the chat interface
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,41 @@
|
||||
# QA: Shared State (Writing) — AG2
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the shared-state-write demo page
|
||||
- [ ] Verify the chat interface loads with title "Shared State (Writing)"
|
||||
- [ ] Verify the chat input placeholder "Type a message..." is visible
|
||||
- [ ] Send a basic message (e.g. "Hello! What can you do?")
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Get started" suggestion button is visible
|
||||
|
||||
#### Note: Stub Demo
|
||||
|
||||
> **Status: Stub** — This demo is currently a stub (TODO: implement)
|
||||
|
||||
- [ ] Verify the basic CopilotChat loads and accepts messages
|
||||
- [ ] Verify the agent responds to messages
|
||||
- [ ] No custom UI components are expected beyond the chat interface
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,74 @@
|
||||
# QA: Sub-Agents — AG2
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible at `/demos/subagents`
|
||||
- Agent backend is healthy (check `/api/copilotkit` GET → `agent_status: reachable`)
|
||||
- Backend has `OPENAI_API_KEY` set
|
||||
- The `subagents` supervisor is mounted at `/subagents` on the FastAPI
|
||||
server (see `src/agent_server.py`)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Page renders with delegation log + chat
|
||||
|
||||
- [ ] Navigate to `/demos/subagents`
|
||||
- [ ] Left pane shows the **Delegation log** panel
|
||||
(`data-testid="delegation-log"`)
|
||||
- [ ] Header reads "Sub-agent delegations" with a counter
|
||||
`data-testid="delegation-count"` showing `0 calls`
|
||||
- [ ] Empty-state copy: "Ask the supervisor to complete a task. Every
|
||||
sub-agent it calls will appear here."
|
||||
- [ ] Right pane shows the chat with placeholder
|
||||
"Give the supervisor a task..."
|
||||
|
||||
### 2. Single delegation chain
|
||||
|
||||
- [ ] Click suggestion **"Write a blog post"** (or send the equivalent
|
||||
message).
|
||||
- [ ] While the supervisor runs, the badge
|
||||
`data-testid="supervisor-running"` ("Supervisor running") appears in
|
||||
the header.
|
||||
- [ ] As the supervisor delegates, entries appear in the log
|
||||
(`data-testid="delegation-entry"`). Expect at least 3 entries — one
|
||||
`Research`, one `Writing`, one `Critique` — in that order.
|
||||
- [ ] Each entry shows:
|
||||
- A `#N` index, a colored badge with the sub-agent name + emoji.
|
||||
- A `Task: ...` line summarizing what was delegated.
|
||||
- A `result` block containing the sub-agent's output (real LLM text,
|
||||
not placeholders).
|
||||
- [ ] Counter updates to `3 calls` (or more if the supervisor iterated).
|
||||
|
||||
### 3. Independent delegations
|
||||
|
||||
- [ ] Reload the page (state resets).
|
||||
- [ ] Send: **"Research what causes the northern lights."**
|
||||
- [ ] At least 1 `Research` delegation appears with a bulleted list of
|
||||
facts in the result.
|
||||
- [ ] Send: **"Now write a paragraph aimed at a 10-year-old, using those
|
||||
facts."**
|
||||
- [ ] A `Writing` delegation appears with a polished paragraph in the
|
||||
result.
|
||||
- [ ] Send: **"Critique that paragraph."**
|
||||
- [ ] A `Critique` delegation appears with 2-3 actionable critiques.
|
||||
|
||||
### 4. Supervisor reply hygiene
|
||||
|
||||
- [ ] After each chain, the supervisor's chat reply is short — it
|
||||
summarizes rather than re-pasting the full sub-agent output (which
|
||||
already lives in the delegation log).
|
||||
- [ ] The "Supervisor running" badge disappears once the run is complete.
|
||||
|
||||
### 5. Error handling
|
||||
|
||||
- [ ] Send a very short message (e.g. "Hi"). The supervisor responds
|
||||
gracefully (it may not delegate for a trivial greeting).
|
||||
- [ ] No console errors during normal usage.
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Page loads in < 3 seconds.
|
||||
- Each user request that's non-trivial produces at least one delegation
|
||||
entry.
|
||||
- The delegation log grows live during the run, not just at the end.
|
||||
- Sub-agent results are real LLM outputs (not stubbed strings).
|
||||
@@ -0,0 +1,10 @@
|
||||
# QA: Tool Rendering (Custom Catch-all) — AG2
|
||||
|
||||
- [ ] Navigate to /demos/tool-rendering-custom-catchall
|
||||
- [ ] Click "Weather in SF"
|
||||
- [ ] Verify custom catch-all card renders (`data-testid="custom-catchall-card"`)
|
||||
- [ ] Status progresses through streaming / running / done
|
||||
|
||||
## Expected Results
|
||||
|
||||
- The single custom wildcard renderer paints every tool call
|
||||
@@ -0,0 +1,10 @@
|
||||
# QA: Tool Rendering (Default Catch-all) — AG2
|
||||
|
||||
- [ ] Navigate to /demos/tool-rendering-default-catchall
|
||||
- [ ] Click "Weather in SF" suggestion
|
||||
- [ ] Verify DefaultToolCallRenderer fires — tool name visible, Running → Done
|
||||
- [ ] Expand arguments / result
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Out-of-box default tool-call card renders without custom config
|
||||
@@ -0,0 +1,61 @@
|
||||
# QA: Tool Rendering — AG2
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Demo is deployed and accessible
|
||||
- Agent backend is healthy (check /api/health)
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Basic Functionality
|
||||
|
||||
- [ ] Navigate to the tool-rendering demo page
|
||||
- [ ] Verify the chat interface loads in a centered full-height layout
|
||||
- [ ] Verify the chat input placeholder "Type a message" is visible
|
||||
- [ ] Send a basic message
|
||||
- [ ] Verify the agent responds
|
||||
|
||||
### 2. Feature-Specific Checks
|
||||
|
||||
#### Suggestions
|
||||
|
||||
- [ ] Verify "Weather in San Francisco" suggestion button is visible
|
||||
- [ ] Verify "Weather in New York" suggestion button is visible
|
||||
- [ ] Verify "Weather in Tokyo" suggestion button is visible
|
||||
- [ ] Click a weather suggestion and verify it populates the input or sends the message
|
||||
|
||||
#### Weather Card Rendering (useRenderTool)
|
||||
|
||||
- [ ] Type "What's the weather in San Francisco?"
|
||||
- [ ] Verify loading state shows "Retrieving weather..." with a spinner
|
||||
- [ ] Verify the WeatherCard renders (`data-testid="weather-card"`) with:
|
||||
- [ ] City name displayed (`data-testid="weather-city"`)
|
||||
- [ ] Temperature in both Celsius and Fahrenheit
|
||||
- [ ] Humidity percentage (`data-testid="weather-humidity"`)
|
||||
- [ ] Wind speed in mph (`data-testid="weather-wind"`)
|
||||
- [ ] Feels-like temperature (`data-testid="weather-feels-like"`)
|
||||
- [ ] Conditions text with appropriate weather icon (sun/rain/cloud)
|
||||
- [ ] Verify the card background color matches the weather condition theme:
|
||||
- Clear/Sunny: #667eea (blue-purple)
|
||||
- Rain/Storm: #4A5568 (dark gray)
|
||||
- Cloudy: #718096 (medium gray)
|
||||
- Snow: #63B3ED (light blue)
|
||||
|
||||
#### Multiple Weather Queries
|
||||
|
||||
- [ ] Ask about weather in a second city
|
||||
- [ ] Verify a second WeatherCard renders without breaking the first
|
||||
- [ ] Verify each card shows the correct city name
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
- [ ] Send an empty message (should be handled gracefully)
|
||||
- [ ] Verify no console errors during normal usage
|
||||
|
||||
## Expected Results
|
||||
|
||||
- Chat loads within 3 seconds
|
||||
- Agent responds within 10 seconds
|
||||
- Weather cards render with all data fields populated
|
||||
- Weather icon and theme color match the conditions
|
||||
- No UI errors or broken layouts
|
||||
@@ -0,0 +1,8 @@
|
||||
ag2[openai,ag-ui]>=0.9.0
|
||||
ag-ui-protocol>=0.1.10
|
||||
fastapi>=0.115.0
|
||||
uvicorn>=0.34.0
|
||||
httpx>=0.27.0
|
||||
python-dotenv==1.0.1
|
||||
pypdf>=4.0.0
|
||||
openai>=1.50.0
|
||||
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Agent Server for AG2
|
||||
|
||||
FastAPI server that hosts the AG2 agent backends.
|
||||
The Next.js CopilotKit runtime proxies requests here via AG-UI protocol.
|
||||
|
||||
Most demos share a single ConversableAgent at the root path. Demos that
|
||||
require dedicated state mechanics or multi-agent topologies are mounted
|
||||
as their own sub-apps at distinct paths so each demo gets its own
|
||||
ContextVariables-backed state slot.
|
||||
"""
|
||||
|
||||
# ORDER-CRITICAL: load .env BEFORE any agent module imports. The agent
|
||||
# modules (agents/agent.py et al.) construct module-level
|
||||
# ``openai.AsyncOpenAI()`` / autogen ``LLMConfig`` clients that read
|
||||
# ``OPENAI_API_KEY`` (and friends) at construction time. If we import the
|
||||
# agent modules before calling ``load_dotenv()``, those module-level
|
||||
# clients latch onto whatever the OS environment had at import time
|
||||
# (usually nothing in a dev shell), and subsequent .env values never
|
||||
# reach them. ``load_dotenv()`` is idempotent so the redundant call
|
||||
# inside each agent module is harmless — but the FIRST call must happen
|
||||
# here, before the agent imports below.
|
||||
# CVDIAG bootstrap — MUST be the first non-stdlib import (folded in from the
|
||||
# dropped L1-H slot). Importing this module configures the root logger via
|
||||
# ``logging.basicConfig`` so the ``agents._header_forwarding`` (and sibling
|
||||
# ``agents.*``) CVDIAG loggers actually EMIT (fixes the silent-drop bug), and
|
||||
# resolves the verbosity tier + PB writer. It imports pydantic/starlette only
|
||||
# and has no dependency on ``.env``, so it is safe to run before ``load_dotenv``.
|
||||
import _shared.cvdiag_bootstrap # noqa: F401,E402 (first non-stdlib import — bootstrap side effects)
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
import os
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
# ORDER-CRITICAL: install the global httpx hook BEFORE any agent module
|
||||
# imports. The autogen / openai SDK construct their httpx client lazily
|
||||
# per-call, but other integrations construct at module-import time;
|
||||
# keeping the patch at the top of agent_server.py is the consistent
|
||||
# placement across all Python showcase integrations and is harmless here.
|
||||
from agents._cvdiag_backend import CvdiagBackendMiddleware
|
||||
from agents._header_forwarding import (
|
||||
HeaderForwardingHTTPMiddleware,
|
||||
install_executor_contextvar_propagation,
|
||||
install_global_httpx_hook,
|
||||
)
|
||||
from agents._request_context import RequestUserMessageMiddleware
|
||||
|
||||
install_global_httpx_hook()
|
||||
# AG2-specific: autogen's ConversableAgent.a_generate_oai_reply dispatches
|
||||
# the underlying sync LLM call onto the default ThreadPoolExecutor via
|
||||
# loop.run_in_executor(...), which does NOT propagate ContextVars to the
|
||||
# worker thread. Without this, the forwarded-header ContextVar set on the
|
||||
# inbound request task is empty by the time the outbound httpx hook fires,
|
||||
# and aimock can't match the right fixture for the request.
|
||||
install_executor_contextvar_propagation()
|
||||
|
||||
from agents.agent import stream as default_stream
|
||||
from agents.a2ui_dynamic import a2ui_dynamic_app
|
||||
from agents.a2ui_fixed import a2ui_fixed_app
|
||||
from agents.agent_config_agent import agent_config_app
|
||||
from agents.beautiful_chat import beautiful_chat_app
|
||||
from agents.byoc_hashbrown_agent import byoc_hashbrown_app
|
||||
from agents.byoc_json_render_agent import byoc_json_render_app
|
||||
from agents.gen_ui_agent import gen_ui_agent_app
|
||||
from agents.headless_complete import headless_complete_app
|
||||
from agents.mcp_apps_agent import mcp_apps_app
|
||||
from agents.multimodal_agent import multimodal_app
|
||||
from agents.open_gen_ui_advanced_agent import open_gen_ui_advanced_app
|
||||
from agents.open_gen_ui_agent import open_gen_ui_app
|
||||
from agents.shared_state_read_write import (
|
||||
shared_state_read_write_app,
|
||||
)
|
||||
from agents.subagents import subagents_app
|
||||
from agents.interrupt_agent import interrupt_app
|
||||
from agents.reasoning_agent import reasoning_app
|
||||
from agents.tool_rendering_reasoning_chain import (
|
||||
tool_rendering_reasoning_chain_app,
|
||||
)
|
||||
|
||||
app = FastAPI(title="AG2 Agent Server")
|
||||
|
||||
|
||||
# Serve /health via middleware so it short-circuits BEFORE route resolution.
|
||||
# A plain `@app.get("/health")` decorator is shadowed by the subsequent
|
||||
# `app.mount("/", ...)` call: Starlette's Mount at "/" matches every path
|
||||
# (including /health) and the decorated route never fires. Middleware runs
|
||||
# above the routing layer, so the health endpoint stays reachable regardless
|
||||
# of what the framework-specific AG-UI adapter mounts at root.
|
||||
class HealthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
if request.url.path == "/health" and request.method == "GET":
|
||||
return JSONResponse({"status": "ok"})
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ORDER-CRITICAL: Starlette's ``add_middleware`` is LIFO — the LAST call
|
||||
# becomes the OUTERMOST layer in the request pipeline. This ordering
|
||||
# matters because ``BaseHTTPMiddleware`` (HealthMiddleware,
|
||||
# HeaderForwardingHTTPMiddleware) internally uses anyio TaskGroups that
|
||||
# can sever ``contextvars.ContextVar`` propagation from outer layers to
|
||||
# the inner ASGI app. The raw-ASGI ``RequestUserMessageMiddleware`` sets
|
||||
# a ContextVar that downstream tool handlers must observe, so it MUST
|
||||
# sit OUTSIDE the BaseHTTPMiddleware layers — i.e. be added LAST so it
|
||||
# wraps them. CORSMiddleware (also raw ASGI) is added last of all so it
|
||||
# remains the absolute outermost layer (handles preflight + headers
|
||||
# before anything else runs).
|
||||
#
|
||||
# Resulting outer→inner execution order:
|
||||
# CORS → RequestUserMessage → HeaderForwarding → Health → routes/mounts
|
||||
|
||||
# Innermost: serve /health via middleware so it short-circuits BEFORE
|
||||
# route resolution. (Already declared above as HealthMiddleware.)
|
||||
app.add_middleware(HealthMiddleware)
|
||||
|
||||
# Capture inbound CopilotKit `x-*` headers (e.g. `x-aimock-context`) into a
|
||||
# per-request ContextVar so any outbound LLM/provider httpx call made inside
|
||||
# the request scope copies them onto its outbound request. The matching
|
||||
# ``install_httpx_hook(...)`` call lives next to each LLM client
|
||||
# construction site (see ``agents/agent.py``).
|
||||
app.add_middleware(HeaderForwardingHTTPMiddleware)
|
||||
|
||||
# CVDIAG backend emitter (spec §3 Layer 2) — emits the HTTP-observable backend
|
||||
# boundaries (request.ingress, sse.first_byte, sse.event, sse.aborted,
|
||||
# response.complete, error.caught) as structured CVDIAG envelopes. Added here so
|
||||
# it wraps the Health + HeaderForwarding BaseHTTPMiddleware layers but stays
|
||||
# INSIDE the outer raw-ASGI RequestUserMessage + CORS layers (CORS remains the
|
||||
# absolute outermost so preflight is handled first). Gated behind
|
||||
# ``CVDIAG_BACKEND_EMITTER`` (default OFF, canary-safe) — the middleware
|
||||
# fast-paths to a bare pass-through when the flag is unset.
|
||||
app.add_middleware(CvdiagBackendMiddleware)
|
||||
|
||||
# R2-A3: Capture the latest user message from each inbound RunAgentInput POST
|
||||
# into a per-request ContextVar so tool handlers (e.g. generate_a2ui) can read
|
||||
# the per-request prompt without consulting autogen's shared, race-prone
|
||||
# ``ConversableAgent.chat_messages`` state. See agents/_request_context.py.
|
||||
# Added AFTER the BaseHTTPMiddlewares above so it wraps them (raw ASGI on
|
||||
# the outside preserves ContextVar propagation across the anyio
|
||||
# TaskGroups they spawn internally).
|
||||
app.add_middleware(RequestUserMessageMiddleware)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Mount per-demo sub-apps FIRST. Starlette's router resolves mounts in
|
||||
# registration order; the catch-all `/` mount below shadows everything
|
||||
# under it, so the named mounts must come first.
|
||||
app.mount("/shared-state-read-write", shared_state_read_write_app)
|
||||
app.mount("/subagents", subagents_app)
|
||||
app.mount("/headless-complete", headless_complete_app)
|
||||
app.mount("/gen-ui-agent", gen_ui_agent_app)
|
||||
app.mount("/declarative-gen-ui", a2ui_dynamic_app)
|
||||
app.mount("/a2ui-fixed-schema", a2ui_fixed_app)
|
||||
app.mount("/beautiful-chat", beautiful_chat_app)
|
||||
app.mount("/mcp-apps", mcp_apps_app)
|
||||
# IMPORTANT: mount /open-gen-ui-advanced BEFORE /open-gen-ui — Starlette
|
||||
# resolves mounts via prefix matching in registration order, so the shorter
|
||||
# prefix "/open-gen-ui" would shadow "/open-gen-ui-advanced" if it came first.
|
||||
app.mount("/open-gen-ui-advanced", open_gen_ui_advanced_app)
|
||||
app.mount("/open-gen-ui", open_gen_ui_app)
|
||||
app.mount(
|
||||
"/tool-rendering-reasoning-chain",
|
||||
tool_rendering_reasoning_chain_app,
|
||||
)
|
||||
# Reasoning-aware route. AG2's stock AGUIStream emits no REASONING_MESSAGE_*
|
||||
# events (and autogen drops the model's reasoning_content channel), so the
|
||||
# reasoning-custom / reasoning-default cells use this custom sub-app instead.
|
||||
# Mirrors agno's /reasoning/agui mount.
|
||||
app.mount("/reasoning", reasoning_app)
|
||||
app.mount("/agent-config", agent_config_app)
|
||||
app.mount("/multimodal", multimodal_app)
|
||||
app.mount("/byoc-hashbrown", byoc_hashbrown_app)
|
||||
app.mount("/byoc-json-render", byoc_json_render_app)
|
||||
|
||||
# Interrupt-adapted scheduling agent. Shared by gen-ui-interrupt and
|
||||
# interrupt-headless demos — backend has tools=[], the frontend provides
|
||||
# `schedule_meeting` via `useFrontendTool` with an async Promise handler.
|
||||
app.mount("/interrupt-adapted", interrupt_app)
|
||||
|
||||
|
||||
# Mount the default AG2 AG-UI endpoint at the root.
|
||||
# `app.mount("/", ...)` is a catch-all Mount that shadows any later route
|
||||
# decorators, which is why /health is served by HealthMiddleware above
|
||||
# rather than a `@app.get("/health")` handler registered here.
|
||||
app.mount("/", default_stream.build_asgi())
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the uvicorn server.
|
||||
|
||||
``reload=True`` is gated behind ``DEV_RELOAD=1`` so production
|
||||
containers (which set neither var) get a single non-reloading
|
||||
process. The reloader spawns a watcher process and re-imports the
|
||||
app on every file change, which is appropriate for local dev but
|
||||
burns memory + risks half-imported state in prod.
|
||||
"""
|
||||
port = int(os.getenv("PORT", "8000"))
|
||||
dev_reload = os.getenv("DEV_RELOAD", "0") == "1"
|
||||
uvicorn.run(
|
||||
"agent_server:app",
|
||||
host="0.0.0.0",
|
||||
port=port,
|
||||
reload=dev_reload,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,812 @@
|
||||
"""_cvdiag_backend.py — backend-layer CVDIAG boundary instrumentation.
|
||||
|
||||
This module wires the spec §3 / §5 **11 backend boundaries** into a Python
|
||||
showcase integration, emitting schema-v1 CVDIAG envelopes through the shared
|
||||
``_shared.cvdiag_bootstrap.emit_cvdiag`` sink. It is the per-integration
|
||||
companion to the header-forwarding shim (``_header_forwarding.py``): that file
|
||||
forwards correlation headers onto outbound LLM calls and logs lightweight
|
||||
``CVDIAG component=backend-<fw> boundary=...`` breadcrumbs; THIS file emits the
|
||||
full structured ``CVDIAG {<json>}`` envelopes the harness/classifier consume.
|
||||
|
||||
The 11 backend boundaries (spec §5 / §6 tier matrix):
|
||||
|
||||
1. ``backend.request.ingress`` — HTTP request received (default)
|
||||
2. ``backend.agent.enter`` — agent loop entered (default)
|
||||
3. ``backend.llm.call.start`` — outbound LLM call dispatched (verbose)
|
||||
4. ``backend.llm.call.heartbeat`` — fires ~10s while an LLM call is
|
||||
outstanding (verbose)
|
||||
5. ``backend.llm.call.response`` — LLM response received (verbose)
|
||||
6. ``backend.sse.first_byte`` — first SSE byte written (verbose)
|
||||
7. ``backend.sse.event`` — every SSE event written (debug)
|
||||
8. ``backend.sse.aborted`` — stream terminated abnormally (default)
|
||||
9. ``backend.agent.exit`` — agent loop exited (default)
|
||||
10. ``backend.response.complete`` — HTTP response stream closed (default)
|
||||
11. ``backend.error.caught`` — exception caught in the agent loop
|
||||
(default)
|
||||
|
||||
Guarding
|
||||
--------
|
||||
ALL emission is gated behind the ``CVDIAG_BACKEND_EMITTER`` env flag, default
|
||||
OFF. With the flag off this module is byte-for-byte inert — no envelope is
|
||||
built, no stdout line is written, the middleware passes the request straight
|
||||
through. This is the canary-safe default: the flag is flipped ON only after a
|
||||
deploy is confirmed healthy.
|
||||
|
||||
Tier gating
|
||||
-----------
|
||||
Each boundary carries a tier per the §6 matrix. ``_shared.cvdiag_bootstrap``
|
||||
resolves the active tier (default | verbose | debug) once at import; this
|
||||
module suppresses a boundary whose tier exceeds the active tier so the
|
||||
default-tier production emit stays within the §7 event-count budget.
|
||||
|
||||
Pure instrumentation
|
||||
--------------------
|
||||
Nothing here may throw into the request path. ``emit_cvdiag`` already swallows
|
||||
its own errors; the helpers below additionally guard envelope construction so a
|
||||
malformed metadata bag degrades to a dropped emit, never a 500.
|
||||
|
||||
Plan unit: L1-C.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from _shared.cvdiag_bootstrap import _resolve_tier, current_tier, emit_cvdiag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Framework tag — mirrors ``_header_forwarding._CVDIAG_FRAMEWORK`` so the
|
||||
# structured envelopes and the breadcrumb log lines agree on the integration
|
||||
# identity. (L1-D: change this single constant when copying to a sibling.)
|
||||
_CVDIAG_FRAMEWORK = "ag2"
|
||||
|
||||
# ── Env gate ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_BACKEND_EMITTER_ENV = "CVDIAG_BACKEND_EMITTER"
|
||||
|
||||
|
||||
def cvdiag_backend_enabled() -> bool:
|
||||
"""True iff the backend emitter is explicitly enabled (default OFF).
|
||||
|
||||
Read live (not cached) so a test can toggle the env var per-case via
|
||||
``monkeypatch.setenv``; the cost is one ``os.environ`` lookup per emit,
|
||||
which is negligible against the JSON serialization that follows.
|
||||
"""
|
||||
return os.environ.get(_BACKEND_EMITTER_ENV) == "1"
|
||||
|
||||
|
||||
# ── Tier ordering (spec §6) ────────────────────────────────────────────────
|
||||
|
||||
_TIER_RANK = {"default": 0, "verbose": 1, "debug": 2}
|
||||
|
||||
# Per-boundary minimum tier required to emit (spec §6 matrix, backend rows).
|
||||
_BOUNDARY_TIER: Dict[str, str] = {
|
||||
"backend.request.ingress": "verbose",
|
||||
"backend.agent.enter": "default",
|
||||
"backend.llm.call.start": "verbose",
|
||||
"backend.llm.call.heartbeat": "verbose",
|
||||
"backend.llm.call.response": "verbose",
|
||||
"backend.sse.first_byte": "verbose",
|
||||
"backend.sse.event": "debug",
|
||||
"backend.sse.aborted": "default",
|
||||
"backend.agent.exit": "default",
|
||||
"backend.response.complete": "default",
|
||||
"backend.error.caught": "default",
|
||||
}
|
||||
|
||||
|
||||
def _active_tier() -> str:
|
||||
"""Resolve the verbosity tier from a LIVE env read.
|
||||
|
||||
``cvdiag_backend_enabled()`` reads ``CVDIAG_BACKEND_EMITTER`` live, so the
|
||||
tier MUST be read from the same live source — otherwise flipping
|
||||
``CVDIAG_VERBOSE`` / ``CVDIAG_DEBUG`` AFTER import arms the emitter but the
|
||||
tier stays frozen at the import-time ``setup()`` value, silently no-op'ing
|
||||
every verbose/debug-gated boundary. We reuse the bootstrap's
|
||||
``_resolve_tier`` so the §6 fail-closed DEBUG guard still applies (a
|
||||
production / unresolved DEBUG request raises → degrade to the frozen tier).
|
||||
"""
|
||||
try:
|
||||
return _resolve_tier(dict(os.environ))
|
||||
except RuntimeError:
|
||||
# Fail-closed DEBUG refusal: fall back to the import-time resolved tier
|
||||
# (never silently escalate to debug in production).
|
||||
return current_tier()
|
||||
|
||||
|
||||
def _tier_permits(boundary: str) -> bool:
|
||||
"""True iff the active tier is at-or-above the boundary's minimum tier."""
|
||||
need = _TIER_RANK.get(_BOUNDARY_TIER.get(boundary, "default"), 0)
|
||||
have = _TIER_RANK.get(_active_tier(), 0)
|
||||
return have >= need
|
||||
|
||||
|
||||
# ── Edge headers (spec §5 — 9-key allow-list + 12-name deny-list) ───────────
|
||||
|
||||
# The closed 9-key edge-header allow-list. Always-present in the envelope;
|
||||
# absent header → ``None``.
|
||||
_EDGE_ALLOW = (
|
||||
"cf-ray",
|
||||
"cf-mitigated",
|
||||
"cf-cache-status",
|
||||
"x-railway-edge",
|
||||
"x-railway-request-id",
|
||||
"x-hikari-trace",
|
||||
"retry-after",
|
||||
"via",
|
||||
"server",
|
||||
)
|
||||
|
||||
# Exact-match deny-list (spec §5). REJECTED even if accidentally present in the
|
||||
# allow-list — these carry client IP / geo PII and must never round-trip.
|
||||
_EDGE_DENY = frozenset(
|
||||
{
|
||||
"cf-ipcountry",
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcity",
|
||||
"cf-iplatitude",
|
||||
"cf-iplongitude",
|
||||
"cf-iptimezone",
|
||||
"cf-visitor",
|
||||
"cf-worker",
|
||||
"true-client-ip",
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
"forwarded",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def extract_edge_headers(headers: Any) -> Dict[str, Optional[str]]:
|
||||
"""Build the closed 9-key ``edge_headers`` bag from a headers mapping.
|
||||
|
||||
All nine keys are ALWAYS present; an absent (or deny-listed) header maps to
|
||||
``None``. ``headers`` is any case-insensitive mapping exposing ``.get`` /
|
||||
iteration of ``(name, value)`` pairs (Starlette ``Headers``, httpx, dict).
|
||||
"""
|
||||
bag: Dict[str, Optional[str]] = {k: None for k in _EDGE_ALLOW}
|
||||
if headers is None:
|
||||
return bag
|
||||
try:
|
||||
getter = headers.get
|
||||
except AttributeError:
|
||||
return bag
|
||||
for key in _EDGE_ALLOW:
|
||||
if key in _EDGE_DENY: # belt-and-braces: never emit a deny-listed key
|
||||
continue
|
||||
val = getter(key)
|
||||
if val is not None:
|
||||
bag[key] = str(val)
|
||||
return bag
|
||||
|
||||
|
||||
# ── PII scrub (spec §6) ──────────────────────────────────────────────────────
|
||||
|
||||
# Bearer tokens, OpenAI/Stripe-style secret keys, publishable keys, and URL
|
||||
# userinfo. Applied to any captured free-text metadata value
|
||||
# (``message_scrubbed``, stack frames) before it is emitted. The ``sk-``/``pk-``
|
||||
# key bodies allow hyphens/underscores so test-style keys such as the spec
|
||||
# regression fixture ``sk-test-12345`` are redacted alongside real production
|
||||
# keys (``sk-<48+ base62>``).
|
||||
#
|
||||
# Parity with the canonical TS scrubber (``harness/src/cvdiag/scrub.ts``):
|
||||
# * Bearer — grabs the WHOLE token (``\S+``) to match TS ``Bearer\s+\S+``;
|
||||
# the legacy ``[A-Za-z0-9._\-]+`` stopped at ``/``/``+``/``=`` and left an
|
||||
# un-redacted JWT tail (e.g. ``Bearer a.b.c/sig+more=`` → ``…/sig+more=``).
|
||||
# * URL userinfo — redacts BOTH ``scheme://user:pw@host`` AND colon-less
|
||||
# ``scheme://token@host`` (TS ``([scheme]://)[^/\s?#]*@``); the legacy
|
||||
# ``[^/\s:@]+:[^/\s@]+@`` required a mandatory ``:`` so a bare-token
|
||||
# authority such as ``https://ghp_xxx@host`` LEAKED. The userinfo class
|
||||
# excludes ``?``/``#`` so the match never crosses into the query/fragment.
|
||||
_SCRUB_PATTERNS = (
|
||||
re.compile(r"Bearer\s+\S+", re.IGNORECASE),
|
||||
re.compile(r"\bsk-[A-Za-z0-9][A-Za-z0-9_-]{3,}"),
|
||||
re.compile(r"\bpk-[A-Za-z0-9][A-Za-z0-9_-]{3,}"),
|
||||
re.compile(r"(?P<scheme>[a-z][a-z0-9+.\-]*://)[^/\s?#]*@", re.IGNORECASE),
|
||||
)
|
||||
|
||||
# Per-event field byte caps (spec §5). message_scrubbed ≤512B.
|
||||
_MESSAGE_CAP = 512
|
||||
|
||||
# Hard input-size guard (mirrors TS ``SCRUB_MAX_SCAN_LEN``): no regex ever runs
|
||||
# on a string longer than this. A longer value has only its bounded prefix
|
||||
# scanned and a self-describing ``…[unscanned:<N>]`` marker records the dropped
|
||||
# tail length, so an adversarial multi-KB string can never make the regex
|
||||
# engine scan unbounded input. 2 KB covers any legitimate metadata value with
|
||||
# headroom. Set below the byte cap so the marker survives the §5 byte clamp.
|
||||
_SCRUB_MAX_SCAN_LEN = 400
|
||||
|
||||
|
||||
def _run_scrub_regexes(s: str) -> str:
|
||||
"""Apply the secret regexes in sequence (TS ``runScrubRegexes`` parity)."""
|
||||
for pat in _SCRUB_PATTERNS:
|
||||
if pat.groupindex.get("scheme"):
|
||||
s = pat.sub(r"\g<scheme>[REDACTED]@", s)
|
||||
else:
|
||||
s = pat.sub("[REDACTED]", s)
|
||||
return s
|
||||
|
||||
|
||||
def scrub(text: Any) -> str:
|
||||
"""Redact secrets from a free-text value and cap it at 512 bytes.
|
||||
|
||||
Returns ``"[REDACTED]"`` substitutions for any matched secret pattern so a
|
||||
synthetic ``sk-test-12345`` in an exception message can never reach the
|
||||
emitted envelope. A value longer than ``_SCRUB_MAX_SCAN_LEN`` has only its
|
||||
bounded prefix scanned, with an ``…[unscanned:<N>]`` marker (TS parity).
|
||||
"""
|
||||
if text is None:
|
||||
return ""
|
||||
s = str(text)
|
||||
if len(s) > _SCRUB_MAX_SCAN_LEN:
|
||||
dropped_tail = len(s) - _SCRUB_MAX_SCAN_LEN
|
||||
s = f"{_run_scrub_regexes(s[:_SCRUB_MAX_SCAN_LEN])}…[unscanned:{dropped_tail}]"
|
||||
else:
|
||||
s = _run_scrub_regexes(s)
|
||||
encoded = s.encode("utf-8")
|
||||
if len(encoded) > _MESSAGE_CAP:
|
||||
s = encoded[:_MESSAGE_CAP].decode("utf-8", errors="ignore")
|
||||
return s
|
||||
|
||||
|
||||
# ── Envelope construction ──────────────────────────────────────────────────
|
||||
|
||||
_TEST_ID_HEADER = "x-test-id"
|
||||
_AIMOCK_CONTEXT_HEADER = "x-aimock-context"
|
||||
# UUIDv7 variant/version nibbles (RFC 9562) the schema regex requires.
|
||||
_SLUG_FALLBACK = "unknown"
|
||||
_DEMO_FALLBACK = "default"
|
||||
|
||||
|
||||
def _uuid7() -> str:
|
||||
"""Generate a lowercase-hyphenated UUIDv7 (RFC 9562) string.
|
||||
|
||||
48-bit Unix-ms timestamp, version nibble 7, variant 10 — matches the
|
||||
schema ``TEST_ID_PATTERN``. Used as the fallback ``test_id`` when no
|
||||
inbound ``x-test-id`` correlation header is present.
|
||||
"""
|
||||
unix_ms = int(time.time() * 1000) & ((1 << 48) - 1)
|
||||
rand_a = secrets.randbits(12)
|
||||
rand_b = secrets.randbits(62)
|
||||
msb = (unix_ms << 16) | (0x7 << 12) | rand_a
|
||||
lsb = (0b10 << 62) | rand_b
|
||||
return str(uuid.UUID(int=(msb << 64) | lsb))
|
||||
|
||||
|
||||
_UUID7_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
)
|
||||
|
||||
|
||||
def normalize_test_id(raw: Optional[str]) -> str:
|
||||
"""Return a schema-valid lowercased UUIDv7, minting one if ``raw`` is
|
||||
absent or not a well-formed UUIDv7."""
|
||||
if raw:
|
||||
candidate = raw.strip().lower()
|
||||
if _UUID7_RE.match(candidate):
|
||||
return candidate
|
||||
return _uuid7()
|
||||
|
||||
|
||||
def _span_id() -> str:
|
||||
"""16-hex span id, unique per emit (schema ``SPAN_ID_PATTERN``)."""
|
||||
return secrets.token_hex(8)
|
||||
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
||||
|
||||
|
||||
def _normalize_slug(raw: Optional[str]) -> str:
|
||||
"""Coerce the inbound ``x-aimock-context`` slug into the closed slug shape
|
||||
(``^[a-z][a-z0-9-]{0,63}$``), falling back to ``unknown`` when unusable."""
|
||||
if raw:
|
||||
candidate = raw.strip().lower()
|
||||
if _SLUG_RE.match(candidate):
|
||||
return candidate
|
||||
return _SLUG_FALLBACK
|
||||
|
||||
|
||||
def build_envelope(
|
||||
*,
|
||||
boundary: str,
|
||||
outcome: str,
|
||||
test_id: str,
|
||||
slug: str,
|
||||
demo: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
edge_headers: Optional[Dict[str, Optional[str]]] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
parent_span_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Assemble a schema-v1 backend envelope (``layer=backend``).
|
||||
|
||||
All envelope-required fields are populated; ``edge_headers`` defaults to the
|
||||
closed 9-key all-null bag when not supplied. ``metadata`` is passed through
|
||||
verbatim — unknown keys are stamped ``_metadata_dropped`` by the schema
|
||||
validator inside ``emit_cvdiag``.
|
||||
"""
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"test_id": test_id,
|
||||
"trace_id": test_id,
|
||||
"span_id": _span_id(),
|
||||
"parent_span_id": parent_span_id,
|
||||
"layer": "backend",
|
||||
"boundary": boundary,
|
||||
"slug": slug,
|
||||
"demo": demo,
|
||||
"ts": _now_iso(),
|
||||
"mono_ns": time.monotonic_ns(),
|
||||
"duration_ms": duration_ms,
|
||||
"outcome": outcome,
|
||||
"edge_headers": edge_headers or {k: None for k in _EDGE_ALLOW},
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""ISO-8601 millisecond-precision timestamp with a ``Z`` suffix."""
|
||||
# ``time.gmtime`` + manual ms keeps this dependency-free and 3.9-safe.
|
||||
now = time.time()
|
||||
secs = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now))
|
||||
ms = int((now - int(now)) * 1000)
|
||||
return f"{secs}.{ms:03d}Z"
|
||||
|
||||
|
||||
def emit_backend_boundary(
|
||||
boundary: str,
|
||||
*,
|
||||
outcome: str = "info",
|
||||
test_id: str,
|
||||
slug: str,
|
||||
demo: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
edge_headers: Optional[Dict[str, Optional[str]]] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
parent_span_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Emit one backend boundary envelope, honoring the env gate + tier matrix.
|
||||
|
||||
No-op when the emitter is disabled or the active tier does not permit this
|
||||
boundary. Never raises into the caller.
|
||||
"""
|
||||
if not cvdiag_backend_enabled():
|
||||
return
|
||||
if not _tier_permits(boundary):
|
||||
return
|
||||
try:
|
||||
envelope = build_envelope(
|
||||
boundary=boundary,
|
||||
outcome=outcome,
|
||||
test_id=test_id,
|
||||
slug=slug,
|
||||
demo=demo,
|
||||
metadata=metadata,
|
||||
edge_headers=edge_headers,
|
||||
duration_ms=duration_ms,
|
||||
parent_span_id=parent_span_id,
|
||||
)
|
||||
emit_cvdiag(envelope)
|
||||
except Exception as err: # noqa: BLE001 - instrumentation must not throw
|
||||
logger.warning("CVDIAG backend emit-failed boundary=%s error=%s", boundary, err)
|
||||
|
||||
|
||||
# ── Per-request correlation context ─────────────────────────────────────────
|
||||
|
||||
|
||||
class _RequestCtx:
|
||||
"""Holds the per-request correlation identity + timing the boundaries share.
|
||||
|
||||
Carried on ``request.state`` so the middleware, the LLM hook, and the agent
|
||||
hooks all stamp the same ``test_id`` / ``slug`` / ``demo`` onto their
|
||||
envelopes.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"test_id",
|
||||
"slug",
|
||||
"demo",
|
||||
"ingress_mono_ns",
|
||||
"sse_seq",
|
||||
"first_byte_emitted",
|
||||
"bytes_streamed",
|
||||
)
|
||||
|
||||
def __init__(self, *, test_id: str, slug: str, demo: str) -> None:
|
||||
self.test_id = test_id
|
||||
self.slug = slug
|
||||
self.demo = demo
|
||||
self.ingress_mono_ns = time.monotonic_ns()
|
||||
self.sse_seq = 0
|
||||
self.first_byte_emitted = False
|
||||
self.bytes_streamed = 0
|
||||
|
||||
|
||||
def _demo_from_path(path: str) -> str:
|
||||
"""Derive the ``demo`` label from the mounted sub-app path.
|
||||
|
||||
Each demo is mounted at ``/<demo>`` (e.g. ``/voice``, ``/byoc-hashbrown``);
|
||||
the root agent serves the default demo. Strip the leading slash and any
|
||||
trailing AG-UI segment so ``/byoc-hashbrown/`` → ``byoc-hashbrown`` and
|
||||
``/`` → ``default``.
|
||||
"""
|
||||
trimmed = path.strip("/")
|
||||
if not trimmed:
|
||||
return _DEMO_FALLBACK
|
||||
return trimmed.split("/", 1)[0] or _DEMO_FALLBACK
|
||||
|
||||
|
||||
# ── HTTP middleware: ingress / first_byte / sse.event / sse.aborted /
|
||||
# response.complete / error.caught ─────────────────────────────────────────
|
||||
|
||||
|
||||
class CvdiagBackendMiddleware(BaseHTTPMiddleware):
|
||||
"""Starlette middleware emitting the HTTP-observable backend boundaries.
|
||||
|
||||
Wires six of the eleven boundaries around the request lifecycle:
|
||||
|
||||
* ``backend.request.ingress`` on entry
|
||||
* ``backend.sse.first_byte`` on the first streamed chunk
|
||||
* ``backend.sse.event`` per streamed chunk (debug tier)
|
||||
* ``backend.sse.aborted`` on premature stream termination
|
||||
* ``backend.response.complete`` on clean stream close
|
||||
* ``backend.error.caught`` on any exception escaping the inner app
|
||||
|
||||
The agent/LLM boundaries (``agent.enter``, ``llm.call.*``, ``agent.exit``)
|
||||
are emitted by the agent hooks / LLM httpx hook installed separately, all
|
||||
keyed on the same ``test_id`` this middleware stamps onto ``request.state``.
|
||||
|
||||
Inert when ``CVDIAG_BACKEND_EMITTER`` is off: the dispatch fast-paths to a
|
||||
bare ``call_next`` with no envelope construction and no response wrapping.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
if not cvdiag_backend_enabled():
|
||||
return await call_next(request)
|
||||
|
||||
headers = request.headers
|
||||
ctx = _RequestCtx(
|
||||
test_id=normalize_test_id(headers.get(_TEST_ID_HEADER)),
|
||||
slug=_normalize_slug(headers.get(_AIMOCK_CONTEXT_HEADER)),
|
||||
demo=_demo_from_path(request.url.path),
|
||||
)
|
||||
request.state.cvdiag = ctx
|
||||
|
||||
emit_backend_boundary(
|
||||
"backend.request.ingress",
|
||||
outcome="info",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
edge_headers=extract_edge_headers(headers),
|
||||
metadata={
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"content_length": _int_or_none(headers.get("content-length")),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as exc: # noqa: BLE001 - observe then re-raise
|
||||
emit_backend_boundary(
|
||||
"backend.error.caught",
|
||||
outcome="err",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
metadata={
|
||||
"exception_type": type(exc).__name__,
|
||||
"message_scrubbed": scrub(str(exc)),
|
||||
"stack_brief": [],
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
return self._wrap_response(request, response, ctx)
|
||||
|
||||
def _wrap_response(
|
||||
self, request: Request, response: Response, ctx: "_RequestCtx"
|
||||
) -> Response:
|
||||
"""Wrap a streaming response so SSE boundaries fire as chunks flow.
|
||||
|
||||
Non-streaming responses are returned unwrapped after emitting
|
||||
``backend.response.complete`` directly.
|
||||
|
||||
NOTE: ``BaseHTTPMiddleware`` re-wraps the inner ``StreamingResponse`` as
|
||||
a private ``_StreamingResponse`` before it reaches us, so an
|
||||
``isinstance(response, StreamingResponse)`` check is always False here.
|
||||
Detect streaming by the presence of a ``body_iterator`` (which both the
|
||||
public and the private response carry) instead.
|
||||
"""
|
||||
if not hasattr(response, "body_iterator"):
|
||||
emit_backend_boundary(
|
||||
"backend.response.complete",
|
||||
outcome="ok",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
duration_ms=_elapsed_ms(ctx.ingress_mono_ns),
|
||||
edge_headers=extract_edge_headers(response.headers),
|
||||
metadata={
|
||||
"http_status": response.status_code,
|
||||
"content_length": _int_or_none(
|
||||
response.headers.get("content-length")
|
||||
),
|
||||
"total_duration_ms": _elapsed_ms(ctx.ingress_mono_ns),
|
||||
"sse_event_count": ctx.sse_seq,
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
inner = response.body_iterator
|
||||
edge = extract_edge_headers(response.headers)
|
||||
status = response.status_code
|
||||
|
||||
async def _instrumented():
|
||||
# ``completed`` distinguishes a clean stream exhaustion (→
|
||||
# response.complete) from an early termination (→ sse.aborted).
|
||||
#
|
||||
# IMPORTANT (Starlette ``BaseHTTPMiddleware`` quirk): when the INNER
|
||||
# endpoint generator raises mid-stream, Starlette swallows the error
|
||||
# internally and our ``async for`` simply ends — we never see an
|
||||
# exception there. The abort surface we CAN observe is the consumer
|
||||
# tearing the stream down early (client disconnect), which closes
|
||||
# this generator and raises ``GeneratorExit`` / ``CancelledError``
|
||||
# into it. We therefore catch ``BaseException`` (not just
|
||||
# ``Exception``) so a disconnect-driven abort is captured, and emit
|
||||
# ``backend.response.complete`` only on a clean exhaustion.
|
||||
completed = False
|
||||
terminated_kind = "rst"
|
||||
try:
|
||||
async for chunk in inner:
|
||||
ctx.bytes_streamed += len(chunk) if chunk else 0
|
||||
if not ctx.first_byte_emitted:
|
||||
ctx.first_byte_emitted = True
|
||||
emit_backend_boundary(
|
||||
"backend.sse.first_byte",
|
||||
outcome="info",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
edge_headers=edge,
|
||||
metadata={
|
||||
"delta_ms_from_ingress": _elapsed_ms(
|
||||
ctx.ingress_mono_ns
|
||||
)
|
||||
},
|
||||
)
|
||||
emit_backend_boundary(
|
||||
"backend.sse.event",
|
||||
outcome="info",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
metadata={
|
||||
"event_type": "chunk",
|
||||
"payload_size_bytes": len(chunk) if chunk else 0,
|
||||
"sequence_num": ctx.sse_seq,
|
||||
},
|
||||
)
|
||||
ctx.sse_seq += 1
|
||||
yield chunk
|
||||
completed = True
|
||||
except BaseException as exc: # noqa: BLE001 - observe abort then re-raise
|
||||
# GeneratorExit (disconnect) and CancelledError carry no
|
||||
# message; an in-iterator error would. Pick a termination_kind.
|
||||
terminated_kind = (
|
||||
"rst"
|
||||
if isinstance(exc, (GeneratorExit,))
|
||||
else (
|
||||
"timeout"
|
||||
if isinstance(exc, asyncio.CancelledError)
|
||||
else "chunk_error"
|
||||
)
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if completed:
|
||||
emit_backend_boundary(
|
||||
"backend.response.complete",
|
||||
outcome="ok",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
duration_ms=_elapsed_ms(ctx.ingress_mono_ns),
|
||||
edge_headers=edge,
|
||||
metadata={
|
||||
"http_status": status,
|
||||
"content_length": ctx.bytes_streamed,
|
||||
"total_duration_ms": _elapsed_ms(ctx.ingress_mono_ns),
|
||||
"sse_event_count": ctx.sse_seq,
|
||||
},
|
||||
)
|
||||
else:
|
||||
emit_backend_boundary(
|
||||
"backend.sse.aborted",
|
||||
outcome="err",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
edge_headers=edge,
|
||||
metadata={
|
||||
"termination_kind": terminated_kind,
|
||||
"bytes_before_abort": ctx.bytes_streamed,
|
||||
},
|
||||
)
|
||||
|
||||
response.body_iterator = _instrumented()
|
||||
return response
|
||||
|
||||
|
||||
def _int_or_none(raw: Any) -> Optional[int]:
|
||||
"""Parse an int header value, returning ``None`` on absence / malformed."""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _elapsed_ms(start_mono_ns: int) -> int:
|
||||
"""Whole milliseconds elapsed since a ``time.monotonic_ns`` start mark."""
|
||||
return max(0, (time.monotonic_ns() - start_mono_ns) // 1_000_000)
|
||||
|
||||
|
||||
# ── Agent + LLM boundaries ──────────────────────────────────────────────────
|
||||
|
||||
# The LLM-call boundaries (start / heartbeat / response) and the agent
|
||||
# enter/exit boundaries are emitted via the explicit helpers below. They are
|
||||
# called from the agent factory's hook points (strands ``HookProvider``) and
|
||||
# from the outbound httpx event hook, all keyed on the request ``ctx``.
|
||||
|
||||
|
||||
def emit_agent_enter(ctx: "_RequestCtx", *, agent_name: str, model_id: str) -> None:
|
||||
"""Emit ``backend.agent.enter`` (default tier)."""
|
||||
emit_backend_boundary(
|
||||
"backend.agent.enter",
|
||||
outcome="info",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
metadata={"agent_name": agent_name, "model_id": model_id},
|
||||
)
|
||||
|
||||
|
||||
def emit_agent_exit(
|
||||
ctx: "_RequestCtx", *, terminal_outcome: str, total_duration_ms: int
|
||||
) -> None:
|
||||
"""Emit ``backend.agent.exit`` (default tier)."""
|
||||
emit_backend_boundary(
|
||||
"backend.agent.exit",
|
||||
outcome="ok" if terminal_outcome == "ok" else "err",
|
||||
test_id=ctx.test_id,
|
||||
slug=ctx.slug,
|
||||
demo=ctx.demo,
|
||||
duration_ms=total_duration_ms,
|
||||
metadata={
|
||||
"terminal_outcome": terminal_outcome,
|
||||
"total_duration_ms": total_duration_ms,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class LlmCallScope:
|
||||
"""Async context manager spanning one outbound LLM call.
|
||||
|
||||
On ``__aenter__`` emits ``backend.llm.call.start`` and launches a heartbeat
|
||||
task that emits ``backend.llm.call.heartbeat`` every ``interval_s`` (≈10s)
|
||||
while the call is outstanding (verbose tier). On ``__aexit__`` emits
|
||||
``backend.llm.call.response`` with the measured latency.
|
||||
|
||||
All emission is gated/tiered through ``emit_backend_boundary``, so with the
|
||||
emitter off or at default tier this scope is effectively free (the
|
||||
heartbeat task still ticks but every emit is suppressed; callers that want
|
||||
zero task overhead can skip the scope when ``cvdiag_backend_enabled()`` is
|
||||
false).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: "_RequestCtx",
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
prompt_token_count_estimate: int = 0,
|
||||
interval_s: float = 10.0,
|
||||
) -> None:
|
||||
self._ctx = ctx
|
||||
self._provider = provider
|
||||
self._model = model
|
||||
self._prompt_tokens = prompt_token_count_estimate
|
||||
self._interval_s = interval_s
|
||||
self._start_mono_ns = 0
|
||||
self._hb_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def __aenter__(self) -> "LlmCallScope":
|
||||
self._start_mono_ns = time.monotonic_ns()
|
||||
emit_backend_boundary(
|
||||
"backend.llm.call.start",
|
||||
outcome="info",
|
||||
test_id=self._ctx.test_id,
|
||||
slug=self._ctx.slug,
|
||||
demo=self._ctx.demo,
|
||||
metadata={
|
||||
"provider": self._provider,
|
||||
"model": self._model,
|
||||
"prompt_token_count_estimate": self._prompt_tokens,
|
||||
},
|
||||
)
|
||||
self._hb_task = asyncio.ensure_future(self._heartbeat())
|
||||
return self
|
||||
|
||||
async def _heartbeat(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(self._interval_s)
|
||||
emit_backend_boundary(
|
||||
"backend.llm.call.heartbeat",
|
||||
outcome="info",
|
||||
test_id=self._ctx.test_id,
|
||||
slug=self._ctx.slug,
|
||||
demo=self._ctx.demo,
|
||||
metadata={
|
||||
"elapsed_ms_since_start": _elapsed_ms(self._start_mono_ns)
|
||||
},
|
||||
)
|
||||
except asyncio.CancelledError: # normal shutdown on call completion
|
||||
return
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||
if self._hb_task is not None:
|
||||
hb_task = self._hb_task
|
||||
self._hb_task = None
|
||||
hb_task.cancel()
|
||||
try:
|
||||
await hb_task
|
||||
except asyncio.CancelledError:
|
||||
# Cooperative cancellation (was ``except (CancelledError,
|
||||
# Exception)``, which swallowed the CALLER's cancel and broke
|
||||
# cooperative cancellation). Suppress ONLY the heartbeat task's
|
||||
# OWN cancellation — the one we just requested. If THIS task is
|
||||
# being cancelled by the caller (a pending cancellation request,
|
||||
# ``current_task().cancelling() > 0``), the CancelledError is the
|
||||
# caller's and MUST propagate. ``Task.cancelling()`` is 3.11+
|
||||
# (production runs 3.12); on older runtimes the attribute is
|
||||
# absent and we degrade to suppressing (the legacy behavior).
|
||||
current = asyncio.current_task()
|
||||
cancelling = getattr(current, "cancelling", None)
|
||||
if current is not None and cancelling is not None and cancelling() > 0:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - heartbeat body must never throw out
|
||||
pass
|
||||
emit_backend_boundary(
|
||||
"backend.llm.call.response",
|
||||
outcome="err" if exc_type is not None else "ok",
|
||||
test_id=self._ctx.test_id,
|
||||
slug=self._ctx.slug,
|
||||
demo=self._ctx.demo,
|
||||
duration_ms=_elapsed_ms(self._start_mono_ns),
|
||||
metadata={
|
||||
"provider": self._provider,
|
||||
"model": self._model,
|
||||
"response_token_count": None,
|
||||
"latency_ms": _elapsed_ms(self._start_mono_ns),
|
||||
"error_class": type(exc).__name__ if exc is not None else None,
|
||||
},
|
||||
)
|
||||
return False # never suppress the underlying exception
|
||||
@@ -0,0 +1,466 @@
|
||||
"""Standalone header-forwarding shim for showcase integrations.
|
||||
|
||||
Forward CopilotKit request-context headers (e.g. ``x-aimock-context``)
|
||||
onto outbound LLM/provider HTTP calls so the locally-served aimock test
|
||||
server can match the right fixture for each in-flight showcase request.
|
||||
|
||||
This module is a SELF-CONTAINED port of the langgraph-python reference
|
||||
shim at ``copilotkit/header_propagation.py`` plus a small Starlette HTTP
|
||||
middleware that extracts inbound ``x-*`` headers at request scope.
|
||||
|
||||
It is intentionally duplicated into every Python showcase integration
|
||||
that does NOT already depend on the ``copilotkit`` SDK so each backend
|
||||
has a single self-contained file it can import without adding a heavy
|
||||
``copilotkit`` (langchain-pulling) dependency.
|
||||
|
||||
What this module does
|
||||
---------------------
|
||||
Three things, kept deliberately small:
|
||||
|
||||
1. ``HeaderForwardingHTTPMiddleware`` — a Starlette/FastAPI HTTP
|
||||
middleware that, on every inbound request, extracts ``x-*`` prefixed
|
||||
headers and stashes them on a per-request ``contextvars.ContextVar``.
|
||||
2. ``install_httpx_hook(client)`` — attaches an httpx request event hook
|
||||
to the given LLM client's underlying httpx client (walking the
|
||||
``._client`` chain that modern provider SDKs wrap their httpx client
|
||||
behind). The hook copies the recorded headers onto outbound requests.
|
||||
3. ``set_forwarded_headers`` / ``get_forwarded_headers`` — direct
|
||||
ContextVar accessors for integrations that need to populate the
|
||||
header set from a non-HTTP source (e.g. LangGraph's RunnableConfig
|
||||
``configurable`` channel).
|
||||
|
||||
Scope and limits
|
||||
----------------
|
||||
* Only ``x-*`` prefixed headers are forwarded. ``authorization``,
|
||||
``content-type``, and any other non-``x-*`` headers are dropped.
|
||||
* Nothing is collected, persisted, or sent anywhere — the module only
|
||||
attaches headers to an HTTP request that the caller was already going
|
||||
to make. No telemetry, no out-of-band channel. (Diagnostic CVDIAG
|
||||
breadcrumbs ARE logged via the stdlib ``logging`` module: header
|
||||
PRESENCE plus a short value prefix only — never full header values.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
import warnings
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# CVDIAG correlation-header instrumentation tag for this integration. Each
|
||||
# showcase backend that copies this shim sets a distinct framework tag so the
|
||||
# CVDIAG breadcrumb trail identifies which backend captured/forwarded headers.
|
||||
_CVDIAG_FRAMEWORK = "ag2"
|
||||
|
||||
# Correlation headers carried end-to-end through the showcase request chain.
|
||||
_DIAG_RUN_ID_HEADER = "x-diag-run-id"
|
||||
_DIAG_HOPS_HEADER = "x-diag-hops"
|
||||
_AIMOCK_CONTEXT_HEADER = "x-aimock-context"
|
||||
_TEST_ID_HEADER = "x-test-id"
|
||||
|
||||
|
||||
def _cvdiag(
|
||||
boundary: str,
|
||||
headers: Dict[str, str],
|
||||
*,
|
||||
status: str,
|
||||
hop: object = "-",
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""Emit a single standardized CVDIAG breadcrumb line.
|
||||
|
||||
Logs ONLY header presence + a short value prefix (never full header
|
||||
values). ``headers`` is the lowercased ``x-*`` header mapping for the
|
||||
current request context.
|
||||
"""
|
||||
slug = headers.get(_AIMOCK_CONTEXT_HEADER)
|
||||
run_id = headers.get(_DIAG_RUN_ID_HEADER, "none")
|
||||
test_id = headers.get(_TEST_ID_HEADER, "none")
|
||||
present = slug is not None
|
||||
prefix = (slug or "")[:12]
|
||||
logger.info(
|
||||
"CVDIAG component=backend-%s boundary=%s run_id=%s slug=%s "
|
||||
"header_present=%s header_value_prefix=%s hop=%s status=%s "
|
||||
"test_id=%s error=%s",
|
||||
_CVDIAG_FRAMEWORK,
|
||||
boundary,
|
||||
run_id,
|
||||
slug if present else "MISSING",
|
||||
"true" if present else "false",
|
||||
prefix,
|
||||
hop,
|
||||
status,
|
||||
test_id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
# Per-request storage for the headers the application has asked to forward
|
||||
# onto outbound LLM/provider calls.
|
||||
_forwarded_headers: contextvars.ContextVar[Dict[str, str]] = contextvars.ContextVar(
|
||||
"copilotkit_forwarded_headers"
|
||||
)
|
||||
|
||||
# Marker used to identify hooks we have already installed so the install
|
||||
# call is idempotent across repeated invocations on the same client.
|
||||
_HOOK_MARKER = "_copilotkit_forwarded_header_hook"
|
||||
|
||||
# Bound on how deep we'll walk a ``._client`` chain looking for event_hooks.
|
||||
# Modern provider SDKs (OpenAI, Anthropic, pydantic-ai wrappers, agno's
|
||||
# OpenAIChat, strands' OpenAIModel) wrap their httpx client behind 2-4
|
||||
# layers of ``._client`` indirection; 5 hops is enough headroom without
|
||||
# risking pathological loops.
|
||||
_MAX_CHAIN_DEPTH = 5
|
||||
|
||||
|
||||
def set_forwarded_headers(headers: Dict[str, str]) -> None:
|
||||
"""Record headers to forward onto outbound LLM/provider calls.
|
||||
|
||||
Only ``x-*`` prefixed headers are kept; everything else is dropped.
|
||||
"""
|
||||
filtered = {k.lower(): v for k, v in headers.items() if k.lower().startswith("x-")}
|
||||
_forwarded_headers.set(filtered)
|
||||
|
||||
|
||||
def get_forwarded_headers() -> Dict[str, str]:
|
||||
"""Return the headers recorded for the current request context."""
|
||||
return _forwarded_headers.get({})
|
||||
|
||||
|
||||
class HeaderForwardingHTTPMiddleware(BaseHTTPMiddleware):
|
||||
"""Starlette/FastAPI middleware that captures inbound ``x-*`` headers.
|
||||
|
||||
On every inbound HTTP request, copies all ``x-*`` prefixed headers
|
||||
onto the per-request ContextVar so any outbound httpx call made
|
||||
inside the request scope (the LLM call hop 2) sees them via
|
||||
``get_forwarded_headers()`` and the installed httpx event hook.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
headers = {
|
||||
k: v for k, v in request.headers.items() if k.lower().startswith("x-")
|
||||
}
|
||||
set_forwarded_headers(headers)
|
||||
captured = {k.lower(): v for k, v in headers.items()}
|
||||
_cvdiag(
|
||||
"contextvar-capture",
|
||||
captured,
|
||||
status="ok" if _AIMOCK_CONTEXT_HEADER in captured else "miss",
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _find_event_hooks_target(client: Any) -> Optional[Any]:
|
||||
"""Walk ``._client`` chain looking for the first httpx-style event_hooks.
|
||||
|
||||
Returns the target object, or ``None`` if not found within
|
||||
``_MAX_CHAIN_DEPTH`` hops.
|
||||
"""
|
||||
current = client
|
||||
for _ in range(_MAX_CHAIN_DEPTH + 1):
|
||||
if current is None:
|
||||
return None
|
||||
if hasattr(current, "event_hooks"):
|
||||
return current
|
||||
nxt = getattr(current, "_client", None)
|
||||
if nxt is current or nxt is None:
|
||||
return None
|
||||
current = nxt
|
||||
return None
|
||||
|
||||
|
||||
def _is_async_httpx_target(target: Any) -> bool:
|
||||
"""Best-effort detection: is this an httpx async client?
|
||||
|
||||
Detection is HIGH-CONFIDENCE when ``isinstance`` against the real
|
||||
``httpx.AsyncClient`` / ``httpx.Client`` succeeds. The MRO name-only
|
||||
fallback (matching a class literally named ``AsyncClient``) is
|
||||
LOW-CONFIDENCE: a wrapped/duck-typed client whose class happens to be
|
||||
named ``AsyncClient`` (or that is async but is NOT so named) can be
|
||||
misclassified, which would install a sync hook on an async client (an
|
||||
un-awaited coroutine → silent header drop) or vice versa. Each path
|
||||
emits a CVDIAG breadcrumb tagged with the chosen confidence so a
|
||||
misdetection is greppable in the logs. The return values themselves are
|
||||
unchanged — only the diagnostics are new.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
if isinstance(target, httpx.AsyncClient):
|
||||
_cvdiag(
|
||||
"async-detect",
|
||||
{},
|
||||
status="ok",
|
||||
error="path=isinstance-async confidence=high",
|
||||
)
|
||||
return True
|
||||
if isinstance(target, httpx.Client):
|
||||
_cvdiag(
|
||||
"async-detect",
|
||||
{},
|
||||
status="ok",
|
||||
error="path=isinstance-sync confidence=high",
|
||||
)
|
||||
return False
|
||||
except ImportError: # pragma: no cover
|
||||
pass
|
||||
|
||||
# Fall back to exact class-name match for wrapped/duck-typed clients.
|
||||
# LOW-CONFIDENCE: this can misdetect async-vs-sync for oddly-named
|
||||
# wrappers; the breadcrumb records the fallback so a wrong hook kind is
|
||||
# traceable to this path.
|
||||
for cls in type(target).__mro__:
|
||||
if cls.__name__ == "AsyncClient":
|
||||
_cvdiag(
|
||||
"async-detect",
|
||||
{},
|
||||
status="ok",
|
||||
error=(
|
||||
"path=mro-name-match confidence=low "
|
||||
f"target_type={type(target).__name__}"
|
||||
),
|
||||
)
|
||||
return True
|
||||
_cvdiag(
|
||||
"async-detect",
|
||||
{},
|
||||
status="ok",
|
||||
error=(f"path=default-sync confidence=low target_type={type(target).__name__}"),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _inject_diag_hop(request: Any, headers: Dict[str, str]) -> None:
|
||||
"""Append this backend's hop tag to ``x-diag-hops`` on the outbound
|
||||
request and emit the ``outbound-llm`` CVDIAG breadcrumb.
|
||||
|
||||
``x-diag-hops`` is a comma-separated trail of the backends that touched
|
||||
the request; appending ``backend-<framework>`` here records that this
|
||||
integration forwarded the correlation headers onto the LLM/provider
|
||||
call. ``x-diag-run-id`` is carried verbatim (already copied above via
|
||||
the ``headers`` loop) the same way ``x-aimock-context`` is.
|
||||
|
||||
GATED on diagnostic-header presence: the breadcrumb append and the
|
||||
outbound CVDIAG log fire ONLY when the forwarded headers carry a
|
||||
diagnostic header (``x-diag-run-id`` OR ``x-aimock-context``). When
|
||||
NEITHER is present this is a no-op, so the outbound request is
|
||||
byte-identical to pre-instrumentation behavior.
|
||||
"""
|
||||
if _DIAG_RUN_ID_HEADER not in headers and _AIMOCK_CONTEXT_HEADER not in headers:
|
||||
return
|
||||
|
||||
hop_tag = f"backend-{_CVDIAG_FRAMEWORK}"
|
||||
existing = headers.get(_DIAG_HOPS_HEADER, "")
|
||||
trail = [h for h in (existing.split(",") if existing else []) if h]
|
||||
trail.append(hop_tag)
|
||||
new_hops = ",".join(trail)
|
||||
request.headers[_DIAG_HOPS_HEADER] = new_hops
|
||||
|
||||
_cvdiag(
|
||||
"outbound-llm",
|
||||
headers,
|
||||
status="ok" if _AIMOCK_CONTEXT_HEADER in headers else "miss",
|
||||
hop=len(trail),
|
||||
)
|
||||
|
||||
|
||||
def install_httpx_hook(client: Any) -> None:
|
||||
"""Attach an httpx request event hook to ``client``'s httpx client.
|
||||
|
||||
Walks the ``._client`` chain to find the first object with an
|
||||
``event_hooks`` mapping, then appends a request hook that copies the
|
||||
ContextVar-recorded headers onto each outbound request.
|
||||
|
||||
Works with OpenAI / Anthropic / pydantic-ai / agno / strands client
|
||||
wrappers (all wrap httpx internally), as well as raw
|
||||
``httpx.Client`` / ``httpx.AsyncClient`` instances.
|
||||
|
||||
Idempotent: a marker attribute on the installed callable prevents
|
||||
double-installation on the same target.
|
||||
"""
|
||||
target = _find_event_hooks_target(client)
|
||||
|
||||
if target is None:
|
||||
msg = (
|
||||
f"install_httpx_hook: client of type {type(client).__name__} has no "
|
||||
"recognized event_hooks attribute; x-* headers will NOT be forwarded "
|
||||
"for this client"
|
||||
)
|
||||
warnings.warn(msg, stacklevel=2)
|
||||
# warnings.warn is invisible in most prod runtimes (filtered/once);
|
||||
# ALSO log at WARNING so a non-forwarding client surfaces.
|
||||
logger.warning("CVDIAG boundary=hook-install status=error error=%s", msg)
|
||||
_cvdiag("hook-install", {}, status="error", error="no-event-hooks-target")
|
||||
return
|
||||
|
||||
request_hooks = target.event_hooks.get("request", [])
|
||||
|
||||
# Idempotency: don't double-install on the same target.
|
||||
for existing in request_hooks:
|
||||
if getattr(existing, _HOOK_MARKER, False):
|
||||
return
|
||||
|
||||
is_async = _is_async_httpx_target(target)
|
||||
|
||||
if is_async:
|
||||
|
||||
async def _inject_headers_async(request):
|
||||
headers = get_forwarded_headers()
|
||||
for key, value in headers.items():
|
||||
request.headers[key] = value
|
||||
_inject_diag_hop(request, headers)
|
||||
|
||||
setattr(_inject_headers_async, _HOOK_MARKER, True)
|
||||
request_hooks.append(_inject_headers_async)
|
||||
else:
|
||||
|
||||
def _inject_headers(request):
|
||||
headers = get_forwarded_headers()
|
||||
for key, value in headers.items():
|
||||
request.headers[key] = value
|
||||
_inject_diag_hop(request, headers)
|
||||
|
||||
setattr(_inject_headers, _HOOK_MARKER, True)
|
||||
request_hooks.append(_inject_headers)
|
||||
|
||||
target.event_hooks["request"] = request_hooks
|
||||
|
||||
|
||||
# Module-scope sentinel preventing repeated global patching.
|
||||
_GLOBAL_HTTPX_PATCHED = False
|
||||
|
||||
|
||||
def install_global_httpx_hook() -> None:
|
||||
"""Patch ``httpx.Client`` / ``httpx.AsyncClient`` so EVERY future
|
||||
instance auto-attaches the forwarded-header hook on construction.
|
||||
|
||||
Use this when the LLM client is buried behind opaque framework
|
||||
machinery (AG2's ``ConversableAgent`` constructs OpenAI clients
|
||||
lazily, CrewAI uses litellm which constructs httpx clients per-call,
|
||||
etc.) and there is no single client instance to call
|
||||
:func:`install_httpx_hook` on at startup.
|
||||
|
||||
Safe to call at import time. Idempotent: a module-scope sentinel
|
||||
prevents repeated patching, and the per-instance idempotency check
|
||||
in :func:`install_httpx_hook` prevents double-hooking on each new
|
||||
client. Pre-existing ``httpx.Client`` instances are not retroactively
|
||||
hooked — only those constructed AFTER this call.
|
||||
"""
|
||||
global _GLOBAL_HTTPX_PATCHED
|
||||
if _GLOBAL_HTTPX_PATCHED:
|
||||
return
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError: # pragma: no cover
|
||||
return
|
||||
|
||||
_orig_sync_init = httpx.Client.__init__
|
||||
_orig_async_init = httpx.AsyncClient.__init__
|
||||
|
||||
def _patched_sync_init(self, *args, **kwargs):
|
||||
_orig_sync_init(self, *args, **kwargs)
|
||||
try:
|
||||
install_httpx_hook(self)
|
||||
except Exception as exc: # pragma: no cover - never break client construction
|
||||
# A failed hook install means x-aimock-context silently never
|
||||
# forwards (the whole point of this shim). Keep swallowing the
|
||||
# exception so client construction never breaks, but FAIL LOUD:
|
||||
# log at ERROR with the FULL detail (not 80-char-truncated) so a
|
||||
# broken install is visible, not buried at INFO.
|
||||
detail = f"sync-init {type(exc).__name__}: {exc}"
|
||||
logger.error(
|
||||
"CVDIAG boundary=hook-install status=error error=%s",
|
||||
detail,
|
||||
exc_info=True,
|
||||
)
|
||||
_cvdiag("hook-install", {}, status="error", error=detail)
|
||||
|
||||
def _patched_async_init(self, *args, **kwargs):
|
||||
_orig_async_init(self, *args, **kwargs)
|
||||
try:
|
||||
install_httpx_hook(self)
|
||||
except Exception as exc: # pragma: no cover
|
||||
# See _patched_sync_init: swallow to protect construction, but
|
||||
# FAIL LOUD at ERROR with full detail so a broken install (which
|
||||
# silently drops x-aimock-context forwarding) is visible.
|
||||
detail = f"async-init {type(exc).__name__}: {exc}"
|
||||
logger.error(
|
||||
"CVDIAG boundary=hook-install status=error error=%s",
|
||||
detail,
|
||||
exc_info=True,
|
||||
)
|
||||
_cvdiag("hook-install", {}, status="error", error=detail)
|
||||
|
||||
httpx.Client.__init__ = _patched_sync_init
|
||||
httpx.AsyncClient.__init__ = _patched_async_init
|
||||
_GLOBAL_HTTPX_PATCHED = True
|
||||
|
||||
|
||||
# Module-scope sentinel preventing repeated executor patching.
|
||||
_EXECUTOR_CTXVAR_PATCHED = False
|
||||
|
||||
|
||||
def install_executor_contextvar_propagation() -> None:
|
||||
"""Patch ``asyncio.events.AbstractEventLoop.run_in_executor`` so the
|
||||
parent task's ContextVars are propagated into the executor thread.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
autogen's ``ConversableAgent.a_generate_oai_reply`` dispatches the
|
||||
underlying (sync) OpenAI/LiteLLM call onto the default thread pool
|
||||
via ``loop.run_in_executor(None, functools.partial(...))``. The stock
|
||||
``run_in_executor`` does NOT copy the caller's :pep:`567` context to
|
||||
the worker thread — so the :class:`HeaderForwardingHTTPMiddleware`
|
||||
ContextVar (set on the inbound request task) is empty inside the
|
||||
executor, and our outbound httpx hook sees no headers to forward.
|
||||
|
||||
``asyncio.to_thread`` (Python 3.9+) does copy context the right way;
|
||||
this patch makes plain ``run_in_executor`` behave the same. It only
|
||||
affects functions submitted via ``run_in_executor`` — coroutines and
|
||||
other constructs are unaffected.
|
||||
|
||||
Safe to call at import time. Idempotent via a module-scope sentinel.
|
||||
|
||||
Scope caveat: this patches ``asyncio.base_events.BaseEventLoop`` only.
|
||||
Pre-existing *stdlib asyncio* event-loop instances inherit the patch
|
||||
(``run_in_executor`` is defined on ``BaseEventLoop`` and resolved
|
||||
per-call via normal method resolution). It is INERT under uvloop —
|
||||
uvloop's loop does not subclass ``BaseEventLoop`` and resolves
|
||||
``run_in_executor`` from its own C implementation, so the stdlib
|
||||
method this patch rebinds is never consulted. Under uvloop, ContextVar
|
||||
propagation into ``run_in_executor`` worker threads is NOT provided by
|
||||
this shim.
|
||||
"""
|
||||
global _EXECUTOR_CTXVAR_PATCHED
|
||||
if _EXECUTOR_CTXVAR_PATCHED:
|
||||
return
|
||||
|
||||
import asyncio.base_events as _base_events
|
||||
|
||||
_orig_run_in_executor = _base_events.BaseEventLoop.run_in_executor
|
||||
|
||||
def _patched_run_in_executor(self, executor, func, *args):
|
||||
# Capture the CURRENT task's context at submit time, then run the
|
||||
# submitted callable inside that context on the worker thread.
|
||||
ctx = contextvars.copy_context()
|
||||
|
||||
def _ctx_wrapper(*a, **kw):
|
||||
return ctx.run(func, *a, **kw)
|
||||
|
||||
# Preserve __name__/__qualname__ for nicer tracebacks where possible.
|
||||
try:
|
||||
_ctx_wrapper.__wrapped__ = func # type: ignore[attr-defined]
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
return _orig_run_in_executor(self, executor, _ctx_wrapper, *args)
|
||||
|
||||
_base_events.BaseEventLoop.run_in_executor = _patched_run_in_executor
|
||||
_EXECUTOR_CTXVAR_PATCHED = True
|
||||
@@ -0,0 +1,390 @@
|
||||
"""AG-UI → autogen multimodal content normalization for the AG2 backend.
|
||||
|
||||
Problem
|
||||
-------
|
||||
The ``multimodal`` showcase cell sends user messages whose ``content`` is a
|
||||
list of AG-UI ``InputContent`` parts. The shapes that actually arrive on
|
||||
the wire are:
|
||||
|
||||
* Modern AG-UI image:
|
||||
``{"type": "image", "source": {"type": "data" | "url", "value": "...",
|
||||
"mimeType" | "mime_type": "image/png"}}``
|
||||
* Modern AG-UI document (PDF, etc):
|
||||
``{"type": "document", "source": {...}}``
|
||||
* Legacy AG-UI binary mirror (appended by
|
||||
``src/app/demos/multimodal/legacy-converter-shim.tsx``):
|
||||
``{"type": "binary", "mimeType": "image/png", "data": "..." | "url": "..."}``
|
||||
|
||||
AG2's ``ConversableAgent`` runs every user message through
|
||||
``autogen.code_utils.content_str``, which only accepts content-part types
|
||||
``{"text", "input_text", "image_url", "input_image", "function",
|
||||
"tool_call", "tool_calls"}``. Any other ``type`` raises
|
||||
``ValueError("Wrong content format: unknown type <type> within the
|
||||
content")`` BEFORE the request reaches the model — observed live in the
|
||||
D6 ``multimodal`` probe (image turn errored out with that message; see
|
||||
commit d8a0a25db for the symptom report and the original NSF
|
||||
quarantine).
|
||||
|
||||
Fix
|
||||
---
|
||||
``NormalizingAGUIStream`` subclasses ``AGUIStream`` and overrides
|
||||
``dispatch`` to normalise each user message's content list so AG-UI
|
||||
image / document / binary parts become OpenAI Chat Completions
|
||||
``image_url`` parts (which autogen accepts and forwards to the
|
||||
vision-capable model natively).
|
||||
|
||||
The normalization runs AFTER ``RunAgentInput`` Pydantic parsing (which
|
||||
accepts the standard AG-UI ``image``/``document``/``binary`` content
|
||||
types) and BEFORE the messages are passed to ``AgentService``, which
|
||||
serialises them via ``model_dump()`` into raw dicts and passes them to
|
||||
``ConversableAgent``. That is the correct interception point: too early
|
||||
(before Pydantic) would require rewriting ``image_url`` into the AG-UI
|
||||
body, which ``RunAgentInput`` rejects; too late (inside ConversableAgent)
|
||||
would require patching autogen internals.
|
||||
|
||||
Conversions:
|
||||
|
||||
* ``{"type": "image", "source": {"type": "data", value, mime_type}}`` →
|
||||
``{"type": "image_url", "image_url": {"url": "data:<mime>;base64,<value>"}}``
|
||||
* ``{"type": "image", "source": {"type": "url", value}}`` →
|
||||
``{"type": "image_url", "image_url": {"url": value}}``
|
||||
* ``{"type": "document", "source": ...}`` → ``image_url`` with the
|
||||
document's mime preserved (data:application/pdf;base64,...). The vision
|
||||
model still cannot natively read PDFs, but the request reaches the model
|
||||
instead of being rejected upstream.
|
||||
* ``{"type": "binary", mimeType, data | url}`` → ``image_url`` (the
|
||||
legacy-shim parts ride through cleanly).
|
||||
* ``{"type": "text", ...}`` and already-normalised ``image_url`` parts
|
||||
pass through unchanged (idempotent on no-op turns).
|
||||
|
||||
Failure path: any normalization error is logged at WARNING and the
|
||||
original message is replayed unchanged — autogen's own ``ValueError``
|
||||
fires verbatim, preserving the failure surface.
|
||||
|
||||
The normalizer is mounted ONLY on the ``multimodal_app`` sub-app
|
||||
(``agents/multimodal_agent.py``), not on the global FastAPI server in
|
||||
``agent_server.py`` — keeping the blast radius scoped to the one route
|
||||
that actually sees image content parts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from autogen.ag_ui import AGUIStream, RunAgentInput
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_IMAGE_URL_TYPE = "image_url"
|
||||
_TEXT_TYPE = "text"
|
||||
|
||||
|
||||
def _build_data_url(mime: str, payload: str) -> str:
|
||||
"""Assemble a ``data:<mime>;base64,<payload>`` URL.
|
||||
|
||||
The OpenAI Chat Completions ``image_url`` part accepts either a
|
||||
plain ``https://`` URL or an inline base64 data URL — both flow
|
||||
through autogen's ``content_str`` allowed-types gate as
|
||||
``image_url``. Building a data URL from the AG-UI ``data`` source
|
||||
keeps the inline payload intact end-to-end.
|
||||
"""
|
||||
return f"data:{mime};base64,{payload}"
|
||||
|
||||
|
||||
def _normalize_modern_part(part: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Convert a modern AG-UI ``image`` / ``document`` part to ``image_url``.
|
||||
|
||||
Returns ``None`` if the shape is unrecognised — the caller passes
|
||||
the original part through unchanged in that case.
|
||||
|
||||
Modern AG-UI content shape (see ``ag_ui.core.types.ImageInputContent``):
|
||||
``{"type": "image" | "document",
|
||||
"source": {"type": "data" | "url",
|
||||
"value": "<base64>" | "<https://...>",
|
||||
"mime_type" | "mimeType": "..."}}``
|
||||
"""
|
||||
source = part.get("source")
|
||||
if not isinstance(source, dict):
|
||||
return None
|
||||
value = source.get("value")
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
# The AG-UI pydantic model uses ``mime_type``; the legacy converter
|
||||
# shim and some hand-rolled payloads use ``mimeType``. Accept both
|
||||
# so a frontend running either schema version round-trips cleanly.
|
||||
mime = source.get("mime_type") or source.get("mimeType") or ""
|
||||
if not isinstance(mime, str) or not mime:
|
||||
# Fall back to a generic mime so the URL is at least well-formed
|
||||
# data:URL syntax. The model side will likely ignore an unknown
|
||||
# mime, but autogen's allowed-types gate only inspects ``type``.
|
||||
mime = "application/octet-stream"
|
||||
src_type = source.get("type")
|
||||
if src_type == "url":
|
||||
# Pass URL-source values through as the image_url url directly.
|
||||
return {"type": _IMAGE_URL_TYPE, "image_url": {"url": value}}
|
||||
if src_type == "data":
|
||||
return {
|
||||
"type": _IMAGE_URL_TYPE,
|
||||
"image_url": {"url": _build_data_url(mime, value)},
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_legacy_binary_part(part: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Convert a legacy AG-UI ``binary`` part to ``image_url``.
|
||||
|
||||
The frontend at ``src/app/demos/multimodal/legacy-converter-shim.tsx``
|
||||
APPENDS one of these alongside every modern ``image``/``document``
|
||||
part to feed the @ag-ui/langgraph converter (LangChain integrations
|
||||
only understand the legacy shape). Those appended parts ride along
|
||||
on the same payload that hits the AG2 backend, and autogen also
|
||||
rejects ``binary`` as an unknown content type. Normalising them
|
||||
here turns the round-trip into a no-op for AG2 instead of a hard
|
||||
rejection.
|
||||
|
||||
Shape:
|
||||
``{"type": "binary", "mimeType": "<mime>",
|
||||
"data": "<base64>" | "url": "<https://...>"}``
|
||||
"""
|
||||
mime = part.get("mimeType") or part.get("mime_type") or "application/octet-stream"
|
||||
if not isinstance(mime, str):
|
||||
mime = "application/octet-stream"
|
||||
data = part.get("data")
|
||||
if isinstance(data, str) and data:
|
||||
return {
|
||||
"type": _IMAGE_URL_TYPE,
|
||||
"image_url": {"url": _build_data_url(mime, data)},
|
||||
}
|
||||
url = part.get("url")
|
||||
if isinstance(url, str) and url:
|
||||
return {"type": _IMAGE_URL_TYPE, "image_url": {"url": url}}
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_content_part(part: Any) -> Any:
|
||||
"""Return an autogen-acceptable content part for ``part``.
|
||||
|
||||
Recognised conversions:
|
||||
* ``{"type": "image", "source": ...}`` → ``image_url``
|
||||
* ``{"type": "document", "source": ...}`` → ``image_url`` (data
|
||||
URL with the original mime; vision model gets the raw bytes
|
||||
and the system prompt steers it on what to do with them)
|
||||
* ``{"type": "binary", ...}`` → ``image_url``
|
||||
|
||||
Everything else (``text``, already-normalised ``image_url``,
|
||||
unknown shapes) passes through untouched. Returning the original
|
||||
part on no-op keeps the rewrite idempotent and preserves any extra
|
||||
keys autogen / the model might consume.
|
||||
"""
|
||||
if not isinstance(part, dict):
|
||||
return part
|
||||
part_type = part.get("type")
|
||||
if part_type in ("image", "document", "audio", "video"):
|
||||
normalized = _normalize_modern_part(part)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
# Recognised modality with an unrecognised source — log and
|
||||
# drop to a plain text placeholder so autogen accepts the
|
||||
# part instead of choking. Without this, an empty/malformed
|
||||
# source would survive as ``image``/``document`` and trip the
|
||||
# exact ValueError we're working around.
|
||||
logger.warning(
|
||||
"[ag2:multimodal-normalize] dropping unrecognised %s source "
|
||||
"shape; replacing with text placeholder",
|
||||
part_type,
|
||||
)
|
||||
return {
|
||||
"type": _TEXT_TYPE,
|
||||
"text": f"[unreadable {part_type} attachment]",
|
||||
}
|
||||
if part_type == "binary":
|
||||
normalized = _normalize_legacy_binary_part(part)
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
logger.warning(
|
||||
"[ag2:multimodal-normalize] dropping unrecognised binary shape; "
|
||||
"replacing with text placeholder",
|
||||
)
|
||||
return {
|
||||
"type": _TEXT_TYPE,
|
||||
"text": "[unreadable binary attachment]",
|
||||
}
|
||||
return part
|
||||
|
||||
|
||||
def normalize_messages_for_autogen(messages: Any) -> Any:
|
||||
"""Rewrite a list of message dicts so AG-UI multimodal parts are
|
||||
converted to autogen-acceptable ``image_url`` parts.
|
||||
|
||||
Accepts the dict-serialised form produced by
|
||||
``RunAgentInput.messages[i].model_dump(exclude_none=True)`` — the
|
||||
same dicts that ``run_stream`` in autogen's AG-UI adapter passes to
|
||||
``AgentService``.
|
||||
|
||||
Returns the input value untouched if it is not the expected list
|
||||
shape. Otherwise returns a NEW list with rewritten user-message
|
||||
content; non-user messages are forwarded as-is.
|
||||
|
||||
The function is pure: it never mutates the input.
|
||||
"""
|
||||
if not isinstance(messages, list):
|
||||
return messages
|
||||
rewritten: list[Any] = []
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
rewritten.append(msg)
|
||||
continue
|
||||
if msg.get("role") != "user":
|
||||
rewritten.append(msg)
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
# String content (plain text) and ``None`` pass through
|
||||
# untouched. Autogen accepts both.
|
||||
rewritten.append(msg)
|
||||
continue
|
||||
new_content = [_normalize_content_part(part) for part in content]
|
||||
if new_content == content:
|
||||
# No-op for this message — preserve the original dict so we
|
||||
# never accidentally drop a key the downstream app reads.
|
||||
rewritten.append(msg)
|
||||
continue
|
||||
new_msg = dict(msg)
|
||||
new_msg["content"] = new_content
|
||||
rewritten.append(new_msg)
|
||||
return rewritten
|
||||
|
||||
|
||||
class NormalizingAGUIStream(AGUIStream):
|
||||
"""``AGUIStream`` subclass that normalises AG-UI multimodal content.
|
||||
|
||||
Overrides ``dispatch`` to call ``normalize_messages_for_autogen``
|
||||
on the parsed ``RunAgentInput.messages`` (as serialised dicts) AFTER
|
||||
Pydantic validation and BEFORE ``AgentService`` processes them. This
|
||||
is the only correct interception point:
|
||||
|
||||
* Too early (ASGI body rewrite before Pydantic): ``RunAgentInput``
|
||||
rejects ``image_url`` because it is not an AG-UI standard content
|
||||
type — the validator only accepts ``image``, ``document``,
|
||||
``binary``, ``text``, ``audio``, ``video``.
|
||||
* Too late (inside ConversableAgent): requires patching autogen
|
||||
internals that can change across versions.
|
||||
|
||||
The override patches ``autogen.ag_ui.adapter.run_stream`` at call
|
||||
time by supplying pre-normalised messages via a thin
|
||||
``RequestMessage`` shim, replacing only the ``messages`` field in
|
||||
the ``AGStreamInput`` passed to the inherited ``dispatch`` machinery.
|
||||
"""
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
incoming: RunAgentInput,
|
||||
*,
|
||||
context: dict[str, Any] | None = None,
|
||||
accept: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
# Serialise all messages to dicts (same as run_stream does) then
|
||||
# normalise, then re-inject via a patched incoming object so the
|
||||
# rest of the dispatch machinery sees image_url parts instead of
|
||||
# AG-UI image/document/binary parts.
|
||||
raw_msgs: list[dict[str, Any]] | None = None
|
||||
try:
|
||||
raw_msgs = [m.model_dump(exclude_none=True) for m in incoming.messages]
|
||||
normalised_msgs = normalize_messages_for_autogen(raw_msgs)
|
||||
except Exception as exc: # noqa: BLE001 — log + fall back to original
|
||||
logger.warning(
|
||||
"[ag2:multimodal-normalize] pre-dispatch normalization failed "
|
||||
"(%s); forwarding original messages to autogen",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
normalised_msgs = None
|
||||
|
||||
if (
|
||||
normalised_msgs is not None
|
||||
and raw_msgs is not None
|
||||
and normalised_msgs is not raw_msgs
|
||||
):
|
||||
# Re-validate the normalised dicts back into Pydantic Message
|
||||
# objects so the rest of AGUIStream.dispatch / run_stream can
|
||||
# work with a properly typed RunAgentInput.
|
||||
# We use model_validate (not model_validate_json) since we already
|
||||
# have a Python dict. The normalised content uses image_url parts,
|
||||
# which are NOT in the AG-UI InputContent union — so we re-validate
|
||||
# just the message list using the raw dict form and pass it via a
|
||||
# reconstructed RunAgentInput.
|
||||
#
|
||||
# IMPORTANT: we pass the normalised dicts as plain dicts; autogen's
|
||||
# run_stream calls model_dump() on each message in
|
||||
# command.incoming.messages. To avoid a double round-trip we
|
||||
# instead *monkey-patch the model_dump contract* by building a
|
||||
# lightweight wrapper list that returns the pre-normalised dict on
|
||||
# model_dump() — keeping the rest of dispatch's typing clean.
|
||||
incoming = _PatchedRunAgentInput(incoming, normalised_msgs)
|
||||
|
||||
# Delegate to the parent implementation with the (possibly patched)
|
||||
# incoming object. AGUIStream.dispatch is a normal async generator so
|
||||
# we must use "yield from" semantics via the async iterator protocol.
|
||||
async for chunk in super().dispatch(incoming, context=context, accept=accept):
|
||||
yield chunk
|
||||
|
||||
|
||||
class _DictMessage:
|
||||
"""Minimal message wrapper that returns a pre-computed dict on model_dump.
|
||||
|
||||
``run_stream`` in autogen's adapter calls
|
||||
``m.model_dump(exclude_none=True)`` on each message in
|
||||
``command.incoming.messages``. This wrapper satisfies that call
|
||||
without the round-trip overhead of re-parsing the normalised dict
|
||||
back through Pydantic (which would fail anyway since ``image_url``
|
||||
is not an AG-UI content type).
|
||||
"""
|
||||
|
||||
__slots__ = ("_d",)
|
||||
|
||||
def __init__(self, d: dict[str, Any]) -> None:
|
||||
self._d = d
|
||||
|
||||
def model_dump(self, *, exclude_none: bool = False) -> dict[str, Any]: # noqa: ARG002
|
||||
return self._d
|
||||
|
||||
|
||||
class _PatchedRunAgentInput:
|
||||
"""Thin wrapper around ``RunAgentInput`` that substitutes a pre-normalised
|
||||
message list while forwarding all other attribute access to the original.
|
||||
|
||||
``AGUIStream.dispatch`` and ``run_stream`` read ``incoming.messages``,
|
||||
``incoming.tools``, ``incoming.thread_id``, ``incoming.run_id``,
|
||||
``incoming.state``, ``incoming.context``, and ``incoming.forwarded_props``
|
||||
(plus optionally ``incoming.resume``). We override only ``messages``; all
|
||||
others fall through to the real ``RunAgentInput`` object.
|
||||
"""
|
||||
|
||||
__slots__ = ("_real", "_messages")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
real: RunAgentInput,
|
||||
normalised_dicts: list[dict[str, Any]],
|
||||
) -> None:
|
||||
object.__setattr__(self, "_real", real)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"_messages",
|
||||
[_DictMessage(d) for d in normalised_dicts],
|
||||
)
|
||||
|
||||
@property
|
||||
def messages(self) -> list[_DictMessage]:
|
||||
return object.__getattribute__(self, "_messages")
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(object.__getattribute__(self, "_real"), name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"NormalizingAGUIStream",
|
||||
"normalize_messages_for_autogen",
|
||||
]
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Per-request context capture for AG2 showcase backends.
|
||||
|
||||
Problem
|
||||
-------
|
||||
The AG2 showcase backends construct a single module-level
|
||||
``ConversableAgent`` and re-use it across every inbound request (see
|
||||
``agents/agent.py`` and ``agents/a2ui_dynamic.py``). Autogen mutates the
|
||||
agent's ``chat_messages`` dict in place per turn, which means reading
|
||||
"the latest user message" off ``agent.chat_messages`` is a cross-request
|
||||
data race under any concurrency: a second request landing while the
|
||||
first is still mid-tool-call observes the first request's messages.
|
||||
|
||||
The R2-A3 fix-cycle resolves this by reading the latest user prompt
|
||||
directly from the per-request ``RunAgentInput.messages`` payload (the
|
||||
runtime-supplied per-request body) instead of from autogen's shared
|
||||
``chat_messages`` state. This module captures that payload at the HTTP
|
||||
request boundary and exposes it via a ``contextvars.ContextVar`` so deep
|
||||
tool-handler code (e.g. ``generate_a2ui``) can read it without threading
|
||||
parameters through autogen's internal driver.
|
||||
|
||||
Mechanics
|
||||
---------
|
||||
1. ``RequestUserMessageMiddleware`` (Starlette/FastAPI ``BaseHTTPMiddleware``)
|
||||
runs on every inbound POST. It reads the body (Starlette caches the
|
||||
body internally so downstream handlers still see it), parses
|
||||
``RunAgentInput.messages`` from the JSON payload, walks the list in
|
||||
chronological order, and stores the most recent ``role == "user"``
|
||||
message text in a per-request ``ContextVar``.
|
||||
2. ``get_latest_user_message()`` returns the captured text (or ``""``).
|
||||
|
||||
Failures are intentionally NON-fatal: any parse error (non-JSON body,
|
||||
missing ``messages``, schema drift, etc.) is logged at WARNING with the
|
||||
exception type/message, and the ContextVar is set to ``""`` so callers
|
||||
fall back to their hardcoded default. This is the R2-A2 fix discipline:
|
||||
visibility into the fallback path rather than silent swallowing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_latest_user_message: contextvars.ContextVar[str] = contextvars.ContextVar(
|
||||
"ag2_latest_user_message",
|
||||
default="",
|
||||
)
|
||||
|
||||
|
||||
def get_latest_user_message() -> str:
|
||||
"""Return the latest user message text captured for the current request.
|
||||
|
||||
Returns ``""`` when no message was captured (non-AG-UI request, parse
|
||||
failure, empty ``messages`` array, an actually-empty user message,
|
||||
etc.) — callers should treat the empty string as "fall back to the
|
||||
hardcoded default prompt". The distinction between "user message
|
||||
present but empty" and "no user message in payload" is preserved at
|
||||
the ``_extract_latest_user_text`` boundary via ``Optional[str]`` but
|
||||
collapsed at the ContextVar boundary since downstream callers all
|
||||
fall back the same way.
|
||||
"""
|
||||
return _latest_user_message.get()
|
||||
|
||||
|
||||
def _extract_latest_user_text(payload: Any) -> Optional[str]:
|
||||
"""Walk a parsed ``RunAgentInput``-shaped dict for the last user message.
|
||||
|
||||
Iterates ``payload["messages"]`` in chronological order (the AG-UI
|
||||
contract: the runtime sends the conversation history in order) and
|
||||
returns the ``content`` of the last entry whose ``role == "user"``.
|
||||
|
||||
Return semantics:
|
||||
* ``None`` — no user message present in the payload at all
|
||||
(non-dict payload, missing/empty ``messages`` list, no entry
|
||||
with ``role == "user"``, or every user entry had an
|
||||
unrecognised content shape).
|
||||
* ``""`` — a user message IS present but its content is the
|
||||
empty string (legitimate empty turn from the runtime).
|
||||
* non-empty ``str`` — the actual latest user text.
|
||||
|
||||
Distinguishing ``None`` from ``""`` lets the caller decide whether
|
||||
to log "missing" vs "present but empty"; collapsing them at this
|
||||
boundary would force a guess. Schema-drift early-returns log at
|
||||
WARNING here (rather than via the caller wrapping in try/except)
|
||||
because no exception is raised — there's nothing for the caller to
|
||||
catch.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning(
|
||||
"[ag2:request-context] payload is not a dict (got %s); "
|
||||
"no user message extractable",
|
||||
type(payload).__name__,
|
||||
)
|
||||
return None
|
||||
messages = payload.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
logger.warning(
|
||||
"[ag2:request-context] payload.messages missing or not a list "
|
||||
"(got %s); no user message extractable",
|
||||
type(messages).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
latest: Optional[str] = None
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
# Present-but-empty is a legitimate value; set unconditionally
|
||||
# so the caller can distinguish "" (empty turn) from None
|
||||
# (no user message at all).
|
||||
latest = content
|
||||
elif isinstance(content, list):
|
||||
# Multimodal content: join the text parts, mirroring the
|
||||
# coercion in reasoning_agent._coerce_content. An empty
|
||||
# parts list collapses to "" — still "present but empty".
|
||||
parts: list[str] = []
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
text = part.get("text")
|
||||
elif hasattr(part, "text"):
|
||||
text = getattr(part, "text", None)
|
||||
else:
|
||||
text = None
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
latest = "".join(parts)
|
||||
# Unknown content shapes (None, int, …) leave ``latest`` untouched
|
||||
# so a later well-formed user message still wins.
|
||||
|
||||
if latest is None:
|
||||
logger.warning(
|
||||
"[ag2:request-context] no user message found in payload "
|
||||
"(messages len=%d); leaving latest-user-message empty",
|
||||
len(messages),
|
||||
)
|
||||
elif latest == "":
|
||||
logger.warning("[ag2:request-context] latest user message is present but empty")
|
||||
return latest
|
||||
|
||||
|
||||
class RequestUserMessageMiddleware:
|
||||
"""Capture the latest user message from each inbound ``RunAgentInput`` POST.
|
||||
|
||||
Implemented as a raw ASGI middleware (NOT
|
||||
``starlette.middleware.base.BaseHTTPMiddleware``) so we can buffer the
|
||||
inbound request body and replay it to the downstream ASGI app via a
|
||||
wrapped ``receive`` callable. ``BaseHTTPMiddleware`` does not re-emit
|
||||
consumed body chunks to the inner app, which would silently truncate
|
||||
the request to autogen / AG-UI.
|
||||
|
||||
For POST requests with a JSON-ish body, parses ``RunAgentInput.messages``
|
||||
and stores the chronologically last ``role == "user"`` message in a
|
||||
per-request ContextVar. Non-POST requests and non-HTTP scopes pass
|
||||
through untouched. Parse failures are logged at WARNING (R2-A2
|
||||
visibility) and leave the ContextVar at its empty-string default.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
# R5-A2: Unconditionally reset the ContextVar at __call__ entry,
|
||||
# BEFORE any branching by scope type or method. Autogen's
|
||||
# ``install_executor_contextvar_propagation`` makes
|
||||
# ``ThreadPoolExecutor`` workers inherit the dispatching request's
|
||||
# Context, and those workers are reused across requests. Without
|
||||
# this reset, a non-POST request, an empty-body POST, or any path
|
||||
# that doesn't reach the body-parse ``.set(...)`` below would
|
||||
# inherit whatever value the worker's prior request left in the
|
||||
# ContextVar — leaking the previous request's prompt into this
|
||||
# one. The body-parse path further down overrides this default
|
||||
# when a real user message is parsed.
|
||||
_latest_user_message.set("")
|
||||
|
||||
if scope["type"] != "http" or scope.get("method") != "POST":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
# Buffer the entire request body so we can both inspect it AND
|
||||
# replay it to the inner ASGI app via a wrapped ``receive``.
|
||||
body_chunks: list[bytes] = []
|
||||
more_body = True
|
||||
client_disconnected = False
|
||||
while more_body:
|
||||
message = await receive()
|
||||
if message["type"] == "http.request":
|
||||
body_chunks.append(message.get("body", b"") or b"")
|
||||
more_body = bool(message.get("more_body", False))
|
||||
elif message["type"] == "http.disconnect":
|
||||
# Client hung up before the body fully arrived. Do NOT
|
||||
# invoke the downstream app with a truncated body: that
|
||||
# would feed autogen / AG-UI half a JSON document and
|
||||
# surface as a confusing parse error in the agent rather
|
||||
# than the actual root cause. Short-circuit instead and
|
||||
# log so the truncation is visible in the operator
|
||||
# dashboard.
|
||||
client_disconnected = True
|
||||
more_body = False
|
||||
else:
|
||||
# Unknown message kind for an HTTP scope — pass it
|
||||
# through unchanged and stop buffering.
|
||||
more_body = False
|
||||
|
||||
raw = b"".join(body_chunks)
|
||||
|
||||
if client_disconnected:
|
||||
logger.warning(
|
||||
"[ag2:request-context] client disconnected before request "
|
||||
"body fully received (%d bytes buffered); short-circuiting "
|
||||
"without invoking downstream app",
|
||||
len(raw),
|
||||
)
|
||||
return
|
||||
|
||||
if raw:
|
||||
# NOTE: ``_extract_latest_user_text`` itself does NOT raise
|
||||
# on shape violations — it logs at WARNING and returns
|
||||
# ``None``. The try/except here is strictly for decoding
|
||||
# failures (``json.loads`` / UTF-8). A previous version
|
||||
# wrapped a broader ``(AttributeError, KeyError, TypeError)``
|
||||
# branch around the extractor call, but the extractor never
|
||||
# raises those — so the branch was dead code that hid the
|
||||
# real source of any shape-drift signal. The extractor now
|
||||
# owns its own logging on those paths.
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning(
|
||||
"[ag2:request-context] body is not valid JSON; "
|
||||
"leaving latest-user-message empty: %s",
|
||||
exc,
|
||||
)
|
||||
_latest_user_message.set("")
|
||||
except UnicodeDecodeError as exc:
|
||||
# ``json.loads`` accepts ``bytes`` and decodes them as
|
||||
# UTF-8 internally; a non-UTF-8 payload (rare but
|
||||
# possible from a misbehaving client) raises
|
||||
# ``UnicodeDecodeError`` rather than ``JSONDecodeError``.
|
||||
# Without this branch the exception escapes and crashes
|
||||
# the request silently from the operator's perspective.
|
||||
logger.warning(
|
||||
"[ag2:request-context] body is not valid UTF-8; "
|
||||
"leaving latest-user-message empty: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
_latest_user_message.set("")
|
||||
else:
|
||||
text = _extract_latest_user_text(payload)
|
||||
# Collapse None → "" at the ContextVar boundary: callers
|
||||
# all fall back to the hardcoded default the same way,
|
||||
# so the present-but-empty vs missing distinction has
|
||||
# already done its job via the extractor's WARNING logs.
|
||||
_latest_user_message.set(text if text is not None else "")
|
||||
|
||||
replayed = False
|
||||
original_receive = receive
|
||||
|
||||
async def _replay_receive() -> Message:
|
||||
nonlocal replayed
|
||||
if not replayed:
|
||||
replayed = True
|
||||
return {
|
||||
"type": "http.request",
|
||||
"body": raw,
|
||||
"more_body": False,
|
||||
}
|
||||
# R7-A1: After the buffered body is delivered once, the inner
|
||||
# app may keep calling ``receive()`` for the lifetime of the
|
||||
# response — SSE / AG-UI streams in particular poll
|
||||
# ``receive()`` (via Starlette's ``listen_for_disconnect``) to
|
||||
# detect client disconnect. Per the ASGI spec, ANY
|
||||
# ``http.disconnect`` message terminates the response stream:
|
||||
# an earlier revision synthesised a single disconnect
|
||||
# immediately after body drain and that one synthesised
|
||||
# message was enough to cancel the SSE response prematurely.
|
||||
# The correct behaviour is to NEVER synthesise disconnect
|
||||
# post-drain and instead await ``original_receive()``, which
|
||||
# uvicorn blocks on until the REAL client ``http.disconnect``
|
||||
# arrives. That is precisely the long-poll semantics SSE /
|
||||
# AG-UI streams require.
|
||||
message = await original_receive()
|
||||
# Defensive: uvicorn should not deliver further
|
||||
# ``http.request`` messages after the body is drained (the
|
||||
# buffering loop above consumed every chunk until
|
||||
# ``more_body=False``), but the ASGI spec is not strictly
|
||||
# enforced by every server. Log and continue awaiting so the
|
||||
# inner app only ever observes ``http.disconnect`` (or other
|
||||
# legitimate post-body messages) on this code path.
|
||||
while message.get("type") == "http.request":
|
||||
logger.warning(
|
||||
"[ag2:request-context] unexpected http.request after "
|
||||
"body drain (more_body=%s, body_len=%d); ignoring and "
|
||||
"awaiting real disconnect",
|
||||
message.get("more_body"),
|
||||
len(message.get("body", b"") or b""),
|
||||
)
|
||||
message = await original_receive()
|
||||
return message
|
||||
|
||||
await self.app(scope, _replay_receive, send)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""AG2 agent for the Declarative Generative UI (A2UI Dynamic Schema) demo.
|
||||
|
||||
Mirrors the langgraph-python `a2ui_dynamic.py` pattern: the agent owns the
|
||||
`generate_a2ui` tool explicitly. When called, it invokes a secondary LLM
|
||||
bound to `render_a2ui` (tool_choice forced) using the registered client
|
||||
catalog injected via the runtime's `copilotkit.context`. The tool result
|
||||
returns an `a2ui_operations` container which the runtime's A2UI middleware
|
||||
detects and forwards to the frontend renderer.
|
||||
|
||||
The dedicated runtime route (`api/copilotkit-declarative-gen-ui/route.ts`)
|
||||
sets `injectA2UITool: false` so the runtime does not double-bind a second
|
||||
A2UI tool on top of this one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
import openai
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream # type: ignore[import-not-found] # runtime-only submodule (ag2[ag-ui] extra); not present in static type stubs
|
||||
from fastapi import FastAPI
|
||||
from openai.types.chat import ChatCompletionFunctionToolParam
|
||||
from openai.types.shared_params import FunctionDefinition
|
||||
|
||||
from tools import (
|
||||
build_a2ui_operations_from_tool_call,
|
||||
RENDER_A2UI_TOOL_SCHEMA,
|
||||
)
|
||||
|
||||
from ._header_forwarding import get_forwarded_headers
|
||||
from ._request_context import get_latest_user_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level async client: re-used across requests (httpx connection pool is
|
||||
# thread-safe). Using AsyncOpenAI inside an `async def` avoids blocking the
|
||||
# ASGI event loop on the secondary LLM call.
|
||||
_async_openai_client = openai.AsyncOpenAI()
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a demo assistant for Declarative Generative UI (A2UI — Dynamic "
|
||||
"Schema). Whenever a response would benefit from a rich visual — a "
|
||||
"dashboard, status report, KPI summary, card layout, info grid, a "
|
||||
"pie/donut chart of part-of-whole breakdowns, a bar chart comparing "
|
||||
"values across categories, or anything more structured than plain text — "
|
||||
"call `generate_a2ui` to draw it. The registered catalog includes "
|
||||
"`Card`, `StatusBadge`, `Metric`, `InfoRow`, `PrimaryButton`, `PieChart`, "
|
||||
"and `BarChart` (in addition to the basic A2UI primitives). Prefer "
|
||||
"`PieChart` for part-of-whole breakdowns (sales by region, traffic "
|
||||
"sources, portfolio allocation) and `BarChart` for comparisons across "
|
||||
"categories (quarterly revenue, headcount by team, signups per month). "
|
||||
"`generate_a2ui` takes no arguments and handles the rendering "
|
||||
"automatically. Keep chat replies to one short sentence; let the UI do "
|
||||
"the talking."
|
||||
)
|
||||
|
||||
|
||||
async def generate_a2ui() -> str:
|
||||
"""Generate dynamic A2UI components based on the conversation.
|
||||
|
||||
Takes NO arguments. The outer agent calls this tool with empty
|
||||
arguments (``{}``); the per-request user prompt is read from the
|
||||
``RequestUserMessageMiddleware`` ContextVar (see ``_request_context``)
|
||||
rather than threaded through a tool parameter. This mirrors the
|
||||
langgraph-python sibling, whose ``generate_a2ui`` also takes no args
|
||||
(``a2ui_dynamic.py``), and keeps the tool schema aligned with the D6
|
||||
fixtures, which emit ``generate_a2ui`` with ``arguments="{}"``. A
|
||||
required ``context`` parameter here would make pydantic reject every
|
||||
empty-args call and drive the outer agent into a retry hot loop.
|
||||
|
||||
A secondary LLM designs the UI schema and data using the `render_a2ui`
|
||||
tool schema. The result is returned as an `a2ui_operations` container
|
||||
for the runtime A2UI middleware to detect and forward to the frontend.
|
||||
"""
|
||||
# A4 / R2-A3: thread the latest user prompt from the outer conversation
|
||||
# into the inner call so each pill's request body is byte-distinct
|
||||
# (without this, all 4 declarative pills produce IDENTICAL wire payloads
|
||||
# because the outer agent calls generate_a2ui with arguments="{}" →
|
||||
# context defaults → system message is constant, and the user message
|
||||
# below is hardcoded).
|
||||
#
|
||||
# The prompt is read from a per-request ContextVar populated by
|
||||
# ``RequestUserMessageMiddleware`` at the inbound HTTP boundary — NOT
|
||||
# from ``agent.chat_messages`` (which is shared module-level mutable
|
||||
# state racing across concurrent requests). If the middleware did not
|
||||
# capture anything (non-AG-UI request, parse failure already logged at
|
||||
# WARNING) we fall back to the original hardcoded prompt so the inner
|
||||
# LLM call still produces a sensible default.
|
||||
user_prompt = get_latest_user_message() or (
|
||||
"Generate a dynamic A2UI dashboard based on the conversation."
|
||||
)
|
||||
# The inner-call system message is constant; per-pill distinctness comes
|
||||
# from ``user_prompt`` above (the outer conversation's latest user
|
||||
# message, captured per-request). Previously this was the outer agent's
|
||||
# ``context`` tool argument, but the outer agent calls ``generate_a2ui``
|
||||
# with empty args ``{}`` (see the no-arg signature + the D6 fixtures),
|
||||
# so a required ``context`` param only produced a pydantic hot loop.
|
||||
inner_system_prompt = "Generate a useful dashboard UI."
|
||||
# A13: forward inbound x-* headers via extra_headers as a defense in depth
|
||||
# alongside the global httpx hook (see _header_forwarding.py). The hook
|
||||
# patches httpx at module load, but extra_headers makes the intent
|
||||
# explicit at the call site and is robust to alternative HTTP transports.
|
||||
forwarded = get_forwarded_headers()
|
||||
try:
|
||||
response = await _async_openai_client.chat.completions.create(
|
||||
model="gpt-4.1",
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": inner_system_prompt,
|
||||
},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
tools=[
|
||||
ChatCompletionFunctionToolParam(
|
||||
type="function",
|
||||
# RENDER_A2UI_TOOL_SCHEMA is an untyped dict literal that
|
||||
# conforms to the OpenAI FunctionDefinition TypedDict shape;
|
||||
# cast so the type checker accepts it (no runtime change).
|
||||
function=cast(FunctionDefinition, RENDER_A2UI_TOOL_SCHEMA),
|
||||
)
|
||||
],
|
||||
tool_choice={"type": "function", "function": {"name": "render_a2ui"}},
|
||||
extra_headers=forwarded or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"generate_a2ui: inner LLM call failed type=%s err=%s",
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return json.dumps({"error": f"inner LLM call failed: {type(exc).__name__}"})
|
||||
|
||||
if not response.choices:
|
||||
logger.warning("generate_a2ui: LLM returned no choices")
|
||||
return json.dumps({"error": "LLM returned no choices"})
|
||||
|
||||
choice = response.choices[0]
|
||||
if not choice.message.tool_calls:
|
||||
logger.warning("generate_a2ui: secondary LLM produced no render_a2ui tool call")
|
||||
return json.dumps({"error": "LLM did not call render_a2ui"})
|
||||
|
||||
# tool_calls is a union of function- and custom-tool calls; only the
|
||||
# function variant carries `.function`. `tool_choice` above forces the
|
||||
# `render_a2ui` FUNCTION tool, so the first call is always the function
|
||||
# variant at runtime — narrow on `.type` to make that explicit to the type
|
||||
# checker (and degrade gracefully to the same error shape if it ever isn't).
|
||||
first_call = choice.message.tool_calls[0]
|
||||
if first_call.type != "function":
|
||||
logger.warning(
|
||||
"generate_a2ui: secondary LLM returned non-function tool call type=%s",
|
||||
first_call.type,
|
||||
)
|
||||
return json.dumps({"error": "LLM did not call render_a2ui"})
|
||||
|
||||
try:
|
||||
args = json.loads(first_call.function.arguments)
|
||||
result = build_a2ui_operations_from_tool_call(args)
|
||||
return json.dumps(result)
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
||||
logger.error(
|
||||
"generate_a2ui: failed to parse render_a2ui args type=%s err=%s",
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return json.dumps(
|
||||
{"error": f"failed to parse render_a2ui args: {type(exc).__name__}"}
|
||||
)
|
||||
|
||||
|
||||
agent = ConversableAgent(
|
||||
name="declarative_gen_ui_assistant",
|
||||
system_message=SYSTEM_PROMPT,
|
||||
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=8,
|
||||
functions=[generate_a2ui],
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
a2ui_dynamic_app = FastAPI()
|
||||
a2ui_dynamic_app.mount("", stream.build_asgi())
|
||||
@@ -0,0 +1,112 @@
|
||||
"""AG2 agent for the Declarative Generative UI (A2UI Fixed Schema) demo.
|
||||
|
||||
Fixed-schema A2UI: the component tree (schema) is authored ahead of time
|
||||
as JSON and shipped with the backend. The agent only streams *data* into
|
||||
the data model at runtime via the `display_flight` tool. The frontend
|
||||
registers a matching catalog (see
|
||||
`src/app/demos/a2ui-fixed-schema/a2ui/catalog.ts`).
|
||||
|
||||
Mirrors the langgraph-python `a2ui_fixed.py` reference. The dedicated
|
||||
runtime route at `api/copilotkit-a2ui-fixed-schema/route.ts` runs the
|
||||
A2UI middleware with `injectA2UITool: false` because the backend owns
|
||||
the rendering tool itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
CATALOG_ID = "copilotkit://flight-fixed-catalog"
|
||||
SURFACE_ID = "flight-fixed-schema"
|
||||
|
||||
_SCHEMAS_DIR = Path(__file__).parent / "a2ui_schemas"
|
||||
|
||||
|
||||
def _load_schema(filename: str) -> list[dict]:
|
||||
"""Load an A2UI fixed schema from the local schemas directory."""
|
||||
with open(_SCHEMAS_DIR / filename, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
FLIGHT_SCHEMA = _load_schema("flight_schema.json")
|
||||
|
||||
|
||||
async def display_flight(
|
||||
origin: Annotated[str, "Origin airport code, e.g. 'SFO'"],
|
||||
destination: Annotated[str, "Destination airport code, e.g. 'JFK'"],
|
||||
airline: Annotated[str, "Airline name, e.g. 'United'"],
|
||||
price: Annotated[str, "Price string, e.g. '$289'"],
|
||||
) -> str:
|
||||
"""Show a flight card for the given trip.
|
||||
|
||||
Emits an `a2ui_operations` container the runtime A2UI middleware
|
||||
detects in tool results and forwards to the frontend renderer. The
|
||||
frontend catalog resolves component names against the local React
|
||||
components.
|
||||
"""
|
||||
# A2UI v0.9 NESTED operation format (createSurface/updateComponents/
|
||||
# updateDataModel keys) — the runtime A2UI middleware's
|
||||
# getOperationSurfaceId and the frontend renderer only understand this
|
||||
# shape (mirrors copilotkit.a2ui helpers in sdk-python/copilotkit/a2ui.py).
|
||||
# The previous flat {"type": "create_surface", ...} form parsed as a
|
||||
# container but produced ops the renderer could not apply, so the
|
||||
# a2ui-fixed-card never mounted.
|
||||
operations = [
|
||||
{
|
||||
"version": "v0.9",
|
||||
"createSurface": {
|
||||
"surfaceId": SURFACE_ID,
|
||||
"catalogId": CATALOG_ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
"version": "v0.9",
|
||||
"updateComponents": {
|
||||
"surfaceId": SURFACE_ID,
|
||||
"components": FLIGHT_SCHEMA,
|
||||
},
|
||||
},
|
||||
{
|
||||
"version": "v0.9",
|
||||
"updateDataModel": {
|
||||
"surfaceId": SURFACE_ID,
|
||||
"path": "/",
|
||||
"value": {
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"airline": airline,
|
||||
"price": price,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
return json.dumps({"a2ui_operations": operations})
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You help users find flights. When asked about a flight, call "
|
||||
"display_flight with origin (3-letter code), destination (3-letter "
|
||||
"code), airline, and price (e.g. '$289'). Keep any chat reply to one "
|
||||
"short sentence."
|
||||
)
|
||||
|
||||
|
||||
agent = ConversableAgent(
|
||||
name="a2ui_fixed_assistant",
|
||||
system_message=SYSTEM_PROMPT,
|
||||
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=4,
|
||||
functions=[display_flight],
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
a2ui_fixed_app = FastAPI()
|
||||
a2ui_fixed_app.mount("", stream.build_asgi())
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"id": "root",
|
||||
"component": "Column",
|
||||
"gap": 8,
|
||||
"children": ["title", "detail"]
|
||||
},
|
||||
{
|
||||
"id": "title",
|
||||
"component": "Text",
|
||||
"text": { "path": "/title" },
|
||||
"variant": "h2"
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"component": "Text",
|
||||
"text": { "path": "/detail" },
|
||||
"variant": "body"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
[
|
||||
{
|
||||
"id": "root",
|
||||
"component": "Card",
|
||||
"child": "content"
|
||||
},
|
||||
{
|
||||
"id": "content",
|
||||
"component": "Column",
|
||||
"children": ["title", "route", "meta", "bookButton"]
|
||||
},
|
||||
{
|
||||
"id": "title",
|
||||
"component": "Title",
|
||||
"text": "Flight Details"
|
||||
},
|
||||
{
|
||||
"id": "route",
|
||||
"component": "Row",
|
||||
"justify": "spaceBetween",
|
||||
"align": "center",
|
||||
"children": ["from", "arrow", "to"]
|
||||
},
|
||||
{
|
||||
"id": "from",
|
||||
"component": "Airport",
|
||||
"code": { "path": "/origin" }
|
||||
},
|
||||
{
|
||||
"id": "arrow",
|
||||
"component": "Arrow"
|
||||
},
|
||||
{
|
||||
"id": "to",
|
||||
"component": "Airport",
|
||||
"code": { "path": "/destination" }
|
||||
},
|
||||
{
|
||||
"id": "meta",
|
||||
"component": "Row",
|
||||
"justify": "spaceBetween",
|
||||
"align": "center",
|
||||
"children": ["airline", "price"]
|
||||
},
|
||||
{
|
||||
"id": "airline",
|
||||
"component": "AirlineBadge",
|
||||
"name": { "path": "/airline" }
|
||||
},
|
||||
{
|
||||
"id": "price",
|
||||
"component": "PriceTag",
|
||||
"amount": { "path": "/price" }
|
||||
},
|
||||
{
|
||||
"id": "bookButton",
|
||||
"component": "Button",
|
||||
"variant": "primary",
|
||||
"child": "bookButtonLabel",
|
||||
"action": {
|
||||
"event": {
|
||||
"name": "book_flight",
|
||||
"context": {
|
||||
"origin": { "path": "/origin" },
|
||||
"destination": { "path": "/destination" },
|
||||
"airline": { "path": "/airline" },
|
||||
"price": { "path": "/price" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bookButtonLabel",
|
||||
"component": "Text",
|
||||
"text": "Book flight"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
AG2 agent with weather and sales tools for CopilotKit showcase.
|
||||
|
||||
Uses AG2's ConversableAgent with AGUIStream to expose
|
||||
the agent via the AG-UI protocol.
|
||||
"""
|
||||
|
||||
# @region[weather-tool-backend]
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
import openai
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import ValidationError
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Import shared tool implementations
|
||||
from tools import (
|
||||
get_weather_impl,
|
||||
query_data_impl,
|
||||
manage_sales_todos_impl,
|
||||
get_sales_todos_impl,
|
||||
schedule_meeting_impl,
|
||||
search_flights_impl,
|
||||
build_a2ui_operations_from_tool_call,
|
||||
RENDER_A2UI_TOOL_SCHEMA,
|
||||
)
|
||||
from tools.types import Flight
|
||||
|
||||
from ._header_forwarding import get_forwarded_headers
|
||||
from ._request_context import get_latest_user_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level async client: re-used across requests (httpx connection pool is
|
||||
# thread-safe). Using AsyncOpenAI inside an `async def` avoids blocking the
|
||||
# ASGI event loop on the secondary LLM call.
|
||||
_async_openai_client = openai.AsyncOpenAI()
|
||||
|
||||
|
||||
# =====
|
||||
# Tools
|
||||
# =====
|
||||
async def get_weather(
|
||||
location: Annotated[str, "City name to get weather for"],
|
||||
) -> str:
|
||||
"""Get current weather for a location."""
|
||||
result = get_weather_impl(location)
|
||||
# Return a JSON string (not a dict): autogen serializes dict returns with
|
||||
# str(), producing a Python repr (single quotes) that the frontend's
|
||||
# parseJsonResult/JSON.parse cannot parse — the weather card then renders
|
||||
# "--" placeholders. Same pattern as search_flights below.
|
||||
return json.dumps(
|
||||
{
|
||||
"city": result["city"],
|
||||
"temperature": result["temperature"],
|
||||
"feels_like": result["feels_like"],
|
||||
"humidity": result["humidity"],
|
||||
"wind_speed": result["wind_speed"],
|
||||
"conditions": result["conditions"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# @endregion[weather-tool-backend]
|
||||
|
||||
|
||||
async def query_data(
|
||||
query: Annotated[str, "Natural language query for financial data"],
|
||||
) -> str:
|
||||
"""Query financial database for chart data."""
|
||||
# Return a JSON string (not a list): autogen serializes non-str returns
|
||||
# with str(), producing a Python repr (single quotes) that the frontend's
|
||||
# parseJsonResult/JSON.parse cannot parse. Same pattern as get_weather.
|
||||
return json.dumps(query_data_impl(query))
|
||||
|
||||
|
||||
async def manage_sales_todos(
|
||||
todos: Annotated[list, "Complete list of sales todos"],
|
||||
) -> str:
|
||||
"""Manage the sales pipeline."""
|
||||
# See contract comment on query_data above — return JSON, not dict.
|
||||
# SalesTodo is a Pydantic model; coerce via model_dump for serialisability.
|
||||
result = [t.model_dump() for t in manage_sales_todos_impl(todos)]
|
||||
return json.dumps({"todos": result})
|
||||
|
||||
|
||||
async def get_sales_todos() -> str:
|
||||
"""Get the current sales pipeline."""
|
||||
# See contract comment on query_data above — return JSON, not list.
|
||||
# SalesTodo is a Pydantic model; coerce via model_dump for serialisability.
|
||||
return json.dumps([t.model_dump() for t in get_sales_todos_impl(None)])
|
||||
|
||||
|
||||
async def schedule_meeting(
|
||||
reason: Annotated[str, "Reason for the meeting"],
|
||||
) -> str:
|
||||
"""Schedule a meeting with user approval."""
|
||||
# See contract comment on query_data above — return JSON, not dict.
|
||||
return json.dumps(schedule_meeting_impl(reason))
|
||||
|
||||
|
||||
async def search_flights(
|
||||
flights: Annotated[
|
||||
list[dict[str, Any]], "List of flight objects to display as rich A2UI cards"
|
||||
],
|
||||
) -> str:
|
||||
"""Search for flights and display the results as rich cards. Return exactly 2 flights.
|
||||
|
||||
Each flight must have: airline, airlineLogo, 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"),
|
||||
statusColor (hex color for status dot),
|
||||
price (e.g. "$289"), and currency (e.g. "USD").
|
||||
|
||||
For airlineLogo use Google favicon API:
|
||||
https://www.google.com/s2/favicons?domain={airline_domain}&sz=128
|
||||
"""
|
||||
try:
|
||||
typed_flights: list[Flight] = [Flight(**f) for f in flights]
|
||||
except ValidationError as exc:
|
||||
logger.warning(
|
||||
"search_flights: invalid flight shape type=%s err=%s",
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return json.dumps({"error": f"invalid flight shape: {exc}"})
|
||||
result = search_flights_impl(typed_flights)
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
async def generate_a2ui(
|
||||
context: Annotated[str, "Conversation context to generate UI for"],
|
||||
) -> str:
|
||||
"""Generate dynamic A2UI components based on the conversation.
|
||||
|
||||
A secondary LLM designs the UI schema and data. The result is
|
||||
returned as an a2ui_operations container for the middleware to detect.
|
||||
"""
|
||||
# A13: AsyncOpenAI inside async def (was sync openai.OpenAI which blocks
|
||||
# the ASGI event loop). Forward x-* headers via extra_headers in addition
|
||||
# to the global httpx hook so aimock context routing is explicit at the
|
||||
# call site.
|
||||
#
|
||||
# R2-A1 / A4: thread the latest user prompt from the inbound
|
||||
# RunAgentInput.messages payload (captured into a per-request ContextVar
|
||||
# by RequestUserMessageMiddleware — see agents/_request_context.py) into
|
||||
# the inner LLM call so each pill's request body is byte-distinct.
|
||||
# Without this, every pill landing on the omnibus agent (agentic-chat /
|
||||
# tool-rendering / chat-customization-css / hitl) produces an IDENTICAL
|
||||
# inner-LLM body and the aimock fixture cannot disambiguate. Falls back
|
||||
# to the original hardcoded prompt when the middleware captured nothing
|
||||
# (parse failure already logged at WARNING).
|
||||
user_prompt = get_latest_user_message() or (
|
||||
"Generate a dynamic A2UI dashboard based on the conversation."
|
||||
)
|
||||
forwarded = get_forwarded_headers()
|
||||
try:
|
||||
response = await _async_openai_client.chat.completions.create(
|
||||
model="gpt-4.1",
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": context or "Generate a useful dashboard UI.",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": user_prompt,
|
||||
},
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": RENDER_A2UI_TOOL_SCHEMA,
|
||||
}
|
||||
],
|
||||
tool_choice={"type": "function", "function": {"name": "render_a2ui"}},
|
||||
extra_headers=forwarded or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"generate_a2ui: inner LLM call failed type=%s err=%s",
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return json.dumps({"error": f"inner LLM call failed: {type(exc).__name__}"})
|
||||
|
||||
if not response.choices:
|
||||
logger.warning("generate_a2ui: LLM returned no choices")
|
||||
return json.dumps({"error": "LLM returned no choices"})
|
||||
|
||||
choice = response.choices[0]
|
||||
if not choice.message.tool_calls:
|
||||
logger.warning("generate_a2ui: secondary LLM produced no render_a2ui tool call")
|
||||
return json.dumps({"error": "LLM did not call render_a2ui"})
|
||||
|
||||
try:
|
||||
args = json.loads(choice.message.tool_calls[0].function.arguments)
|
||||
result = build_a2ui_operations_from_tool_call(args)
|
||||
return json.dumps(result)
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
||||
logger.error(
|
||||
"generate_a2ui: failed to parse render_a2ui args type=%s err=%s",
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return json.dumps(
|
||||
{"error": f"failed to parse render_a2ui args: {type(exc).__name__}"}
|
||||
)
|
||||
|
||||
|
||||
# =====
|
||||
# Agent
|
||||
# =====
|
||||
agent = ConversableAgent(
|
||||
name="assistant",
|
||||
system_message=(
|
||||
"You are a helpful sales assistant. You can look up current weather "
|
||||
"for any city using the get_weather tool, query financial data with "
|
||||
"query_data, manage the sales pipeline with manage_sales_todos and "
|
||||
"get_sales_todos, schedule meetings with schedule_meeting, search "
|
||||
"flights and display rich A2UI cards with search_flights, and "
|
||||
"generate dynamic A2UI dashboards with generate_a2ui. "
|
||||
"When asked about the weather, always use the tool rather than guessing. "
|
||||
"Be concise and friendly in your responses."
|
||||
),
|
||||
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
# Guard against infinite tool-call loops: AG2's ConversableAgent with
|
||||
# human_input_mode="NEVER" will keep executing tool calls indefinitely
|
||||
# if the LLM keeps requesting them. Without this limit the agent floods
|
||||
# Railway's log stream (500 logs/sec rate-limit), becomes unresponsive
|
||||
# to health probes, and gets killed by the watchdog.
|
||||
max_consecutive_auto_reply=15,
|
||||
functions=[
|
||||
get_weather,
|
||||
query_data,
|
||||
manage_sales_todos,
|
||||
get_sales_todos,
|
||||
schedule_meeting,
|
||||
search_flights,
|
||||
generate_a2ui,
|
||||
],
|
||||
)
|
||||
|
||||
# AG-UI stream wrapper
|
||||
stream = AGUIStream(agent)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""AG2 agent backing the Agent Config Object demo.
|
||||
|
||||
Reads three forwarded properties — tone, expertise, responseLength — from
|
||||
shared state (ContextVariables on each run) and adapts its responses
|
||||
accordingly.
|
||||
|
||||
Wire format
|
||||
-----------
|
||||
The frontend uses `agent.setState({ tone, expertise, responseLength })` from
|
||||
the demo page. AG2's AGUIStream maps that initial state into ContextVariables
|
||||
on every run. The agent has a `get_current_config` tool that returns the
|
||||
current rulebook for the assistant to consult before answering.
|
||||
|
||||
The system prompt instructs the agent to call `get_current_config` once at
|
||||
the start of every conversation turn so the response style adapts to the
|
||||
latest UI selection.
|
||||
|
||||
References:
|
||||
- src/agents/shared_state_read_write.py — same ContextVariables pattern.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from autogen.agentchat import ContextVariables
|
||||
from autogen.tools import tool
|
||||
from fastapi import FastAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_TONES = {"professional", "casual", "enthusiastic"}
|
||||
VALID_EXPERTISE = {"beginner", "intermediate", "expert"}
|
||||
VALID_RESPONSE_LENGTHS = {"concise", "detailed"}
|
||||
|
||||
DEFAULT_TONE = "professional"
|
||||
DEFAULT_EXPERTISE = "intermediate"
|
||||
DEFAULT_RESPONSE_LENGTH = "concise"
|
||||
|
||||
TONE_RULES = {
|
||||
"professional": "Use neutral, precise language. No emoji. Short sentences.",
|
||||
"casual": (
|
||||
"Use friendly, conversational language. Contractions OK. Light humor welcome."
|
||||
),
|
||||
"enthusiastic": (
|
||||
"Use upbeat, energetic language. Exclamation points OK. Emoji OK."
|
||||
),
|
||||
}
|
||||
|
||||
EXPERTISE_RULES = {
|
||||
"beginner": "Assume no prior knowledge. Define jargon. Use analogies.",
|
||||
"intermediate": ("Assume common terms are understood; explain specialized terms."),
|
||||
"expert": ("Assume technical fluency. Use precise terminology. Skip basics."),
|
||||
}
|
||||
|
||||
LENGTH_RULES = {
|
||||
"concise": "Respond in 1-3 sentences.",
|
||||
"detailed": ("Respond in multiple paragraphs with examples where relevant."),
|
||||
}
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant whose response style is governed by a UI-"
|
||||
"supplied configuration object. Before answering ANY user question, "
|
||||
"call the `get_current_config` tool exactly once to read the latest "
|
||||
"tone / expertise / response-length rulebook. Then answer the user's "
|
||||
"question, strictly following those rules. Never mention the tool call "
|
||||
"or the configuration in your reply — just adapt your style."
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
def get_current_config(context_variables: ContextVariables) -> str:
|
||||
"""Return the current rulebook (tone / expertise / length) for the assistant.
|
||||
|
||||
Reads the forwarded ``tone``, ``expertise``, and ``responseLength``
|
||||
properties from shared state, falling back to defaults for any missing
|
||||
or unrecognized value.
|
||||
"""
|
||||
data = context_variables.data or {}
|
||||
tone = data.get("tone", DEFAULT_TONE)
|
||||
expertise = data.get("expertise", DEFAULT_EXPERTISE)
|
||||
response_length = data.get("responseLength", DEFAULT_RESPONSE_LENGTH)
|
||||
|
||||
if tone not in VALID_TONES:
|
||||
tone = DEFAULT_TONE
|
||||
if expertise not in VALID_EXPERTISE:
|
||||
expertise = DEFAULT_EXPERTISE
|
||||
if response_length not in VALID_RESPONSE_LENGTHS:
|
||||
response_length = DEFAULT_RESPONSE_LENGTH
|
||||
|
||||
return (
|
||||
f"Tone ({tone}): {TONE_RULES[tone]}\n"
|
||||
f"Expertise ({expertise}): {EXPERTISE_RULES[expertise]}\n"
|
||||
f"Response length ({response_length}): {LENGTH_RULES[response_length]}"
|
||||
)
|
||||
|
||||
|
||||
agent_config_agent = ConversableAgent(
|
||||
name="agent_config_assistant",
|
||||
system_message=SYSTEM_PROMPT,
|
||||
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=5,
|
||||
functions=[get_current_config],
|
||||
)
|
||||
|
||||
agent_config_stream = AGUIStream(agent_config_agent)
|
||||
|
||||
agent_config_app = FastAPI()
|
||||
agent_config_app.mount("/", agent_config_stream.build_asgi())
|
||||
@@ -0,0 +1,142 @@
|
||||
"""AG2 agent for the simplified Beautiful Chat demo.
|
||||
|
||||
This is a SIMPLIFIED port of the langgraph-python `beautiful_chat` graph.
|
||||
The canonical version simultaneously exercises three big features:
|
||||
|
||||
1. A2UI Dynamic Schema (a `generate_a2ui` tool whose secondary LLM emits
|
||||
schema-validated component compositions).
|
||||
2. Open Generative UI (the runtime auto-registers `generateSandboxedUi`
|
||||
on the frontend; the agent calls it for richer free-form widgets).
|
||||
3. MCP Apps (an mcpApps server is mounted on the runtime; its tools and
|
||||
UI resources are surfaced to the agent).
|
||||
|
||||
For AG2 we ship the FIRST TWO surfaces in a single cell: A2UI dynamic
|
||||
generation for branded, component-bound visuals (KPIs, dashboards, status
|
||||
reports, simple charts) AND Open Generative UI for free-form / educational
|
||||
visualisations the catalog cannot express. We deliberately leave MCP out
|
||||
to keep the AG2 port focused — `/demos/mcp-apps` already covers MCP on
|
||||
its own.
|
||||
|
||||
The agent owns `generate_a2ui` explicitly (mirroring `a2ui_dynamic.py`).
|
||||
The runtime route at `src/app/api/copilotkit-beautiful-chat/route.ts`
|
||||
sets `a2ui.injectA2UITool: false` so the runtime doesn't double-bind a
|
||||
second A2UI tool, and turns on `openGenerativeUI` for this agent so the
|
||||
runtime injects `generateSandboxedUi` on the frontend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
import openai
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from fastapi import FastAPI
|
||||
|
||||
from tools import (
|
||||
build_a2ui_operations_from_tool_call,
|
||||
RENDER_A2UI_TOOL_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are the Beautiful Chat assistant — a CopilotKit
|
||||
showcase agent that answers user questions with rich, branded visuals.
|
||||
|
||||
You have TWO complementary visual surfaces. Pick whichever fits the
|
||||
request best, but ALWAYS render something visual rather than replying
|
||||
with plain text when the question warrants it.
|
||||
|
||||
1. `generate_a2ui` — for STRUCTURED, branded visuals composed from a
|
||||
registered React catalog. Use it for:
|
||||
- KPI dashboards (Metric + Card + Row/Column layouts)
|
||||
- Status reports (StatusBadge / Card)
|
||||
- Pie charts of part-of-whole breakdowns (PieChart)
|
||||
- Bar charts comparing categories (BarChart)
|
||||
- Info panels and quick summaries
|
||||
|
||||
Pass a single `context` argument summarising the conversation; the
|
||||
secondary LLM will design the composition against the registered
|
||||
catalog (Card, StatusBadge, Metric, InfoRow, PrimaryButton,
|
||||
PieChart, BarChart, plus the basic A2UI primitives).
|
||||
|
||||
2. `generateSandboxedUi` — auto-registered by the frontend when Open
|
||||
Generative UI is enabled. Use it for FREE-FORM visualisations the
|
||||
catalog cannot express:
|
||||
- Educational visualisations (algorithm walkthroughs, neural-net
|
||||
activations, geometric proofs, physics simulations)
|
||||
- Custom illustrations / diagrams
|
||||
- Anything intricate that needs inline SVG, CSS animation, or an
|
||||
interactive sandboxed widget
|
||||
|
||||
Output `initialHeight` (typically 480-560), a short
|
||||
`placeholderMessages` array, complete `css`, then `html` with inline
|
||||
SVG. No fetch / XHR / localStorage.
|
||||
|
||||
Decision rule of thumb: if the request maps to a chart, dashboard,
|
||||
status report, or KPI summary, prefer `generate_a2ui`. If it asks for a
|
||||
diagram, animation, or anything outside the catalog's components,
|
||||
prefer `generateSandboxedUi`. Either way, keep the chat reply to one
|
||||
short sentence — let the visual do the talking.
|
||||
"""
|
||||
|
||||
|
||||
async def generate_a2ui(
|
||||
context: Annotated[
|
||||
str, "Conversation context summary the secondary LLM should design UI from"
|
||||
],
|
||||
) -> str:
|
||||
"""Generate dynamic A2UI components based on the conversation.
|
||||
|
||||
Mirrors `a2ui_dynamic.py`: a secondary LLM is bound to the
|
||||
`render_a2ui` tool with `tool_choice` forced, and the resulting
|
||||
arguments are wrapped into an `a2ui_operations` container the
|
||||
runtime A2UI middleware detects and forwards to the frontend.
|
||||
"""
|
||||
client = openai.OpenAI()
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4.1",
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": context or "Generate a useful dashboard UI.",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Generate a dynamic A2UI dashboard based on the conversation.",
|
||||
},
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": RENDER_A2UI_TOOL_SCHEMA,
|
||||
}
|
||||
],
|
||||
tool_choice={"type": "function", "function": {"name": "render_a2ui"}},
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
if choice.message.tool_calls:
|
||||
args = json.loads(choice.message.tool_calls[0].function.arguments)
|
||||
result = build_a2ui_operations_from_tool_call(args)
|
||||
return json.dumps(result)
|
||||
|
||||
return json.dumps({"error": "LLM did not call render_a2ui"})
|
||||
|
||||
|
||||
agent = ConversableAgent(
|
||||
name="beautiful_chat_assistant",
|
||||
system_message=SYSTEM_PROMPT,
|
||||
llm_config=LLMConfig({"model": "gpt-4.1", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
# The agent may call generate_a2ui (its own backend tool) and
|
||||
# generateSandboxedUi (frontend tool injected by the OGUI runtime
|
||||
# middleware). Cap the loop to keep tool storms bounded.
|
||||
max_consecutive_auto_reply=8,
|
||||
functions=[generate_a2ui],
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
beautiful_chat_app = FastAPI()
|
||||
beautiful_chat_app.mount("", stream.build_asgi())
|
||||
@@ -0,0 +1,102 @@
|
||||
"""AG2 agent backing the byoc-hashbrown demo.
|
||||
|
||||
Emits hashbrown-shaped structured output that the ported HashBrownDashboard
|
||||
renderer (`src/app/demos/byoc-hashbrown/hashbrown-renderer.tsx`) progressively
|
||||
parses via `@hashbrownai/react`'s `useJsonParser` + `useUiKit`.
|
||||
|
||||
Wire format
|
||||
-----------
|
||||
A single JSON object literal of the form:
|
||||
|
||||
{
|
||||
"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: {...}}}`. `pieChart` and
|
||||
`barChart` receive `data` as a JSON-encoded string (kept stable under partial
|
||||
streaming).
|
||||
"""
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
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}]"}}}]}
|
||||
"""
|
||||
|
||||
|
||||
byoc_hashbrown_agent = ConversableAgent(
|
||||
name="byoc_hashbrown_assistant",
|
||||
system_message=BYOC_HASHBROWN_SYSTEM_PROMPT,
|
||||
llm_config=LLMConfig(
|
||||
{
|
||||
"model": "gpt-4o-mini",
|
||||
"stream": True,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=3,
|
||||
functions=[],
|
||||
)
|
||||
|
||||
byoc_hashbrown_stream = AGUIStream(byoc_hashbrown_agent)
|
||||
|
||||
byoc_hashbrown_app = FastAPI()
|
||||
byoc_hashbrown_app.mount("/", byoc_hashbrown_stream.build_asgi())
|
||||
@@ -0,0 +1,118 @@
|
||||
"""AG2 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.
|
||||
"""
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Respond with the JSON object only.
|
||||
"""
|
||||
|
||||
|
||||
byoc_json_render_agent = ConversableAgent(
|
||||
name="byoc_json_render_assistant",
|
||||
system_message=SYSTEM_PROMPT.strip(),
|
||||
llm_config=LLMConfig(
|
||||
{
|
||||
"model": "gpt-4o-mini",
|
||||
"stream": True,
|
||||
"temperature": 0.2,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=3,
|
||||
functions=[],
|
||||
)
|
||||
|
||||
byoc_json_render_stream = AGUIStream(byoc_json_render_agent)
|
||||
|
||||
byoc_json_render_app = FastAPI()
|
||||
byoc_json_render_app.mount("/", byoc_json_render_stream.build_asgi())
|
||||
@@ -0,0 +1,126 @@
|
||||
"""gen-ui-agent — minimal AG2 agent with explicit `steps` state schema.
|
||||
|
||||
Mirrors `langgraph-python/src/agents/gen_ui_agent.py` and
|
||||
`ms-agent-python/src/agents/gen_ui_agent.py`. The frontend
|
||||
(`src/app/demos/gen-ui-agent/page.tsx`) subscribes to
|
||||
`agent.state.steps` via `useAgent` and renders a live progress card; the
|
||||
backend's job is to plan exactly 3 steps and walk each
|
||||
pending -> in_progress -> completed by calling the `set_steps` tool.
|
||||
Every call to `set_steps` returns a `ReplyResult` whose
|
||||
`context_variables` carry the updated `steps` array, which AG2's
|
||||
`AGUIStream` surfaces back to the UI as a state snapshot so the
|
||||
progress card re-renders in-place after every transition.
|
||||
|
||||
State shape (mirrors LGP `GenUiAgentState.steps`):
|
||||
[
|
||||
{"id": "...", "title": "...", "status": "pending" | "in_progress" | "completed"},
|
||||
...
|
||||
]
|
||||
|
||||
AG2 specifics:
|
||||
- Uses `ContextVariables` + `ReplyResult` (same mechanism as
|
||||
`shared_state_read_write.py`) to publish state. AG2's AG-UI adapter
|
||||
emits a STATE_SNAPSHOT event after every `ReplyResult` so the
|
||||
frontend sees the full `steps` list on each `set_steps` call.
|
||||
- Mounts a dedicated FastAPI sub-app so this demo gets its own
|
||||
ContextVariables slot, isolated from the shared default agent.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from textwrap import dedent
|
||||
from typing import Annotated, List
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from autogen.agentchat import ContextVariables, ReplyResult
|
||||
from autogen.tools import tool
|
||||
from fastapi import FastAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool()
|
||||
async def set_steps(
|
||||
context_variables: ContextVariables,
|
||||
steps: Annotated[
|
||||
List[dict],
|
||||
(
|
||||
"The complete source of truth for the plan: every step "
|
||||
"with `id`, `title`, and `status` ('pending' | "
|
||||
"'in_progress' | 'completed'). Always include the FULL "
|
||||
"list on every call, never a diff."
|
||||
),
|
||||
],
|
||||
) -> ReplyResult:
|
||||
"""Publish the current plan and step statuses.
|
||||
|
||||
Call this every time a step transitions (including the first
|
||||
enumeration of steps). Always include the full list of steps on
|
||||
each call.
|
||||
"""
|
||||
# Normalize: keep only the fields the UI consumes, in case the LLM
|
||||
# tacked on extras. Tolerant of missing fields so the agent doesn't
|
||||
# hard-fail mid-run.
|
||||
cleaned: list[dict] = []
|
||||
for step in steps or []:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
cleaned.append(
|
||||
{
|
||||
"id": str(step.get("id", "")),
|
||||
"title": str(step.get("title", step.get("description", ""))),
|
||||
"status": str(step.get("status", "pending")),
|
||||
}
|
||||
)
|
||||
context_variables.update({"steps": cleaned})
|
||||
return ReplyResult(
|
||||
message=f"Published {len(cleaned)} step(s).",
|
||||
context_variables=context_variables,
|
||||
)
|
||||
|
||||
|
||||
SYSTEM_PROMPT = dedent(
|
||||
"""
|
||||
You are an agentic planner. For each user request, follow this exact
|
||||
sequence:
|
||||
1. Plan exactly 3 concrete steps and call `set_steps` ONCE with all
|
||||
three steps at status="pending".
|
||||
2. Step 1: call `set_steps` with step 1 at status="in_progress",
|
||||
then call `set_steps` again with step 1 at status="completed".
|
||||
3. Step 2: call `set_steps` with step 2 at status="in_progress",
|
||||
then call `set_steps` again with step 2 at status="completed".
|
||||
4. Step 3: call `set_steps` with step 3 at status="in_progress",
|
||||
then call `set_steps` again with step 3 at status="completed".
|
||||
5. Send ONE final conversational assistant message summarizing the
|
||||
plan, then stop. Do not call any more tools after step 3 is
|
||||
completed.
|
||||
|
||||
Rules:
|
||||
- Never call set_steps in parallel — always wait for one call to
|
||||
return before the next.
|
||||
- Always pass the COMPLETE list of steps on every call (existing +
|
||||
updated), never a diff.
|
||||
- Each step needs `id` (stable string id like "step-1"), `title`
|
||||
(short human-readable description), and `status`
|
||||
('pending' | 'in_progress' | 'completed').
|
||||
- After all three steps are completed you MUST send a final
|
||||
assistant message and terminate.
|
||||
"""
|
||||
).strip()
|
||||
|
||||
|
||||
agent = ConversableAgent(
|
||||
name="gen_ui_agent",
|
||||
system_message=SYSTEM_PROMPT,
|
||||
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
# Nominal cost is ~7 set_steps cycles + 1 final model turn.
|
||||
# 15 gives ~2x headroom for retries inside the LLM loop while still
|
||||
# bounding pathological runaway behavior (Railway log-rate limits).
|
||||
max_consecutive_auto_reply=15,
|
||||
functions=[set_steps],
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
gen_ui_agent_app = FastAPI()
|
||||
gen_ui_agent_app.mount("", stream.build_asgi())
|
||||
@@ -0,0 +1,88 @@
|
||||
"""AG2 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``)
|
||||
which the frontend renders via app-registered ``useRenderTool``
|
||||
renderers, plus a frontend-registered ``useComponent`` tool
|
||||
(``highlight_note``) that the agent can invoke -- the UI flows through
|
||||
the same ``useRenderToolCall`` path.
|
||||
|
||||
The system prompt nudges the model toward the right surface per user
|
||||
question and falls back to plain text otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
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 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"
|
||||
" - 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."
|
||||
)
|
||||
|
||||
|
||||
async def get_weather(
|
||||
location: Annotated[str, "City or place to look up the weather for"],
|
||||
) -> dict:
|
||||
"""Get the current weather for a given location.
|
||||
|
||||
Returns a mock payload with city, temperature in Fahrenheit, humidity,
|
||||
wind speed, and conditions.
|
||||
"""
|
||||
return {
|
||||
"city": location,
|
||||
"temperature": 68,
|
||||
"humidity": 55,
|
||||
"wind_speed": 10,
|
||||
"conditions": "Sunny",
|
||||
}
|
||||
|
||||
|
||||
async def get_stock_price(
|
||||
ticker: Annotated[str, "Stock ticker symbol (e.g. AAPL, TSLA, MSFT)"],
|
||||
) -> 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.
|
||||
"""
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": 189.42,
|
||||
"change_pct": 1.27,
|
||||
}
|
||||
|
||||
|
||||
agent = ConversableAgent(
|
||||
name="headless_complete_assistant",
|
||||
system_message=SYSTEM_PROMPT,
|
||||
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=8,
|
||||
functions=[get_weather, get_stock_price],
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
headless_complete_app = FastAPI()
|
||||
headless_complete_app.mount("", stream.build_asgi())
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
AG2 scheduling agent -- interrupt-adapted.
|
||||
|
||||
This agent powers two demos (gen-ui-interrupt, interrupt-headless) that in the
|
||||
LangGraph showcase rely on the native `interrupt()` primitive with
|
||||
checkpoint/resume. AG2 does NOT have that primitive, so we adapt using the
|
||||
same "Strategy B" pattern as the MS Agent Framework port: the backend agent's
|
||||
system prompt tells the LLM to call `schedule_meeting`, but no local
|
||||
implementation is registered -- the tool is provided entirely by the frontend
|
||||
via `useFrontendTool` with an async handler that returns a Promise resolving
|
||||
only once the user picks a time slot (or cancels).
|
||||
|
||||
See `src/agents/agent.py` for the shared ConversableAgent used by most other
|
||||
AG2 demos.
|
||||
"""
|
||||
|
||||
# @region[backend-interrupt-tool]
|
||||
from __future__ import annotations
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
# @region[backend-tool-call]
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a scheduling assistant. Whenever the user asks you to book a call "
|
||||
"or schedule a meeting, you MUST call the `schedule_meeting` tool. Pass a "
|
||||
"short `topic` describing the purpose of the meeting and, if known, an "
|
||||
"`attendee` describing who the meeting is with.\n\n"
|
||||
"The `schedule_meeting` tool is implemented on the client: it surfaces a "
|
||||
"time-picker UI to the user and returns the user's selection. After the "
|
||||
"tool returns, briefly confirm whether the meeting was scheduled and at "
|
||||
"what time, or note that the user cancelled. Do NOT ask for approval "
|
||||
"yourself -- always call the tool and let the picker handle the decision.\n\n"
|
||||
"Keep responses short and friendly. After you finish executing tools, "
|
||||
"always send a brief final assistant message summarizing what happened so "
|
||||
"the message persists."
|
||||
)
|
||||
|
||||
interrupt_agent = ConversableAgent(
|
||||
name="scheduling_agent",
|
||||
system_message=SYSTEM_PROMPT,
|
||||
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=5,
|
||||
# No backend tools. `schedule_meeting` is registered on the frontend
|
||||
# via `useFrontendTool` and dispatched through the CopilotKit runtime.
|
||||
# When the agent calls `schedule_meeting`, the request is routed to
|
||||
# the frontend handler, which returns a Promise that only resolves
|
||||
# once the user picks a slot -- equivalent to `interrupt()` in the
|
||||
# LangGraph reference.
|
||||
functions=[],
|
||||
)
|
||||
# @endregion[backend-tool-call]
|
||||
# @endregion[backend-interrupt-tool]
|
||||
|
||||
# AG-UI stream wrapper
|
||||
interrupt_stream = AGUIStream(interrupt_agent)
|
||||
|
||||
# FastAPI sub-app so agent_server.py can mount at /interrupt-adapted
|
||||
interrupt_app = FastAPI(title="AG2 Interrupt Agent")
|
||||
interrupt_app.mount("/", interrupt_stream.build_asgi())
|
||||
@@ -0,0 +1,71 @@
|
||||
"""AG2 agent for the CopilotKit MCP Apps demo.
|
||||
|
||||
This agent has no bespoke tools. The CopilotKit runtime (see
|
||||
`src/app/api/copilotkit-mcp-apps/route.ts`) is wired with
|
||||
``mcpApps: { servers: [...] }`` pointing at the public Excalidraw MCP
|
||||
server. The runtime auto-applies the MCP Apps middleware: it merges the
|
||||
remote MCP server's tools into the agent's tool list at request time and
|
||||
emits the activity events that CopilotKit's built-in
|
||||
``MCPAppsActivityRenderer`` renders inline as a sandboxed iframe.
|
||||
|
||||
Mirrors the langgraph-python `mcp_apps_agent.py` — a no-tools agent that
|
||||
relies entirely on the runtime to inject MCP-backed tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
agent = ConversableAgent(
|
||||
name="mcp_apps_assistant",
|
||||
system_message=SYSTEM_PROMPT,
|
||||
# gpt-4o-mini for speed, mirroring the langgraph reference.
|
||||
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=6,
|
||||
# No bespoke tools — MCP server tools are injected by the runtime
|
||||
# middleware at request time.
|
||||
functions=[],
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
mcp_apps_app = FastAPI()
|
||||
mcp_apps_app.mount("", stream.build_asgi())
|
||||
@@ -0,0 +1,68 @@
|
||||
"""AG2 agent backing the Multimodal Attachments demo.
|
||||
|
||||
Vision-capable AG2 ConversableAgent (gpt-4o) that accepts image + PDF
|
||||
attachments. Images are forwarded to the model natively; PDFs are flattened
|
||||
to inline text via `pypdf` so the model can read them without needing
|
||||
file-part support.
|
||||
|
||||
The frontend (src/app/demos/multimodal/page.tsx) sends attachments as
|
||||
AG-UI message content parts. AG2's ConversableAgent passes them through to
|
||||
the underlying OpenAI API so vision adapters work natively.
|
||||
|
||||
Content-shape normalization
|
||||
---------------------------
|
||||
AG2's ``ConversableAgent`` runs every user message through
|
||||
``autogen.code_utils.content_str``, which only accepts content-part
|
||||
types in ``{"text", "input_text", "image_url", "input_image",
|
||||
"function", "tool_call", "tool_calls"}``. CopilotChat / the AG-UI
|
||||
runtime emits image and document attachments as the modern
|
||||
``{"type": "image" | "document", "source": {...}}`` shape (and the
|
||||
frontend at ``src/app/demos/multimodal/legacy-converter-shim.tsx``
|
||||
APPENDS a legacy ``{"type": "binary", ...}`` mirror alongside it for
|
||||
LangChain-based integrations). Both of those shapes trip the
|
||||
allowed-types gate with::
|
||||
|
||||
ValueError("Wrong content format: unknown type image within the
|
||||
content")
|
||||
|
||||
…before the request reaches the vision model (observed live in the D6
|
||||
``multimodal`` probe; see commit d8a0a25db for the original NSF
|
||||
quarantine). ``NormalizingAGUIStream`` (defined in
|
||||
``_multimodal_normalize.py``) intercepts the parsed ``RunAgentInput``
|
||||
messages AFTER Pydantic validation and rewrites the AG-UI content parts
|
||||
to OpenAI ``image_url`` format before they reach autogen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ._multimodal_normalize import NormalizingAGUIStream
|
||||
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
multimodal_agent = ConversableAgent(
|
||||
name="multimodal_assistant",
|
||||
system_message=SYSTEM_PROMPT,
|
||||
llm_config=LLMConfig({"model": "gpt-4o", "stream": True, "temperature": 0.2}),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=5,
|
||||
functions=[],
|
||||
)
|
||||
|
||||
# NormalizingAGUIStream wraps AGUIStream and normalises AG-UI
|
||||
# image/document/binary content parts to OpenAI image_url format AFTER
|
||||
# RunAgentInput Pydantic parsing, BEFORE AgentService processes them.
|
||||
# See _multimodal_normalize.py for the full interception-point rationale.
|
||||
multimodal_stream = NormalizingAGUIStream(multimodal_agent)
|
||||
|
||||
multimodal_app = FastAPI()
|
||||
multimodal_app.mount("/", multimodal_stream.build_asgi())
|
||||
@@ -0,0 +1,81 @@
|
||||
"""AG2 agent for the Open-Ended Generative UI (Advanced) demo.
|
||||
|
||||
Extends the minimal Open Generative UI cell with sandbox-function
|
||||
calling: the agent-authored, sandboxed UI invokes host-page functions
|
||||
(see `src/app/demos/open-gen-ui-advanced/sandbox-functions.ts`) via
|
||||
`Websandbox.connection.remote.<name>(...)` from inside the iframe.
|
||||
|
||||
The frontend passes `openGenerativeUI={{ sandboxFunctions }}` to the
|
||||
provider; the runtime middleware injects descriptors of those functions
|
||||
into agent context. The LLM reads the descriptors and emits HTML/JS that
|
||||
calls into them.
|
||||
|
||||
Mirrors the langgraph-python `open_gen_ui_advanced_agent.py` reference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
agent = ConversableAgent(
|
||||
name="open_gen_ui_advanced_assistant",
|
||||
system_message=SYSTEM_PROMPT,
|
||||
llm_config=LLMConfig({"model": "gpt-4.1", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=4,
|
||||
functions=[],
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
open_gen_ui_advanced_app = FastAPI()
|
||||
open_gen_ui_advanced_app.mount("", stream.build_asgi())
|
||||
@@ -0,0 +1,63 @@
|
||||
"""AG2 agent for the Open-Ended Generative UI (minimal) demo.
|
||||
|
||||
The agent has no tools. The frontend-registered `generateSandboxedUi`
|
||||
tool (auto-registered by `CopilotKitProvider` when the runtime has
|
||||
`openGenerativeUI` enabled) is merged into the agent's tool list at
|
||||
request time by the AG-UI integration. When the LLM calls
|
||||
`generateSandboxedUi`, the runtime's `OpenGenerativeUIMiddleware`
|
||||
converts the streaming tool call into `open-generative-ui` activity
|
||||
events the built-in renderer mounts inside a sandboxed iframe.
|
||||
|
||||
Mirrors the langgraph-python `open_gen_ui_agent.py` reference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
agent = ConversableAgent(
|
||||
name="open_gen_ui_assistant",
|
||||
system_message=SYSTEM_PROMPT,
|
||||
llm_config=LLMConfig({"model": "gpt-4.1", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=4,
|
||||
functions=[],
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
open_gen_ui_app = FastAPI()
|
||||
open_gen_ui_app.mount("", stream.build_asgi())
|
||||
@@ -0,0 +1,339 @@
|
||||
"""AG2 reasoning agent — emits AG-UI REASONING_MESSAGE_* events.
|
||||
|
||||
Backs two showcase cells (both share this one backend):
|
||||
- reasoning-custom (custom amber ReasoningBlock slot)
|
||||
- reasoning-default (CopilotKit's built-in reasoning card)
|
||||
|
||||
Mirrors `showcase/integrations/agno/src/agents/reasoning_agent.py` plus its
|
||||
`/reasoning/agui` server mount in `agno/src/agent_server.py`, adapted to AG2.
|
||||
|
||||
Why a custom route instead of the stock AGUIStream
|
||||
--------------------------------------------------
|
||||
AG2's stock `AGUIStream` (autogen.ag_ui) streams the model's text as
|
||||
TEXT_MESSAGE_CONTENT and emits NO REASONING_MESSAGE_* events. Worse,
|
||||
autogen's `ConversableAgent` consumes only `delta.content` / `delta.tool_calls`
|
||||
from the OpenAI chat-completions stream — it drops the `delta.reasoning_content`
|
||||
side-channel entirely (the channel aimock fixtures populate via their
|
||||
`reasoning` field, and that reasoning models emit in production). So the stock
|
||||
adapter can never light up CopilotKit's reasoning slot.
|
||||
|
||||
This module builds a small custom `/reasoning` sub-app (mounted by
|
||||
`agent_server.py`, mirroring agno's `_run_reasoning_agent`) that:
|
||||
1. Calls the OpenAI-compatible chat-completions endpoint directly
|
||||
(streaming) with the agent's system prompt plus the full prior
|
||||
conversation history (so follow-up questions keep their context, parity
|
||||
with the agno reference) — a single LLM call, so it stays
|
||||
aimock-friendly (no multi-call CoT loop).
|
||||
2. Buffers the FULL upstream response, accumulating BOTH
|
||||
`delta.reasoning_content` (native reasoning channel, what aimock's
|
||||
`reasoning` field feeds) AND `delta.content` (the answer); it does not
|
||||
forward upstream deltas incrementally.
|
||||
3. Falls back to parsing <reasoning>...</reasoning> tags out of the text
|
||||
when no native reasoning channel is present (defensive parity with
|
||||
agno's fallback path).
|
||||
4. Emits each channel as a single CONTENT delta:
|
||||
REASONING_MESSAGE_START/CONTENT/END for the buffered reasoning portion,
|
||||
then TEXT_MESSAGE_START/CONTENT/END for the buffered answer.
|
||||
|
||||
The emitted channel is REASONING_MESSAGE_* (role "reasoning") — NOT THINKING_*,
|
||||
which @ag-ui/client silently drops.
|
||||
|
||||
The global httpx hook installed in agent_server.py forwards the inbound
|
||||
`x-aimock-context` header onto the outbound OpenAI call so aimock matches the
|
||||
ag2-scoped fixture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import AsyncIterator
|
||||
|
||||
import openai
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
EventType,
|
||||
ReasoningMessageContentEvent,
|
||||
ReasoningMessageEndEvent,
|
||||
ReasoningMessageStartEvent,
|
||||
RunAgentInput,
|
||||
RunErrorEvent,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
)
|
||||
from ag_ui.encoder import EventEncoder
|
||||
from fastapi import FastAPI
|
||||
from starlette.endpoints import HTTPEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a helpful assistant. For each user question, first think "
|
||||
"step-by-step about the approach, then give a concise answer."
|
||||
)
|
||||
|
||||
MODEL = "gpt-4o-mini"
|
||||
|
||||
_REASONING_PATTERN = re.compile(
|
||||
r"<reasoning>(.*?)</reasoning>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _coerce_content(content) -> str:
|
||||
"""Coerce an AG-UI message's content into a plain string.
|
||||
|
||||
Handles the multimodal list shape (join the text parts) and the
|
||||
None/non-string fallbacks — the same coercion the previous
|
||||
single-turn `_extract_user_input` applied to the last user message.
|
||||
"""
|
||||
content = content or ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
# Multimodal content: join the text parts. Coerce each part's text to
|
||||
# a string — a None or non-str `text` (e.g. an image part) would make
|
||||
# str.join raise TypeError, so fall back to "" for any non-str value.
|
||||
def _part_text(part) -> str:
|
||||
text = (
|
||||
part.get("text", "")
|
||||
if isinstance(part, dict)
|
||||
else getattr(part, "text", "")
|
||||
)
|
||||
return text if isinstance(text, str) else ""
|
||||
|
||||
return "".join(_part_text(part) for part in content)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _to_chat_messages(messages: list) -> list:
|
||||
"""Map the AG-UI message list into chat-completions `messages`.
|
||||
|
||||
System prompt first, then every prior user/assistant turn (in order)
|
||||
with its coerced text content. tool/system messages from the input are
|
||||
skipped — only the conversation turns are threaded so follow-up
|
||||
questions keep their context (parity with the agno reference, which
|
||||
threads full history through Agno's Agent).
|
||||
|
||||
For a single user-message input this returns exactly
|
||||
``[{system}, {user: <text>}]`` — byte-equal to the previous single-turn
|
||||
behaviour, which the aimock D6 fixtures replay. When the input has no
|
||||
user/assistant turns the result is ``[{system}, {user: ""}]`` (an empty
|
||||
user turn), preserving the prior empty-input behaviour.
|
||||
"""
|
||||
chat: list = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
turns = [
|
||||
{"role": role, "content": _coerce_content(getattr(msg, "content", ""))}
|
||||
for msg in (messages or [])
|
||||
for role in (getattr(msg, "role", None),)
|
||||
if role in ("user", "assistant")
|
||||
]
|
||||
if turns:
|
||||
chat.extend(turns)
|
||||
else:
|
||||
chat.append({"role": "user", "content": ""})
|
||||
return chat
|
||||
|
||||
|
||||
async def _run_reasoning_agent(
|
||||
run_input: RunAgentInput,
|
||||
) -> AsyncIterator[BaseEvent]:
|
||||
"""Stream one reasoning run, synthesizing REASONING_MESSAGE_* events.
|
||||
|
||||
Mirrors agno's `_run_reasoning_agent`: buffer the full response, split
|
||||
reasoning from answer, emit REASONING_MESSAGE_* then TEXT_MESSAGE_*.
|
||||
"""
|
||||
run_id = run_input.run_id or str(uuid.uuid4())
|
||||
thread_id = run_input.thread_id
|
||||
|
||||
# Track the in-flight message frame so a mid-stream failure can close it
|
||||
# with the matching *_END before RUN_ERROR. @ag-ui/client's verifyEvents
|
||||
# rejects a RUN_FINISHED while a text/tool frame is open, and the apply
|
||||
# layer otherwise leaves a half-built message in client state.
|
||||
reasoning_msg_id: str | None = None
|
||||
text_msg_id: str | None = None
|
||||
|
||||
try:
|
||||
chat_messages = _to_chat_messages(run_input.messages or [])
|
||||
|
||||
yield RunStartedEvent(
|
||||
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
|
||||
# Single streaming chat-completions call. The global httpx hook
|
||||
# (installed in agent_server.py) forwards x-aimock-context so aimock
|
||||
# matches the ag2-scoped fixture. OPENAI_BASE_URL points the client at
|
||||
# aimock in local/D6 runs and at the real API in production.
|
||||
client = openai.AsyncOpenAI()
|
||||
response_stream = await client.chat.completions.create(
|
||||
model=MODEL,
|
||||
messages=chat_messages,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
# Accumulate both channels. autogen drops reasoning_content, so we read
|
||||
# the chat-completions stream directly to capture it.
|
||||
full_text = ""
|
||||
native_reasoning = ""
|
||||
async for chunk in response_stream:
|
||||
if not chunk.choices:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
if delta is None:
|
||||
continue
|
||||
# Native reasoning channel — aimock `reasoning` field / reasoning
|
||||
# models surface this as delta.reasoning_content.
|
||||
reasoning_piece = getattr(delta, "reasoning_content", None)
|
||||
if reasoning_piece:
|
||||
native_reasoning += reasoning_piece
|
||||
if delta.content:
|
||||
full_text += delta.content
|
||||
|
||||
native_reasoning = native_reasoning.strip()
|
||||
|
||||
if native_reasoning:
|
||||
# Native channel present — gold-standard parity path. The answer is
|
||||
# the streamed text minus any stray <reasoning> tags.
|
||||
reasoning_text = native_reasoning
|
||||
answer_text = _REASONING_PATTERN.sub("", full_text).strip()
|
||||
else:
|
||||
# Fallback: parse <reasoning>...</reasoning> tags from the text
|
||||
# (non-reasoning models / no-native-reasoning fixtures).
|
||||
match = _REASONING_PATTERN.search(full_text)
|
||||
if match:
|
||||
reasoning_text = match.group(1).strip()
|
||||
answer_text = (
|
||||
full_text[: match.start()] + full_text[match.end() :]
|
||||
).strip()
|
||||
else:
|
||||
reasoning_text = ""
|
||||
answer_text = full_text.strip()
|
||||
|
||||
# The stream completed successfully but yielded neither reasoning nor
|
||||
# answer text — the run would otherwise emit RUN_STARTED→RUN_FINISHED
|
||||
# with zero message frames and no diagnostics. Log one server-side warn
|
||||
# so a silent-empty run is visible (no synthetic message frames).
|
||||
if not reasoning_text and not answer_text:
|
||||
print(
|
||||
"[reasoning] WARN: stream completed but produced no reasoning"
|
||||
" or answer text",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Emit reasoning message if we have reasoning content.
|
||||
if reasoning_text:
|
||||
reasoning_msg_id = str(uuid.uuid4())
|
||||
yield ReasoningMessageStartEvent(
|
||||
type=EventType.REASONING_MESSAGE_START,
|
||||
message_id=reasoning_msg_id,
|
||||
role="reasoning",
|
||||
)
|
||||
yield ReasoningMessageContentEvent(
|
||||
type=EventType.REASONING_MESSAGE_CONTENT,
|
||||
message_id=reasoning_msg_id,
|
||||
delta=reasoning_text,
|
||||
)
|
||||
yield ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=reasoning_msg_id,
|
||||
)
|
||||
reasoning_msg_id = None
|
||||
|
||||
# Emit a text message (only when non-empty answer text exists) so
|
||||
# CopilotKit renders an assistant bubble.
|
||||
if answer_text:
|
||||
text_msg_id = str(uuid.uuid4())
|
||||
yield TextMessageStartEvent(
|
||||
type=EventType.TEXT_MESSAGE_START,
|
||||
message_id=text_msg_id,
|
||||
role="assistant",
|
||||
)
|
||||
yield TextMessageContentEvent(
|
||||
type=EventType.TEXT_MESSAGE_CONTENT,
|
||||
message_id=text_msg_id,
|
||||
delta=answer_text,
|
||||
)
|
||||
yield TextMessageEndEvent(
|
||||
type=EventType.TEXT_MESSAGE_END,
|
||||
message_id=text_msg_id,
|
||||
)
|
||||
text_msg_id = None
|
||||
|
||||
yield RunFinishedEvent(
|
||||
type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id
|
||||
)
|
||||
|
||||
except asyncio.CancelledError: # noqa: TRY302 — propagate cancellation
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Log the full failure server-side (never sent to the browser).
|
||||
print(f"[reasoning] run failed: {exc!r}", file=sys.stderr, flush=True)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
# Close any message frame opened before the failure so the terminal
|
||||
# RUN_ERROR is protocol-clean (no dangling *_START in client state).
|
||||
if text_msg_id is not None:
|
||||
yield TextMessageEndEvent(
|
||||
type=EventType.TEXT_MESSAGE_END,
|
||||
message_id=text_msg_id,
|
||||
)
|
||||
if reasoning_msg_id is not None:
|
||||
yield ReasoningMessageEndEvent(
|
||||
type=EventType.REASONING_MESSAGE_END,
|
||||
message_id=reasoning_msg_id,
|
||||
)
|
||||
# Generic client-facing message — no raw exception text (which can
|
||||
# carry provider URLs / request details) reaches the SSE stream.
|
||||
# RUN_ERROR is terminal: @ag-ui/client's verifyEvents rejects ANY
|
||||
# event after it, so we do NOT emit RUN_FINISHED here.
|
||||
yield RunErrorEvent(
|
||||
type=EventType.RUN_ERROR,
|
||||
message=f"agent run failed: {type(exc).__name__} (see server logs)",
|
||||
)
|
||||
|
||||
|
||||
class ReasoningEndpoint(HTTPEndpoint):
|
||||
"""Starlette HTTPEndpoint that emits REASONING_MESSAGE_* + TEXT_MESSAGE_*.
|
||||
|
||||
Mounted at the sub-app root (``reasoning_app.mount("/", ...)``) — the exact
|
||||
same shape as AG2's stock ``AGUIStream.build_asgi()`` HTTPEndpoint that the
|
||||
other ag2 sub-apps mount (see e.g. ``interrupt_agent.py``). agent_server
|
||||
mounts this sub-app at ``/reasoning``; the HttpAgent posts to
|
||||
``/reasoning/`` (route.ts ``createAgent("/reasoning/")``), so the outer
|
||||
Mount strips ``/reasoning`` and the inner Mount at ``/`` resolves here.
|
||||
"""
|
||||
|
||||
async def post(self, request: Request) -> StreamingResponse:
|
||||
encoder = EventEncoder()
|
||||
run_input = RunAgentInput.model_validate_json(await request.body())
|
||||
|
||||
async def _gen() -> AsyncIterator[str]:
|
||||
async for event in _run_reasoning_agent(run_input):
|
||||
yield encoder.encode(event)
|
||||
|
||||
return StreamingResponse(
|
||||
_gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# FastAPI sub-app so agent_server.py can mount at /reasoning. Mounting the
|
||||
# HTTPEndpoint at "/" mirrors the stock AGUIStream sub-apps
|
||||
# (``app.mount("/", stream.build_asgi())``) — the HttpAgent posts to
|
||||
# ``/reasoning/`` so the outer Mount strips ``/reasoning`` and this inner
|
||||
# Mount at ``/`` resolves the endpoint.
|
||||
reasoning_app = FastAPI(title="AG2 Reasoning Agent")
|
||||
reasoning_app.mount("/", ReasoningEndpoint)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""AG2 agent for the Shared State (Read + Write) demo.
|
||||
|
||||
Demonstrates the full bidirectional shared-state pattern between UI and
|
||||
agent using AG2's ContextVariables + ReplyResult mechanism:
|
||||
|
||||
- **UI -> agent (write)**: The UI owns a `preferences` object (the user's
|
||||
profile) that it writes into agent state via `agent.setState({...})`.
|
||||
AG2's AGUIStream maps incoming initial state into ContextVariables on
|
||||
every run. The agent calls `get_current_preferences` to read them, and
|
||||
the system prompt tells it to do so before answering.
|
||||
- **agent -> UI (read)**: The agent calls `set_notes` to update the
|
||||
`notes` slot in shared state. Each call returns a ReplyResult that
|
||||
attaches the updated ContextVariables, which AGUIStream surfaces back
|
||||
to the UI so `useAgent({ updates: [OnStateChanged] })` re-renders.
|
||||
|
||||
Together this gives bidirectional shared state: frontend writes,
|
||||
backend reads AND writes, frontend re-renders.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from textwrap import dedent
|
||||
from typing import List, Optional
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from autogen.agentchat import ContextVariables, ReplyResult
|
||||
from autogen.tools import tool
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Preferences(BaseModel):
|
||||
"""User preferences written by the UI into shared state."""
|
||||
|
||||
name: str = Field(default="", description="The user's preferred name")
|
||||
tone: str = Field(
|
||||
default="casual",
|
||||
description="Preferred tone: 'formal', 'casual', or 'playful'",
|
||||
)
|
||||
language: str = Field(
|
||||
default="English",
|
||||
description="Preferred language (e.g. English, Spanish, ...)",
|
||||
)
|
||||
interests: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="The user's interests (e.g. Cooking, Tech, Travel)",
|
||||
)
|
||||
|
||||
|
||||
class SharedSnapshot(BaseModel):
|
||||
"""Full shape of the shared state slot.
|
||||
|
||||
Both the UI and the backend agree on this shape; it round-trips through
|
||||
AG2's ContextVariables on every turn.
|
||||
"""
|
||||
|
||||
preferences: Preferences = Field(default_factory=Preferences)
|
||||
notes: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _load_snapshot(context_variables: ContextVariables) -> SharedSnapshot:
|
||||
"""Best-effort load of the SharedSnapshot from context variables.
|
||||
|
||||
Falls back to an empty snapshot if state is missing or malformed —
|
||||
this keeps the agent operational on the very first turn before the UI
|
||||
has called ``agent.setState``.
|
||||
"""
|
||||
data = context_variables.data or {}
|
||||
try:
|
||||
return SharedSnapshot.model_validate(data)
|
||||
except Exception as exc:
|
||||
# Tolerant of partial state (e.g. only `preferences` set), but log
|
||||
# WARNING so silent corruption is visible in server logs instead of
|
||||
# quietly degrading to an empty snapshot.
|
||||
logger.warning(
|
||||
"shared_state_read_write: failed to validate SharedSnapshot "
|
||||
"(%s: %s); attempting partial recovery from individual slots",
|
||||
exc.__class__.__name__,
|
||||
exc,
|
||||
)
|
||||
prefs_raw = data.get("preferences") or {}
|
||||
notes_raw = data.get("notes") or []
|
||||
try:
|
||||
prefs = Preferences.model_validate(prefs_raw)
|
||||
except Exception as prefs_exc:
|
||||
logger.warning(
|
||||
"shared_state_read_write: failed to validate Preferences "
|
||||
"(%s: %s); falling back to defaults",
|
||||
prefs_exc.__class__.__name__,
|
||||
prefs_exc,
|
||||
)
|
||||
prefs = Preferences()
|
||||
notes = [str(n) for n in notes_raw if isinstance(n, (str, int, float))]
|
||||
return SharedSnapshot(preferences=prefs, notes=notes)
|
||||
|
||||
|
||||
@tool()
|
||||
async def get_current_preferences(context_variables: ContextVariables) -> str:
|
||||
"""Return the user's preferences (name, tone, language, interests) as JSON.
|
||||
|
||||
Always call this BEFORE answering, so your reply respects the user's
|
||||
preferred name, tone, language, and interests.
|
||||
"""
|
||||
snapshot = _load_snapshot(context_variables)
|
||||
return snapshot.preferences.model_dump_json(indent=2)
|
||||
|
||||
|
||||
@tool()
|
||||
async def set_notes(
|
||||
context_variables: ContextVariables,
|
||||
notes: List[str],
|
||||
) -> ReplyResult:
|
||||
"""Replace the notes array in shared state with the FULL updated list.
|
||||
|
||||
Use this whenever the user asks you to "remember" something, or when you
|
||||
have an observation worth surfacing in the UI's notes panel. Always
|
||||
pass the FULL notes list (existing + new) — not a diff. Keep each note
|
||||
short (< 120 chars).
|
||||
"""
|
||||
snapshot = _load_snapshot(context_variables)
|
||||
cleaned = [str(n).strip() for n in notes if str(n).strip()]
|
||||
snapshot.notes = cleaned
|
||||
context_variables.update(snapshot.model_dump())
|
||||
return ReplyResult(
|
||||
message=f"Notes updated. Total notes: {len(cleaned)}.",
|
||||
context_variables=context_variables,
|
||||
)
|
||||
|
||||
|
||||
agent = ConversableAgent(
|
||||
name="shared_state_read_write_assistant",
|
||||
system_message=dedent(
|
||||
"""
|
||||
You are a helpful, concise assistant.
|
||||
|
||||
Shared state contract:
|
||||
- The UI writes the user's `preferences` (name, tone, language,
|
||||
interests) into shared state. Call `get_current_preferences`
|
||||
BEFORE answering, every turn, and tailor your reply to those
|
||||
preferences. Address the user by name when appropriate.
|
||||
- The UI displays a `notes` panel that mirrors a list you control.
|
||||
When the user asks you to remember something, OR when you observe
|
||||
something worth surfacing, call `set_notes` with the FULL updated
|
||||
list of short note strings.
|
||||
|
||||
Rules:
|
||||
- Never repeat preferences back at the user verbatim — just adapt.
|
||||
- When calling `set_notes`, pass the COMPLETE list (existing +
|
||||
new), never a diff.
|
||||
- Keep messages short and respect the preferred tone.
|
||||
"""
|
||||
).strip(),
|
||||
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=10,
|
||||
functions=[get_current_preferences, set_notes],
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
shared_state_read_write_app = FastAPI()
|
||||
shared_state_read_write_app.mount("", stream.build_asgi())
|
||||
@@ -0,0 +1,316 @@
|
||||
"""AG2 agent for the Sub-Agents demo.
|
||||
|
||||
Demonstrates multi-agent delegation with a visible delegation log.
|
||||
|
||||
A top-level "supervisor" ConversableAgent orchestrates three specialized
|
||||
sub-agents — each itself a ConversableAgent — exposed as supervisor tools:
|
||||
|
||||
- `research_agent` — gathers facts
|
||||
- `writing_agent` — drafts prose
|
||||
- `critique_agent` — reviews drafts
|
||||
|
||||
Every delegation appends an entry to the `delegations` slot in shared
|
||||
agent state (via AG2's ContextVariables + ReplyResult), so the UI can
|
||||
render a live "delegation log" as the supervisor fans work out and
|
||||
collects results. This is the canonical AG2 sub-agents-as-tools pattern,
|
||||
adapted to surface delegation events to the frontend via AG-UI's
|
||||
shared-state channel.
|
||||
"""
|
||||
|
||||
# @region[supervisor-delegation-tools]
|
||||
# @region[subagent-setup]
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from textwrap import dedent
|
||||
from typing import List, Literal
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from autogen.agentchat import ContextVariables, ReplyResult
|
||||
from autogen.tools import tool
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SubAgentName = Literal["research_agent", "writing_agent", "critique_agent"]
|
||||
DelegationStatus = Literal["running", "completed", "failed"]
|
||||
|
||||
|
||||
class Delegation(BaseModel):
|
||||
"""One entry in the delegation log shown by the UI."""
|
||||
|
||||
id: str
|
||||
sub_agent: SubAgentName
|
||||
task: str
|
||||
status: DelegationStatus = "completed"
|
||||
result: str = ""
|
||||
|
||||
|
||||
class SubagentsSnapshot(BaseModel):
|
||||
"""Shape of the shared `delegations` state slot rendered by the UI."""
|
||||
|
||||
delegations: List[Delegation] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-agents (real ConversableAgents under the hood)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Each sub-agent is its own LLM ConversableAgent with a focused system
|
||||
# prompt. They don't share memory or tools with the supervisor — the
|
||||
# supervisor only sees what each sub-agent's final reply produces.
|
||||
|
||||
_SUB_LLM_CONFIG = LLMConfig({"model": "gpt-4o-mini", "stream": False})
|
||||
|
||||
_research_agent = ConversableAgent(
|
||||
name="research_sub_agent",
|
||||
system_message=dedent(
|
||||
"""
|
||||
You are a research sub-agent. Given a topic, produce a concise
|
||||
bulleted list of 3-5 key facts. No preamble, no closing.
|
||||
"""
|
||||
).strip(),
|
||||
llm_config=_SUB_LLM_CONFIG,
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=1,
|
||||
)
|
||||
|
||||
_writing_agent = ConversableAgent(
|
||||
name="writing_sub_agent",
|
||||
system_message=dedent(
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
).strip(),
|
||||
llm_config=_SUB_LLM_CONFIG,
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=1,
|
||||
)
|
||||
|
||||
_critique_agent = ConversableAgent(
|
||||
name="critique_sub_agent",
|
||||
system_message=dedent(
|
||||
"""
|
||||
You are an editorial critique sub-agent. Given a draft, produce
|
||||
2-3 crisp, actionable critiques. No preamble.
|
||||
"""
|
||||
).strip(),
|
||||
llm_config=_SUB_LLM_CONFIG,
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=1,
|
||||
)
|
||||
# @endregion[subagent-setup]
|
||||
|
||||
|
||||
async def _invoke_sub_agent(sub_agent: ConversableAgent, task: str) -> str:
|
||||
"""Run a sub-agent on `task` and return its final reply text.
|
||||
|
||||
`generate_reply` produces a single LLM completion against a one-shot
|
||||
user message. AG2's ``generate_reply`` is synchronous and performs a
|
||||
blocking LLM round-trip, so we offload it to a worker thread to keep
|
||||
the asyncio event loop responsive while the call is in flight.
|
||||
"""
|
||||
reply = await asyncio.to_thread(
|
||||
sub_agent.generate_reply,
|
||||
messages=[{"role": "user", "content": task}],
|
||||
)
|
||||
if reply is None:
|
||||
return ""
|
||||
if isinstance(reply, dict):
|
||||
# ConversableAgent.generate_reply may return {"content": "..."}.
|
||||
return str(reply.get("content") or "")
|
||||
return str(reply)
|
||||
|
||||
|
||||
def _load_snapshot(context_variables: ContextVariables) -> SubagentsSnapshot:
|
||||
"""Best-effort load of the SubagentsSnapshot from context variables.
|
||||
|
||||
Logs at WARNING when state fails validation so silent corruption is
|
||||
visible in server logs instead of degrading to an empty snapshot
|
||||
without a trace.
|
||||
"""
|
||||
data = context_variables.data or {}
|
||||
try:
|
||||
return SubagentsSnapshot.model_validate(data)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"subagents: failed to validate SubagentsSnapshot from context "
|
||||
"variables (%s: %s); falling back to empty snapshot",
|
||||
exc.__class__.__name__,
|
||||
exc,
|
||||
)
|
||||
return SubagentsSnapshot()
|
||||
|
||||
|
||||
def _record_delegation(
|
||||
context_variables: ContextVariables,
|
||||
sub_agent: SubAgentName,
|
||||
task: str,
|
||||
result: str,
|
||||
status: DelegationStatus = "completed",
|
||||
) -> ReplyResult:
|
||||
"""Append a delegation entry to shared state and return ReplyResult."""
|
||||
snapshot = _load_snapshot(context_variables)
|
||||
snapshot.delegations.append(
|
||||
Delegation(
|
||||
id=str(uuid.uuid4()),
|
||||
sub_agent=sub_agent,
|
||||
task=task,
|
||||
status=status,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
context_variables.update(snapshot.model_dump())
|
||||
return ReplyResult(
|
||||
message=result,
|
||||
context_variables=context_variables,
|
||||
)
|
||||
|
||||
|
||||
async def _run_delegation(
|
||||
context_variables: ContextVariables,
|
||||
sub_agent_name: SubAgentName,
|
||||
sub_agent: ConversableAgent,
|
||||
task: str,
|
||||
) -> ReplyResult:
|
||||
"""Invoke a sub-agent and record the outcome (completed or failed).
|
||||
|
||||
If the underlying ``generate_reply`` raises (transport error, quota,
|
||||
SDK bug, ...), we record the delegation with ``status='failed'`` and
|
||||
return a sane ReplyResult so the supervisor can recover instead of
|
||||
crashing the turn. The full traceback is logged server-side; the
|
||||
user-facing ``result`` text only mentions the exception class to
|
||||
avoid leaking internals.
|
||||
"""
|
||||
try:
|
||||
result = await _invoke_sub_agent(sub_agent, task)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"subagents: sub-agent %s failed while handling task", sub_agent_name
|
||||
)
|
||||
failure_message = (
|
||||
f"sub-agent call failed: {exc.__class__.__name__} (see server logs)"
|
||||
)
|
||||
return _record_delegation(
|
||||
context_variables,
|
||||
sub_agent_name,
|
||||
task,
|
||||
failure_message,
|
||||
status="failed",
|
||||
)
|
||||
|
||||
return _record_delegation(
|
||||
context_variables,
|
||||
sub_agent_name,
|
||||
task,
|
||||
result,
|
||||
status="completed",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 asynchronously runs the
|
||||
# matching sub-agent, records the delegation into shared state via
|
||||
# ContextVariables, and returns a ReplyResult the supervisor reads as
|
||||
# its tool output on the next step.
|
||||
@tool()
|
||||
async def research_agent(
|
||||
context_variables: ContextVariables,
|
||||
task: str,
|
||||
) -> ReplyResult:
|
||||
"""Delegate a research task to the research sub-agent.
|
||||
|
||||
Use for: gathering facts, background, definitions, statistics. Returns
|
||||
a bulleted list of key facts.
|
||||
|
||||
Args:
|
||||
task: The specific research question or topic to investigate.
|
||||
"""
|
||||
return await _run_delegation(
|
||||
context_variables, "research_agent", _research_agent, task
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def writing_agent(
|
||||
context_variables: ContextVariables,
|
||||
task: str,
|
||||
) -> ReplyResult:
|
||||
"""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``.
|
||||
|
||||
Args:
|
||||
task: The brief plus any relevant facts the writer should use.
|
||||
"""
|
||||
return await _run_delegation(
|
||||
context_variables, "writing_agent", _writing_agent, task
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def critique_agent(
|
||||
context_variables: ContextVariables,
|
||||
task: str,
|
||||
) -> ReplyResult:
|
||||
"""Delegate a critique task to the critique sub-agent.
|
||||
|
||||
Use for: reviewing a draft and suggesting concrete improvements.
|
||||
|
||||
Args:
|
||||
task: The draft to critique (paste it directly into ``task``).
|
||||
"""
|
||||
return await _run_delegation(
|
||||
context_variables, "critique_agent", _critique_agent, task
|
||||
)
|
||||
|
||||
|
||||
# @endregion[supervisor-delegation-tools]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor (the agent we export)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
supervisor = ConversableAgent(
|
||||
name="supervisor",
|
||||
system_message=dedent(
|
||||
"""
|
||||
You are a supervisor agent that coordinates three specialized
|
||||
sub-agents to produce high-quality deliverables.
|
||||
|
||||
Available sub-agents (call them as tools):
|
||||
- research_agent: gathers facts on a topic.
|
||||
- writing_agent: turns facts + a brief into a polished draft.
|
||||
- critique_agent: reviews a draft and suggests improvements.
|
||||
|
||||
For most non-trivial user requests, delegate in sequence:
|
||||
research -> write -> critique. Pass the relevant facts/draft
|
||||
through the `task` argument of each tool. Keep your own messages
|
||||
short — explain the plan once, delegate, then return a concise
|
||||
summary once done. The UI shows the user a live log of every
|
||||
sub-agent delegation, so don't repeat sub-agent output verbatim
|
||||
in your final reply — just summarize.
|
||||
"""
|
||||
).strip(),
|
||||
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
# Limit supervisor steps to bound delegation fan-out.
|
||||
max_consecutive_auto_reply=8,
|
||||
functions=[research_agent, writing_agent, critique_agent],
|
||||
)
|
||||
|
||||
stream = AGUIStream(supervisor)
|
||||
subagents_app = FastAPI()
|
||||
subagents_app.mount("", stream.build_asgi())
|
||||
@@ -0,0 +1,111 @@
|
||||
"""AG2 agent for the Tool Rendering (Reasoning Chain) demo.
|
||||
|
||||
A travel & lifestyle concierge that chains 2+ tool calls in succession
|
||||
when relevant. The frontend wires renderers for `get_weather` and
|
||||
`search_flights` plus a custom catch-all for the rest.
|
||||
|
||||
Note: AG2's ConversableAgent does not natively emit AG-UI
|
||||
REASONING_MESSAGE_* events the way LangGraph's `deepagents` does, so the
|
||||
reasoning slot may not show streaming "thinking…" text. The cell still
|
||||
exercises the full tool-rendering chain and the custom reasoning slot
|
||||
plumbing — the slot simply renders empty/skeletal until/if a reasoning
|
||||
event arrives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from random import choice, randint
|
||||
from typing import Annotated
|
||||
|
||||
from autogen import ConversableAgent, LLMConfig
|
||||
from autogen.ag_ui import AGUIStream
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
async def get_weather(
|
||||
location: Annotated[str, "City or place to look up the weather for"],
|
||||
) -> dict:
|
||||
"""Get the current weather for a given location."""
|
||||
return {
|
||||
"city": location,
|
||||
"temperature": 68,
|
||||
"humidity": 55,
|
||||
"wind_speed": 10,
|
||||
"conditions": "Sunny",
|
||||
}
|
||||
|
||||
|
||||
async def search_flights(
|
||||
origin: Annotated[str, "Origin airport code, e.g. 'SFO'"],
|
||||
destination: Annotated[str, "Destination airport code, e.g. 'JFK'"],
|
||||
) -> str:
|
||||
"""Search mock flights from an origin airport to a destination."""
|
||||
payload = {
|
||||
"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,
|
||||
},
|
||||
],
|
||||
}
|
||||
return json.dumps(payload)
|
||||
|
||||
|
||||
async def get_stock_price(
|
||||
ticker: Annotated[str, "Stock ticker symbol (e.g. AAPL, TSLA, MSFT)"],
|
||||
) -> dict:
|
||||
"""Get a mock current price for a stock ticker."""
|
||||
return {
|
||||
"ticker": ticker.upper(),
|
||||
"price_usd": round(100 + randint(0, 400) + randint(0, 99) / 100, 2),
|
||||
"change_pct": round(choice([-1, 1]) * (randint(0, 300) / 100), 2),
|
||||
}
|
||||
|
||||
|
||||
async def roll_dice(
|
||||
sides: Annotated[int, "Number of sides on the die (default 6)"] = 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 travel & lifestyle concierge. When a user asks a question, "
|
||||
"reason step-by-step and call 2+ tools in succession when relevant. "
|
||||
"For weather + travel questions, call get_weather then search_flights. "
|
||||
"Keep the final summary to one short sentence."
|
||||
)
|
||||
|
||||
|
||||
agent = ConversableAgent(
|
||||
name="tool_rendering_reasoning_chain_assistant",
|
||||
system_message=SYSTEM_PROMPT,
|
||||
llm_config=LLMConfig({"model": "gpt-4o-mini", "stream": True}),
|
||||
human_input_mode="NEVER",
|
||||
max_consecutive_auto_reply=10,
|
||||
functions=[get_weather, search_flights, get_stock_price, roll_dice],
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
tool_rendering_reasoning_chain_app = FastAPI()
|
||||
tool_rendering_reasoning_chain_app.mount("", stream.build_asgi())
|
||||
@@ -0,0 +1,52 @@
|
||||
// Dedicated runtime for the A2UI — Fixed Schema cell. Splitting into its
|
||||
// own endpoint lets us set `a2ui.injectA2UITool: false` — the backend AG2
|
||||
// agent owns the `display_flight` tool which emits its own
|
||||
// `a2ui_operations` container directly in the tool result.
|
||||
//
|
||||
// Reference:
|
||||
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-a2ui-fixed-schema/route.ts
|
||||
// - src/agents/a2ui_fixed.py (the AG2 backend)
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const a2uiFixedSchemaAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/a2ui-fixed-schema/`,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: { "a2ui-fixed-schema": a2uiFixedSchemaAgent },
|
||||
a2ui: {
|
||||
// The backend agent emits its own `a2ui_operations` container inside
|
||||
// `display_flight` (see src/agents/a2ui_fixed.py). We still run the A2UI
|
||||
// middleware so it detects the container in tool results and forwards
|
||||
// surfaces to the frontend — but we do NOT inject a runtime
|
||||
// `render_a2ui` tool on top of the agent's existing tools.
|
||||
injectA2UITool: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-a2ui-fixed-schema",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
// Dedicated runtime for the Agent Config Object demo (AG2).
|
||||
//
|
||||
// The page at src/app/demos/agent-config/page.tsx points its `runtimeUrl` at
|
||||
// this endpoint and sets `agent="agent-config-demo"` (the slug registered
|
||||
// below). The backing AG2 agent is a FastAPI sub-app mounted at
|
||||
// `/agent-config` in src/agent_server.py, with its AGUIStream at the mount
|
||||
// root — hence the trailing-slash URL, matching the sibling
|
||||
// copilotkit-multimodal route's convention.
|
||||
//
|
||||
// Wire-contract bridge:
|
||||
// The CopilotKit runtime forwards `CopilotKitCore.properties` as flat
|
||||
// top-level keys on `forwardedProps`. To keep the wire contract identical
|
||||
// across framework showcases (LangGraph / LlamaIndex / AG2 / etc.), we repack
|
||||
// any non-structural forwardedProps key into
|
||||
// `forwardedProps.config.configurable.properties` before forwarding the
|
||||
// request to the Python backend. This mirrors the LlamaIndex showcase's
|
||||
// agent-config route (see llamaindex/src/app/api/copilotkit-agent-config/
|
||||
// route.ts) so a single TS-side wire contract serves all frameworks. (The
|
||||
// AG2 demo page itself relays config via `useAgentContext` → shared state →
|
||||
// ContextVariables, so the repack is a pass-through unless provider
|
||||
// `properties` are supplied.)
|
||||
//
|
||||
// References:
|
||||
// - src/agents/agent_config_agent.py — the AG2 agent + AGUIStream sub-app
|
||||
// - src/app/demos/agent-config/page.tsx — the provider config
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
// Shape of the AG-UI run input we care about. We avoid a direct import of
|
||||
// `RunAgentInput` from `@ag-ui/client` so this route has no additional
|
||||
// peer-dep on internal AG-UI packages — the field we touch (`forwardedProps`)
|
||||
// is part of the stable AG-UI protocol contract.
|
||||
type RunInputWithForwardedProps = {
|
||||
forwardedProps?: Record<string, unknown> | undefined;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
// Keys on `forwardedProps` that AG-UI treats as reserved stream-payload
|
||||
// fields (e.g. `config`, `command`, `streamMode`). These must NOT be
|
||||
// repacked under `configurable.properties` — they are structural fields.
|
||||
// Anything else on `forwardedProps` is user-supplied frontend state that
|
||||
// needs to reach the Python agent.
|
||||
//
|
||||
// Kept in sync with ag-ui/langgraph/typescript/src/agent.ts
|
||||
// `RunAgentExtendedInput["forwardedProps"]`. AG2's stream uses a subset of
|
||||
// these, but the superset is safe: structural keys present in the request
|
||||
// body pass through to AG-UI's canonical shape regardless of which backend
|
||||
// consumes them.
|
||||
const RESERVED_FORWARDED_PROPS_KEYS = new Set<string>([
|
||||
"config",
|
||||
"command",
|
||||
"streamMode",
|
||||
"streamSubgraphs",
|
||||
"nodeName",
|
||||
"threadMetadata",
|
||||
"checkpointId",
|
||||
"checkpointDuring",
|
||||
"interruptBefore",
|
||||
"interruptAfter",
|
||||
"multitaskStrategy",
|
||||
"ifNotExists",
|
||||
"afterSeconds",
|
||||
"onCompletion",
|
||||
"onDisconnect",
|
||||
"webhook",
|
||||
"feedbackKeys",
|
||||
"metadata",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Wrapper around `HttpAgent` that repacks the CopilotKit provider's
|
||||
* `properties` (which arrive as top-level keys on `forwardedProps`) into
|
||||
* `forwardedProps.config.configurable.properties`.
|
||||
*
|
||||
* Why this bridge exists: the CopilotKit runtime forwards
|
||||
* `CopilotKitCore.properties` as `forwardedProps` (see core's run-handler).
|
||||
* For wire-contract consistency with the LangGraph showcase, we stash them
|
||||
* under `forwardedProps.config.configurable.properties` so a Python-side
|
||||
* recomposer can read them from a single canonical location instead of
|
||||
* sniffing top-level keys.
|
||||
*/
|
||||
class AgentConfigHttpAgent extends HttpAgent {
|
||||
// Passthrough constructor so TS sees the same signature HttpAgent
|
||||
// accepts ({ url }). Without this, subclassing narrows the inferred
|
||||
// constructor to zero-arg when @ag-ui/client isn't fully resolvable in
|
||||
// isolated typecheck passes.
|
||||
constructor(...args: ConstructorParameters<typeof HttpAgent>) {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
// Intercept each run() to repack provider `properties` (which land on
|
||||
// `forwardedProps`) into `forwardedProps.config.configurable.properties`.
|
||||
run(input: Parameters<HttpAgent["run"]>[0]): ReturnType<HttpAgent["run"]> {
|
||||
const repacked = repackForwardedPropsIntoConfigurable(
|
||||
input as unknown as RunInputWithForwardedProps,
|
||||
);
|
||||
return super.run(repacked as unknown as Parameters<HttpAgent["run"]>[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function repackForwardedPropsIntoConfigurable<
|
||||
T extends RunInputWithForwardedProps,
|
||||
>(input: T): T {
|
||||
const fp = (input.forwardedProps ?? {}) as Record<string, unknown>;
|
||||
if (!fp || typeof fp !== "object") return input;
|
||||
|
||||
// Split forwardedProps into (structural) and (user-supplied) halves.
|
||||
const userProps: Record<string, unknown> = {};
|
||||
const structural: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(fp)) {
|
||||
if (RESERVED_FORWARDED_PROPS_KEYS.has(key)) {
|
||||
structural[key] = value;
|
||||
} else {
|
||||
userProps[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(userProps).length === 0) return input;
|
||||
|
||||
const existingConfig = (structural.config ?? {}) as {
|
||||
configurable?: Record<string, unknown>;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
const existingConfigurable =
|
||||
(existingConfig.configurable as Record<string, unknown> | undefined) ?? {};
|
||||
const existingProperties =
|
||||
(existingConfigurable.properties as Record<string, unknown> | undefined) ??
|
||||
{};
|
||||
|
||||
const mergedConfig = {
|
||||
...existingConfig,
|
||||
configurable: {
|
||||
...existingConfigurable,
|
||||
properties: {
|
||||
...existingProperties,
|
||||
...userProps,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...input,
|
||||
forwardedProps: {
|
||||
...structural,
|
||||
config: mergedConfig,
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
// Trailing-slash mount root: src/agent_server.py mounts the agent-config
|
||||
// FastAPI sub-app at /agent-config, and the sub-app mounts its AGUIStream
|
||||
// at "/" (same shape as the multimodal agent).
|
||||
const agentConfigAgent = new AgentConfigHttpAgent({
|
||||
url: `${AGENT_URL}/agent-config/`,
|
||||
});
|
||||
|
||||
const agents = {
|
||||
"agent-config-demo": agentConfigAgent,
|
||||
default: agentConfigAgent,
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-agent-config",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-expect-error -- see main route.ts; published CopilotRuntime's `agents`
|
||||
// type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects
|
||||
// plain Records. Fixed in source, pending release.
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
// Log full details server-side (operators grep `errorId` to correlate),
|
||||
// but never echo `err.message` / `err.stack` back to the HTTP client —
|
||||
// that leaks internal paths, dependency versions, and stack traces.
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const errorId = crypto.randomUUID();
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
at: new Date().toISOString(),
|
||||
level: "error",
|
||||
scope: "copilotkit-agent-config/route",
|
||||
errorId,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
}),
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "internal runtime error", errorId },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
// Dedicated runtime for the /demos/auth cell.
|
||||
//
|
||||
// Demonstrates framework-native request authentication via the V2 runtime's
|
||||
// `onRequest` hook, which runs before routing and can short-circuit the
|
||||
// request by throwing a Response. We validate a static `Authorization: Bearer
|
||||
// <DEMO_TOKEN>` header; mismatch throws 401 before the request reaches the
|
||||
// AG2 backend.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
createCopilotRuntimeHandler,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import { DEMO_AUTH_HEADER } from "@/app/demos/auth/demo-token";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
// Reuse the neutral default AG2 agent for the authenticated path. The
|
||||
// point of this demo is the gate mechanism, not per-user agent branching.
|
||||
const authDemoAgent = new HttpAgent({ url: `${AGENT_URL}/` });
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
"auth-demo": authDemoAgent,
|
||||
default: authDemoAgent,
|
||||
},
|
||||
});
|
||||
|
||||
const BASE_PATH = "/api/copilotkit-auth";
|
||||
|
||||
const handler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: BASE_PATH,
|
||||
hooks: {
|
||||
onRequest: ({ request }) => {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (authHeader !== DEMO_AUTH_HEADER) {
|
||||
throw new Response(
|
||||
JSON.stringify({
|
||||
error: "unauthorized",
|
||||
message:
|
||||
"Missing or invalid Authorization header. Click Authenticate above to send messages.",
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = (req: NextRequest) => handler(req);
|
||||
export const GET = (req: NextRequest) => handler(req);
|
||||
export const PUT = (req: NextRequest) => handler(req);
|
||||
export const DELETE = (req: NextRequest) => handler(req);
|
||||
@@ -0,0 +1,74 @@
|
||||
// Dedicated runtime for the (simplified) Beautiful Chat showcase cell.
|
||||
//
|
||||
// Beautiful Chat combines TWO of the canonical reference's three flagship
|
||||
// features in a single cell:
|
||||
// - A2UI Dynamic Schema (branded React catalog, agent-owned `generate_a2ui`)
|
||||
// - Open Generative UI (auto-injected `generateSandboxedUi` frontend tool)
|
||||
//
|
||||
// Splitting into its own endpoint matters because:
|
||||
// - `openGenerativeUI` flips a global probe flag that, on the shared
|
||||
// `/api/copilotkit` route, would wipe per-cell `useFrontendTool` /
|
||||
// `useComponent` registrations (see comment in `copilotkit-ogui/route.ts`).
|
||||
// - `a2ui.injectA2UITool: false` is required so the runtime doesn't
|
||||
// double-bind a second A2UI tool over the agent-owned `generate_a2ui`.
|
||||
//
|
||||
// References:
|
||||
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-beautiful-chat/route.ts
|
||||
// - src/app/api/copilotkit-declarative-gen-ui/route.ts (a2ui scoping pattern)
|
||||
// - src/app/api/copilotkit-ogui/route.ts (openGenerativeUI scoping pattern)
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const beautifulChatAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/beautiful-chat/`,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: { "beautiful-chat": beautifulChatAgent },
|
||||
// The agent owns `generate_a2ui` explicitly (see
|
||||
// src/agents/beautiful_chat.py). The runtime middleware still serialises
|
||||
// the registered client catalog into agent context and detects
|
||||
// `a2ui_operations` containers in the tool result; it just must NOT bind
|
||||
// a second A2UI tool on top.
|
||||
a2ui: {
|
||||
injectA2UITool: false,
|
||||
// Models follow the tool-usage guide and omit `catalogId`, and the
|
||||
// middleware then falls back to the unregistered spec basic catalog
|
||||
// ("Catalog not found" render error). Pin the catalog the page registers.
|
||||
defaultCatalogId: "copilotkit://app-dashboard-catalog",
|
||||
},
|
||||
// Turn on Open Generative UI for this agent. The runtime middleware
|
||||
// injects `generateSandboxedUi` as a frontend tool the LLM can call,
|
||||
// and converts streaming tool-call deltas into `open-generative-ui`
|
||||
// activity events the built-in renderer mounts in a sandboxed iframe.
|
||||
openGenerativeUI: {
|
||||
agents: ["beautiful-chat"],
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-beautiful-chat",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
// Dedicated runtime for the byoc-hashbrown demo (AG2).
|
||||
//
|
||||
// The demo page wraps CopilotChat in the HashBrownDashboard provider and
|
||||
// overrides the assistant message slot with a renderer that consumes
|
||||
// hashbrown-shaped structured output via `@hashbrownai/react`'s `useUiKit` +
|
||||
// `useJsonParser`. The agent behind this endpoint (`byoc_hashbrown`) has a
|
||||
// system prompt tuned to emit that shape — see
|
||||
// `src/agents/byoc_hashbrown_agent.py`.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const byocHashbrownAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/byoc-hashbrown/`,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts; published agents type generic mismatch.
|
||||
agents: {
|
||||
"byoc-hashbrown-demo": byocHashbrownAgent,
|
||||
default: byocHashbrownAgent,
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-byoc-hashbrown",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
// Dedicated runtime for the BYOC json-render demo (AG2).
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const byocJsonRenderAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/byoc-json-render/`,
|
||||
});
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts; published agents type generic mismatch.
|
||||
agents: {
|
||||
byoc_json_render: byocJsonRenderAgent,
|
||||
default: byocJsonRenderAgent,
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-byoc-json-render",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
// Dedicated runtime for the Declarative Generative UI (A2UI — Dynamic Schema)
|
||||
// cell. The backend is the dedicated `a2ui_dynamic.py` agent mounted at
|
||||
// `/declarative-gen-ui` (NOT the root catch-all `agent.py`): it owns the
|
||||
// `generate_a2ui` tool explicitly and runs its own secondary `render_a2ui`
|
||||
// LLM pass, returning an `a2ui_operations` container that the A2UI
|
||||
// middleware detects and streams to the frontend. This mirrors the sibling
|
||||
// dedicated routes (`/a2ui-fixed-schema/`, `/beautiful-chat/`, etc.) which
|
||||
// all point at their named mount, and matches the D6 fixtures + PARITY_NOTES.
|
||||
//
|
||||
// `injectA2UITool: false` — the agent already owns `generate_a2ui`, so the
|
||||
// runtime must NOT double-bind a second injected A2UI tool over it.
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: {
|
||||
"declarative-gen-ui": new HttpAgent({
|
||||
url: `${AGENT_URL}/declarative-gen-ui/`,
|
||||
}),
|
||||
},
|
||||
a2ui: {
|
||||
// The dedicated agent owns `generate_a2ui` and produces the
|
||||
// `a2ui_operations` container itself; do not inject a second A2UI tool.
|
||||
injectA2UITool: false,
|
||||
// Pin the catalog the page registers (mirrors the sibling
|
||||
// `/copilotkit-beautiful-chat` and `/copilotkit-a2ui-fixed-schema`
|
||||
// routes). The agent's emitted ops already carry this catalogId, but
|
||||
// pinning it guards against any op that omits it falling back to the
|
||||
// unregistered basic catalog ("Catalog not found" → surface never mounts).
|
||||
defaultCatalogId: "declarative-gen-ui-catalog",
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-declarative-gen-ui",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
// CopilotKit runtime for the MCP Apps cell.
|
||||
//
|
||||
// The runtime's `mcpApps` config auto-applies the MCP Apps middleware to the
|
||||
// agent: when the agent calls a tool backed by an MCP UI resource, the
|
||||
// middleware fetches the resource and emits the activity event that the
|
||||
// built-in `MCPAppsActivityRenderer` renders in the chat as a sandboxed iframe.
|
||||
//
|
||||
// Reference:
|
||||
// - showcase/integrations/langgraph-python/src/app/api/copilotkit-mcp-apps/route.ts
|
||||
// - src/agents/mcp_apps_agent.py (the AG2 backend, no bespoke tools)
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const mcpAppsAgent = new HttpAgent({ url: `${AGENT_URL}/mcp-apps/` });
|
||||
|
||||
const headlessCompleteAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/headless-complete/`,
|
||||
});
|
||||
|
||||
// @region[runtime-mcpapps-config]
|
||||
// The `mcpApps.servers` config is all you need server-side. The runtime
|
||||
// auto-applies the MCP Apps middleware to every registered agent: on each
|
||||
// MCP tool call it fetches the associated UI resource and emits an
|
||||
// `activity` event that the built-in `MCPAppsActivityRenderer` renders
|
||||
// inline in the chat.
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents: {
|
||||
"mcp-apps": mcpAppsAgent,
|
||||
// headless-complete shares this runtime because its cell also exercises
|
||||
// MCP Apps rendering (via a hand-rolled `useRenderActivityMessage` in
|
||||
// `use-rendered-messages.tsx`).
|
||||
"headless-complete": headlessCompleteAgent,
|
||||
},
|
||||
mcpApps: {
|
||||
servers: [
|
||||
{
|
||||
type: "http",
|
||||
url: process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com",
|
||||
// Pin a stable `serverId`. Without it CopilotKit hashes the URL and
|
||||
// a URL change silently breaks restoration of persisted MCP Apps in
|
||||
// prior conversation threads.
|
||||
serverId: "excalidraw",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// @endregion[runtime-mcpapps-config]
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-mcp-apps",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime,
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
// Dedicated runtime for the Multimodal Attachments demo (AG2).
|
||||
//
|
||||
// The backing AG2 agent runs gpt-4o (vision-capable). A dedicated route keeps
|
||||
// vision cost scoped to this cell.
|
||||
//
|
||||
// The page at src/app/demos/multimodal/page.tsx points its `runtimeUrl` at
|
||||
// this endpoint and sets `agent="multimodal-demo"` (the slug registered below).
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const multimodalAgent = new HttpAgent({ url: `${AGENT_URL}/multimodal/` });
|
||||
|
||||
const agents = {
|
||||
"multimodal-demo": multimodalAgent,
|
||||
default: multimodalAgent,
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-multimodal",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts; published agents type generic mismatch.
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
// Dedicated runtime for the Open Generative UI demos.
|
||||
//
|
||||
// Isolated here because the `openGenerativeUI` runtime flag sets
|
||||
// `openGenerativeUIEnabled: true` globally on the probe response, which
|
||||
// causes the CopilotKit provider's setTools effect to wipe per-demo
|
||||
// `useFrontendTool`/`useComponent` registrations in the default runtime.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const openGenUiAgent = new HttpAgent({ url: `${AGENT_URL}/open-gen-ui/` });
|
||||
const openGenUiAdvancedAgent = new HttpAgent({
|
||||
url: `${AGENT_URL}/open-gen-ui-advanced/`,
|
||||
});
|
||||
|
||||
const agents = {
|
||||
"open-gen-ui": openGenUiAgent,
|
||||
"open-gen-ui-advanced": openGenUiAdvancedAgent,
|
||||
};
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit-ogui",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
// @region[advanced-runtime-config]
|
||||
// @region[minimal-runtime-flag]
|
||||
// Server-side config is identical for the minimal and advanced cells —
|
||||
// the advanced behaviour (sandbox -> host function calls) is wired
|
||||
// entirely on the frontend via `openGenerativeUI.sandboxFunctions` on
|
||||
// the provider. The single `openGenerativeUI` flag below turns on
|
||||
// Open Generative UI for the listed agent(s); the runtime middleware
|
||||
// converts each agent's streamed `generateSandboxedUi` tool call into
|
||||
// `open-generative-ui` activity events.
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts
|
||||
agents,
|
||||
openGenerativeUI: {
|
||||
agents: ["open-gen-ui", "open-gen-ui-advanced"],
|
||||
},
|
||||
}),
|
||||
// @endregion[minimal-runtime-flag]
|
||||
// @endregion[advanced-runtime-config]
|
||||
});
|
||||
return await handleRequest(req);
|
||||
} catch (error: unknown) {
|
||||
const e = error as { message?: string; stack?: string };
|
||||
return NextResponse.json(
|
||||
{ error: e.message, stack: e.stack },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
// Dedicated runtime for the /demos/voice cell (AG2).
|
||||
//
|
||||
// Goals
|
||||
// -----
|
||||
// 1. Advertise `audioFileTranscriptionEnabled: true` on `/info` so the chat
|
||||
// composer renders the mic button.
|
||||
// 2. Handle `POST /transcribe` by invoking an OpenAI-backed
|
||||
// `TranscriptionServiceOpenAI` (from `@copilotkit/voice`).
|
||||
// 3. Return a deterministic 4xx when `OPENAI_API_KEY` is not configured.
|
||||
//
|
||||
// Wires the V2 `CopilotRuntime` directly because the V1 wrapper drops the
|
||||
// `transcriptionService` option. V2 URL-routes on `/info`, `/agent/:id/run`,
|
||||
// `/transcribe`, etc., so the route lives at `[[...slug]]/route.ts`.
|
||||
|
||||
// @region[voice-runtime]
|
||||
// @region[transcription-service-guard]
|
||||
import type { NextRequest } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
TranscriptionService,
|
||||
createCopilotRuntimeHandler,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import type { TranscribeFileOptions } from "@copilotkit/runtime/v2";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import { TranscriptionServiceOpenAI } from "@copilotkit/voice";
|
||||
import OpenAI from "openai";
|
||||
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
const voiceDemoAgent = new HttpAgent({ url: `${AGENT_URL}/` });
|
||||
|
||||
class GuardedOpenAITranscriptionService extends TranscriptionService {
|
||||
private delegate: TranscriptionServiceOpenAI | null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
this.delegate = apiKey
|
||||
? new TranscriptionServiceOpenAI({ openai: new OpenAI({ apiKey }) })
|
||||
: null;
|
||||
}
|
||||
|
||||
async transcribeFile(options: TranscribeFileOptions): Promise<string> {
|
||||
if (!this.delegate) {
|
||||
throw new Error(
|
||||
"OPENAI_API_KEY not configured for this deployment (api key missing). " +
|
||||
"Set OPENAI_API_KEY to enable voice transcription.",
|
||||
);
|
||||
}
|
||||
return this.delegate.transcribeFile(options);
|
||||
}
|
||||
}
|
||||
// @endregion[transcription-service-guard]
|
||||
|
||||
let cachedHandler: ((req: Request) => Promise<Response>) | null = null;
|
||||
function getHandler(): (req: Request) => Promise<Response> {
|
||||
if (cachedHandler) return cachedHandler;
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
// @ts-ignore -- see main route.ts; published agents type generic mismatch
|
||||
agents: {
|
||||
"voice-demo": voiceDemoAgent,
|
||||
default: voiceDemoAgent,
|
||||
},
|
||||
transcriptionService: new GuardedOpenAITranscriptionService(),
|
||||
});
|
||||
|
||||
cachedHandler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit-voice",
|
||||
});
|
||||
return cachedHandler;
|
||||
}
|
||||
|
||||
export const POST = (req: NextRequest) => getHandler()(req);
|
||||
export const GET = (req: NextRequest) => getHandler()(req);
|
||||
export const PUT = (req: NextRequest) => getHandler()(req);
|
||||
export const DELETE = (req: NextRequest) => getHandler()(req);
|
||||
// @endregion[voice-runtime]
|
||||
@@ -0,0 +1,182 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
||||
|
||||
// The agent backend runs as a separate process on port 8000.
|
||||
// This runtime proxies CopilotKit requests to it via AG-UI protocol.
|
||||
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
|
||||
|
||||
console.log("[copilotkit/route] Initializing CopilotKit runtime");
|
||||
console.log(`[copilotkit/route] AGENT_URL: ${AGENT_URL}`);
|
||||
|
||||
// Per-request request/response logging is gated behind this flag (default off).
|
||||
// Under d6 probe fan-out, unconditional per-request logs flooded Railway's
|
||||
// 500-logs/sec cap and killed the replica ("Messages dropped" → container stop).
|
||||
// Set SHOWCASE_ROUTE_DEBUG=1 to re-enable verbose per-request tracing locally.
|
||||
const ROUTE_DEBUG =
|
||||
process.env.SHOWCASE_ROUTE_DEBUG === "1" ||
|
||||
process.env.SHOWCASE_ROUTE_DEBUG === "true";
|
||||
|
||||
function createAgent(path = "/") {
|
||||
return new HttpAgent({ url: `${AGENT_URL}${path}` });
|
||||
}
|
||||
|
||||
// Register the same default agent under all shared names used by demo
|
||||
// pages. AG2's AGUIStream wraps a single ConversableAgent; most names
|
||||
// proxy to the same backend process. Frontend-only variations (slots,
|
||||
// sidebar, CSS theming, headless chat, tool rendering wildcards, etc.)
|
||||
// all reuse the shared `agent.py` ConversableAgent under a unique
|
||||
// registered name.
|
||||
const sharedAgentNames = [
|
||||
"agentic_chat",
|
||||
"human_in_the_loop",
|
||||
"tool-rendering",
|
||||
"gen-ui-tool-based",
|
||||
"shared-state-read",
|
||||
"shared-state-write",
|
||||
"shared-state-streaming",
|
||||
// Frontend-only variants (Batch 1) — same ConversableAgent, different UI.
|
||||
"prebuilt-sidebar",
|
||||
"prebuilt-popup",
|
||||
"chat-slots",
|
||||
"chat-customization-css",
|
||||
"headless-simple",
|
||||
"readonly-state-agent-context",
|
||||
"tool-rendering-default-catchall",
|
||||
"tool-rendering-custom-catchall",
|
||||
"frontend_tools",
|
||||
"frontend-tools-async",
|
||||
"hitl-in-app",
|
||||
"hitl-in-chat",
|
||||
];
|
||||
|
||||
// Reasoning agent names — backed by the reasoning-enabled AG2 agent at
|
||||
// /reasoning. Emits AG-UI REASONING_MESSAGE_* events that the frontend
|
||||
// renders via the `reasoningMessage` slot (built-in card for
|
||||
// `reasoning-default`, custom amber ReasoningBlock for `reasoning-custom`).
|
||||
// The demo pages use the ids `reasoning-default` / `reasoning-custom`; both
|
||||
// share the one reasoning backend. `agentic-chat-reasoning` and
|
||||
// `reasoning-default-render` are legacy aliases kept for any cell that still
|
||||
// references them.
|
||||
const reasoningAgentNames = [
|
||||
"reasoning-default",
|
||||
"reasoning-custom",
|
||||
"reasoning-default-render",
|
||||
"agentic-chat-reasoning",
|
||||
];
|
||||
|
||||
// Demos that own a dedicated FastAPI sub-app (mounted at a named path
|
||||
// in `agent_server.py`). Each gets its own HttpAgent pointed at that
|
||||
// path so its ContextVariables state slot is isolated from the shared
|
||||
// default agent.
|
||||
const dedicatedAgents: Record<string, string> = {
|
||||
"shared-state-read-write": "/shared-state-read-write/",
|
||||
subagents: "/subagents/",
|
||||
"headless-complete": "/headless-complete/",
|
||||
"tool-rendering-reasoning-chain": "/tool-rendering-reasoning-chain/",
|
||||
"agent-config-demo": "/agent-config/",
|
||||
"gen-ui-agent": "/gen-ui-agent/",
|
||||
};
|
||||
|
||||
// Interrupt-adapted demos: gen-ui-interrupt and interrupt-headless share the
|
||||
// same AG2 scheduling agent at /interrupt-adapted. The agent has tools=[];
|
||||
// `schedule_meeting` is provided by the frontend via `useFrontendTool`.
|
||||
const interruptAgentNames = ["gen-ui-interrupt", "interrupt-headless"];
|
||||
|
||||
const agents: Record<string, AbstractAgent> = {};
|
||||
for (const name of sharedAgentNames) {
|
||||
agents[name] = createAgent();
|
||||
}
|
||||
for (const name of reasoningAgentNames) {
|
||||
agents[name] = createAgent("/reasoning/");
|
||||
}
|
||||
for (const [name, path] of Object.entries(dedicatedAgents)) {
|
||||
agents[name] = createAgent(path);
|
||||
}
|
||||
for (const name of interruptAgentNames) {
|
||||
agents[name] = createAgent("/interrupt-adapted/");
|
||||
}
|
||||
agents["default"] = createAgent();
|
||||
|
||||
console.log(
|
||||
`[copilotkit/route] Registered ${Object.keys(agents).length} agent names: ${Object.keys(agents).join(", ")}`,
|
||||
);
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
const url = req.url;
|
||||
const contentType = req.headers.get("content-type");
|
||||
if (ROUTE_DEBUG) {
|
||||
console.log(
|
||||
`[copilotkit/route] POST ${url} (content-type: ${contentType})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
endpoint: "/api/copilotkit",
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
runtime: new CopilotRuntime({
|
||||
// @ts-ignore -- Published CopilotRuntime agents type wraps Record in MaybePromise<NonEmptyRecord<...>> which rejects plain Records; fixed in source, pending release
|
||||
agents,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await handleRequest(req);
|
||||
if (!response.ok) {
|
||||
console.log(`[copilotkit/route] Response status: ${response.status}`);
|
||||
} else if (ROUTE_DEBUG) {
|
||||
console.log(`[copilotkit/route] Response status: ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
// Log full details server-side (operators grep `errorId` to correlate),
|
||||
// but never echo `err.message` / `err.stack` back to the HTTP client —
|
||||
// that leaks internal paths, dependency versions, and stack traces.
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const errorId = crypto.randomUUID();
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
at: new Date().toISOString(),
|
||||
level: "error",
|
||||
scope: "copilotkit/route",
|
||||
errorId,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
}),
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "internal runtime error", errorId },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = async () => {
|
||||
if (ROUTE_DEBUG) {
|
||||
console.log("[copilotkit/route] GET /api/copilotkit (health probe)");
|
||||
}
|
||||
|
||||
let agentStatus = "unknown";
|
||||
try {
|
||||
const res = await fetch(`${AGENT_URL}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
agentStatus = res.ok ? "reachable" : `error (${res.status})`;
|
||||
} catch (e: unknown) {
|
||||
agentStatus = `unreachable (${(e as Error).message})`;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
agent_url: AGENT_URL,
|
||||
agent_status: agentStatus,
|
||||
env: {
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY ? "set" : "NOT SET",
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
});
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user