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:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 +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,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,
|
||||
)
|
||||
)
|
||||
|
||||
+1
-1
@@ -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
|
||||
+90
@@ -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();
|
||||
});
|
||||
});
|
||||
+79
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+124
@@ -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);
|
||||
});
|
||||
});
|
||||
+26
@@ -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");
|
||||
});
|
||||
});
|
||||
+52
@@ -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 +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 +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
Reference in New Issue
Block a user