fix(showcase): D5 integration fixes across 12 frameworks

Per-framework fixes to pass D5 e2e-deep probes:
- agno: deduplicate agent_server routes
- claude-sdk-python: handle ParsedContentBlockStopEvent (SDK v0.97+)
- claude-sdk-typescript: remove orphan tool-rendering page
- crewai-crews: add backend tool_rendering agent + shared_state fix
- google-adk: add AGUIToolset to all ADK agents for frontend tools
- langgraph-typescript: remove stale import
- langroid: emit ToolCallResultEvent for backend tools + fix adapter
- llamaindex: v2 provider import, book_call stub, PYTHONPATH fix
- ms-agent-python: disable Responses API store for aimock compat
- pydantic-ai: simplify gen-ui page component
- spring-ai: raise tool iteration cap (1→5) + fix connection pooling
- strands: shared tools symlink + requirements update
This commit is contained in:
Jordan Ritter
2026-04-29 19:39:36 -07:00
parent 8d0bc3cd93
commit 534cd1efa7
212 changed files with 10652 additions and 390 deletions
-1
View File
@@ -1 +0,0 @@
../../shared/python/tools
@@ -0,0 +1,46 @@
"""Barrel exports for all shared showcase tool implementations."""
from .types import (
SalesStage,
SalesTodo,
Flight,
WeatherResult,
)
from .get_weather import get_weather_impl
from .query_data import query_data_impl
from .sales_todos import (
INITIAL_TODOS,
manage_sales_todos_impl,
get_sales_todos_impl,
)
from .search_flights import search_flights_impl
from .generate_a2ui import (
RENDER_A2UI_TOOL_SCHEMA,
generate_a2ui_impl,
build_a2ui_operations_from_tool_call,
)
from .schedule_meeting import schedule_meeting_impl
__all__ = [
# Types
"SalesStage",
"SalesTodo",
"Flight",
"WeatherResult",
# Weather
"get_weather_impl",
# Query data
"query_data_impl",
# Sales todos
"INITIAL_TODOS",
"manage_sales_todos_impl",
"get_sales_todos_impl",
# Flight search (fixed-schema A2UI)
"search_flights_impl",
# Dynamic A2UI
"RENDER_A2UI_TOOL_SCHEMA",
"generate_a2ui_impl",
"build_a2ui_operations_from_tool_call",
# Schedule meeting (HITL)
"schedule_meeting_impl",
]
@@ -0,0 +1,104 @@
"""Dynamic A2UI tool: LLM-generated UI from conversation context.
This module provides the data preparation for a secondary LLM call that
generates v0.9 A2UI components. The actual LLM call is made by the
framework-specific wrapper (LangGraph, CrewAI, etc.) since each framework
has its own way of invoking LLMs.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
_logger = logging.getLogger(__name__)
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
# The render_a2ui tool schema that the secondary LLM is bound to.
RENDER_A2UI_TOOL_SCHEMA = {
"name": "render_a2ui",
"description": (
"Render a dynamic A2UI v0.9 surface.\n\n"
"Args:\n"
" surfaceId: Unique surface identifier.\n"
" catalogId: The catalog ID (use \"copilotkit://app-dashboard-catalog\").\n"
" components: A2UI v0.9 component array (flat format). "
"The root component must have id \"root\".\n"
" data: Optional initial data model for the surface."
),
"parameters": {
"type": "object",
"properties": {
"surfaceId": {"type": "string", "description": "Unique surface identifier."},
"catalogId": {"type": "string", "description": "The catalog ID."},
"components": {
"type": "array",
"items": {"type": "object"},
"description": "A2UI v0.9 component array (flat format).",
},
"data": {
"type": "object",
"description": "Optional initial data model for the surface.",
},
},
"required": ["surfaceId", "catalogId", "components"],
},
}
def generate_a2ui_impl(
messages: list[dict[str, Any]],
context_entries: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Prepare inputs for a secondary LLM call that generates A2UI components.
Returns a dict with:
- system_prompt: The system prompt for the secondary LLM (built from context)
- tool_schema: The render_a2ui tool schema to bind to the LLM
- tool_choice: The tool name to force
- messages: The conversation messages to pass through
- catalog_id: The default catalog ID
The framework wrapper should:
1. Make an LLM call with these inputs
2. Extract the tool call args (surfaceId, catalogId, components, data)
3. Build a2ui_operations from the args and return them
"""
context_text = ""
if context_entries:
context_text = "\n\n".join(
entry.get("value", "")
for entry in context_entries
if isinstance(entry, dict) and entry.get("value")
)
return {
"system_prompt": context_text,
"tool_schema": RENDER_A2UI_TOOL_SCHEMA,
"tool_choice": "render_a2ui",
"messages": messages,
"catalog_id": CUSTOM_CATALOG_ID,
}
def build_a2ui_operations_from_tool_call(args: dict[str, Any]) -> dict[str, Any]:
"""Build a2ui_operations dict from the secondary LLM's tool call args.
Call this after the framework wrapper extracts the tool call arguments.
"""
surface_id = args.get("surfaceId", "dynamic-surface")
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
components = args.get("components", [])
if not components:
_logger.warning("build_a2ui_operations_from_tool_call received empty components list")
data = args.get("data")
ops = [
{"type": "create_surface", "surfaceId": surface_id, "catalogId": catalog_id},
{"type": "update_components", "surfaceId": surface_id, "components": components},
]
if data:
ops.append({"type": "update_data_model", "surfaceId": surface_id, "data": data})
return {"a2ui_operations": ops}
@@ -0,0 +1,40 @@
"""Mock weather data tool implementation."""
import random
from .types import WeatherResult
_CONDITIONS = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
]
def get_weather_impl(city: str) -> WeatherResult:
"""Return mock weather data for the given city.
Uses a seeded random based on the city name so repeated calls
for the same city return consistent results within a session.
"""
rng = random.Random(city.lower())
temperature = rng.randint(20, 95)
humidity = rng.randint(30, 90)
wind_speed = rng.randint(2, 30)
feels_like = temperature + rng.randint(-5, 5)
conditions = rng.choice(_CONDITIONS)
return WeatherResult(
city=city,
temperature=temperature,
humidity=humidity,
wind_speed=wind_speed,
feels_like=feels_like,
conditions=conditions,
)
@@ -0,0 +1,58 @@
"""Query data tool implementation — reads db.csv at module load time."""
from __future__ import annotations
import csv
import logging
from pathlib import Path
from typing import Any
_logger = logging.getLogger(__name__)
_csv_path = Path(__file__).resolve().parent.parent / "data" / "db.csv"
_MOCK_DATA = [
{
"date": "2026-01-05",
"category": "Revenue",
"subcategory": "Enterprise Subscriptions",
"amount": "28000",
"type": "income",
"notes": "3 new enterprise customers",
},
{
"date": "2026-01-10",
"category": "Expenses",
"subcategory": "Engineering Salaries",
"amount": "42000",
"type": "expense",
"notes": "7 engineers + 2 contractors",
},
{
"date": "2026-02-03",
"category": "Revenue",
"subcategory": "Pro Tier Upgrades",
"amount": "22500",
"type": "income",
"notes": "31 upgrades + reduced churn",
},
]
try:
with open(_csv_path) as _f:
_cached_data: list[dict[str, Any]] = list(csv.DictReader(_f))
if not _cached_data:
_logger.warning("CSV at %s is empty, falling back to mock data", _csv_path)
_cached_data = _MOCK_DATA
except (FileNotFoundError, OSError) as exc:
_logger.warning("Could not load CSV at %s (%s), falling back to mock data", _csv_path, exc)
_cached_data = _MOCK_DATA
def query_data_impl(query: str) -> list[dict[str, Any]]:
"""Query the database. Takes natural language.
Always call before showing a chart or graph. Returns the full
dataset as a list of dicts (rows from the CSV).
"""
return _cached_data
@@ -0,0 +1,61 @@
"""Sales todos tool implementation."""
from __future__ import annotations
import uuid
from typing import Optional
from .types import SalesTodo
INITIAL_TODOS: list[SalesTodo] = [
SalesTodo(
id="st-001",
title="Follow up with Acme Corp on enterprise proposal",
stage="proposal",
value=85000,
dueDate="2026-04-15",
assignee="Sarah Chen",
completed=False,
),
SalesTodo(
id="st-002",
title="Qualify lead from TechFlow demo request",
stage="prospect",
value=42000,
dueDate="2026-04-18",
assignee="Mike Johnson",
completed=False,
),
SalesTodo(
id="st-003",
title="Send contract to DataViz Inc for final review",
stage="negotiation",
value=120000,
dueDate="2026-04-20",
assignee="Sarah Chen",
completed=False,
),
]
def manage_sales_todos_impl(todos: list[dict]) -> list[SalesTodo]:
"""Assign UUIDs to any todos missing an ID, then return the updated list."""
result: list[SalesTodo] = []
for todo in todos:
result.append(SalesTodo(
id=todo.get("id") or str(uuid.uuid4()),
title=todo.get("title", ""),
stage=todo.get("stage", "prospect"),
value=todo.get("value", 0),
dueDate=todo.get("dueDate", ""),
assignee=todo.get("assignee", ""),
completed=todo.get("completed", False),
))
return result
def get_sales_todos_impl(current_todos: Optional[list[dict]] = None) -> list[SalesTodo]:
"""Return current todos or initial defaults if none provided."""
if current_todos is not None:
return manage_sales_todos_impl(current_todos)
return list(INITIAL_TODOS)
@@ -0,0 +1,31 @@
"""Schedule meeting tool implementation.
The HITL gating happens on the frontend via useHumanInTheLoop.
This tool just returns a pending approval status for the framework
wrapper to surface.
"""
from __future__ import annotations
from typing import Any
def schedule_meeting_impl(
reason: str,
duration_minutes: int = 30,
) -> dict[str, Any]:
"""Schedule a meeting (requires human approval).
Returns a pending_approval status. The actual gating is done by the
frontend's useHumanInTheLoop hook — the agent pauses until the user
approves or rejects.
"""
return {
"status": "pending_approval",
"reason": reason,
"duration_minutes": duration_minutes,
"message": (
f"Meeting request: {reason} ({duration_minutes} min). "
"Awaiting human approval."
),
}
@@ -0,0 +1,106 @@
"""Fixed-schema A2UI tool: flight search results.
Packages flight data with an A2UI schema for rendering. The schema is loaded
from the shared frontend package's flight_schema.json.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from .types import Flight
_logger = logging.getLogger(__name__)
CATALOG_ID = "copilotkit://app-dashboard-catalog"
SURFACE_ID = "flight-search-results"
# Resolve the flight schema from the shared frontend package.
# Walk up from this file to showcase/shared/, then into frontend/src/a2ui/.
_SHARED_DIR = Path(__file__).resolve().parent.parent.parent # showcase/shared/
_SCHEMA_CANDIDATES = [
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight-schema.json",
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight_schema.json",
]
_flight_schema: list[dict[str, Any]] | None = None
for _candidate in _SCHEMA_CANDIDATES:
if _candidate.exists():
with open(_candidate) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from shared frontend: %s", _candidate)
break
# Fallback: use the schema from the examples directory if present
if _flight_schema is None:
try:
_fallback = Path(__file__).resolve().parents[4] / (
"examples/integrations/langgraph-python/apps/agent/src/a2ui/schemas/flight_schema.json"
)
if _fallback.exists():
with open(_fallback) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from examples fallback: %s", _fallback)
except IndexError:
# In Docker the file path is too shallow for parents[4]; skip this fallback.
pass
# Last resort: inline minimal schema
if _flight_schema is None:
_logger.warning("No flight schema file found, using inline minimal schema")
_flight_schema = [
{
"id": "root",
"component": "Row",
"children": {"componentId": "flight-card", "path": "/flights"},
"gap": 16,
},
{
"id": "flight-card",
"component": "FlightCard",
"airline": {"path": "airline"},
"airlineLogo": {"path": "airlineLogo"},
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"date": {"path": "date"},
"departureTime": {"path": "departureTime"},
"arrivalTime": {"path": "arrivalTime"},
"duration": {"path": "duration"},
"status": {"path": "status"},
"price": {"path": "price"},
"action": {
"event": {
"name": "book_flight",
"context": {
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"price": {"path": "price"},
},
}
},
},
]
def search_flights_impl(flights: list[Flight]) -> dict[str, Any]:
"""Package flight data with A2UI schema for rendering.
Returns a dict with a2ui_operations that the middleware detects in the
TOOL_CALL_RESULT and renders automatically.
Each flight should have: airline, airlineLogo, flightNumber, origin,
destination, date, departureTime, arrivalTime, duration, status,
statusColor, price, currency.
"""
return {
"a2ui_operations": [
{"type": "create_surface", "surfaceId": SURFACE_ID, "catalogId": CATALOG_ID},
{"type": "update_components", "surfaceId": SURFACE_ID, "components": _flight_schema},
{"type": "update_data_model", "surfaceId": SURFACE_ID, "data": {"flights": flights}},
]
}
+47
View File
@@ -0,0 +1,47 @@
"""Shared type definitions for showcase tools."""
from typing import TypedDict, Literal
SalesStage = Literal[
"prospect",
"qualified",
"proposal",
"negotiation",
"closed-won",
"closed-lost",
]
class SalesTodo(TypedDict):
id: str
title: str
stage: SalesStage
value: int
dueDate: str
assignee: str
completed: bool
class Flight(TypedDict):
airline: str
airlineLogo: str
flightNumber: str
origin: str
destination: str
date: str
departureTime: str
arrivalTime: str
duration: str
status: str
statusColor: str
price: str
currency: str
class WeatherResult(TypedDict):
city: str
temperature: int
humidity: int
wind_speed: int
feels_like: int
conditions: str
+187 -2
View File
@@ -6,6 +6,8 @@ The Next.js CopilotKit runtime proxies requests to each interface via AG-UI.
Interfaces:
/agui → main agent (sales assistant, most demos)
Custom handler that forwards tool results
from AGUI messages so HITL round-trips work.
/reasoning/agui → reasoning-capable agent
/shared-state-rw/agui → bidirectional shared-state agent
(custom router emits STATE_SNAPSHOT)
@@ -16,7 +18,7 @@ Interfaces:
import asyncio
import os
import uuid
from typing import Any, AsyncIterator, Optional, Union
from typing import Any, AsyncIterator, List, Optional, Set, Union
import dotenv
from ag_ui.core import (
@@ -28,8 +30,10 @@ from ag_ui.core import (
RunStartedEvent,
StateSnapshotEvent,
)
from ag_ui.core.types import Message as AGUIMessage
from ag_ui.encoder import EventEncoder
from agno.agent import Agent, RemoteAgent
from agno.models.message import Message
from agno.os import AgentOS
from agno.os.interfaces.agui import AGUI
from agno.os.interfaces.agui.utils import (
@@ -37,6 +41,7 @@ from agno.os.interfaces.agui.utils import (
extract_agui_user_input,
validate_agui_state,
)
from agno.utils.log import log_debug, log_warning
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from starlette.middleware.base import BaseHTTPMiddleware
@@ -61,6 +66,179 @@ from agents.subagents import agent as subagents_supervisor
dotenv.load_dotenv()
# ---------------------------------------------------------------------------
# AGUI message conversion for HITL tool-result forwarding
# ---------------------------------------------------------------------------
#
# agno >= 2.5.17 changed the stock AGUI router to use
# `extract_agui_user_input()` which passes ONLY the last user message to
# the agent. This works for simple chat but breaks Human-in-the-Loop
# flows: the second request (after the user confirms/rejects in the HITL
# UI) carries the tool result as an AGUI "tool" role message. Since
# `extract_agui_user_input` discards all non-user messages, the tool
# result never reaches the LLM and the agent just re-calls the tool and
# pauses again.
#
# The helper below converts AGUI messages to agno Messages — the same
# thing `convert_agui_messages_to_agno_messages` did in older agno
# releases — so we can detect tool results and pass the full conversation
# to the agent when they exist.
def _has_tool_results(messages: List[AGUIMessage]) -> bool:
"""Return True if the message list contains any tool-result messages."""
return any(msg.role == "tool" for msg in messages)
def _convert_agui_messages(messages: List[AGUIMessage]) -> List[Message]:
"""Convert AG-UI messages to Agno messages (full conversation).
Mirrors the old `convert_agui_messages_to_agno_messages` from
agno < 2.5.17. Keeps assistant tool_calls only when a matching
tool-result message exists, so the LLM always sees complete pairs.
"""
# First pass: collect tool_call_ids that have results
tool_ids_with_results: Set[str] = set()
for msg in messages:
if msg.role == "tool" and msg.tool_call_id:
tool_ids_with_results.add(msg.tool_call_id)
result: List[Message] = []
seen_tool_ids: Set[str] = set()
for msg in messages:
if msg.role == "tool":
if msg.tool_call_id in seen_tool_ids:
continue
seen_tool_ids.add(msg.tool_call_id)
result.append(
Message(
role="tool",
tool_call_id=msg.tool_call_id,
content=msg.content,
)
)
elif msg.role == "assistant":
tool_calls = None
if msg.tool_calls:
filtered = [
tc for tc in msg.tool_calls
if tc.id in tool_ids_with_results
]
if filtered:
tool_calls = [tc.model_dump(exclude_none=True) for tc in filtered]
result.append(
Message(
role="assistant",
content=msg.content,
tool_calls=tool_calls,
)
)
elif msg.role == "user":
result.append(Message(role="user", content=msg.content))
# system messages are skipped — agent builds its own
return result
# ---------------------------------------------------------------------------
# HITL-aware AGUI handler for the main agent
# ---------------------------------------------------------------------------
#
# The stock AGUI handler passes only the last user message to the agent,
# relying on agno's session DB for history. This works for standard chat
# but breaks HITL: the second leg (tool-result) is dropped.
#
# This custom handler detects tool results in the incoming AGUI messages
# and, when present, passes the full message list to the agent instead.
# For first-leg requests (no tool results) it falls back to the stock
# `extract_agui_user_input` behaviour.
async def _run_main_agent_hitl_aware(
agent: Union[Agent, RemoteAgent], run_input: RunAgentInput
) -> AsyncIterator[BaseEvent]:
"""Stream one agent run, forwarding tool results when present."""
run_id = run_input.run_id or str(uuid.uuid4())
thread_id = run_input.thread_id
try:
messages = run_input.messages or []
has_results = _has_tool_results(messages)
if has_results:
# Second leg: convert full conversation so the LLM sees the
# tool result and can generate a follow-up response.
agent_input = _convert_agui_messages(messages)
log_debug("HITL-aware handler: forwarding full messages (tool results present)")
else:
# First leg: extract only the user message (stock behaviour).
agent_input = extract_agui_user_input(messages)
log_debug("HITL-aware handler: extracting user input (no tool results)")
yield RunStartedEvent(
type=EventType.RUN_STARTED, thread_id=thread_id, run_id=run_id
)
user_id: Optional[str] = None
if run_input.forwarded_props and isinstance(run_input.forwarded_props, dict):
user_id = run_input.forwarded_props.get("user_id")
session_state = validate_agui_state(run_input.state, thread_id) or {}
response_stream = agent.arun( # type: ignore[attr-defined]
input=agent_input,
session_id=thread_id,
stream=True,
stream_events=True,
user_id=user_id,
session_state=session_state,
run_id=run_id,
# When we pass full messages (HITL second leg), disable session
# history to avoid duplicating messages the caller already sent.
add_history_to_context=not has_results,
)
async for event in async_stream_agno_response_as_agui_events(
response_stream=response_stream, # type: ignore[arg-type]
thread_id=thread_id,
run_id=run_id,
):
yield event
except asyncio.CancelledError: # noqa: TRY302
raise
except Exception as exc: # noqa: BLE001
yield RunErrorEvent(type=EventType.RUN_ERROR, message=str(exc))
def _attach_hitl_aware_route(
app: FastAPI, agent: Agent, prefix: str
) -> None:
"""Mount a HITL-aware AGUI POST endpoint at `<prefix>/agui`."""
encoder = EventEncoder()
route = f"{prefix.rstrip('/')}/agui"
async def _handler(run_input: RunAgentInput) -> StreamingResponse:
async def _gen():
async for event in _run_main_agent_hitl_aware(agent, run_input):
yield encoder.encode(event)
return StreamingResponse(
_gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
)
app.post(route, name=f"agui_hitl_aware_{prefix.strip('/')}")(_handler)
# ---------------------------------------------------------------------------
# State-aware AGUI handler
# ---------------------------------------------------------------------------
@@ -292,7 +470,8 @@ agent_os = AgentOS(
subagents_supervisor,
],
interfaces=[
AGUI(agent=main_agent), # default prefix "" -> /agui
# main_agent is mounted separately below via _attach_hitl_aware_route
# so it can forward tool results for HITL round-trips.
AGUI(agent=reasoning_agent, prefix="/reasoning"), # -> /reasoning/agui
# No-tools agent for the MCP Apps cell. The CopilotKit runtime's
# `mcpApps.servers` middleware injects MCP server tools at request
@@ -323,6 +502,12 @@ agent_os = AgentOS(
)
app = agent_os.get_app()
# HITL-aware route for the main agent. Replaces the stock AGUI interface
# (``AGUI(agent=main_agent)``) so tool results from the CopilotKit runtime
# are forwarded to the LLM on the second leg of HITL flows instead of being
# silently dropped by ``extract_agui_user_input()``.
_attach_hitl_aware_route(app, main_agent, "")
# State-aware routes (bidirectional shared state via StateSnapshotEvent).
# Mounted directly on the AgentOS FastAPI app so they share routing and
# CORS with the stock AGUI interfaces above.
+25 -2
View File
@@ -75,6 +75,20 @@ def schedule_meeting(reason: str):
return json.dumps(schedule_meeting_impl(reason))
@tool(external_execution=True, external_execution_silent=True)
def request_user_approval(message: str, context: str = ""):
"""
Ask the operator to approve or reject an action before you take it.
The operator will respond via an in-app modal dialog that appears
OUTSIDE the chat surface. The tool returns an object of the shape
{ approved: boolean, reason?: string }.
Args:
message (str): Short summary of the action needing approval (include concrete numbers / IDs).
context (str): Optional extra context — e.g. the ticket ID or policy rule.
"""
@tool(external_execution=True)
def change_background(background: str):
"""
@@ -88,7 +102,7 @@ def change_background(background: str):
"""
@tool(external_execution=True)
@tool(external_execution=True, external_execution_silent=True)
def book_call(topic: str, name: str):
"""
Ask the user to pick a time slot for a call. The picker UI presents
@@ -100,7 +114,7 @@ def book_call(topic: str, name: str):
"""
@tool(external_execution=True)
@tool(external_execution=True, external_execution_silent=True)
def generate_task_steps(steps: list[dict]):
"""
Generates a list of steps for the user to perform.
@@ -238,6 +252,7 @@ agent = Agent(
change_background,
book_call,
generate_task_steps,
request_user_approval,
search_flights,
get_stock_price,
roll_dice,
@@ -288,5 +303,13 @@ agent = Agent(
DYNAMIC A2UI:
Use generate_a2ui when the user asks for a dashboard or dynamic UI.
USER APPROVAL (HITL):
When asked to take any action that affects a customer — for example
issuing a refund, updating a plan, cancelling a subscription,
escalating a ticket, or sending a credit — call request_user_approval
FIRST with a short summary and optional context. Follow the tool
result: if approved, confirm in one short sentence; if rejected,
acknowledge and do not retry.
""",
)
-1
View File
@@ -1 +0,0 @@
../../shared/python/tools
@@ -0,0 +1,46 @@
"""Barrel exports for all shared showcase tool implementations."""
from .types import (
SalesStage,
SalesTodo,
Flight,
WeatherResult,
)
from .get_weather import get_weather_impl
from .query_data import query_data_impl
from .sales_todos import (
INITIAL_TODOS,
manage_sales_todos_impl,
get_sales_todos_impl,
)
from .search_flights import search_flights_impl
from .generate_a2ui import (
RENDER_A2UI_TOOL_SCHEMA,
generate_a2ui_impl,
build_a2ui_operations_from_tool_call,
)
from .schedule_meeting import schedule_meeting_impl
__all__ = [
# Types
"SalesStage",
"SalesTodo",
"Flight",
"WeatherResult",
# Weather
"get_weather_impl",
# Query data
"query_data_impl",
# Sales todos
"INITIAL_TODOS",
"manage_sales_todos_impl",
"get_sales_todos_impl",
# Flight search (fixed-schema A2UI)
"search_flights_impl",
# Dynamic A2UI
"RENDER_A2UI_TOOL_SCHEMA",
"generate_a2ui_impl",
"build_a2ui_operations_from_tool_call",
# Schedule meeting (HITL)
"schedule_meeting_impl",
]
@@ -0,0 +1,104 @@
"""Dynamic A2UI tool: LLM-generated UI from conversation context.
This module provides the data preparation for a secondary LLM call that
generates v0.9 A2UI components. The actual LLM call is made by the
framework-specific wrapper (LangGraph, CrewAI, etc.) since each framework
has its own way of invoking LLMs.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
_logger = logging.getLogger(__name__)
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
# The render_a2ui tool schema that the secondary LLM is bound to.
RENDER_A2UI_TOOL_SCHEMA = {
"name": "render_a2ui",
"description": (
"Render a dynamic A2UI v0.9 surface.\n\n"
"Args:\n"
" surfaceId: Unique surface identifier.\n"
" catalogId: The catalog ID (use \"copilotkit://app-dashboard-catalog\").\n"
" components: A2UI v0.9 component array (flat format). "
"The root component must have id \"root\".\n"
" data: Optional initial data model for the surface."
),
"parameters": {
"type": "object",
"properties": {
"surfaceId": {"type": "string", "description": "Unique surface identifier."},
"catalogId": {"type": "string", "description": "The catalog ID."},
"components": {
"type": "array",
"items": {"type": "object"},
"description": "A2UI v0.9 component array (flat format).",
},
"data": {
"type": "object",
"description": "Optional initial data model for the surface.",
},
},
"required": ["surfaceId", "catalogId", "components"],
},
}
def generate_a2ui_impl(
messages: list[dict[str, Any]],
context_entries: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Prepare inputs for a secondary LLM call that generates A2UI components.
Returns a dict with:
- system_prompt: The system prompt for the secondary LLM (built from context)
- tool_schema: The render_a2ui tool schema to bind to the LLM
- tool_choice: The tool name to force
- messages: The conversation messages to pass through
- catalog_id: The default catalog ID
The framework wrapper should:
1. Make an LLM call with these inputs
2. Extract the tool call args (surfaceId, catalogId, components, data)
3. Build a2ui_operations from the args and return them
"""
context_text = ""
if context_entries:
context_text = "\n\n".join(
entry.get("value", "")
for entry in context_entries
if isinstance(entry, dict) and entry.get("value")
)
return {
"system_prompt": context_text,
"tool_schema": RENDER_A2UI_TOOL_SCHEMA,
"tool_choice": "render_a2ui",
"messages": messages,
"catalog_id": CUSTOM_CATALOG_ID,
}
def build_a2ui_operations_from_tool_call(args: dict[str, Any]) -> dict[str, Any]:
"""Build a2ui_operations dict from the secondary LLM's tool call args.
Call this after the framework wrapper extracts the tool call arguments.
"""
surface_id = args.get("surfaceId", "dynamic-surface")
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
components = args.get("components", [])
if not components:
_logger.warning("build_a2ui_operations_from_tool_call received empty components list")
data = args.get("data")
ops = [
{"type": "create_surface", "surfaceId": surface_id, "catalogId": catalog_id},
{"type": "update_components", "surfaceId": surface_id, "components": components},
]
if data:
ops.append({"type": "update_data_model", "surfaceId": surface_id, "data": data})
return {"a2ui_operations": ops}
@@ -0,0 +1,40 @@
"""Mock weather data tool implementation."""
import random
from .types import WeatherResult
_CONDITIONS = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
]
def get_weather_impl(city: str) -> WeatherResult:
"""Return mock weather data for the given city.
Uses a seeded random based on the city name so repeated calls
for the same city return consistent results within a session.
"""
rng = random.Random(city.lower())
temperature = rng.randint(20, 95)
humidity = rng.randint(30, 90)
wind_speed = rng.randint(2, 30)
feels_like = temperature + rng.randint(-5, 5)
conditions = rng.choice(_CONDITIONS)
return WeatherResult(
city=city,
temperature=temperature,
humidity=humidity,
wind_speed=wind_speed,
feels_like=feels_like,
conditions=conditions,
)
@@ -0,0 +1,58 @@
"""Query data tool implementation — reads db.csv at module load time."""
from __future__ import annotations
import csv
import logging
from pathlib import Path
from typing import Any
_logger = logging.getLogger(__name__)
_csv_path = Path(__file__).resolve().parent.parent / "data" / "db.csv"
_MOCK_DATA = [
{
"date": "2026-01-05",
"category": "Revenue",
"subcategory": "Enterprise Subscriptions",
"amount": "28000",
"type": "income",
"notes": "3 new enterprise customers",
},
{
"date": "2026-01-10",
"category": "Expenses",
"subcategory": "Engineering Salaries",
"amount": "42000",
"type": "expense",
"notes": "7 engineers + 2 contractors",
},
{
"date": "2026-02-03",
"category": "Revenue",
"subcategory": "Pro Tier Upgrades",
"amount": "22500",
"type": "income",
"notes": "31 upgrades + reduced churn",
},
]
try:
with open(_csv_path) as _f:
_cached_data: list[dict[str, Any]] = list(csv.DictReader(_f))
if not _cached_data:
_logger.warning("CSV at %s is empty, falling back to mock data", _csv_path)
_cached_data = _MOCK_DATA
except (FileNotFoundError, OSError) as exc:
_logger.warning("Could not load CSV at %s (%s), falling back to mock data", _csv_path, exc)
_cached_data = _MOCK_DATA
def query_data_impl(query: str) -> list[dict[str, Any]]:
"""Query the database. Takes natural language.
Always call before showing a chart or graph. Returns the full
dataset as a list of dicts (rows from the CSV).
"""
return _cached_data
@@ -0,0 +1,61 @@
"""Sales todos tool implementation."""
from __future__ import annotations
import uuid
from typing import Optional
from .types import SalesTodo
INITIAL_TODOS: list[SalesTodo] = [
SalesTodo(
id="st-001",
title="Follow up with Acme Corp on enterprise proposal",
stage="proposal",
value=85000,
dueDate="2026-04-15",
assignee="Sarah Chen",
completed=False,
),
SalesTodo(
id="st-002",
title="Qualify lead from TechFlow demo request",
stage="prospect",
value=42000,
dueDate="2026-04-18",
assignee="Mike Johnson",
completed=False,
),
SalesTodo(
id="st-003",
title="Send contract to DataViz Inc for final review",
stage="negotiation",
value=120000,
dueDate="2026-04-20",
assignee="Sarah Chen",
completed=False,
),
]
def manage_sales_todos_impl(todos: list[dict]) -> list[SalesTodo]:
"""Assign UUIDs to any todos missing an ID, then return the updated list."""
result: list[SalesTodo] = []
for todo in todos:
result.append(SalesTodo(
id=todo.get("id") or str(uuid.uuid4()),
title=todo.get("title", ""),
stage=todo.get("stage", "prospect"),
value=todo.get("value", 0),
dueDate=todo.get("dueDate", ""),
assignee=todo.get("assignee", ""),
completed=todo.get("completed", False),
))
return result
def get_sales_todos_impl(current_todos: Optional[list[dict]] = None) -> list[SalesTodo]:
"""Return current todos or initial defaults if none provided."""
if current_todos is not None:
return manage_sales_todos_impl(current_todos)
return list(INITIAL_TODOS)
@@ -0,0 +1,31 @@
"""Schedule meeting tool implementation.
The HITL gating happens on the frontend via useHumanInTheLoop.
This tool just returns a pending approval status for the framework
wrapper to surface.
"""
from __future__ import annotations
from typing import Any
def schedule_meeting_impl(
reason: str,
duration_minutes: int = 30,
) -> dict[str, Any]:
"""Schedule a meeting (requires human approval).
Returns a pending_approval status. The actual gating is done by the
frontend's useHumanInTheLoop hook — the agent pauses until the user
approves or rejects.
"""
return {
"status": "pending_approval",
"reason": reason,
"duration_minutes": duration_minutes,
"message": (
f"Meeting request: {reason} ({duration_minutes} min). "
"Awaiting human approval."
),
}
@@ -0,0 +1,106 @@
"""Fixed-schema A2UI tool: flight search results.
Packages flight data with an A2UI schema for rendering. The schema is loaded
from the shared frontend package's flight_schema.json.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from .types import Flight
_logger = logging.getLogger(__name__)
CATALOG_ID = "copilotkit://app-dashboard-catalog"
SURFACE_ID = "flight-search-results"
# Resolve the flight schema from the shared frontend package.
# Walk up from this file to showcase/shared/, then into frontend/src/a2ui/.
_SHARED_DIR = Path(__file__).resolve().parent.parent.parent # showcase/shared/
_SCHEMA_CANDIDATES = [
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight-schema.json",
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight_schema.json",
]
_flight_schema: list[dict[str, Any]] | None = None
for _candidate in _SCHEMA_CANDIDATES:
if _candidate.exists():
with open(_candidate) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from shared frontend: %s", _candidate)
break
# Fallback: use the schema from the examples directory if present
if _flight_schema is None:
try:
_fallback = Path(__file__).resolve().parents[4] / (
"examples/integrations/langgraph-python/apps/agent/src/a2ui/schemas/flight_schema.json"
)
if _fallback.exists():
with open(_fallback) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from examples fallback: %s", _fallback)
except IndexError:
# In Docker the file path is too shallow for parents[4]; skip this fallback.
pass
# Last resort: inline minimal schema
if _flight_schema is None:
_logger.warning("No flight schema file found, using inline minimal schema")
_flight_schema = [
{
"id": "root",
"component": "Row",
"children": {"componentId": "flight-card", "path": "/flights"},
"gap": 16,
},
{
"id": "flight-card",
"component": "FlightCard",
"airline": {"path": "airline"},
"airlineLogo": {"path": "airlineLogo"},
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"date": {"path": "date"},
"departureTime": {"path": "departureTime"},
"arrivalTime": {"path": "arrivalTime"},
"duration": {"path": "duration"},
"status": {"path": "status"},
"price": {"path": "price"},
"action": {
"event": {
"name": "book_flight",
"context": {
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"price": {"path": "price"},
},
}
},
},
]
def search_flights_impl(flights: list[Flight]) -> dict[str, Any]:
"""Package flight data with A2UI schema for rendering.
Returns a dict with a2ui_operations that the middleware detects in the
TOOL_CALL_RESULT and renders automatically.
Each flight should have: airline, airlineLogo, flightNumber, origin,
destination, date, departureTime, arrivalTime, duration, status,
statusColor, price, currency.
"""
return {
"a2ui_operations": [
{"type": "create_surface", "surfaceId": SURFACE_ID, "catalogId": CATALOG_ID},
{"type": "update_components", "surfaceId": SURFACE_ID, "components": _flight_schema},
{"type": "update_data_model", "surfaceId": SURFACE_ID, "data": {"flights": flights}},
]
}
+47
View File
@@ -0,0 +1,47 @@
"""Shared type definitions for showcase tools."""
from typing import TypedDict, Literal
SalesStage = Literal[
"prospect",
"qualified",
"proposal",
"negotiation",
"closed-won",
"closed-lost",
]
class SalesTodo(TypedDict):
id: str
title: str
stage: SalesStage
value: int
dueDate: str
assignee: str
completed: bool
class Flight(TypedDict):
airline: str
airlineLogo: str
flightNumber: str
origin: str
destination: str
date: str
departureTime: str
arrivalTime: str
duration: str
status: str
statusColor: str
price: str
currency: str
class WeatherResult(TypedDict):
city: str
temperature: int
humidity: int
wind_speed: int
feels_like: int
conditions: str
@@ -1,4 +1,5 @@
anthropic>=0.43.0
openai>=1.40.0
ag-ui-protocol>=0.1.14
fastapi>=0.115.0
uvicorn>=0.34.0
@@ -186,7 +186,7 @@ async def run_a2ui_dynamic_agent(input_data: RunAgentInput) -> AsyncIterator[str
tool_call_id=current_tool_id or "",
delta=delta.partial_json,
))
elif etype == "RawContentBlockStopEvent":
elif etype in ("RawContentBlockStopEvent", "ParsedContentBlockStopEvent"):
if current_tool_id and current_tool_name:
yield encoder.encode(ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
@@ -189,7 +189,7 @@ async def run_a2ui_fixed_agent(input_data: RunAgentInput) -> AsyncIterator[str]:
tool_call_id=current_tool_id or "",
delta=delta.partial_json,
))
elif etype == "RawContentBlockStopEvent":
elif etype in ("RawContentBlockStopEvent", "ParsedContentBlockStopEvent"):
if current_tool_id and current_tool_name:
yield encoder.encode(ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
@@ -362,6 +362,36 @@ def _execute_tool(name: str, tool_input: dict[str, Any], state: AgentState, conv
return f"Unknown tool: {name}", None
def _build_frontend_tools(input_data: RunAgentInput) -> list[dict[str, Any]]:
"""Extract frontend-defined tools from the AG-UI request.
The CopilotKit runtime forwards frontend tool definitions (registered
via ``useFrontendTool``, ``useHumanInTheLoop``, etc.) in
``input_data.tools``. We convert them to the Anthropic ``tools``
schema so the LLM can call them. The runtime intercepts the resulting
tool-call events and routes them to the frontend for resolution.
"""
out: list[dict[str, Any]] = []
for t in (input_data.tools or []):
name = getattr(t, "name", None) or (
t.get("name") if isinstance(t, dict) else None
)
description = getattr(t, "description", None) or (
t.get("description", "") if isinstance(t, dict) else ""
)
parameters = getattr(t, "parameters", None) or (
t.get("parameters", {}) if isinstance(t, dict) else {}
)
if not name:
continue
out.append({
"name": name,
"description": description or "",
"input_schema": parameters or {"type": "object", "properties": {}},
})
return out
async def run_agent(
input_data: RunAgentInput,
*,
@@ -398,9 +428,57 @@ async def run_agent(
# supplied we preserve the structured content list (image blocks,
# document text, etc.) — otherwise we collapse to a flat string for
# the text-only happy path used by most demos.
#
# AG-UI delivers three message roles:
# - "user" → plain user text
# - "assistant" → assistant text + optional tool_use blocks
# - "tool" → tool result from a resolved frontend tool
#
# Anthropic's Messages API represents tool results as a "user" role
# message with content blocks of type "tool_result". We must convert
# AG-UI "tool" messages into that shape so the LLM sees the resolved
# result and aimock's ``hasToolResult`` matcher fires correctly.
messages: list[dict[str, Any]] = []
for msg in (input_data.messages or []):
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
# Handle tool result messages from AG-UI (resolved frontend tools).
# Convert to Anthropic's format: role="user" with tool_result blocks.
if role == "tool":
tool_call_id = getattr(msg, "tool_call_id", None) or (
getattr(msg, "toolCallId", None)
)
raw_content = getattr(msg, "content", None)
result_text = ""
if isinstance(raw_content, str):
result_text = raw_content
elif isinstance(raw_content, list):
parts = []
for part in raw_content:
if hasattr(part, "text"):
parts.append(part.text)
elif isinstance(part, dict) and "text" in part:
parts.append(part["text"])
parts_text = "".join(parts)
if parts_text:
result_text = parts_text
else:
result_text = json.dumps(raw_content)
if tool_call_id:
# Anthropic expects the assistant message containing the
# tool_use to precede this tool_result message. The runtime
# ensures message ordering, so we just need to emit the
# tool_result in the right shape.
messages.append({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_call_id,
"content": result_text,
}],
})
continue
if role not in ("user", "assistant"):
continue
@@ -425,6 +503,53 @@ async def run_agent(
messages.append({"role": role, "content": converted_parts})
continue
# For assistant messages, check if there are tool calls (AG-UI's
# AssistantMessage stores them in `tool_calls`, not in `content`).
# Anthropic requires tool_use blocks in the assistant content so
# the subsequent tool_result can pair with them.
if role == "assistant":
msg_tool_calls = getattr(msg, "tool_calls", None)
text_content = ""
if isinstance(raw_content, str):
text_content = raw_content
elif isinstance(raw_content, list):
for part in raw_content:
if hasattr(part, "text"):
text_content += part.text
elif isinstance(part, dict) and "text" in part:
text_content += part["text"]
if msg_tool_calls:
content_blocks: list[dict[str, Any]] = []
if text_content:
content_blocks.append({"type": "text", "text": text_content})
for tc in msg_tool_calls:
# AG-UI ToolCall: {id, function: {name, arguments}}
tc_id = getattr(tc, "id", None) or (tc.get("id") if isinstance(tc, dict) else None)
func = getattr(tc, "function", None) or (tc.get("function") if isinstance(tc, dict) else None)
if func:
tc_name = getattr(func, "name", None) or (func.get("name") if isinstance(func, dict) else "unknown")
tc_args_str = getattr(func, "arguments", None) or (func.get("arguments", "{}") if isinstance(func, dict) else "{}")
else:
tc_name = "unknown"
tc_args_str = "{}"
try:
tc_args = json.loads(tc_args_str) if isinstance(tc_args_str, str) else tc_args_str
except json.JSONDecodeError:
tc_args = {}
content_blocks.append({
"type": "tool_use",
"id": tc_id or "unknown",
"name": tc_name,
"input": tc_args,
})
messages.append({"role": "assistant", "content": content_blocks})
continue
elif text_content:
messages.append({"role": "assistant", "content": text_content})
continue
# Fall through to the generic handler if nothing matched
content = ""
if isinstance(raw_content, str):
content = raw_content
@@ -465,10 +590,24 @@ async def run_agent(
role="assistant",
))
# Stream Claude response. BYOC / multimodal demos opt out of the
# shared sales-assistant tool schemas so the model replies as pure
# text (or structured JSON for BYOC) rather than chasing tool
# calls.
# Build the combined tools list: backend TOOLS + any frontend-
# defined tools forwarded by the CopilotKit runtime in
# input_data.tools. Frontend tools (registered via useFrontendTool,
# useHumanInTheLoop, etc.) are included so the LLM can call them;
# the runtime intercepts the resulting events and routes them to
# the frontend for resolution. Backend tools are executed locally.
backend_tool_names = {t["name"] for t in TOOLS}
frontend_tools = _build_frontend_tools(input_data)
# Merge: backend tools first, then frontend tools that don't
# shadow a backend tool (frontend wins when names collide, because
# the frontend registration means the runtime should intercept).
frontend_tool_names = {t["name"] for t in frontend_tools}
combined_tools: list[dict[str, Any]] = []
for t in TOOLS:
if t["name"] not in frontend_tool_names:
combined_tools.append(t)
combined_tools.extend(frontend_tools)
stream_kwargs: dict[str, Any] = {
"model": os.getenv("ANTHROPIC_MODEL", "claude-opus-4-5"),
"max_tokens": 4096,
@@ -476,7 +615,7 @@ async def run_agent(
"messages": messages,
}
if not disable_tools:
stream_kwargs["tools"] = TOOLS # type: ignore[assignment]
stream_kwargs["tools"] = combined_tools # type: ignore[assignment]
try:
async with client.messages.stream(**stream_kwargs) as stream:
@@ -519,7 +658,7 @@ async def run_agent(
delta=delta.partial_json,
))
elif etype == "RawContentBlockStopEvent":
elif etype in ("RawContentBlockStopEvent", "ParsedContentBlockStopEvent"):
if current_tool_id and current_tool_name:
yield encoder.encode(ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
@@ -558,6 +697,30 @@ async def run_agent(
if not tool_calls:
break
# Separate tool calls into backend (locally executed) and frontend
# (deferred to the CopilotKit runtime / frontend for resolution).
# A tool whose name was registered on the frontend (present in
# frontend_tool_names) is a frontend tool even if the backend also
# defines it — the frontend registration takes precedence because
# hooks like useHumanInTheLoop rely on intercepting the tool call.
has_frontend_tool = any(
tc["name"] in frontend_tool_names for tc in tool_calls
)
if has_frontend_tool:
# At least one tool call targets a frontend tool. Break the
# agentic loop: the CopilotKit runtime will intercept the
# pending frontend tool call(s), route them to the frontend
# for user interaction, and re-invoke the agent with the
# resolved tool result(s) in a subsequent request.
#
# We do NOT emit ToolCallResultEvent for frontend tools and
# we do NOT add them to the message history — the runtime
# owns the continuation from here.
break
# All tool calls are backend-only — execute locally and continue
# the agentic loop.
# Add assistant turn with tool calls to message history
assistant_content: list[dict[str, Any]] = []
if response_text:
@@ -53,13 +53,111 @@ async def run_hitl_in_chat_agent(input_data: RunAgentInput) -> AsyncIterator[str
encoder = EventEncoder()
client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY", ""))
# Convert AG-UI messages to Anthropic format (text-only).
# Convert AG-UI messages to Anthropic format.
#
# AG-UI delivers three message roles:
# - "user" → plain user text
# - "assistant" → assistant text + optional tool_use blocks
# - "tool" → tool result from a resolved frontend tool
#
# When the CopilotKit runtime re-invokes this agent after the user
# resolves a frontend tool (e.g. picks a time slot in the book_call
# HITL UI), the messages array includes:
# 1. assistant message with tool_use content (the original tool call)
# 2. tool message with the resolved result
#
# Anthropic's Messages API represents tool results as a "user" role
# message with content blocks of type "tool_result". We must convert
# AG-UI "tool" messages into that shape, and assistant messages with
# tool_use content into Anthropic's structured format, so the LLM
# sees the full conversation and aimock's ``hasToolResult`` matcher
# fires correctly.
messages: list[dict[str, Any]] = []
for msg in (input_data.messages or []):
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
# Handle tool result messages from AG-UI (resolved frontend tools).
if role == "tool":
tool_call_id = getattr(msg, "tool_call_id", None) or (
getattr(msg, "toolCallId", None)
)
raw = getattr(msg, "content", None)
result_text = ""
if isinstance(raw, str):
result_text = raw
elif isinstance(raw, list):
parts = []
for part in raw:
if hasattr(part, "text"):
parts.append(part.text)
elif isinstance(part, dict) and "text" in part:
parts.append(part["text"])
parts_text = "".join(parts)
if parts_text:
result_text = parts_text
else:
result_text = json.dumps(raw)
if tool_call_id:
messages.append({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_call_id,
"content": result_text,
}],
})
continue
if role not in ("user", "assistant"):
continue
raw = getattr(msg, "content", None)
# For assistant messages, check for tool calls (AG-UI's
# AssistantMessage stores them in `tool_calls`, not in `content`).
# Anthropic requires tool_use blocks in the assistant content so
# the subsequent tool_result can pair with them.
if role == "assistant":
msg_tool_calls = getattr(msg, "tool_calls", None)
text_content = ""
if isinstance(raw, str):
text_content = raw
elif isinstance(raw, list):
for part in raw:
if hasattr(part, "text"):
text_content += part.text
elif isinstance(part, dict) and "text" in part:
text_content += part["text"]
if msg_tool_calls:
content_blocks: list[dict[str, Any]] = []
if text_content:
content_blocks.append({"type": "text", "text": text_content})
for tc in msg_tool_calls:
tc_id = getattr(tc, "id", None) or (tc.get("id") if isinstance(tc, dict) else None)
func = getattr(tc, "function", None) or (tc.get("function") if isinstance(tc, dict) else None)
if func:
tc_name = getattr(func, "name", None) or (func.get("name") if isinstance(func, dict) else "unknown")
tc_args_str = getattr(func, "arguments", None) or (func.get("arguments", "{}") if isinstance(func, dict) else "{}")
else:
tc_name = "unknown"
tc_args_str = "{}"
try:
tc_args = json.loads(tc_args_str) if isinstance(tc_args_str, str) else tc_args_str
except json.JSONDecodeError:
tc_args = {}
content_blocks.append({
"type": "tool_use",
"id": tc_id or "unknown",
"name": tc_name,
"input": tc_args,
})
messages.append({"role": "assistant", "content": content_blocks})
continue
elif text_content:
messages.append({"role": "assistant", "content": text_content})
continue
content = ""
if isinstance(raw, str):
content = raw
@@ -147,7 +245,7 @@ async def run_hitl_in_chat_agent(input_data: RunAgentInput) -> AsyncIterator[str
delta=delta.partial_json,
))
elif etype == "RawContentBlockStopEvent":
elif etype in ("RawContentBlockStopEvent", "ParsedContentBlockStopEvent"):
if current_tool_id:
yield encoder.encode(ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
@@ -239,7 +239,7 @@ async def run_mcp_apps_agent(input_data: RunAgentInput) -> AsyncIterator[str]:
delta=delta.partial_json,
)
)
elif etype == "RawContentBlockStopEvent":
elif etype in ("RawContentBlockStopEvent", "ParsedContentBlockStopEvent"):
if current_tool_id:
yield encoder.encode(
ToolCallEndEvent(
@@ -254,7 +254,7 @@ async def run_shared_state_read_write_agent(
)
)
elif etype == "RawContentBlockStopEvent":
elif etype in ("RawContentBlockStopEvent", "ParsedContentBlockStopEvent"):
if current_tool_id and current_tool_name:
yield encoder.encode(
ToolCallEndEvent(
@@ -355,6 +355,7 @@ async def run_shared_state_read_write_agent(
ToolCallResultEvent(
type=EventType.TOOL_CALL_RESULT,
tool_call_id=tc["id"],
message_id=f"{msg_id}-tool-result-{tc['id']}",
content=result_text,
)
)
@@ -297,7 +297,7 @@ async def run_subagents_agent(
)
)
elif etype == "RawContentBlockStopEvent":
elif etype in ("RawContentBlockStopEvent", "ParsedContentBlockStopEvent"):
if current_tool_id and current_tool_name:
yield encoder.encode(
ToolCallEndEvent(
@@ -385,6 +385,7 @@ async def run_subagents_agent(
ToolCallResultEvent(
type=EventType.TOOL_CALL_RESULT,
tool_call_id=tc["id"],
message_id=f"{msg_id}-tool-result-{tc['id']}",
content=err,
)
)
@@ -440,6 +441,7 @@ async def run_subagents_agent(
ToolCallResultEvent(
type=EventType.TOOL_CALL_RESULT,
tool_call_id=tc["id"],
message_id=f"{msg_id}-tool-result-{tc['id']}",
content=result_text,
)
)
@@ -298,7 +298,7 @@ async def run_tool_rendering_reasoning_chain_agent(
tool_call_id=current_tool_id or "",
delta=delta.partial_json,
))
elif etype == "RawContentBlockStopEvent":
elif etype in ("RawContentBlockStopEvent", "ParsedContentBlockStopEvent"):
if current_tool_id and current_tool_name:
yield encoder.encode(ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
@@ -1 +0,0 @@
../../shared/python/tools
@@ -0,0 +1,46 @@
"""Barrel exports for all shared showcase tool implementations."""
from .types import (
SalesStage,
SalesTodo,
Flight,
WeatherResult,
)
from .get_weather import get_weather_impl
from .query_data import query_data_impl
from .sales_todos import (
INITIAL_TODOS,
manage_sales_todos_impl,
get_sales_todos_impl,
)
from .search_flights import search_flights_impl
from .generate_a2ui import (
RENDER_A2UI_TOOL_SCHEMA,
generate_a2ui_impl,
build_a2ui_operations_from_tool_call,
)
from .schedule_meeting import schedule_meeting_impl
__all__ = [
# Types
"SalesStage",
"SalesTodo",
"Flight",
"WeatherResult",
# Weather
"get_weather_impl",
# Query data
"query_data_impl",
# Sales todos
"INITIAL_TODOS",
"manage_sales_todos_impl",
"get_sales_todos_impl",
# Flight search (fixed-schema A2UI)
"search_flights_impl",
# Dynamic A2UI
"RENDER_A2UI_TOOL_SCHEMA",
"generate_a2ui_impl",
"build_a2ui_operations_from_tool_call",
# Schedule meeting (HITL)
"schedule_meeting_impl",
]
@@ -0,0 +1,104 @@
"""Dynamic A2UI tool: LLM-generated UI from conversation context.
This module provides the data preparation for a secondary LLM call that
generates v0.9 A2UI components. The actual LLM call is made by the
framework-specific wrapper (LangGraph, CrewAI, etc.) since each framework
has its own way of invoking LLMs.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
_logger = logging.getLogger(__name__)
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
# The render_a2ui tool schema that the secondary LLM is bound to.
RENDER_A2UI_TOOL_SCHEMA = {
"name": "render_a2ui",
"description": (
"Render a dynamic A2UI v0.9 surface.\n\n"
"Args:\n"
" surfaceId: Unique surface identifier.\n"
" catalogId: The catalog ID (use \"copilotkit://app-dashboard-catalog\").\n"
" components: A2UI v0.9 component array (flat format). "
"The root component must have id \"root\".\n"
" data: Optional initial data model for the surface."
),
"parameters": {
"type": "object",
"properties": {
"surfaceId": {"type": "string", "description": "Unique surface identifier."},
"catalogId": {"type": "string", "description": "The catalog ID."},
"components": {
"type": "array",
"items": {"type": "object"},
"description": "A2UI v0.9 component array (flat format).",
},
"data": {
"type": "object",
"description": "Optional initial data model for the surface.",
},
},
"required": ["surfaceId", "catalogId", "components"],
},
}
def generate_a2ui_impl(
messages: list[dict[str, Any]],
context_entries: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Prepare inputs for a secondary LLM call that generates A2UI components.
Returns a dict with:
- system_prompt: The system prompt for the secondary LLM (built from context)
- tool_schema: The render_a2ui tool schema to bind to the LLM
- tool_choice: The tool name to force
- messages: The conversation messages to pass through
- catalog_id: The default catalog ID
The framework wrapper should:
1. Make an LLM call with these inputs
2. Extract the tool call args (surfaceId, catalogId, components, data)
3. Build a2ui_operations from the args and return them
"""
context_text = ""
if context_entries:
context_text = "\n\n".join(
entry.get("value", "")
for entry in context_entries
if isinstance(entry, dict) and entry.get("value")
)
return {
"system_prompt": context_text,
"tool_schema": RENDER_A2UI_TOOL_SCHEMA,
"tool_choice": "render_a2ui",
"messages": messages,
"catalog_id": CUSTOM_CATALOG_ID,
}
def build_a2ui_operations_from_tool_call(args: dict[str, Any]) -> dict[str, Any]:
"""Build a2ui_operations dict from the secondary LLM's tool call args.
Call this after the framework wrapper extracts the tool call arguments.
"""
surface_id = args.get("surfaceId", "dynamic-surface")
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
components = args.get("components", [])
if not components:
_logger.warning("build_a2ui_operations_from_tool_call received empty components list")
data = args.get("data")
ops = [
{"type": "create_surface", "surfaceId": surface_id, "catalogId": catalog_id},
{"type": "update_components", "surfaceId": surface_id, "components": components},
]
if data:
ops.append({"type": "update_data_model", "surfaceId": surface_id, "data": data})
return {"a2ui_operations": ops}
@@ -0,0 +1,40 @@
"""Mock weather data tool implementation."""
import random
from .types import WeatherResult
_CONDITIONS = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
]
def get_weather_impl(city: str) -> WeatherResult:
"""Return mock weather data for the given city.
Uses a seeded random based on the city name so repeated calls
for the same city return consistent results within a session.
"""
rng = random.Random(city.lower())
temperature = rng.randint(20, 95)
humidity = rng.randint(30, 90)
wind_speed = rng.randint(2, 30)
feels_like = temperature + rng.randint(-5, 5)
conditions = rng.choice(_CONDITIONS)
return WeatherResult(
city=city,
temperature=temperature,
humidity=humidity,
wind_speed=wind_speed,
feels_like=feels_like,
conditions=conditions,
)
@@ -0,0 +1,58 @@
"""Query data tool implementation — reads db.csv at module load time."""
from __future__ import annotations
import csv
import logging
from pathlib import Path
from typing import Any
_logger = logging.getLogger(__name__)
_csv_path = Path(__file__).resolve().parent.parent / "data" / "db.csv"
_MOCK_DATA = [
{
"date": "2026-01-05",
"category": "Revenue",
"subcategory": "Enterprise Subscriptions",
"amount": "28000",
"type": "income",
"notes": "3 new enterprise customers",
},
{
"date": "2026-01-10",
"category": "Expenses",
"subcategory": "Engineering Salaries",
"amount": "42000",
"type": "expense",
"notes": "7 engineers + 2 contractors",
},
{
"date": "2026-02-03",
"category": "Revenue",
"subcategory": "Pro Tier Upgrades",
"amount": "22500",
"type": "income",
"notes": "31 upgrades + reduced churn",
},
]
try:
with open(_csv_path) as _f:
_cached_data: list[dict[str, Any]] = list(csv.DictReader(_f))
if not _cached_data:
_logger.warning("CSV at %s is empty, falling back to mock data", _csv_path)
_cached_data = _MOCK_DATA
except (FileNotFoundError, OSError) as exc:
_logger.warning("Could not load CSV at %s (%s), falling back to mock data", _csv_path, exc)
_cached_data = _MOCK_DATA
def query_data_impl(query: str) -> list[dict[str, Any]]:
"""Query the database. Takes natural language.
Always call before showing a chart or graph. Returns the full
dataset as a list of dicts (rows from the CSV).
"""
return _cached_data
@@ -0,0 +1,61 @@
"""Sales todos tool implementation."""
from __future__ import annotations
import uuid
from typing import Optional
from .types import SalesTodo
INITIAL_TODOS: list[SalesTodo] = [
SalesTodo(
id="st-001",
title="Follow up with Acme Corp on enterprise proposal",
stage="proposal",
value=85000,
dueDate="2026-04-15",
assignee="Sarah Chen",
completed=False,
),
SalesTodo(
id="st-002",
title="Qualify lead from TechFlow demo request",
stage="prospect",
value=42000,
dueDate="2026-04-18",
assignee="Mike Johnson",
completed=False,
),
SalesTodo(
id="st-003",
title="Send contract to DataViz Inc for final review",
stage="negotiation",
value=120000,
dueDate="2026-04-20",
assignee="Sarah Chen",
completed=False,
),
]
def manage_sales_todos_impl(todos: list[dict]) -> list[SalesTodo]:
"""Assign UUIDs to any todos missing an ID, then return the updated list."""
result: list[SalesTodo] = []
for todo in todos:
result.append(SalesTodo(
id=todo.get("id") or str(uuid.uuid4()),
title=todo.get("title", ""),
stage=todo.get("stage", "prospect"),
value=todo.get("value", 0),
dueDate=todo.get("dueDate", ""),
assignee=todo.get("assignee", ""),
completed=todo.get("completed", False),
))
return result
def get_sales_todos_impl(current_todos: Optional[list[dict]] = None) -> list[SalesTodo]:
"""Return current todos or initial defaults if none provided."""
if current_todos is not None:
return manage_sales_todos_impl(current_todos)
return list(INITIAL_TODOS)
@@ -0,0 +1,31 @@
"""Schedule meeting tool implementation.
The HITL gating happens on the frontend via useHumanInTheLoop.
This tool just returns a pending approval status for the framework
wrapper to surface.
"""
from __future__ import annotations
from typing import Any
def schedule_meeting_impl(
reason: str,
duration_minutes: int = 30,
) -> dict[str, Any]:
"""Schedule a meeting (requires human approval).
Returns a pending_approval status. The actual gating is done by the
frontend's useHumanInTheLoop hook — the agent pauses until the user
approves or rejects.
"""
return {
"status": "pending_approval",
"reason": reason,
"duration_minutes": duration_minutes,
"message": (
f"Meeting request: {reason} ({duration_minutes} min). "
"Awaiting human approval."
),
}
@@ -0,0 +1,106 @@
"""Fixed-schema A2UI tool: flight search results.
Packages flight data with an A2UI schema for rendering. The schema is loaded
from the shared frontend package's flight_schema.json.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from .types import Flight
_logger = logging.getLogger(__name__)
CATALOG_ID = "copilotkit://app-dashboard-catalog"
SURFACE_ID = "flight-search-results"
# Resolve the flight schema from the shared frontend package.
# Walk up from this file to showcase/shared/, then into frontend/src/a2ui/.
_SHARED_DIR = Path(__file__).resolve().parent.parent.parent # showcase/shared/
_SCHEMA_CANDIDATES = [
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight-schema.json",
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight_schema.json",
]
_flight_schema: list[dict[str, Any]] | None = None
for _candidate in _SCHEMA_CANDIDATES:
if _candidate.exists():
with open(_candidate) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from shared frontend: %s", _candidate)
break
# Fallback: use the schema from the examples directory if present
if _flight_schema is None:
try:
_fallback = Path(__file__).resolve().parents[4] / (
"examples/integrations/langgraph-python/apps/agent/src/a2ui/schemas/flight_schema.json"
)
if _fallback.exists():
with open(_fallback) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from examples fallback: %s", _fallback)
except IndexError:
# In Docker the file path is too shallow for parents[4]; skip this fallback.
pass
# Last resort: inline minimal schema
if _flight_schema is None:
_logger.warning("No flight schema file found, using inline minimal schema")
_flight_schema = [
{
"id": "root",
"component": "Row",
"children": {"componentId": "flight-card", "path": "/flights"},
"gap": 16,
},
{
"id": "flight-card",
"component": "FlightCard",
"airline": {"path": "airline"},
"airlineLogo": {"path": "airlineLogo"},
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"date": {"path": "date"},
"departureTime": {"path": "departureTime"},
"arrivalTime": {"path": "arrivalTime"},
"duration": {"path": "duration"},
"status": {"path": "status"},
"price": {"path": "price"},
"action": {
"event": {
"name": "book_flight",
"context": {
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"price": {"path": "price"},
},
}
},
},
]
def search_flights_impl(flights: list[Flight]) -> dict[str, Any]:
"""Package flight data with A2UI schema for rendering.
Returns a dict with a2ui_operations that the middleware detects in the
TOOL_CALL_RESULT and renders automatically.
Each flight should have: airline, airlineLogo, flightNumber, origin,
destination, date, departureTime, arrivalTime, duration, status,
statusColor, price, currency.
"""
return {
"a2ui_operations": [
{"type": "create_surface", "surfaceId": SURFACE_ID, "catalogId": CATALOG_ID},
{"type": "update_components", "surfaceId": SURFACE_ID, "components": _flight_schema},
{"type": "update_data_model", "surfaceId": SURFACE_ID, "data": {"flights": flights}},
]
}
@@ -0,0 +1,47 @@
"""Shared type definitions for showcase tools."""
from typing import TypedDict, Literal
SalesStage = Literal[
"prospect",
"qualified",
"proposal",
"negotiation",
"closed-won",
"closed-lost",
]
class SalesTodo(TypedDict):
id: str
title: str
stage: SalesStage
value: int
dueDate: str
assignee: str
completed: bool
class Flight(TypedDict):
airline: str
airlineLogo: str
flightNumber: str
origin: str
destination: str
date: str
departureTime: str
arrivalTime: str
duration: str
status: str
statusColor: str
price: str
currency: str
class WeatherResult(TypedDict):
city: str
temperature: int
humidity: int
wind_speed: int
feels_like: int
conditions: str
@@ -1 +0,0 @@
../../shared/typescript/tools
@@ -0,0 +1,90 @@
import { describe, it, expect, vi } from "vitest";
import {
generateA2uiImpl,
buildA2uiOperationsFromToolCall,
RENDER_A2UI_TOOL_SCHEMA,
CUSTOM_CATALOG_ID,
} from "../generate-a2ui";
describe("generateA2uiImpl", () => {
it("returns system prompt from context entries", () => {
const result = generateA2uiImpl({
messages: [],
contextEntries: [
{ value: "Component catalog info" },
{ value: "More context" },
],
});
expect(result.systemPrompt).toContain("Component catalog info");
expect(result.systemPrompt).toContain("More context");
});
it("filters empty/missing context values", () => {
const result = generateA2uiImpl({
messages: [],
contextEntries: [{ value: "" }, { noValue: true }, { value: "keep" }],
});
expect(result.systemPrompt).toBe("keep");
});
it("returns tool schema and choice", () => {
const result = generateA2uiImpl({ messages: [] });
expect(result.toolSchema.name).toBe("render_a2ui");
expect(result.toolChoice).toBe("render_a2ui");
});
it("passes messages through", () => {
const msgs = [{ role: "user", content: "hello" }];
const result = generateA2uiImpl({ messages: msgs });
expect(result.messages).toBe(msgs);
});
it("returns the default catalog ID", () => {
const result = generateA2uiImpl({ messages: [] });
expect(result.catalogId).toBe(CUSTOM_CATALOG_ID);
});
});
describe("buildA2uiOperationsFromToolCall", () => {
it("builds create_surface + update_components", () => {
const result = buildA2uiOperationsFromToolCall({
surfaceId: "s1",
catalogId: "cat1",
components: [{ id: "root", component: "Title" }],
});
expect(result.a2ui_operations).toHaveLength(2);
expect(result.a2ui_operations[0].type).toBe("create_surface");
expect(result.a2ui_operations[1].type).toBe("update_components");
});
it("includes update_data_model when data provided", () => {
const result = buildA2uiOperationsFromToolCall({
surfaceId: "s1",
catalogId: "cat1",
components: [{ id: "root" }],
data: { key: "value" },
});
expect(result.a2ui_operations).toHaveLength(3);
expect(result.a2ui_operations[2].type).toBe("update_data_model");
});
it("defaults surfaceId and catalogId", () => {
const result = buildA2uiOperationsFromToolCall({ components: [] });
expect(result.a2ui_operations[0].surfaceId).toBe("dynamic-surface");
expect(result.a2ui_operations[0].catalogId).toBe(CUSTOM_CATALOG_ID);
});
it("warns on empty components", () => {
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
buildA2uiOperationsFromToolCall({
surfaceId: "s1",
catalogId: "c1",
components: [],
});
expect(spy).toHaveBeenCalledWith(
expect.stringContaining("empty components"),
"s1",
);
spy.mockRestore();
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { getWeatherImpl } from "../get-weather";
describe("getWeatherImpl", () => {
it("returns all required fields", () => {
const result = getWeatherImpl("Tokyo");
expect(result).toHaveProperty("city");
expect(result).toHaveProperty("temperature");
expect(result).toHaveProperty("humidity");
expect(result).toHaveProperty("wind_speed");
expect(result).toHaveProperty("feels_like");
expect(result).toHaveProperty("conditions");
});
it("passes city name through", () => {
expect(getWeatherImpl("San Francisco").city).toBe("San Francisco");
});
it("is deterministic for the same city", () => {
const r1 = getWeatherImpl("Tokyo");
const r2 = getWeatherImpl("Tokyo");
expect(r1.temperature).toBe(r2.temperature);
expect(r1.conditions).toBe(r2.conditions);
});
it("produces different results for different cities", () => {
const r1 = getWeatherImpl("Tokyo");
const r2 = getWeatherImpl("London");
expect(r1).not.toEqual(r2);
});
it("temperature is within expected range (20-95)", () => {
const result = getWeatherImpl("Berlin");
expect(result.temperature).toBeGreaterThanOrEqual(20);
expect(result.temperature).toBeLessThanOrEqual(95);
});
it("humidity is within expected range (30-90)", () => {
const result = getWeatherImpl("Paris");
expect(result.humidity).toBeGreaterThanOrEqual(30);
expect(result.humidity).toBeLessThanOrEqual(90);
});
it("wind_speed is within expected range (2-30)", () => {
const result = getWeatherImpl("Sydney");
expect(result.wind_speed).toBeGreaterThanOrEqual(2);
expect(result.wind_speed).toBeLessThanOrEqual(30);
});
it("conditions is one of the known values", () => {
const known = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
];
const result = getWeatherImpl("Miami");
expect(known).toContain(result.conditions);
});
it("is case-insensitive for seed (lowercased internally)", () => {
const r1 = getWeatherImpl("tokyo");
const r2 = getWeatherImpl("TOKYO");
expect(r1.temperature).toBe(r2.temperature);
});
it("feels_like is within ±5 of temperature", () => {
const result = getWeatherImpl("Berlin");
expect(
Math.abs(result.feels_like - result.temperature),
).toBeLessThanOrEqual(5);
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { queryDataImpl } from "../query-data";
describe("queryDataImpl", () => {
it("returns an array", () => {
expect(Array.isArray(queryDataImpl("test"))).toBe(true);
});
it("returns 66 rows (11 categories x 6 months)", () => {
expect(queryDataImpl("test")).toHaveLength(66);
});
it("rows have expected columns", () => {
const row = queryDataImpl("test")[0];
expect(row).toHaveProperty("date");
expect(row).toHaveProperty("category");
expect(row).toHaveProperty("subcategory");
expect(row).toHaveProperty("amount");
expect(row).toHaveProperty("type");
expect(row).toHaveProperty("notes");
});
it("returns same data regardless of query", () => {
const r1 = queryDataImpl("revenue");
const r2 = queryDataImpl("expenses");
expect(r1).toEqual(r2);
});
it("is deterministic (seeded RNG)", () => {
const r1 = queryDataImpl("test");
const r2 = queryDataImpl("test");
expect(r1).toEqual(r2);
});
it("categories are Revenue or Expenses", () => {
const rows = queryDataImpl("test");
const categories = new Set(rows.map((r) => r.category));
expect(categories).toEqual(new Set(["Revenue", "Expenses"]));
});
it("types are income or expense", () => {
const rows = queryDataImpl("test");
const types = new Set(rows.map((r) => r.type));
expect(types).toEqual(new Set(["income", "expense"]));
});
it("dates are in 2026", () => {
const rows = queryDataImpl("test");
for (const row of rows) {
expect(row.date).toMatch(/^2026-/);
}
});
it("amount is a numeric string", () => {
const rows = queryDataImpl("test");
for (const row of rows) {
expect(Number(row.amount)).not.toBeNaN();
expect(Number(row.amount)).toBeGreaterThan(0);
}
});
});
@@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import {
manageSalesTodosImpl,
getSalesTodosImpl,
INITIAL_SALES_TODOS,
} from "../sales-todos";
describe("INITIAL_SALES_TODOS", () => {
it("has 3 items", () => {
expect(INITIAL_SALES_TODOS).toHaveLength(3);
});
it("has fixed IDs", () => {
expect(INITIAL_SALES_TODOS[0].id).toBe("st-001");
expect(INITIAL_SALES_TODOS[1].id).toBe("st-002");
expect(INITIAL_SALES_TODOS[2].id).toBe("st-003");
});
});
describe("manageSalesTodosImpl", () => {
it("assigns UUID to todos missing ID", () => {
const result = manageSalesTodosImpl([{ title: "New deal" }]);
expect(result[0].id).toBeTruthy();
expect(result[0].id.length).toBeGreaterThan(0);
});
it("preserves existing IDs", () => {
const result = manageSalesTodosImpl([{ id: "keep-me", title: "Deal" }]);
expect(result[0].id).toBe("keep-me");
});
it("provides defaults for missing fields", () => {
const result = manageSalesTodosImpl([{ title: "Minimal" }]);
expect(result[0].stage).toBe("prospect");
expect(result[0].value).toBe(0);
expect(result[0].completed).toBe(false);
expect(result[0].dueDate).toBe("");
expect(result[0].assignee).toBe("");
});
it("preserves provided fields", () => {
const result = manageSalesTodosImpl([
{
id: "x",
title: "Big Deal",
stage: "negotiation",
value: 50000,
dueDate: "2026-05-01",
assignee: "Alice",
completed: true,
},
]);
expect(result[0].title).toBe("Big Deal");
expect(result[0].stage).toBe("negotiation");
expect(result[0].value).toBe(50000);
expect(result[0].completed).toBe(true);
});
it("handles empty array", () => {
const result = manageSalesTodosImpl([]);
expect(result).toEqual([]);
});
it("handles multiple todos", () => {
const result = manageSalesTodosImpl([
{ title: "A" },
{ title: "B" },
{ title: "C" },
]);
expect(result).toHaveLength(3);
// Each should get a unique ID
const ids = new Set(result.map((r) => r.id));
expect(ids.size).toBe(3);
});
it("replaces empty string id with a generated UUID", () => {
const result = manageSalesTodosImpl([{ id: "", title: "x" }]);
expect(result[0].id).toBeTruthy();
expect(result[0].id).not.toBe("");
expect(result[0].id.length).toBeGreaterThan(0);
});
});
describe("getSalesTodosImpl", () => {
it("returns initial todos when undefined", () => {
const result = getSalesTodosImpl(undefined);
expect(result).toHaveLength(3);
expect(result[0].id).toBe("st-001");
});
it("returns initial todos when null", () => {
const result = getSalesTodosImpl(null);
expect(result).toHaveLength(3);
});
it("returns empty array when given empty array", () => {
const result = getSalesTodosImpl([]);
// empty array means user cleared all todos — return empty, not defaults
expect(result).toHaveLength(0);
});
it("returns provided todos when non-empty", () => {
const todos = [
{
id: "1",
title: "Test",
stage: "prospect" as const,
value: 100,
dueDate: "",
assignee: "",
completed: false,
},
];
const result = getSalesTodosImpl(todos);
expect(result).toHaveLength(1);
expect(result[0].title).toBe("Test");
});
it("returns a copy, not the original INITIAL_SALES_TODOS reference", () => {
const result = getSalesTodosImpl(undefined);
expect(result).not.toBe(INITIAL_SALES_TODOS);
expect(result).toEqual(INITIAL_SALES_TODOS);
});
});
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { scheduleMeetingImpl } from "../schedule-meeting";
describe("scheduleMeetingImpl", () => {
it("returns pending_approval status", () => {
expect(scheduleMeetingImpl("discuss roadmap").status).toBe(
"pending_approval",
);
});
it("includes the reason", () => {
expect(scheduleMeetingImpl("quarterly review").reason).toBe(
"quarterly review",
);
});
it("uses default 30-minute duration", () => {
expect(scheduleMeetingImpl("sync").duration_minutes).toBe(30);
});
it("accepts custom duration", () => {
expect(scheduleMeetingImpl("deep dive", 60).duration_minutes).toBe(60);
});
it("includes a message", () => {
const result = scheduleMeetingImpl("onboarding", 45);
expect(result.message).toContain("onboarding");
expect(result.message).toContain("45");
});
});
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { searchFlightsImpl } from "../search-flights";
import type { Flight } from "../types";
describe("searchFlightsImpl", () => {
const mockFlight: Flight = {
airline: "Test Air",
airlineLogo: "https://example.com/logo.png",
flightNumber: "TA100",
origin: "SFO",
destination: "JFK",
date: "Tue, Apr 15",
departureTime: "08:00",
arrivalTime: "16:00",
duration: "5h",
status: "On Time",
statusColor: "#22c55e",
price: "$299",
currency: "USD",
};
it("returns flights and schema", () => {
const result = searchFlightsImpl([mockFlight]);
expect(result).toHaveProperty("flights");
expect(result).toHaveProperty("schema");
});
it("passes flights through unchanged", () => {
const result = searchFlightsImpl([mockFlight]);
expect(result.flights).toHaveLength(1);
expect(result.flights[0]).toEqual(mockFlight);
});
it("returns empty schema object", () => {
const result = searchFlightsImpl([mockFlight]);
expect(result.schema).toEqual({});
});
it("handles empty flights array", () => {
const result = searchFlightsImpl([]);
expect(result.flights).toHaveLength(0);
});
it("handles multiple flights", () => {
const result = searchFlightsImpl([
mockFlight,
{ ...mockFlight, flightNumber: "TA200" },
]);
expect(result.flights).toHaveLength(2);
expect(result.flights[1].flightNumber).toBe("TA200");
});
});
@@ -0,0 +1,98 @@
/**
* Dynamic A2UI generation via secondary LLM call.
* TypeScript equivalent of showcase/shared/python/tools/generate_a2ui.py.
*/
export const CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog";
export const RENDER_A2UI_TOOL_SCHEMA = {
name: "render_a2ui",
description: "Render a dynamic A2UI v0.9 surface.",
parameters: {
type: "object",
properties: {
surfaceId: { type: "string", description: "Unique surface identifier." },
catalogId: { type: "string", description: "The catalog ID." },
components: {
type: "array",
items: { type: "object" },
description: "A2UI v0.9 component array (flat format).",
},
data: {
type: "object",
description: "Optional initial data model for the surface.",
},
},
required: ["surfaceId", "catalogId", "components"],
},
} as const;
export interface GenerateA2UIInput {
messages: Array<Record<string, unknown>>;
contextEntries?: Array<Record<string, unknown>>;
}
export interface GenerateA2UIResult {
systemPrompt: string;
toolSchema: typeof RENDER_A2UI_TOOL_SCHEMA;
toolChoice: string;
messages: Array<Record<string, unknown>>;
catalogId: string;
}
export function generateA2uiImpl(input: GenerateA2UIInput): GenerateA2UIResult {
const contextText = (input.contextEntries ?? [])
.filter(
(e): e is Record<string, unknown> & { value: string } =>
typeof e === "object" &&
typeof e.value === "string" &&
e.value.length > 0,
)
.map((e) => e.value)
.join("\n\n");
return {
systemPrompt: contextText,
toolSchema: RENDER_A2UI_TOOL_SCHEMA,
toolChoice: "render_a2ui",
messages: input.messages,
catalogId: CUSTOM_CATALOG_ID,
};
}
export interface A2UIOperation {
type: "create_surface" | "update_components" | "update_data_model";
surfaceId: string;
catalogId?: string;
components?: Array<Record<string, unknown>>;
data?: Record<string, unknown>;
}
export function buildA2uiOperationsFromToolCall(
args: Record<string, unknown>,
): {
a2ui_operations: A2UIOperation[];
} {
const surfaceId = (args.surfaceId as string) ?? "dynamic-surface";
const catalogId = (args.catalogId as string) ?? CUSTOM_CATALOG_ID;
const components = (args.components as Array<Record<string, unknown>>) ?? [];
const data = args.data as Record<string, unknown> | undefined;
if (components.length === 0) {
console.warn(
"buildA2uiOperationsFromToolCall: empty components for surface",
surfaceId,
);
}
const ops: A2UIOperation[] = [
{ type: "create_surface", surfaceId, catalogId },
{ type: "update_components", surfaceId, components },
];
if (data) {
ops.push({ type: "update_data_model", surfaceId, data });
}
return { a2ui_operations: ops };
}
@@ -0,0 +1,67 @@
/**
* Mock weather data tool implementation.
*
* TypeScript equivalent of showcase/shared/python/tools/get_weather.py.
* Uses a simple seed derived from the city name so repeated calls for
* the same city return consistent results within a session.
*/
import { WeatherResult } from "./types";
const CONDITIONS = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
];
/**
* Simple seeded pseudo-random number generator (mulberry32).
* Produces deterministic output for a given seed so the same city
* always returns the same weather within a process lifetime.
*/
function seededRandom(seed: number): () => number {
let t = seed;
return () => {
t = (t + 0x6d2b79f5) | 0;
let v = Math.imul(t ^ (t >>> 15), 1 | t);
v = (v + Math.imul(v ^ (v >>> 7), 61 | v)) ^ v;
return ((v ^ (v >>> 14)) >>> 0) / 4294967296;
};
}
function hashString(s: string): number {
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = (Math.imul(31, hash) + s.charCodeAt(i)) | 0;
}
return hash;
}
function randInt(rng: () => number, min: number, max: number): number {
return Math.floor(rng() * (max - min + 1)) + min;
}
function randChoice<T>(rng: () => number, arr: T[]): T {
return arr[Math.floor(rng() * arr.length)];
}
export function getWeatherImpl(city: string): WeatherResult {
const rng = seededRandom(hashString(city.toLowerCase()));
const temperature = randInt(rng, 20, 95);
return {
city,
temperature,
humidity: randInt(rng, 30, 90),
wind_speed: randInt(rng, 2, 30),
feels_like: temperature + randInt(rng, -5, 5),
conditions: randChoice(rng, CONDITIONS),
};
}
@@ -0,0 +1,31 @@
/**
* Shared TypeScript tool implementations for CopilotKit showcase.
*
* Pure functions with no framework imports — consumed by
* langgraph-typescript, mastra, claude-sdk-typescript, and any
* future TypeScript-based showcase packages.
*/
export { getWeatherImpl } from "./get-weather";
export { queryDataImpl } from "./query-data";
export type { DataRow } from "./query-data";
export {
manageSalesTodosImpl,
getSalesTodosImpl,
INITIAL_SALES_TODOS,
} from "./sales-todos";
export { searchFlightsImpl } from "./search-flights";
export { scheduleMeetingImpl } from "./schedule-meeting";
export type { ScheduleMeetingResult } from "./schedule-meeting";
export {
generateA2uiImpl,
buildA2uiOperationsFromToolCall,
RENDER_A2UI_TOOL_SCHEMA,
CUSTOM_CATALOG_ID,
} from "./generate-a2ui";
export type {
GenerateA2UIInput,
GenerateA2UIResult,
A2UIOperation,
} from "./generate-a2ui";
export type { SalesTodo, SalesStage, Flight, WeatherResult } from "./types";
@@ -0,0 +1,107 @@
/**
* Query data tool implementation — returns mock financial data.
*
* TypeScript equivalent of showcase/shared/python/tools/query_data.py.
* In the future this could read a CSV, but for TS backend simplicity
* we use generated mock data matching the Python fallback format.
*/
export interface DataRow {
date: string;
category: string;
subcategory: string;
amount: string;
type: string;
notes: string;
}
// Seeded random for deterministic mock data
function seededRandom(seed: number): () => number {
let s = seed;
return () => {
s = (s * 1103515245 + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
}
function generateMockData(): DataRow[] {
const rand = seededRandom(42);
const categories: Array<{
category: string;
subcategory: string;
type: string;
}> = [
{
category: "Revenue",
subcategory: "Enterprise Subscriptions",
type: "income",
},
{ category: "Revenue", subcategory: "Pro Tier Upgrades", type: "income" },
{ category: "Revenue", subcategory: "API Usage Overages", type: "income" },
{ category: "Revenue", subcategory: "Consulting Services", type: "income" },
{ category: "Revenue", subcategory: "Marketplace Sales", type: "income" },
{
category: "Expenses",
subcategory: "Engineering Salaries",
type: "expense",
},
{ category: "Expenses", subcategory: "Product Team", type: "expense" },
{
category: "Expenses",
subcategory: "AWS Infrastructure",
type: "expense",
},
{ category: "Expenses", subcategory: "Marketing", type: "expense" },
{ category: "Expenses", subcategory: "Customer Success", type: "expense" },
{ category: "Expenses", subcategory: "AI Model Costs", type: "expense" },
];
const notes: Record<string, string> = {
"Enterprise Subscriptions": "3 new enterprise customers",
"Pro Tier Upgrades": "31 upgrades + reduced churn",
"API Usage Overages": "Heavy usage from top-10 accounts",
"Consulting Services": "2 implementation projects",
"Marketplace Sales": "Partner integrations revenue",
"Engineering Salaries": "7 engineers + 2 contractors",
"Product Team": "PM + designers + QA",
"AWS Infrastructure": "Compute + storage + bandwidth",
Marketing: "Paid ads + content + events",
"Customer Success": "3 CSMs + tooling",
"AI Model Costs": "OpenAI + Anthropic API spend",
};
const rows: DataRow[] = [];
const months = ["01", "02", "03", "04", "05", "06"];
for (const month of months) {
for (const cat of categories) {
const baseAmount =
cat.type === "income"
? 15000 + Math.floor(rand() * 35000)
: 8000 + Math.floor(rand() * 40000);
const day = String(1 + Math.floor(rand() * 28)).padStart(2, "0");
rows.push({
date: `2026-${month}-${day}`,
category: cat.category,
subcategory: cat.subcategory,
amount: String(baseAmount),
type: cat.type,
notes: notes[cat.subcategory] ?? "",
});
}
}
return rows;
}
const MOCK_DATA: DataRow[] = generateMockData();
/**
* Query the database. Takes natural language.
*
* Always call before showing a chart or graph. Returns the full
* dataset as a list of row objects.
*/
export function queryDataImpl(_query: string): DataRow[] {
return MOCK_DATA;
}
@@ -0,0 +1,65 @@
/**
* Sales todos tool implementation.
*
* TypeScript equivalent of showcase/shared/python/tools/sales_todos.py.
*/
import { SalesTodo } from "./types";
export const INITIAL_SALES_TODOS: SalesTodo[] = [
{
id: "st-001",
title: "Follow up with Acme Corp on enterprise proposal",
stage: "proposal",
value: 85000,
dueDate: "2026-04-15",
assignee: "Sarah Chen",
completed: false,
},
{
id: "st-002",
title: "Qualify lead from TechFlow demo request",
stage: "prospect",
value: 42000,
dueDate: "2026-04-18",
assignee: "Mike Johnson",
completed: false,
},
{
id: "st-003",
title: "Send contract to DataViz Inc for final review",
stage: "negotiation",
value: 120000,
dueDate: "2026-04-20",
assignee: "Sarah Chen",
completed: false,
},
];
/**
* Assign crypto.randomUUID() to any todos missing an ID, then return
* the updated list.
*/
export function manageSalesTodosImpl(todos: Partial<SalesTodo>[]): SalesTodo[] {
return todos.map((todo) => ({
id: todo.id || crypto.randomUUID(),
title: todo.title ?? "",
stage: todo.stage ?? "prospect",
value: todo.value ?? 0,
dueDate: todo.dueDate ?? "",
assignee: todo.assignee ?? "",
completed: todo.completed ?? false,
}));
}
/**
* Return current todos or initial defaults if none provided.
*/
export function getSalesTodosImpl(
currentTodos?: Partial<SalesTodo>[] | null,
): SalesTodo[] {
if (currentTodos != null) {
return currentTodos.length > 0 ? manageSalesTodosImpl(currentTodos) : [];
}
return [...INITIAL_SALES_TODOS];
}
@@ -0,0 +1,24 @@
/**
* Schedule meeting tool implementation — HITL gated.
*
* TypeScript equivalent of showcase/shared/python/tools/schedule_meeting.py.
*/
export interface ScheduleMeetingResult {
status: "pending_approval";
reason: string;
duration_minutes: number;
message: string;
}
export function scheduleMeetingImpl(
reason: string,
durationMinutes: number = 30,
): ScheduleMeetingResult {
return {
status: "pending_approval",
reason,
duration_minutes: durationMinutes,
message: `Meeting request: ${reason} (${durationMinutes} min). Awaiting user time selection.`,
};
}
@@ -0,0 +1,19 @@
/**
* Search flights tool implementation.
*
* Simple passthrough that wraps flights for AG-UI rendering.
* The frontend GenUI components handle presentation.
*/
import { Flight } from "./types";
/**
* Wrap the provided flights array for consumption by the frontend
* flight-search GenUI component.
*/
export function searchFlightsImpl(flights: Flight[]): {
flights: Flight[];
schema: Record<string, unknown>;
} {
return { flights, schema: {} };
}
@@ -0,0 +1,49 @@
/**
* Shared type definitions for showcase tools.
*
* These mirror the Python types in showcase/shared/python/tools/types.py
* and the frontend types in showcase/shared/frontend/src/types.ts.
*/
export type SalesStage =
| "prospect"
| "qualified"
| "proposal"
| "negotiation"
| "closed-won"
| "closed-lost";
export interface SalesTodo {
id: string;
title: string;
stage: SalesStage;
value: number;
dueDate: string;
assignee: string;
completed: boolean;
}
export interface Flight {
airline: string;
airlineLogo: string;
flightNumber: string;
origin: string;
destination: string;
date: string;
departureTime: string;
arrivalTime: string;
duration: string;
status: string;
statusColor: string;
price: string;
currency: string;
}
export interface WeatherResult {
city: string;
temperature: number;
humidity: number;
wind_speed: number;
feels_like: number;
conditions: string;
}
@@ -4,6 +4,7 @@ import React from "react";
import { CopilotKit } from "@copilotkit/react-core";
import {
CopilotChat,
useFrontendTool,
useRenderTool,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
@@ -27,6 +28,25 @@ export default function ToolRenderingDemo() {
}
function Chat() {
// The Claude Agent SDK backend is a generic pass-through — it forwards
// tool calls from Claude but does not execute them server-side. Register
// a frontend tool handler so the CopilotKit runtime can execute the tool
// and return a result to the agent.
useFrontendTool({
name: "get_weather",
description: "Get the current weather for a given location.",
parameters: z.object({
location: z.string(),
}),
handler: async ({ location }: { location: string }) => ({
city: location,
temperature: 22,
humidity: 65,
wind_speed: 12,
conditions: "Partly Cloudy",
}),
});
// @region[render-weather-tool]
useRenderTool({
name: "get_weather",
@@ -53,6 +53,10 @@ from agents.declarative_gen_ui import DeclarativeGenUI
from agents.mcp_apps_agent import MCPApps
from agents.shared_state_read_write import shared_state_read_write_flow
from agents.subagents import subagents_flow
try:
from agents.tool_rendering import tool_rendering_flow
except ImportError:
tool_rendering_flow = None
app = FastAPI(title="CrewAI (Crews) Agent Server")
@@ -424,6 +428,8 @@ add_crewai_flow_fastapi_endpoint(
app, shared_state_read_write_flow, "/shared-state-read-write"
)
add_crewai_flow_fastapi_endpoint(app, subagents_flow, "/subagents")
if tool_rendering_flow is not None:
add_crewai_flow_fastapi_endpoint(app, tool_rendering_flow, "/tool-rendering")
add_crewai_crew_fastapi_endpoint(app, LatestAiDevelopment(), "/")
@@ -140,7 +140,19 @@ _BASE_SYSTEM_PROMPT = (
class SharedStateReadWriteFlow(Flow[AgentState]):
"""Single-step chat flow that reads `preferences` and writes `notes`."""
"""Chat flow with tool-execution loop that reads `preferences` and writes `notes`.
Mirrors the LangGraph reference implementation's automatic tool loop:
after the LLM returns a tool call, this flow executes the tool,
appends the tool result to the message history, and calls the LLM
again so it can produce the follow-up text response. Without the
loop the frontend never sees the assistant's confirmation text
("Got it — I noted …") after a `set_notes` call.
"""
# Maximum number of LLM round-trips per user turn. Prevents
# infinite loops if the model keeps calling tools.
_MAX_ITERATIONS = 5
@start()
async def chat(self) -> None:
@@ -159,14 +171,11 @@ class SharedStateReadWriteFlow(Flow[AgentState]):
if prefs_block:
system_content = prefs_block + "\n\n" + system_content
messages = [
{
"role": "system",
"content": system_content,
"id": str(uuid.uuid4()) + "-system",
},
*self.state.messages,
]
system_message = {
"role": "system",
"content": system_content,
"id": str(uuid.uuid4()) + "-system",
}
# Frontend-registered actions + our backend `set_notes` tool.
tools = [
@@ -174,79 +183,89 @@ class SharedStateReadWriteFlow(Flow[AgentState]):
SET_NOTES_TOOL,
]
response = await copilotkit_stream(
await acompletion(
model="openai/gpt-4o-mini",
messages=messages,
tools=tools,
parallel_tool_calls=False,
stream=True,
for _iteration in range(self._MAX_ITERATIONS):
messages = [system_message, *self.state.messages]
response = await copilotkit_stream(
await acompletion(
model="openai/gpt-4o-mini",
messages=messages,
tools=tools,
parallel_tool_calls=False,
stream=True,
)
)
)
message = response.choices[0].message
self.state.messages.append(message)
message = response.choices[0].message
self.state.messages.append(message)
tool_calls = message.get("tool_calls") or []
if not tool_calls:
return
tool_calls = message.get("tool_calls") or []
if not tool_calls:
# No tool calls — the LLM produced a text response.
# We're done.
return
# Iterate ALL tool calls — `parallel_tool_calls=False` is set on
# the LLM call but providers can still emit multiple under
# certain conditions. Indexing `[0]` would silently drop the
# rest, leaving an assistant `tool_calls` message with no
# matching `role: "tool"` reply, which most chat APIs reject on
# the next turn ("an assistant message with tool_calls must be
# followed by tool messages").
notes_changed = False
for tool_call in tool_calls:
tool_call_id = tool_call["id"]
tool_name = tool_call["function"]["name"]
# Iterate ALL tool calls — `parallel_tool_calls=False` is
# set on the LLM call but providers can still emit multiple
# under certain conditions. Indexing `[0]` would silently
# drop the rest, leaving an assistant `tool_calls` message
# with no matching `role: "tool"` reply, which most chat
# APIs reject on the next turn.
notes_changed = False
for tool_call in tool_calls:
tool_call_id = tool_call["id"]
tool_name = tool_call["function"]["name"]
if tool_name != "set_notes":
# Frontend-registered action: the AG-UI client owns
# the round-trip. We still need a placeholder tool
# result so the message thread stays valid.
self.state.messages.append(
{
"role": "tool",
"content": "frontend tool — handled client-side",
"tool_call_id": tool_call_id,
}
)
continue
try:
args = json.loads(
tool_call["function"]["arguments"] or "{}"
)
except json.JSONDecodeError:
args = {}
notes = args.get("notes")
if not isinstance(notes, list):
notes = []
# Coerce every entry to a non-empty string — defensive
# against the model occasionally yielding non-string
# list entries.
cleaned = [
str(n) for n in notes if n is not None and str(n)
]
self.state.notes = cleaned
notes_changed = True
if tool_name != "set_notes":
# Frontend-registered action: the AG-UI client owns the
# round-trip for those. We still need a placeholder tool
# result here so the message thread stays valid for the
# supervisor's next turn (an assistant tool_calls message
# with no matching `role: "tool"` reply causes most chat
# APIs to reject the next turn). The client-side handler
# will produce the real result on its own pass.
self.state.messages.append(
{
"role": "tool",
"content": "frontend tool — handled client-side",
"content": "Notes updated.",
"tool_call_id": tool_call_id,
}
)
continue
try:
args = json.loads(tool_call["function"]["arguments"] or "{}")
except json.JSONDecodeError:
args = {}
notes = args.get("notes")
if not isinstance(notes, list):
notes = []
# Coerce every entry to a non-empty string — defensive against
# the model occasionally yielding non-string list entries.
cleaned = [str(n) for n in notes if n is not None and str(n)]
self.state.notes = cleaned
notes_changed = True
# Emit a state snapshot so the UI's
# `useAgent({updates: [OnStateChanged]})` subscription fires
# and the notes-card re-renders immediately without waiting
# for the next turn. Only emit if notes actually changed;
# pure frontend-tool turns don't mutate shared state.
if notes_changed:
await copilotkit_emit_state(self.state)
self.state.messages.append(
{
"role": "tool",
"content": "Notes updated.",
"tool_call_id": tool_call_id,
}
)
# Emit a state snapshot so the UI's `useAgent({updates: [OnStateChanged]})`
# subscription fires and the notes-card re-renders immediately
# without waiting for the next turn. Only emit if notes actually
# changed; pure frontend-tool turns don't mutate shared state.
if notes_changed:
await copilotkit_emit_state(self.state)
# Loop back to call the LLM again with the tool results
# appended — the LLM will now produce the follow-up text
# response confirming the tool action.
# Module-level singleton — `add_crewai_flow_fastapi_endpoint` deepcopies
@@ -0,0 +1,160 @@
"""CrewAI Flow backing the Tool Rendering demo.
The default ``ChatWithCrewFlow`` (used for the catch-all
``LatestAiDevelopment`` crew on "/") executes backend tools internally
without emitting AG-UI ``TOOL_CALL_START`` / ``TOOL_CALL_END`` events.
The frontend's ``useRenderTool`` hook never sees the tool call and
therefore never renders the WeatherCard.
This module bypasses the crew flow and uses a raw CrewAI ``Flow`` with
``copilotkit_stream`` -- which DOES emit AG-UI tool-call events for
every tool call in the LLM response -- so the frontend receives the
tool call and the registered ``useRenderTool`` renderer fires.
The flow mirrors the LangGraph-Python reference: it makes LLM calls in
a loop, executing backend tools (``get_weather``) locally and streaming
every response (text or tool call) through ``copilotkit_stream``.
"""
from __future__ import annotations
import json
import uuid
from typing import List, Optional
from crewai.flow.flow import Flow, start
from litellm import acompletion
from pydantic import Field
from ag_ui_crewai import CopilotKitState, copilotkit_stream
from tools import get_weather_impl
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
class ToolRenderingState(CopilotKitState):
"""Minimal state -- just the conversation messages."""
pass
# ---------------------------------------------------------------------------
# Tool schema (LiteLLM/OpenAI tool format)
# ---------------------------------------------------------------------------
GET_WEATHER_TOOL = {
"type": "function",
"function": {
"name": "get_weather",
"description": (
"Get current weather for a location. Always call this tool "
"when the user asks about weather. Ensure the location is "
"fully spelled out (e.g. 'San Francisco', not 'SF')."
),
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city or location to get weather for.",
}
},
"required": ["location"],
},
},
}
# ---------------------------------------------------------------------------
# Flow
# ---------------------------------------------------------------------------
_SYSTEM_PROMPT = (
"You are a helpful, concise weather assistant. When the user asks "
"about weather for a location, call the `get_weather` tool with the "
"location. After receiving the tool result, summarise the weather "
"in a short sentence."
)
# Maximum LLM round-trips per user turn (prevents infinite loops).
_MAX_ITERATIONS = 5
class ToolRenderingFlow(Flow[ToolRenderingState]):
"""Chat flow that streams tool calls to the frontend for rendering."""
@start()
async def chat(self) -> None:
system_message = {
"role": "system",
"content": _SYSTEM_PROMPT,
"id": str(uuid.uuid4()) + "-system",
}
# Frontend-registered actions + our backend get_weather tool.
tools = [
*self.state.copilotkit.actions,
GET_WEATHER_TOOL,
]
for _iteration in range(_MAX_ITERATIONS):
messages = [system_message, *self.state.messages]
response = await copilotkit_stream(
await acompletion(
model="openai/gpt-4o-mini",
messages=messages,
tools=tools,
parallel_tool_calls=False,
stream=True,
)
)
message = response.choices[0].message
self.state.messages.append(message)
tool_calls = message.get("tool_calls") or []
if not tool_calls:
# No tool calls -- the LLM produced a text response.
return
for tool_call in tool_calls:
tool_call_id = tool_call["id"]
tool_name = tool_call["function"]["name"]
if tool_name == "get_weather":
try:
args = json.loads(
tool_call["function"]["arguments"] or "{}"
)
except json.JSONDecodeError:
args = {}
location = args.get("location", "Unknown")
result = get_weather_impl(location)
result_str = json.dumps(result)
self.state.messages.append(
{
"role": "tool",
"content": result_str,
"tool_call_id": tool_call_id,
}
)
else:
# Frontend-registered action -- placeholder result.
self.state.messages.append(
{
"role": "tool",
"content": "frontend tool -- handled client-side",
"tool_call_id": tool_call_id,
}
)
# Loop back to call the LLM again with the tool results.
# Module-level singleton -- deepcopied per request by the endpoint.
tool_rendering_flow = ToolRenderingFlow()
@@ -0,0 +1,67 @@
// Dedicated runtime for the Tool Rendering cell.
//
// Backend: a CrewAI `Flow` (NOT a Crew) mounted at `/tool-rendering` on
// the FastAPI agent server. The flow uses `copilotkit_stream` to emit
// AG-UI tool-call events for every tool call, so the frontend's
// `useRenderTool` hook sees the tool calls and renders custom cards
// (e.g. WeatherCard). The default `ChatWithCrewFlow` does NOT emit
// these events for backend-executed tools, which is why the catch-all
// crew endpoint cannot serve tool-rendering.
import { NextRequest, NextResponse } from "next/server";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
import crypto from "node:crypto";
const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";
function createAgent() {
return new HttpAgent({ url: `${AGENT_URL}/tool-rendering` });
}
const agents: Record<string, AbstractAgent> = {
"tool-rendering": createAgent(),
default: createAgent(),
};
const runtime = new CopilotRuntime({
// @ts-ignore -- see main route.ts
agents,
});
function logRouteError(err: unknown): string {
const error = err instanceof Error ? err : new Error(String(err));
const errorId = crypto.randomUUID();
console.error(
JSON.stringify({
at: new Date().toISOString(),
level: "error",
route: "copilotkit-tool-rendering",
errorId,
message: error.message,
stack: error.stack,
}),
);
return errorId;
}
export const POST = async (req: NextRequest) => {
try {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit-tool-rendering",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime,
});
return await handleRequest(req);
} catch (error: unknown) {
const errorId = logRouteError(error);
return NextResponse.json(
{ error: "internal runtime error", errorId },
{ status: 500 },
);
}
};
@@ -20,7 +20,10 @@ function parseJsonResult<T>(result: unknown): T {
export default function ToolRenderingDemo() {
return (
<CopilotKit runtimeUrl="/api/copilotkit" agent="tool-rendering">
<CopilotKit
runtimeUrl="/api/copilotkit-tool-rendering"
agent="tool-rendering"
>
<Chat />
</CopilotKit>
);
-1
View File
@@ -1 +0,0 @@
../../shared/python/tools
@@ -0,0 +1,46 @@
"""Barrel exports for all shared showcase tool implementations."""
from .types import (
SalesStage,
SalesTodo,
Flight,
WeatherResult,
)
from .get_weather import get_weather_impl
from .query_data import query_data_impl
from .sales_todos import (
INITIAL_TODOS,
manage_sales_todos_impl,
get_sales_todos_impl,
)
from .search_flights import search_flights_impl
from .generate_a2ui import (
RENDER_A2UI_TOOL_SCHEMA,
generate_a2ui_impl,
build_a2ui_operations_from_tool_call,
)
from .schedule_meeting import schedule_meeting_impl
__all__ = [
# Types
"SalesStage",
"SalesTodo",
"Flight",
"WeatherResult",
# Weather
"get_weather_impl",
# Query data
"query_data_impl",
# Sales todos
"INITIAL_TODOS",
"manage_sales_todos_impl",
"get_sales_todos_impl",
# Flight search (fixed-schema A2UI)
"search_flights_impl",
# Dynamic A2UI
"RENDER_A2UI_TOOL_SCHEMA",
"generate_a2ui_impl",
"build_a2ui_operations_from_tool_call",
# Schedule meeting (HITL)
"schedule_meeting_impl",
]
@@ -0,0 +1,104 @@
"""Dynamic A2UI tool: LLM-generated UI from conversation context.
This module provides the data preparation for a secondary LLM call that
generates v0.9 A2UI components. The actual LLM call is made by the
framework-specific wrapper (LangGraph, CrewAI, etc.) since each framework
has its own way of invoking LLMs.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
_logger = logging.getLogger(__name__)
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
# The render_a2ui tool schema that the secondary LLM is bound to.
RENDER_A2UI_TOOL_SCHEMA = {
"name": "render_a2ui",
"description": (
"Render a dynamic A2UI v0.9 surface.\n\n"
"Args:\n"
" surfaceId: Unique surface identifier.\n"
" catalogId: The catalog ID (use \"copilotkit://app-dashboard-catalog\").\n"
" components: A2UI v0.9 component array (flat format). "
"The root component must have id \"root\".\n"
" data: Optional initial data model for the surface."
),
"parameters": {
"type": "object",
"properties": {
"surfaceId": {"type": "string", "description": "Unique surface identifier."},
"catalogId": {"type": "string", "description": "The catalog ID."},
"components": {
"type": "array",
"items": {"type": "object"},
"description": "A2UI v0.9 component array (flat format).",
},
"data": {
"type": "object",
"description": "Optional initial data model for the surface.",
},
},
"required": ["surfaceId", "catalogId", "components"],
},
}
def generate_a2ui_impl(
messages: list[dict[str, Any]],
context_entries: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Prepare inputs for a secondary LLM call that generates A2UI components.
Returns a dict with:
- system_prompt: The system prompt for the secondary LLM (built from context)
- tool_schema: The render_a2ui tool schema to bind to the LLM
- tool_choice: The tool name to force
- messages: The conversation messages to pass through
- catalog_id: The default catalog ID
The framework wrapper should:
1. Make an LLM call with these inputs
2. Extract the tool call args (surfaceId, catalogId, components, data)
3. Build a2ui_operations from the args and return them
"""
context_text = ""
if context_entries:
context_text = "\n\n".join(
entry.get("value", "")
for entry in context_entries
if isinstance(entry, dict) and entry.get("value")
)
return {
"system_prompt": context_text,
"tool_schema": RENDER_A2UI_TOOL_SCHEMA,
"tool_choice": "render_a2ui",
"messages": messages,
"catalog_id": CUSTOM_CATALOG_ID,
}
def build_a2ui_operations_from_tool_call(args: dict[str, Any]) -> dict[str, Any]:
"""Build a2ui_operations dict from the secondary LLM's tool call args.
Call this after the framework wrapper extracts the tool call arguments.
"""
surface_id = args.get("surfaceId", "dynamic-surface")
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
components = args.get("components", [])
if not components:
_logger.warning("build_a2ui_operations_from_tool_call received empty components list")
data = args.get("data")
ops = [
{"type": "create_surface", "surfaceId": surface_id, "catalogId": catalog_id},
{"type": "update_components", "surfaceId": surface_id, "components": components},
]
if data:
ops.append({"type": "update_data_model", "surfaceId": surface_id, "data": data})
return {"a2ui_operations": ops}
@@ -0,0 +1,40 @@
"""Mock weather data tool implementation."""
import random
from .types import WeatherResult
_CONDITIONS = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
]
def get_weather_impl(city: str) -> WeatherResult:
"""Return mock weather data for the given city.
Uses a seeded random based on the city name so repeated calls
for the same city return consistent results within a session.
"""
rng = random.Random(city.lower())
temperature = rng.randint(20, 95)
humidity = rng.randint(30, 90)
wind_speed = rng.randint(2, 30)
feels_like = temperature + rng.randint(-5, 5)
conditions = rng.choice(_CONDITIONS)
return WeatherResult(
city=city,
temperature=temperature,
humidity=humidity,
wind_speed=wind_speed,
feels_like=feels_like,
conditions=conditions,
)
@@ -0,0 +1,58 @@
"""Query data tool implementation — reads db.csv at module load time."""
from __future__ import annotations
import csv
import logging
from pathlib import Path
from typing import Any
_logger = logging.getLogger(__name__)
_csv_path = Path(__file__).resolve().parent.parent / "data" / "db.csv"
_MOCK_DATA = [
{
"date": "2026-01-05",
"category": "Revenue",
"subcategory": "Enterprise Subscriptions",
"amount": "28000",
"type": "income",
"notes": "3 new enterprise customers",
},
{
"date": "2026-01-10",
"category": "Expenses",
"subcategory": "Engineering Salaries",
"amount": "42000",
"type": "expense",
"notes": "7 engineers + 2 contractors",
},
{
"date": "2026-02-03",
"category": "Revenue",
"subcategory": "Pro Tier Upgrades",
"amount": "22500",
"type": "income",
"notes": "31 upgrades + reduced churn",
},
]
try:
with open(_csv_path) as _f:
_cached_data: list[dict[str, Any]] = list(csv.DictReader(_f))
if not _cached_data:
_logger.warning("CSV at %s is empty, falling back to mock data", _csv_path)
_cached_data = _MOCK_DATA
except (FileNotFoundError, OSError) as exc:
_logger.warning("Could not load CSV at %s (%s), falling back to mock data", _csv_path, exc)
_cached_data = _MOCK_DATA
def query_data_impl(query: str) -> list[dict[str, Any]]:
"""Query the database. Takes natural language.
Always call before showing a chart or graph. Returns the full
dataset as a list of dicts (rows from the CSV).
"""
return _cached_data
@@ -0,0 +1,61 @@
"""Sales todos tool implementation."""
from __future__ import annotations
import uuid
from typing import Optional
from .types import SalesTodo
INITIAL_TODOS: list[SalesTodo] = [
SalesTodo(
id="st-001",
title="Follow up with Acme Corp on enterprise proposal",
stage="proposal",
value=85000,
dueDate="2026-04-15",
assignee="Sarah Chen",
completed=False,
),
SalesTodo(
id="st-002",
title="Qualify lead from TechFlow demo request",
stage="prospect",
value=42000,
dueDate="2026-04-18",
assignee="Mike Johnson",
completed=False,
),
SalesTodo(
id="st-003",
title="Send contract to DataViz Inc for final review",
stage="negotiation",
value=120000,
dueDate="2026-04-20",
assignee="Sarah Chen",
completed=False,
),
]
def manage_sales_todos_impl(todos: list[dict]) -> list[SalesTodo]:
"""Assign UUIDs to any todos missing an ID, then return the updated list."""
result: list[SalesTodo] = []
for todo in todos:
result.append(SalesTodo(
id=todo.get("id") or str(uuid.uuid4()),
title=todo.get("title", ""),
stage=todo.get("stage", "prospect"),
value=todo.get("value", 0),
dueDate=todo.get("dueDate", ""),
assignee=todo.get("assignee", ""),
completed=todo.get("completed", False),
))
return result
def get_sales_todos_impl(current_todos: Optional[list[dict]] = None) -> list[SalesTodo]:
"""Return current todos or initial defaults if none provided."""
if current_todos is not None:
return manage_sales_todos_impl(current_todos)
return list(INITIAL_TODOS)
@@ -0,0 +1,31 @@
"""Schedule meeting tool implementation.
The HITL gating happens on the frontend via useHumanInTheLoop.
This tool just returns a pending approval status for the framework
wrapper to surface.
"""
from __future__ import annotations
from typing import Any
def schedule_meeting_impl(
reason: str,
duration_minutes: int = 30,
) -> dict[str, Any]:
"""Schedule a meeting (requires human approval).
Returns a pending_approval status. The actual gating is done by the
frontend's useHumanInTheLoop hook — the agent pauses until the user
approves or rejects.
"""
return {
"status": "pending_approval",
"reason": reason,
"duration_minutes": duration_minutes,
"message": (
f"Meeting request: {reason} ({duration_minutes} min). "
"Awaiting human approval."
),
}
@@ -0,0 +1,106 @@
"""Fixed-schema A2UI tool: flight search results.
Packages flight data with an A2UI schema for rendering. The schema is loaded
from the shared frontend package's flight_schema.json.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from .types import Flight
_logger = logging.getLogger(__name__)
CATALOG_ID = "copilotkit://app-dashboard-catalog"
SURFACE_ID = "flight-search-results"
# Resolve the flight schema from the shared frontend package.
# Walk up from this file to showcase/shared/, then into frontend/src/a2ui/.
_SHARED_DIR = Path(__file__).resolve().parent.parent.parent # showcase/shared/
_SCHEMA_CANDIDATES = [
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight-schema.json",
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight_schema.json",
]
_flight_schema: list[dict[str, Any]] | None = None
for _candidate in _SCHEMA_CANDIDATES:
if _candidate.exists():
with open(_candidate) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from shared frontend: %s", _candidate)
break
# Fallback: use the schema from the examples directory if present
if _flight_schema is None:
try:
_fallback = Path(__file__).resolve().parents[4] / (
"examples/integrations/langgraph-python/apps/agent/src/a2ui/schemas/flight_schema.json"
)
if _fallback.exists():
with open(_fallback) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from examples fallback: %s", _fallback)
except IndexError:
# In Docker the file path is too shallow for parents[4]; skip this fallback.
pass
# Last resort: inline minimal schema
if _flight_schema is None:
_logger.warning("No flight schema file found, using inline minimal schema")
_flight_schema = [
{
"id": "root",
"component": "Row",
"children": {"componentId": "flight-card", "path": "/flights"},
"gap": 16,
},
{
"id": "flight-card",
"component": "FlightCard",
"airline": {"path": "airline"},
"airlineLogo": {"path": "airlineLogo"},
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"date": {"path": "date"},
"departureTime": {"path": "departureTime"},
"arrivalTime": {"path": "arrivalTime"},
"duration": {"path": "duration"},
"status": {"path": "status"},
"price": {"path": "price"},
"action": {
"event": {
"name": "book_flight",
"context": {
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"price": {"path": "price"},
},
}
},
},
]
def search_flights_impl(flights: list[Flight]) -> dict[str, Any]:
"""Package flight data with A2UI schema for rendering.
Returns a dict with a2ui_operations that the middleware detects in the
TOOL_CALL_RESULT and renders automatically.
Each flight should have: airline, airlineLogo, flightNumber, origin,
destination, date, departureTime, arrivalTime, duration, status,
statusColor, price, currency.
"""
return {
"a2ui_operations": [
{"type": "create_surface", "surfaceId": SURFACE_ID, "catalogId": CATALOG_ID},
{"type": "update_components", "surfaceId": SURFACE_ID, "components": _flight_schema},
{"type": "update_data_model", "surfaceId": SURFACE_ID, "data": {"flights": flights}},
]
}
@@ -0,0 +1,47 @@
"""Shared type definitions for showcase tools."""
from typing import TypedDict, Literal
SalesStage = Literal[
"prospect",
"qualified",
"proposal",
"negotiation",
"closed-won",
"closed-lost",
]
class SalesTodo(TypedDict):
id: str
title: str
stage: SalesStage
value: int
dueDate: str
assignee: str
completed: bool
class Flight(TypedDict):
airline: str
airlineLogo: str
flightNumber: str
origin: str
destination: str
date: str
departureTime: str
arrivalTime: str
duration: str
status: str
statusColor: str
price: str
currency: str
class WeatherResult(TypedDict):
city: str
temperature: int
humidity: int
wind_speed: int
feels_like: int
conditions: str
@@ -10,6 +10,7 @@ as the tool result.
from __future__ import annotations
from google.adk.agents import LlmAgent
from ag_ui_adk import AGUIToolset
from agents.shared_chat import get_model
@@ -27,5 +28,5 @@ hitl_in_app_agent = LlmAgent(
name="HitlInAppAgent",
model=get_model(),
instruction=_INSTRUCTION,
tools=[],
tools=[AGUIToolset()],
)
@@ -1,37 +1,24 @@
"""Agent backing the In-Chat Human in the Loop demo.
"""ADK agent backing the In-Chat Human in the Loop demo (hitl-steps).
Mirrors the existing google-adk hitl/ demo's pattern: the agent calls a
`generate_task_steps` tool whose execution the frontend resolves via
useHumanInTheLoop({ render }) the user picks/approves steps in the chat
and `respond({...})` is forwarded back to the agent as the tool result.
The ``generate_task_steps`` tool is defined on the FRONTEND via
``useHumanInTheLoop`` the user picks/approves steps in the chat
and the selection flows back as the tool result. This matches the
canonical HITL pattern used by every other showcase integration
(langgraph-python, pydantic-ai, ms-agent-python, etc.).
Backend tool body simply emits a placeholder dict; the real work happens
on the frontend (the renderer waits for user input and resolves the call).
The backend agent has NO tools of its own CopilotKit's middleware
injects the frontend-registered tool definition into the LLM call so
the model can invoke it.
"""
from __future__ import annotations
from google.adk.agents import LlmAgent
from google.adk.tools import ToolContext
from ag_ui_adk import AGUIToolset
from agents.shared_chat import get_model
def generate_task_steps(tool_context: ToolContext, steps: list[dict]) -> dict:
"""Generate a list of steps for the user to review.
Each step has `description: str` and `status: "enabled" | "disabled" |
"executing"`. Always emit each step initially with status="enabled".
The frontend renders the steps as an inline approval UI; the user
enables/disables/confirms and `respond({...})` is forwarded back as
this tool's result.
"""
return {
"status": "pending_human_decision",
"step_count": len(steps),
}
_INSTRUCTION = (
"You are a planning assistant. When the user asks you to plan something, "
"always call generate_task_steps with the proposed list of steps (each "
@@ -45,5 +32,5 @@ hitl_in_chat_agent = LlmAgent(
name="HitlInChatAgent",
model=get_model(),
instruction=_INSTRUCTION,
tools=[generate_task_steps],
tools=[AGUIToolset()],
)
@@ -13,6 +13,7 @@ tool list at request time.
from __future__ import annotations
from google.adk.agents import LlmAgent
from ag_ui_adk import AGUIToolset
from agents.shared_chat import get_model
@@ -27,5 +28,5 @@ hitl_in_chat_book_call_agent = LlmAgent(
name="HitlInChatBookCallAgent",
model=get_model(),
instruction=_INSTRUCTION,
tools=[],
tools=[AGUIToolset()],
)
@@ -42,8 +42,8 @@ from agents.shared_state_streaming_agent import (
SHARED_STATE_STREAMING_PREDICT_STATE,
)
from agents.subagents_agent import subagents_root_agent
from agents.hitl_in_chat_agent import hitl_in_chat_agent
from agents.hitl_in_chat_book_call_agent import hitl_in_chat_book_call_agent
from agents.hitl_in_chat_agent import hitl_in_chat_agent
from agents.hitl_in_app_agent import hitl_in_app_agent
from agents.mcp_apps_agent import mcp_apps_agent
from agents.multimodal_agent import multimodal_agent
@@ -24,6 +24,7 @@ from typing import Union
from google.adk.agents import LlmAgent
from google.adk.models.google_llm import Gemini
from google.genai import types
from ag_ui_adk import AGUIToolset
DEFAULT_MODEL = "gemini-2.5-flash"
@@ -47,7 +48,7 @@ def build_simple_chat_agent(
instruction: str,
model: str = DEFAULT_MODEL,
) -> LlmAgent:
return LlmAgent(name=name, model=get_model(model), instruction=instruction, tools=[])
return LlmAgent(name=name, model=get_model(model), instruction=instruction, tools=[AGUIToolset()])
def build_thinking_chat_agent(
@@ -67,7 +68,7 @@ def build_thinking_chat_agent(
name=name,
model=get_model(model),
instruction=instruction,
tools=[],
tools=[AGUIToolset()],
generate_content_config=types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(
include_thoughts=True,
-1
View File
@@ -1 +0,0 @@
../../shared/python/tools
@@ -0,0 +1,46 @@
"""Barrel exports for all shared showcase tool implementations."""
from .types import (
SalesStage,
SalesTodo,
Flight,
WeatherResult,
)
from .get_weather import get_weather_impl
from .query_data import query_data_impl
from .sales_todos import (
INITIAL_TODOS,
manage_sales_todos_impl,
get_sales_todos_impl,
)
from .search_flights import search_flights_impl
from .generate_a2ui import (
RENDER_A2UI_TOOL_SCHEMA,
generate_a2ui_impl,
build_a2ui_operations_from_tool_call,
)
from .schedule_meeting import schedule_meeting_impl
__all__ = [
# Types
"SalesStage",
"SalesTodo",
"Flight",
"WeatherResult",
# Weather
"get_weather_impl",
# Query data
"query_data_impl",
# Sales todos
"INITIAL_TODOS",
"manage_sales_todos_impl",
"get_sales_todos_impl",
# Flight search (fixed-schema A2UI)
"search_flights_impl",
# Dynamic A2UI
"RENDER_A2UI_TOOL_SCHEMA",
"generate_a2ui_impl",
"build_a2ui_operations_from_tool_call",
# Schedule meeting (HITL)
"schedule_meeting_impl",
]
@@ -0,0 +1,104 @@
"""Dynamic A2UI tool: LLM-generated UI from conversation context.
This module provides the data preparation for a secondary LLM call that
generates v0.9 A2UI components. The actual LLM call is made by the
framework-specific wrapper (LangGraph, CrewAI, etc.) since each framework
has its own way of invoking LLMs.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
_logger = logging.getLogger(__name__)
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
# The render_a2ui tool schema that the secondary LLM is bound to.
RENDER_A2UI_TOOL_SCHEMA = {
"name": "render_a2ui",
"description": (
"Render a dynamic A2UI v0.9 surface.\n\n"
"Args:\n"
" surfaceId: Unique surface identifier.\n"
" catalogId: The catalog ID (use \"copilotkit://app-dashboard-catalog\").\n"
" components: A2UI v0.9 component array (flat format). "
"The root component must have id \"root\".\n"
" data: Optional initial data model for the surface."
),
"parameters": {
"type": "object",
"properties": {
"surfaceId": {"type": "string", "description": "Unique surface identifier."},
"catalogId": {"type": "string", "description": "The catalog ID."},
"components": {
"type": "array",
"items": {"type": "object"},
"description": "A2UI v0.9 component array (flat format).",
},
"data": {
"type": "object",
"description": "Optional initial data model for the surface.",
},
},
"required": ["surfaceId", "catalogId", "components"],
},
}
def generate_a2ui_impl(
messages: list[dict[str, Any]],
context_entries: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Prepare inputs for a secondary LLM call that generates A2UI components.
Returns a dict with:
- system_prompt: The system prompt for the secondary LLM (built from context)
- tool_schema: The render_a2ui tool schema to bind to the LLM
- tool_choice: The tool name to force
- messages: The conversation messages to pass through
- catalog_id: The default catalog ID
The framework wrapper should:
1. Make an LLM call with these inputs
2. Extract the tool call args (surfaceId, catalogId, components, data)
3. Build a2ui_operations from the args and return them
"""
context_text = ""
if context_entries:
context_text = "\n\n".join(
entry.get("value", "")
for entry in context_entries
if isinstance(entry, dict) and entry.get("value")
)
return {
"system_prompt": context_text,
"tool_schema": RENDER_A2UI_TOOL_SCHEMA,
"tool_choice": "render_a2ui",
"messages": messages,
"catalog_id": CUSTOM_CATALOG_ID,
}
def build_a2ui_operations_from_tool_call(args: dict[str, Any]) -> dict[str, Any]:
"""Build a2ui_operations dict from the secondary LLM's tool call args.
Call this after the framework wrapper extracts the tool call arguments.
"""
surface_id = args.get("surfaceId", "dynamic-surface")
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
components = args.get("components", [])
if not components:
_logger.warning("build_a2ui_operations_from_tool_call received empty components list")
data = args.get("data")
ops = [
{"type": "create_surface", "surfaceId": surface_id, "catalogId": catalog_id},
{"type": "update_components", "surfaceId": surface_id, "components": components},
]
if data:
ops.append({"type": "update_data_model", "surfaceId": surface_id, "data": data})
return {"a2ui_operations": ops}
@@ -0,0 +1,40 @@
"""Mock weather data tool implementation."""
import random
from .types import WeatherResult
_CONDITIONS = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
]
def get_weather_impl(city: str) -> WeatherResult:
"""Return mock weather data for the given city.
Uses a seeded random based on the city name so repeated calls
for the same city return consistent results within a session.
"""
rng = random.Random(city.lower())
temperature = rng.randint(20, 95)
humidity = rng.randint(30, 90)
wind_speed = rng.randint(2, 30)
feels_like = temperature + rng.randint(-5, 5)
conditions = rng.choice(_CONDITIONS)
return WeatherResult(
city=city,
temperature=temperature,
humidity=humidity,
wind_speed=wind_speed,
feels_like=feels_like,
conditions=conditions,
)
@@ -0,0 +1,58 @@
"""Query data tool implementation — reads db.csv at module load time."""
from __future__ import annotations
import csv
import logging
from pathlib import Path
from typing import Any
_logger = logging.getLogger(__name__)
_csv_path = Path(__file__).resolve().parent.parent / "data" / "db.csv"
_MOCK_DATA = [
{
"date": "2026-01-05",
"category": "Revenue",
"subcategory": "Enterprise Subscriptions",
"amount": "28000",
"type": "income",
"notes": "3 new enterprise customers",
},
{
"date": "2026-01-10",
"category": "Expenses",
"subcategory": "Engineering Salaries",
"amount": "42000",
"type": "expense",
"notes": "7 engineers + 2 contractors",
},
{
"date": "2026-02-03",
"category": "Revenue",
"subcategory": "Pro Tier Upgrades",
"amount": "22500",
"type": "income",
"notes": "31 upgrades + reduced churn",
},
]
try:
with open(_csv_path) as _f:
_cached_data: list[dict[str, Any]] = list(csv.DictReader(_f))
if not _cached_data:
_logger.warning("CSV at %s is empty, falling back to mock data", _csv_path)
_cached_data = _MOCK_DATA
except (FileNotFoundError, OSError) as exc:
_logger.warning("Could not load CSV at %s (%s), falling back to mock data", _csv_path, exc)
_cached_data = _MOCK_DATA
def query_data_impl(query: str) -> list[dict[str, Any]]:
"""Query the database. Takes natural language.
Always call before showing a chart or graph. Returns the full
dataset as a list of dicts (rows from the CSV).
"""
return _cached_data
@@ -0,0 +1,61 @@
"""Sales todos tool implementation."""
from __future__ import annotations
import uuid
from typing import Optional
from .types import SalesTodo
INITIAL_TODOS: list[SalesTodo] = [
SalesTodo(
id="st-001",
title="Follow up with Acme Corp on enterprise proposal",
stage="proposal",
value=85000,
dueDate="2026-04-15",
assignee="Sarah Chen",
completed=False,
),
SalesTodo(
id="st-002",
title="Qualify lead from TechFlow demo request",
stage="prospect",
value=42000,
dueDate="2026-04-18",
assignee="Mike Johnson",
completed=False,
),
SalesTodo(
id="st-003",
title="Send contract to DataViz Inc for final review",
stage="negotiation",
value=120000,
dueDate="2026-04-20",
assignee="Sarah Chen",
completed=False,
),
]
def manage_sales_todos_impl(todos: list[dict]) -> list[SalesTodo]:
"""Assign UUIDs to any todos missing an ID, then return the updated list."""
result: list[SalesTodo] = []
for todo in todos:
result.append(SalesTodo(
id=todo.get("id") or str(uuid.uuid4()),
title=todo.get("title", ""),
stage=todo.get("stage", "prospect"),
value=todo.get("value", 0),
dueDate=todo.get("dueDate", ""),
assignee=todo.get("assignee", ""),
completed=todo.get("completed", False),
))
return result
def get_sales_todos_impl(current_todos: Optional[list[dict]] = None) -> list[SalesTodo]:
"""Return current todos or initial defaults if none provided."""
if current_todos is not None:
return manage_sales_todos_impl(current_todos)
return list(INITIAL_TODOS)
@@ -0,0 +1,31 @@
"""Schedule meeting tool implementation.
The HITL gating happens on the frontend via useHumanInTheLoop.
This tool just returns a pending approval status for the framework
wrapper to surface.
"""
from __future__ import annotations
from typing import Any
def schedule_meeting_impl(
reason: str,
duration_minutes: int = 30,
) -> dict[str, Any]:
"""Schedule a meeting (requires human approval).
Returns a pending_approval status. The actual gating is done by the
frontend's useHumanInTheLoop hook — the agent pauses until the user
approves or rejects.
"""
return {
"status": "pending_approval",
"reason": reason,
"duration_minutes": duration_minutes,
"message": (
f"Meeting request: {reason} ({duration_minutes} min). "
"Awaiting human approval."
),
}
@@ -0,0 +1,106 @@
"""Fixed-schema A2UI tool: flight search results.
Packages flight data with an A2UI schema for rendering. The schema is loaded
from the shared frontend package's flight_schema.json.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from .types import Flight
_logger = logging.getLogger(__name__)
CATALOG_ID = "copilotkit://app-dashboard-catalog"
SURFACE_ID = "flight-search-results"
# Resolve the flight schema from the shared frontend package.
# Walk up from this file to showcase/shared/, then into frontend/src/a2ui/.
_SHARED_DIR = Path(__file__).resolve().parent.parent.parent # showcase/shared/
_SCHEMA_CANDIDATES = [
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight-schema.json",
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight_schema.json",
]
_flight_schema: list[dict[str, Any]] | None = None
for _candidate in _SCHEMA_CANDIDATES:
if _candidate.exists():
with open(_candidate) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from shared frontend: %s", _candidate)
break
# Fallback: use the schema from the examples directory if present
if _flight_schema is None:
try:
_fallback = Path(__file__).resolve().parents[4] / (
"examples/integrations/langgraph-python/apps/agent/src/a2ui/schemas/flight_schema.json"
)
if _fallback.exists():
with open(_fallback) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from examples fallback: %s", _fallback)
except IndexError:
# In Docker the file path is too shallow for parents[4]; skip this fallback.
pass
# Last resort: inline minimal schema
if _flight_schema is None:
_logger.warning("No flight schema file found, using inline minimal schema")
_flight_schema = [
{
"id": "root",
"component": "Row",
"children": {"componentId": "flight-card", "path": "/flights"},
"gap": 16,
},
{
"id": "flight-card",
"component": "FlightCard",
"airline": {"path": "airline"},
"airlineLogo": {"path": "airlineLogo"},
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"date": {"path": "date"},
"departureTime": {"path": "departureTime"},
"arrivalTime": {"path": "arrivalTime"},
"duration": {"path": "duration"},
"status": {"path": "status"},
"price": {"path": "price"},
"action": {
"event": {
"name": "book_flight",
"context": {
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"price": {"path": "price"},
},
}
},
},
]
def search_flights_impl(flights: list[Flight]) -> dict[str, Any]:
"""Package flight data with A2UI schema for rendering.
Returns a dict with a2ui_operations that the middleware detects in the
TOOL_CALL_RESULT and renders automatically.
Each flight should have: airline, airlineLogo, flightNumber, origin,
destination, date, departureTime, arrivalTime, duration, status,
statusColor, price, currency.
"""
return {
"a2ui_operations": [
{"type": "create_surface", "surfaceId": SURFACE_ID, "catalogId": CATALOG_ID},
{"type": "update_components", "surfaceId": SURFACE_ID, "components": _flight_schema},
{"type": "update_data_model", "surfaceId": SURFACE_ID, "data": {"flights": flights}},
]
}
@@ -0,0 +1,47 @@
"""Shared type definitions for showcase tools."""
from typing import TypedDict, Literal
SalesStage = Literal[
"prospect",
"qualified",
"proposal",
"negotiation",
"closed-won",
"closed-lost",
]
class SalesTodo(TypedDict):
id: str
title: str
stage: SalesStage
value: int
dueDate: str
assignee: str
completed: bool
class Flight(TypedDict):
airline: str
airlineLogo: str
flightNumber: str
origin: str
destination: str
date: str
departureTime: str
arrivalTime: str
duration: str
status: str
statusColor: str
price: str
currency: str
class WeatherResult(TypedDict):
city: str
temperature: int
humidity: int
wind_speed: int
feels_like: int
conditions: str
@@ -1 +0,0 @@
../../shared/python/tools
@@ -0,0 +1,46 @@
"""Barrel exports for all shared showcase tool implementations."""
from .types import (
SalesStage,
SalesTodo,
Flight,
WeatherResult,
)
from .get_weather import get_weather_impl
from .query_data import query_data_impl
from .sales_todos import (
INITIAL_TODOS,
manage_sales_todos_impl,
get_sales_todos_impl,
)
from .search_flights import search_flights_impl
from .generate_a2ui import (
RENDER_A2UI_TOOL_SCHEMA,
generate_a2ui_impl,
build_a2ui_operations_from_tool_call,
)
from .schedule_meeting import schedule_meeting_impl
__all__ = [
# Types
"SalesStage",
"SalesTodo",
"Flight",
"WeatherResult",
# Weather
"get_weather_impl",
# Query data
"query_data_impl",
# Sales todos
"INITIAL_TODOS",
"manage_sales_todos_impl",
"get_sales_todos_impl",
# Flight search (fixed-schema A2UI)
"search_flights_impl",
# Dynamic A2UI
"RENDER_A2UI_TOOL_SCHEMA",
"generate_a2ui_impl",
"build_a2ui_operations_from_tool_call",
# Schedule meeting (HITL)
"schedule_meeting_impl",
]
@@ -0,0 +1,104 @@
"""Dynamic A2UI tool: LLM-generated UI from conversation context.
This module provides the data preparation for a secondary LLM call that
generates v0.9 A2UI components. The actual LLM call is made by the
framework-specific wrapper (LangGraph, CrewAI, etc.) since each framework
has its own way of invoking LLMs.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
_logger = logging.getLogger(__name__)
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
# The render_a2ui tool schema that the secondary LLM is bound to.
RENDER_A2UI_TOOL_SCHEMA = {
"name": "render_a2ui",
"description": (
"Render a dynamic A2UI v0.9 surface.\n\n"
"Args:\n"
" surfaceId: Unique surface identifier.\n"
" catalogId: The catalog ID (use \"copilotkit://app-dashboard-catalog\").\n"
" components: A2UI v0.9 component array (flat format). "
"The root component must have id \"root\".\n"
" data: Optional initial data model for the surface."
),
"parameters": {
"type": "object",
"properties": {
"surfaceId": {"type": "string", "description": "Unique surface identifier."},
"catalogId": {"type": "string", "description": "The catalog ID."},
"components": {
"type": "array",
"items": {"type": "object"},
"description": "A2UI v0.9 component array (flat format).",
},
"data": {
"type": "object",
"description": "Optional initial data model for the surface.",
},
},
"required": ["surfaceId", "catalogId", "components"],
},
}
def generate_a2ui_impl(
messages: list[dict[str, Any]],
context_entries: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Prepare inputs for a secondary LLM call that generates A2UI components.
Returns a dict with:
- system_prompt: The system prompt for the secondary LLM (built from context)
- tool_schema: The render_a2ui tool schema to bind to the LLM
- tool_choice: The tool name to force
- messages: The conversation messages to pass through
- catalog_id: The default catalog ID
The framework wrapper should:
1. Make an LLM call with these inputs
2. Extract the tool call args (surfaceId, catalogId, components, data)
3. Build a2ui_operations from the args and return them
"""
context_text = ""
if context_entries:
context_text = "\n\n".join(
entry.get("value", "")
for entry in context_entries
if isinstance(entry, dict) and entry.get("value")
)
return {
"system_prompt": context_text,
"tool_schema": RENDER_A2UI_TOOL_SCHEMA,
"tool_choice": "render_a2ui",
"messages": messages,
"catalog_id": CUSTOM_CATALOG_ID,
}
def build_a2ui_operations_from_tool_call(args: dict[str, Any]) -> dict[str, Any]:
"""Build a2ui_operations dict from the secondary LLM's tool call args.
Call this after the framework wrapper extracts the tool call arguments.
"""
surface_id = args.get("surfaceId", "dynamic-surface")
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
components = args.get("components", [])
if not components:
_logger.warning("build_a2ui_operations_from_tool_call received empty components list")
data = args.get("data")
ops = [
{"type": "create_surface", "surfaceId": surface_id, "catalogId": catalog_id},
{"type": "update_components", "surfaceId": surface_id, "components": components},
]
if data:
ops.append({"type": "update_data_model", "surfaceId": surface_id, "data": data})
return {"a2ui_operations": ops}
@@ -0,0 +1,40 @@
"""Mock weather data tool implementation."""
import random
from .types import WeatherResult
_CONDITIONS = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
]
def get_weather_impl(city: str) -> WeatherResult:
"""Return mock weather data for the given city.
Uses a seeded random based on the city name so repeated calls
for the same city return consistent results within a session.
"""
rng = random.Random(city.lower())
temperature = rng.randint(20, 95)
humidity = rng.randint(30, 90)
wind_speed = rng.randint(2, 30)
feels_like = temperature + rng.randint(-5, 5)
conditions = rng.choice(_CONDITIONS)
return WeatherResult(
city=city,
temperature=temperature,
humidity=humidity,
wind_speed=wind_speed,
feels_like=feels_like,
conditions=conditions,
)
@@ -0,0 +1,58 @@
"""Query data tool implementation — reads db.csv at module load time."""
from __future__ import annotations
import csv
import logging
from pathlib import Path
from typing import Any
_logger = logging.getLogger(__name__)
_csv_path = Path(__file__).resolve().parent.parent / "data" / "db.csv"
_MOCK_DATA = [
{
"date": "2026-01-05",
"category": "Revenue",
"subcategory": "Enterprise Subscriptions",
"amount": "28000",
"type": "income",
"notes": "3 new enterprise customers",
},
{
"date": "2026-01-10",
"category": "Expenses",
"subcategory": "Engineering Salaries",
"amount": "42000",
"type": "expense",
"notes": "7 engineers + 2 contractors",
},
{
"date": "2026-02-03",
"category": "Revenue",
"subcategory": "Pro Tier Upgrades",
"amount": "22500",
"type": "income",
"notes": "31 upgrades + reduced churn",
},
]
try:
with open(_csv_path) as _f:
_cached_data: list[dict[str, Any]] = list(csv.DictReader(_f))
if not _cached_data:
_logger.warning("CSV at %s is empty, falling back to mock data", _csv_path)
_cached_data = _MOCK_DATA
except (FileNotFoundError, OSError) as exc:
_logger.warning("Could not load CSV at %s (%s), falling back to mock data", _csv_path, exc)
_cached_data = _MOCK_DATA
def query_data_impl(query: str) -> list[dict[str, Any]]:
"""Query the database. Takes natural language.
Always call before showing a chart or graph. Returns the full
dataset as a list of dicts (rows from the CSV).
"""
return _cached_data
@@ -0,0 +1,61 @@
"""Sales todos tool implementation."""
from __future__ import annotations
import uuid
from typing import Optional
from .types import SalesTodo
INITIAL_TODOS: list[SalesTodo] = [
SalesTodo(
id="st-001",
title="Follow up with Acme Corp on enterprise proposal",
stage="proposal",
value=85000,
dueDate="2026-04-15",
assignee="Sarah Chen",
completed=False,
),
SalesTodo(
id="st-002",
title="Qualify lead from TechFlow demo request",
stage="prospect",
value=42000,
dueDate="2026-04-18",
assignee="Mike Johnson",
completed=False,
),
SalesTodo(
id="st-003",
title="Send contract to DataViz Inc for final review",
stage="negotiation",
value=120000,
dueDate="2026-04-20",
assignee="Sarah Chen",
completed=False,
),
]
def manage_sales_todos_impl(todos: list[dict]) -> list[SalesTodo]:
"""Assign UUIDs to any todos missing an ID, then return the updated list."""
result: list[SalesTodo] = []
for todo in todos:
result.append(SalesTodo(
id=todo.get("id") or str(uuid.uuid4()),
title=todo.get("title", ""),
stage=todo.get("stage", "prospect"),
value=todo.get("value", 0),
dueDate=todo.get("dueDate", ""),
assignee=todo.get("assignee", ""),
completed=todo.get("completed", False),
))
return result
def get_sales_todos_impl(current_todos: Optional[list[dict]] = None) -> list[SalesTodo]:
"""Return current todos or initial defaults if none provided."""
if current_todos is not None:
return manage_sales_todos_impl(current_todos)
return list(INITIAL_TODOS)
@@ -0,0 +1,31 @@
"""Schedule meeting tool implementation.
The HITL gating happens on the frontend via useHumanInTheLoop.
This tool just returns a pending approval status for the framework
wrapper to surface.
"""
from __future__ import annotations
from typing import Any
def schedule_meeting_impl(
reason: str,
duration_minutes: int = 30,
) -> dict[str, Any]:
"""Schedule a meeting (requires human approval).
Returns a pending_approval status. The actual gating is done by the
frontend's useHumanInTheLoop hook — the agent pauses until the user
approves or rejects.
"""
return {
"status": "pending_approval",
"reason": reason,
"duration_minutes": duration_minutes,
"message": (
f"Meeting request: {reason} ({duration_minutes} min). "
"Awaiting human approval."
),
}
@@ -0,0 +1,106 @@
"""Fixed-schema A2UI tool: flight search results.
Packages flight data with an A2UI schema for rendering. The schema is loaded
from the shared frontend package's flight_schema.json.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from .types import Flight
_logger = logging.getLogger(__name__)
CATALOG_ID = "copilotkit://app-dashboard-catalog"
SURFACE_ID = "flight-search-results"
# Resolve the flight schema from the shared frontend package.
# Walk up from this file to showcase/shared/, then into frontend/src/a2ui/.
_SHARED_DIR = Path(__file__).resolve().parent.parent.parent # showcase/shared/
_SCHEMA_CANDIDATES = [
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight-schema.json",
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight_schema.json",
]
_flight_schema: list[dict[str, Any]] | None = None
for _candidate in _SCHEMA_CANDIDATES:
if _candidate.exists():
with open(_candidate) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from shared frontend: %s", _candidate)
break
# Fallback: use the schema from the examples directory if present
if _flight_schema is None:
try:
_fallback = Path(__file__).resolve().parents[4] / (
"examples/integrations/langgraph-python/apps/agent/src/a2ui/schemas/flight_schema.json"
)
if _fallback.exists():
with open(_fallback) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from examples fallback: %s", _fallback)
except IndexError:
# In Docker the file path is too shallow for parents[4]; skip this fallback.
pass
# Last resort: inline minimal schema
if _flight_schema is None:
_logger.warning("No flight schema file found, using inline minimal schema")
_flight_schema = [
{
"id": "root",
"component": "Row",
"children": {"componentId": "flight-card", "path": "/flights"},
"gap": 16,
},
{
"id": "flight-card",
"component": "FlightCard",
"airline": {"path": "airline"},
"airlineLogo": {"path": "airlineLogo"},
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"date": {"path": "date"},
"departureTime": {"path": "departureTime"},
"arrivalTime": {"path": "arrivalTime"},
"duration": {"path": "duration"},
"status": {"path": "status"},
"price": {"path": "price"},
"action": {
"event": {
"name": "book_flight",
"context": {
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"price": {"path": "price"},
},
}
},
},
]
def search_flights_impl(flights: list[Flight]) -> dict[str, Any]:
"""Package flight data with A2UI schema for rendering.
Returns a dict with a2ui_operations that the middleware detects in the
TOOL_CALL_RESULT and renders automatically.
Each flight should have: airline, airlineLogo, flightNumber, origin,
destination, date, departureTime, arrivalTime, duration, status,
statusColor, price, currency.
"""
return {
"a2ui_operations": [
{"type": "create_surface", "surfaceId": SURFACE_ID, "catalogId": CATALOG_ID},
{"type": "update_components", "surfaceId": SURFACE_ID, "components": _flight_schema},
{"type": "update_data_model", "surfaceId": SURFACE_ID, "data": {"flights": flights}},
]
}
@@ -0,0 +1,47 @@
"""Shared type definitions for showcase tools."""
from typing import TypedDict, Literal
SalesStage = Literal[
"prospect",
"qualified",
"proposal",
"negotiation",
"closed-won",
"closed-lost",
]
class SalesTodo(TypedDict):
id: str
title: str
stage: SalesStage
value: int
dueDate: str
assignee: str
completed: bool
class Flight(TypedDict):
airline: str
airlineLogo: str
flightNumber: str
origin: str
destination: str
date: str
departureTime: str
arrivalTime: str
duration: str
status: str
statusColor: str
price: str
currency: str
class WeatherResult(TypedDict):
city: str
temperature: int
humidity: int
wind_speed: int
feels_like: int
conditions: str
@@ -1 +0,0 @@
../../shared/python/tools
@@ -0,0 +1,46 @@
"""Barrel exports for all shared showcase tool implementations."""
from .types import (
SalesStage,
SalesTodo,
Flight,
WeatherResult,
)
from .get_weather import get_weather_impl
from .query_data import query_data_impl
from .sales_todos import (
INITIAL_TODOS,
manage_sales_todos_impl,
get_sales_todos_impl,
)
from .search_flights import search_flights_impl
from .generate_a2ui import (
RENDER_A2UI_TOOL_SCHEMA,
generate_a2ui_impl,
build_a2ui_operations_from_tool_call,
)
from .schedule_meeting import schedule_meeting_impl
__all__ = [
# Types
"SalesStage",
"SalesTodo",
"Flight",
"WeatherResult",
# Weather
"get_weather_impl",
# Query data
"query_data_impl",
# Sales todos
"INITIAL_TODOS",
"manage_sales_todos_impl",
"get_sales_todos_impl",
# Flight search (fixed-schema A2UI)
"search_flights_impl",
# Dynamic A2UI
"RENDER_A2UI_TOOL_SCHEMA",
"generate_a2ui_impl",
"build_a2ui_operations_from_tool_call",
# Schedule meeting (HITL)
"schedule_meeting_impl",
]
@@ -0,0 +1,104 @@
"""Dynamic A2UI tool: LLM-generated UI from conversation context.
This module provides the data preparation for a secondary LLM call that
generates v0.9 A2UI components. The actual LLM call is made by the
framework-specific wrapper (LangGraph, CrewAI, etc.) since each framework
has its own way of invoking LLMs.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
_logger = logging.getLogger(__name__)
CUSTOM_CATALOG_ID = "copilotkit://app-dashboard-catalog"
# The render_a2ui tool schema that the secondary LLM is bound to.
RENDER_A2UI_TOOL_SCHEMA = {
"name": "render_a2ui",
"description": (
"Render a dynamic A2UI v0.9 surface.\n\n"
"Args:\n"
" surfaceId: Unique surface identifier.\n"
" catalogId: The catalog ID (use \"copilotkit://app-dashboard-catalog\").\n"
" components: A2UI v0.9 component array (flat format). "
"The root component must have id \"root\".\n"
" data: Optional initial data model for the surface."
),
"parameters": {
"type": "object",
"properties": {
"surfaceId": {"type": "string", "description": "Unique surface identifier."},
"catalogId": {"type": "string", "description": "The catalog ID."},
"components": {
"type": "array",
"items": {"type": "object"},
"description": "A2UI v0.9 component array (flat format).",
},
"data": {
"type": "object",
"description": "Optional initial data model for the surface.",
},
},
"required": ["surfaceId", "catalogId", "components"],
},
}
def generate_a2ui_impl(
messages: list[dict[str, Any]],
context_entries: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Prepare inputs for a secondary LLM call that generates A2UI components.
Returns a dict with:
- system_prompt: The system prompt for the secondary LLM (built from context)
- tool_schema: The render_a2ui tool schema to bind to the LLM
- tool_choice: The tool name to force
- messages: The conversation messages to pass through
- catalog_id: The default catalog ID
The framework wrapper should:
1. Make an LLM call with these inputs
2. Extract the tool call args (surfaceId, catalogId, components, data)
3. Build a2ui_operations from the args and return them
"""
context_text = ""
if context_entries:
context_text = "\n\n".join(
entry.get("value", "")
for entry in context_entries
if isinstance(entry, dict) and entry.get("value")
)
return {
"system_prompt": context_text,
"tool_schema": RENDER_A2UI_TOOL_SCHEMA,
"tool_choice": "render_a2ui",
"messages": messages,
"catalog_id": CUSTOM_CATALOG_ID,
}
def build_a2ui_operations_from_tool_call(args: dict[str, Any]) -> dict[str, Any]:
"""Build a2ui_operations dict from the secondary LLM's tool call args.
Call this after the framework wrapper extracts the tool call arguments.
"""
surface_id = args.get("surfaceId", "dynamic-surface")
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
components = args.get("components", [])
if not components:
_logger.warning("build_a2ui_operations_from_tool_call received empty components list")
data = args.get("data")
ops = [
{"type": "create_surface", "surfaceId": surface_id, "catalogId": catalog_id},
{"type": "update_components", "surfaceId": surface_id, "components": components},
]
if data:
ops.append({"type": "update_data_model", "surfaceId": surface_id, "data": data})
return {"a2ui_operations": ops}
@@ -0,0 +1,40 @@
"""Mock weather data tool implementation."""
import random
from .types import WeatherResult
_CONDITIONS = [
"Sunny",
"Partly Cloudy",
"Cloudy",
"Overcast",
"Light Rain",
"Heavy Rain",
"Thunderstorm",
"Snow",
"Foggy",
"Windy",
]
def get_weather_impl(city: str) -> WeatherResult:
"""Return mock weather data for the given city.
Uses a seeded random based on the city name so repeated calls
for the same city return consistent results within a session.
"""
rng = random.Random(city.lower())
temperature = rng.randint(20, 95)
humidity = rng.randint(30, 90)
wind_speed = rng.randint(2, 30)
feels_like = temperature + rng.randint(-5, 5)
conditions = rng.choice(_CONDITIONS)
return WeatherResult(
city=city,
temperature=temperature,
humidity=humidity,
wind_speed=wind_speed,
feels_like=feels_like,
conditions=conditions,
)
@@ -0,0 +1,58 @@
"""Query data tool implementation — reads db.csv at module load time."""
from __future__ import annotations
import csv
import logging
from pathlib import Path
from typing import Any
_logger = logging.getLogger(__name__)
_csv_path = Path(__file__).resolve().parent.parent / "data" / "db.csv"
_MOCK_DATA = [
{
"date": "2026-01-05",
"category": "Revenue",
"subcategory": "Enterprise Subscriptions",
"amount": "28000",
"type": "income",
"notes": "3 new enterprise customers",
},
{
"date": "2026-01-10",
"category": "Expenses",
"subcategory": "Engineering Salaries",
"amount": "42000",
"type": "expense",
"notes": "7 engineers + 2 contractors",
},
{
"date": "2026-02-03",
"category": "Revenue",
"subcategory": "Pro Tier Upgrades",
"amount": "22500",
"type": "income",
"notes": "31 upgrades + reduced churn",
},
]
try:
with open(_csv_path) as _f:
_cached_data: list[dict[str, Any]] = list(csv.DictReader(_f))
if not _cached_data:
_logger.warning("CSV at %s is empty, falling back to mock data", _csv_path)
_cached_data = _MOCK_DATA
except (FileNotFoundError, OSError) as exc:
_logger.warning("Could not load CSV at %s (%s), falling back to mock data", _csv_path, exc)
_cached_data = _MOCK_DATA
def query_data_impl(query: str) -> list[dict[str, Any]]:
"""Query the database. Takes natural language.
Always call before showing a chart or graph. Returns the full
dataset as a list of dicts (rows from the CSV).
"""
return _cached_data
@@ -0,0 +1,61 @@
"""Sales todos tool implementation."""
from __future__ import annotations
import uuid
from typing import Optional
from .types import SalesTodo
INITIAL_TODOS: list[SalesTodo] = [
SalesTodo(
id="st-001",
title="Follow up with Acme Corp on enterprise proposal",
stage="proposal",
value=85000,
dueDate="2026-04-15",
assignee="Sarah Chen",
completed=False,
),
SalesTodo(
id="st-002",
title="Qualify lead from TechFlow demo request",
stage="prospect",
value=42000,
dueDate="2026-04-18",
assignee="Mike Johnson",
completed=False,
),
SalesTodo(
id="st-003",
title="Send contract to DataViz Inc for final review",
stage="negotiation",
value=120000,
dueDate="2026-04-20",
assignee="Sarah Chen",
completed=False,
),
]
def manage_sales_todos_impl(todos: list[dict]) -> list[SalesTodo]:
"""Assign UUIDs to any todos missing an ID, then return the updated list."""
result: list[SalesTodo] = []
for todo in todos:
result.append(SalesTodo(
id=todo.get("id") or str(uuid.uuid4()),
title=todo.get("title", ""),
stage=todo.get("stage", "prospect"),
value=todo.get("value", 0),
dueDate=todo.get("dueDate", ""),
assignee=todo.get("assignee", ""),
completed=todo.get("completed", False),
))
return result
def get_sales_todos_impl(current_todos: Optional[list[dict]] = None) -> list[SalesTodo]:
"""Return current todos or initial defaults if none provided."""
if current_todos is not None:
return manage_sales_todos_impl(current_todos)
return list(INITIAL_TODOS)
@@ -0,0 +1,31 @@
"""Schedule meeting tool implementation.
The HITL gating happens on the frontend via useHumanInTheLoop.
This tool just returns a pending approval status for the framework
wrapper to surface.
"""
from __future__ import annotations
from typing import Any
def schedule_meeting_impl(
reason: str,
duration_minutes: int = 30,
) -> dict[str, Any]:
"""Schedule a meeting (requires human approval).
Returns a pending_approval status. The actual gating is done by the
frontend's useHumanInTheLoop hook — the agent pauses until the user
approves or rejects.
"""
return {
"status": "pending_approval",
"reason": reason,
"duration_minutes": duration_minutes,
"message": (
f"Meeting request: {reason} ({duration_minutes} min). "
"Awaiting human approval."
),
}
@@ -0,0 +1,106 @@
"""Fixed-schema A2UI tool: flight search results.
Packages flight data with an A2UI schema for rendering. The schema is loaded
from the shared frontend package's flight_schema.json.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from .types import Flight
_logger = logging.getLogger(__name__)
CATALOG_ID = "copilotkit://app-dashboard-catalog"
SURFACE_ID = "flight-search-results"
# Resolve the flight schema from the shared frontend package.
# Walk up from this file to showcase/shared/, then into frontend/src/a2ui/.
_SHARED_DIR = Path(__file__).resolve().parent.parent.parent # showcase/shared/
_SCHEMA_CANDIDATES = [
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight-schema.json",
_SHARED_DIR / "frontend" / "src" / "a2ui" / "flight_schema.json",
]
_flight_schema: list[dict[str, Any]] | None = None
for _candidate in _SCHEMA_CANDIDATES:
if _candidate.exists():
with open(_candidate) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from shared frontend: %s", _candidate)
break
# Fallback: use the schema from the examples directory if present
if _flight_schema is None:
try:
_fallback = Path(__file__).resolve().parents[4] / (
"examples/integrations/langgraph-python/apps/agent/src/a2ui/schemas/flight_schema.json"
)
if _fallback.exists():
with open(_fallback) as _f:
_flight_schema = json.load(_f)
_logger.info("Loaded flight schema from examples fallback: %s", _fallback)
except IndexError:
# In Docker the file path is too shallow for parents[4]; skip this fallback.
pass
# Last resort: inline minimal schema
if _flight_schema is None:
_logger.warning("No flight schema file found, using inline minimal schema")
_flight_schema = [
{
"id": "root",
"component": "Row",
"children": {"componentId": "flight-card", "path": "/flights"},
"gap": 16,
},
{
"id": "flight-card",
"component": "FlightCard",
"airline": {"path": "airline"},
"airlineLogo": {"path": "airlineLogo"},
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"date": {"path": "date"},
"departureTime": {"path": "departureTime"},
"arrivalTime": {"path": "arrivalTime"},
"duration": {"path": "duration"},
"status": {"path": "status"},
"price": {"path": "price"},
"action": {
"event": {
"name": "book_flight",
"context": {
"flightNumber": {"path": "flightNumber"},
"origin": {"path": "origin"},
"destination": {"path": "destination"},
"price": {"path": "price"},
},
}
},
},
]
def search_flights_impl(flights: list[Flight]) -> dict[str, Any]:
"""Package flight data with A2UI schema for rendering.
Returns a dict with a2ui_operations that the middleware detects in the
TOOL_CALL_RESULT and renders automatically.
Each flight should have: airline, airlineLogo, flightNumber, origin,
destination, date, departureTime, arrivalTime, duration, status,
statusColor, price, currency.
"""
return {
"a2ui_operations": [
{"type": "create_surface", "surfaceId": SURFACE_ID, "catalogId": CATALOG_ID},
{"type": "update_components", "surfaceId": SURFACE_ID, "components": _flight_schema},
{"type": "update_data_model", "surfaceId": SURFACE_ID, "data": {"flights": flights}},
]
}
@@ -0,0 +1,47 @@
"""Shared type definitions for showcase tools."""
from typing import TypedDict, Literal
SalesStage = Literal[
"prospect",
"qualified",
"proposal",
"negotiation",
"closed-won",
"closed-lost",
]
class SalesTodo(TypedDict):
id: str
title: str
stage: SalesStage
value: int
dueDate: str
assignee: str
completed: bool
class Flight(TypedDict):
airline: str
airlineLogo: str
flightNumber: str
origin: str
destination: str
date: str
departureTime: str
arrivalTime: str
duration: str
status: str
statusColor: str
price: str
currency: str
class WeatherResult(TypedDict):
city: str
temperature: int
humidity: int
wind_speed: int
feels_like: int
conditions: str

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