chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Shared Python tool implementations for CopilotKit Showcase.
|
||||
|
||||
Pure Python functions with NO framework imports. Each framework's showcase
|
||||
package wraps these in its own tool decorator pattern.
|
||||
"""
|
||||
@@ -0,0 +1,41 @@
|
||||
date,category,subcategory,amount,type,notes
|
||||
2026-01-05,Revenue,Enterprise Subscriptions,28000,income,3 new enterprise customers (Acme Corp, TechFlow, DataViz Inc)
|
||||
2026-01-05,Revenue,Pro Tier Upgrades,18000,income,24 users upgraded from free to pro
|
||||
2026-01-08,Revenue,API Usage Overages,9500,income,High API usage from top 5 customers
|
||||
2026-01-10,Expenses,Engineering Salaries,42000,expense,7 engineers + 2 contractors
|
||||
2026-01-10,Expenses,Product Team,18000,expense,PM and 2 designers
|
||||
2026-01-12,Expenses,AWS Infrastructure,8200,expense,Increased compute for new AI features
|
||||
2026-01-15,Expenses,Marketing - Paid Ads,12000,expense,Google Ads and LinkedIn campaigns
|
||||
2026-01-18,Revenue,Consulting Services,14500,income,Custom integration for Acme Corp
|
||||
2026-01-20,Expenses,Customer Success,15000,expense,3 CSMs + support tools (Intercom)
|
||||
2026-01-22,Expenses,AI Model Costs,4200,expense,OpenAI API usage for product features
|
||||
2026-01-25,Revenue,Marketplace Sales,12800,income,Template and plugin sales
|
||||
2026-01-28,Expenses,Office & Equipment,3500,expense,New laptops and coworking spaces
|
||||
2026-02-03,Revenue,Enterprise Subscriptions,31000,income,2 new customers + expansion from TechFlow
|
||||
2026-02-03,Revenue,Pro Tier Upgrades,22500,income,31 upgrades + reduced churn
|
||||
2026-02-05,Revenue,API Usage Overages,11800,income,DataViz Inc heavy API usage spike
|
||||
2026-02-07,Expenses,Engineering Salaries,42000,expense,Same headcount as January
|
||||
2026-02-07,Expenses,Product Team,18000,expense,No changes to product team
|
||||
2026-02-10,Expenses,AWS Infrastructure,9500,expense,Traffic spike from viral social post
|
||||
2026-02-12,Expenses,Marketing - Paid Ads,15000,expense,Increased ad spend for Q1 push
|
||||
2026-02-14,Revenue,Consulting Services,18000,income,2 custom projects (TechFlow + new client)
|
||||
2026-02-18,Expenses,Customer Success,16500,expense,Hired 1 additional CSM
|
||||
2026-02-20,Expenses,AI Model Costs,5800,expense,Increased usage from new AI features launch
|
||||
2026-02-22,Revenue,Marketplace Sales,14200,income,Top template hit featured list
|
||||
2026-02-25,Expenses,Conference & Travel,4500,expense,Team attended SaaS Conference 2026
|
||||
2026-02-27,Revenue,Partnership Revenue,11500,income,Referral fees from integration partners
|
||||
2026-03-02,Revenue,Enterprise Subscriptions,35000,income,Major win: Fortune 500 customer signed
|
||||
2026-03-02,Revenue,Pro Tier Upgrades,26000,income,42 upgrades - best month yet
|
||||
2026-03-05,Revenue,API Usage Overages,13200,income,Consistent high usage across top tier
|
||||
2026-03-08,Expenses,Engineering Salaries,48000,expense,Hired 1 senior engineer for AI team
|
||||
2026-03-08,Expenses,Product Team,21000,expense,Promoted designer to senior level
|
||||
2026-03-10,Expenses,AWS Infrastructure,11000,expense,Scaled infrastructure for enterprise client
|
||||
2026-03-12,Expenses,Marketing - Paid Ads,18000,expense,Doubled down on successful campaigns
|
||||
2026-03-14,Revenue,Consulting Services,21500,income,Fortune 500 onboarding + 2 other projects
|
||||
2026-03-16,Expenses,Customer Success,19500,expense,Hired dedicated enterprise CSM
|
||||
2026-03-18,Expenses,AI Model Costs,7200,expense,Fortune 500 client heavy AI usage
|
||||
2026-03-20,Revenue,Marketplace Sales,15800,income,3 new templates in top 10
|
||||
2026-03-22,Expenses,Sales & BD,12000,expense,Hired first sales rep for enterprise
|
||||
2026-03-24,Revenue,Partnership Revenue,14200,income,New integration partnerships launched
|
||||
2026-03-26,Expenses,Security & Compliance,6500,expense,SOC 2 audit and security tools
|
||||
2026-03-28,Revenue,Training & Workshops,10200,income,Conducted 2 customer training sessions
|
||||
|
@@ -0,0 +1,21 @@
|
||||
"""Reusable middleware for CopilotKit showcase agents.
|
||||
|
||||
Context-driven middleware that reads render_mode and output_schema from
|
||||
CopilotKit runtime context and adjusts agent behaviour accordingly.
|
||||
"""
|
||||
|
||||
from .render_mode import (
|
||||
get_render_mode,
|
||||
get_output_schema,
|
||||
apply_render_mode_prompt,
|
||||
apply_render_mode,
|
||||
JSONL_RENDER_INSTRUCTION,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_render_mode",
|
||||
"get_output_schema",
|
||||
"apply_render_mode_prompt",
|
||||
"apply_render_mode",
|
||||
"JSONL_RENDER_INSTRUCTION",
|
||||
]
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Render-mode middleware for context-driven GenUI strategy switching.
|
||||
|
||||
Reads ``render_mode`` and ``output_schema`` from the CopilotKit context list
|
||||
and adapts agent output accordingly:
|
||||
|
||||
- **tool-based**: no changes (default)
|
||||
- **a2ui**: no changes (agent decides when to call generate_a2ui tool)
|
||||
- **json-render**: append JSONL instruction to system prompt
|
||||
- **hashbrown**: apply ``response_format`` with the ``output_schema`` from context
|
||||
The ``apply_render_mode`` function is a ``@wrap_model_call`` decorator for
|
||||
LangGraph agents that plugs into the CopilotKit middleware chain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt fragments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
JSONL_RENDER_INSTRUCTION = (
|
||||
"\n\n## Output format — JSONL spec patches\n"
|
||||
"You MUST emit your UI updates as JSONL (one JSON object per line) inside\n"
|
||||
"a fenced code block with the ``spec`` language tag. Each line is a patch\n"
|
||||
'object with at minimum an ``op`` field ("add", "replace", "remove")\n'
|
||||
"and a ``path`` field (JSON-Pointer into the component tree).\n\n"
|
||||
"Example:\n"
|
||||
"```spec\n"
|
||||
'{"op":"replace","path":"/title","value":"Updated Dashboard"}\n'
|
||||
'{"op":"add","path":"/widgets/-","value":{"type":"chart","data":[1,2,3]}}\n'
|
||||
"```\n"
|
||||
"Do NOT wrap the block in any other markup. The frontend renderer will\n"
|
||||
"parse each line and apply the patches incrementally.\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_render_mode(context: list[dict[str, Any]]) -> str:
|
||||
"""Extract render_mode from CopilotKit context entries.
|
||||
|
||||
Scans the context list for an entry whose ``description`` is
|
||||
``"render_mode"`` and returns its ``value``. Falls back to
|
||||
``"tool-based"`` when no matching entry is found.
|
||||
"""
|
||||
for entry in context:
|
||||
if entry.get("description") == "render_mode":
|
||||
return entry.get("value", "tool-based")
|
||||
return "tool-based"
|
||||
|
||||
|
||||
def get_output_schema(context: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
"""Extract output_schema (HashBrown kit schema) from context.
|
||||
|
||||
Returns the parsed JSON schema dict, or ``None`` if the context does not
|
||||
contain an ``output_schema`` entry.
|
||||
"""
|
||||
for entry in context:
|
||||
if entry.get("description") == "output_schema":
|
||||
val = entry.get("value")
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt augmentation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def apply_render_mode_prompt(system_prompt: str, render_mode: str) -> str:
|
||||
"""Return *system_prompt* with render-mode instructions appended.
|
||||
|
||||
For ``tool-based`` and ``a2ui`` modes the prompt is returned unchanged.
|
||||
For ``json-render`` the relevant instruction block is appended.
|
||||
"""
|
||||
if render_mode == "json-render":
|
||||
return system_prompt + JSONL_RENDER_INSTRUCTION
|
||||
return system_prompt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LangGraph @wrap_model_call decorator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def apply_render_mode(fn=None):
|
||||
"""``@wrap_model_call`` middleware that adapts the model request.
|
||||
|
||||
Usage with the CopilotKit middleware chain::
|
||||
|
||||
from middleware.render_mode import apply_render_mode
|
||||
|
||||
agent = create_agent(
|
||||
...,
|
||||
middleware=[CopilotKitMiddleware(), apply_render_mode()],
|
||||
)
|
||||
|
||||
Behaviour per mode:
|
||||
|
||||
* **tool-based / a2ui** -- pass through unchanged.
|
||||
* **json-render** -- prepend JSONL instruction to system messages.
|
||||
* **hashbrown** -- set ``response_format`` with the ``output_schema``
|
||||
extracted from context.
|
||||
"""
|
||||
try:
|
||||
from langchain.agents.middleware import wrap_model_call
|
||||
from langchain.agents.structured_output import ProviderStrategy
|
||||
except ImportError:
|
||||
# Fallback for environments without the CopilotKit langchain extensions
|
||||
from copilotkit.langchain import wrap_model_call, ProviderStrategy
|
||||
|
||||
@wrap_model_call
|
||||
async def _apply_render_mode(request, handler):
|
||||
# --- Extract context from copilotkit state -------------------------
|
||||
copilot_context: list[dict[str, Any]] | None = None
|
||||
state = getattr(request, "state", None)
|
||||
if isinstance(state, dict):
|
||||
copilot_context = state.get("copilotkit", {}).get("context")
|
||||
|
||||
if not isinstance(copilot_context, list):
|
||||
return await handler(request)
|
||||
|
||||
render_mode = get_render_mode(copilot_context)
|
||||
|
||||
# --- Prompt-injection modes ----------------------------------------
|
||||
if render_mode == "json-render":
|
||||
messages = list(getattr(request, "messages", []))
|
||||
augmented = []
|
||||
for msg in messages:
|
||||
if getattr(msg, "type", None) == "system" or (
|
||||
isinstance(msg, dict) and msg.get("role") == "system"
|
||||
):
|
||||
content = (
|
||||
msg.content
|
||||
if hasattr(msg, "content")
|
||||
else msg.get("content", "")
|
||||
)
|
||||
new_content = apply_render_mode_prompt(content, render_mode)
|
||||
if hasattr(msg, "content"):
|
||||
# LangChain message object — copy with new content
|
||||
msg = msg.copy(update={"content": new_content})
|
||||
else:
|
||||
msg = {**msg, "content": new_content}
|
||||
augmented.append(msg)
|
||||
request = request.override(messages=augmented)
|
||||
|
||||
# --- HashBrown mode: structured output via response_format ---------
|
||||
elif render_mode == "hashbrown":
|
||||
schema = get_output_schema(copilot_context)
|
||||
if isinstance(schema, dict):
|
||||
if not schema.get("title"):
|
||||
schema["title"] = "StructuredOutput"
|
||||
if not schema.get("description"):
|
||||
schema["description"] = (
|
||||
"Structured response schema for the CopilotKit agent."
|
||||
)
|
||||
request = request.override(
|
||||
response_format=ProviderStrategy(schema=schema, strict=True),
|
||||
)
|
||||
|
||||
return await handler(request)
|
||||
|
||||
if fn is not None:
|
||||
return _apply_render_mode(fn)
|
||||
return _apply_render_mode
|
||||
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Cross-language parity tests.
|
||||
|
||||
Verify that Python shared tool outputs match the structural contracts
|
||||
that TypeScript equivalents also follow. If these tests pass in Python
|
||||
AND the TS tests pass, the implementations are structurally compatible.
|
||||
"""
|
||||
|
||||
from tools import (
|
||||
get_weather_impl,
|
||||
query_data_impl,
|
||||
manage_sales_todos_impl,
|
||||
get_sales_todos_impl,
|
||||
search_flights_impl,
|
||||
schedule_meeting_impl,
|
||||
INITIAL_TODOS,
|
||||
)
|
||||
|
||||
|
||||
def test_weather_field_names_match_typescript():
|
||||
"""WeatherResult fields must be: city, temperature, humidity, wind_speed, feels_like, conditions"""
|
||||
result = get_weather_impl("Tokyo")
|
||||
expected_fields = {
|
||||
"city",
|
||||
"temperature",
|
||||
"humidity",
|
||||
"wind_speed",
|
||||
"feels_like",
|
||||
"conditions",
|
||||
}
|
||||
assert set(result.keys()) == expected_fields
|
||||
|
||||
|
||||
def test_initial_todos_ids_match_typescript():
|
||||
"""INITIAL_TODOS IDs must be st-001, st-002, st-003 (same as TS)"""
|
||||
assert [t["id"] for t in INITIAL_TODOS] == ["st-001", "st-002", "st-003"]
|
||||
|
||||
|
||||
def test_initial_todos_count_matches_typescript():
|
||||
assert len(INITIAL_TODOS) == 3
|
||||
|
||||
|
||||
def test_manage_todos_provides_same_defaults_as_typescript():
|
||||
"""Missing fields default to: stage=prospect, value=0, completed=False"""
|
||||
result = manage_sales_todos_impl([{"title": "Test"}])
|
||||
assert result[0]["stage"] == "prospect"
|
||||
assert result[0]["value"] == 0
|
||||
assert result[0]["completed"] == False
|
||||
assert result[0]["dueDate"] == ""
|
||||
assert result[0]["assignee"] == ""
|
||||
|
||||
|
||||
def test_get_todos_none_returns_initial():
|
||||
result = get_sales_todos_impl(None)
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
def test_get_todos_empty_returns_empty():
|
||||
result = get_sales_todos_impl([])
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_search_flights_returns_a2ui_operations():
|
||||
result = search_flights_impl([{"airline": "Test"}])
|
||||
assert "a2ui_operations" in result
|
||||
|
||||
|
||||
def test_schedule_meeting_returns_pending():
|
||||
result = schedule_meeting_impl("test")
|
||||
assert result["status"] == "pending_approval"
|
||||
|
||||
|
||||
def test_query_data_returns_list_of_dicts_with_expected_columns():
|
||||
result = query_data_impl("test")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
row = result[0]
|
||||
assert "category" in row and "date" in row # Both columns must be present
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Cross-package equivalence test.
|
||||
|
||||
Verifies that all showcase packages' backend tools produce structurally
|
||||
equivalent outputs when given identical inputs.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from tools import (
|
||||
get_weather_impl,
|
||||
query_data_impl,
|
||||
manage_sales_todos_impl,
|
||||
get_sales_todos_impl,
|
||||
search_flights_impl,
|
||||
generate_a2ui_impl,
|
||||
schedule_meeting_impl,
|
||||
)
|
||||
|
||||
# These tests verify the SHARED implementations. Since all 17 packages
|
||||
# wrap these same functions, if the shared impls are correct, all
|
||||
# packages produce equivalent outputs.
|
||||
|
||||
|
||||
class TestToolOutputEquivalence:
|
||||
"""All tools return consistent structures regardless of caller."""
|
||||
|
||||
def test_weather_consistent_structure(self):
|
||||
cities = ["Tokyo", "London", "New York", "São Paulo", "Sydney"]
|
||||
for city in cities:
|
||||
result = get_weather_impl(city)
|
||||
assert set(result.keys()) == {
|
||||
"city",
|
||||
"temperature",
|
||||
"humidity",
|
||||
"wind_speed",
|
||||
"feels_like",
|
||||
"conditions",
|
||||
}
|
||||
assert result["city"] == city
|
||||
assert isinstance(result["temperature"], int)
|
||||
|
||||
def test_query_data_consistent_columns(self):
|
||||
for query in ["revenue", "expenses", "all", ""]:
|
||||
result = query_data_impl(query)
|
||||
assert len(result) > 0
|
||||
for row in result:
|
||||
assert "category" in row or "date" in row
|
||||
|
||||
def test_manage_todos_idempotent_structure(self):
|
||||
input_todos = [
|
||||
{"title": "Deal A", "stage": "prospect", "value": 10000},
|
||||
{"title": "Deal B", "stage": "qualified", "value": 50000},
|
||||
]
|
||||
result = manage_sales_todos_impl(input_todos)
|
||||
assert len(result) == 2
|
||||
for todo in result:
|
||||
assert all(
|
||||
k in todo
|
||||
for k in [
|
||||
"id",
|
||||
"title",
|
||||
"stage",
|
||||
"value",
|
||||
"dueDate",
|
||||
"assignee",
|
||||
"completed",
|
||||
]
|
||||
)
|
||||
|
||||
def test_get_todos_none_returns_initial(self):
|
||||
result = get_sales_todos_impl(None)
|
||||
assert len(result) == 3
|
||||
assert all(t["id"].startswith("st-") for t in result)
|
||||
|
||||
def test_search_flights_returns_a2ui_ops(self):
|
||||
flights = [
|
||||
{
|
||||
"airline": "Test",
|
||||
"flightNumber": "T1",
|
||||
"origin": "SFO",
|
||||
"destination": "JFK",
|
||||
"date": "Mon",
|
||||
"departureTime": "08:00",
|
||||
"arrivalTime": "16:00",
|
||||
"duration": "8h",
|
||||
"status": "On Time",
|
||||
"statusColor": "#22c55e",
|
||||
"price": "$300",
|
||||
"currency": "USD",
|
||||
"airlineLogo": "https://example.com/logo.png",
|
||||
}
|
||||
]
|
||||
result = search_flights_impl(flights)
|
||||
assert "a2ui_operations" in result
|
||||
ops = result["a2ui_operations"]
|
||||
assert any(op["type"] == "create_surface" for op in ops)
|
||||
assert any(op["type"] == "update_components" for op in ops)
|
||||
|
||||
def test_generate_a2ui_returns_prompt_and_schema(self):
|
||||
result = generate_a2ui_impl(
|
||||
messages=[{"role": "user", "content": "show dashboard"}]
|
||||
)
|
||||
assert "system_prompt" in result
|
||||
assert "tool_schema" in result
|
||||
assert result["tool_schema"]["name"] == "render_a2ui"
|
||||
|
||||
def test_schedule_meeting_returns_pending(self):
|
||||
result = schedule_meeting_impl("quarterly review", 45)
|
||||
assert result["status"] == "pending_approval"
|
||||
assert result["reason"] == "quarterly review"
|
||||
assert result["duration_minutes"] == 45
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Framework wrapper import tests.
|
||||
|
||||
Verify that each showcase package's Python agent module can be imported
|
||||
and has the expected tools/functions defined. Skips packages whose
|
||||
framework dependencies aren't installed locally.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
SHOWCASE_ROOT = os.path.join(os.path.dirname(__file__), "..", "..", "..", "packages")
|
||||
SHARED_PYTHON = os.path.join(os.path.dirname(__file__), "..")
|
||||
|
||||
# Ensure shared tools are importable
|
||||
if SHARED_PYTHON not in sys.path:
|
||||
sys.path.insert(0, SHARED_PYTHON)
|
||||
|
||||
|
||||
def _try_import_agent(package_name, agent_module_path, agent_module_name):
|
||||
"""Try to import a package's agent module. Returns (module, error)."""
|
||||
agent_dir = os.path.join(SHOWCASE_ROOT, package_name, *agent_module_path.split("/"))
|
||||
if agent_dir not in sys.path:
|
||||
sys.path.insert(0, agent_dir)
|
||||
try:
|
||||
# Remove cached module if present
|
||||
if agent_module_name in sys.modules:
|
||||
del sys.modules[agent_module_name]
|
||||
mod = importlib.import_module(agent_module_name)
|
||||
return mod, None
|
||||
except ImportError as e:
|
||||
return None, str(e)
|
||||
finally:
|
||||
if agent_dir in sys.path:
|
||||
sys.path.remove(agent_dir)
|
||||
|
||||
|
||||
# Each entry: (package_name, path_to_agent_dir, module_name, expected_attributes)
|
||||
PACKAGES = [
|
||||
("langgraph-python", "src", "agents.main", ["graph"]),
|
||||
(
|
||||
"langgraph-python",
|
||||
"src",
|
||||
"agents.tools",
|
||||
["query_data", "get_weather", "schedule_meeting"],
|
||||
),
|
||||
("langgraph-fastapi", "src/agents", "src.agent", ["graph"]),
|
||||
("pydantic-ai", "src", "agents.agent", ["agent"]),
|
||||
("crewai-crews", "src", "agents.crew", ["LatestAiDevelopment"]),
|
||||
("google-adk", "src", "agents.main", ["sales_pipeline_agent"]),
|
||||
("agno", "src", "agents.main", ["agent"]),
|
||||
("claude-sdk-python", "src", "agents.agent", ["create_app"]),
|
||||
("ag2", "src", "agents.agent", ["agent"]),
|
||||
("strands", "src", "agents.agent", ["strands_agent", "agui_agent"]),
|
||||
("llamaindex", "src", "agents.agent", ["agent_router"]),
|
||||
("langroid", "src", "agents.agent", ["create_agent"]),
|
||||
("ms-agent-python", "src", "agents.agent", ["create_agent"]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pkg,path,mod_name,attrs", PACKAGES, ids=[p[0] for p in PACKAGES]
|
||||
)
|
||||
def test_agent_import(pkg, path, mod_name, attrs):
|
||||
"""Verify agent module imports and has expected attributes."""
|
||||
mod, err = _try_import_agent(pkg, path, mod_name)
|
||||
if err and ("No module named" in err or "cannot import name" in err):
|
||||
# Framework not installed locally — skip gracefully
|
||||
pytest.skip(f"Framework dependency not installed: {err}")
|
||||
assert mod is not None, f"Import failed for {pkg}: {err}"
|
||||
for attr in attrs:
|
||||
assert hasattr(mod, attr), f"{pkg} agent missing expected attribute: {attr}"
|
||||
@@ -0,0 +1,81 @@
|
||||
import pytest
|
||||
from tools import generate_a2ui_impl, build_a2ui_operations_from_tool_call
|
||||
|
||||
|
||||
def test_returns_system_prompt():
|
||||
result = generate_a2ui_impl(messages=[])
|
||||
assert "system_prompt" in result
|
||||
|
||||
|
||||
def test_returns_tool_schema():
|
||||
result = generate_a2ui_impl(messages=[])
|
||||
assert "tool_schema" in result
|
||||
assert result["tool_schema"]["name"] == "render_a2ui"
|
||||
|
||||
|
||||
def test_returns_tool_choice():
|
||||
result = generate_a2ui_impl(messages=[])
|
||||
assert result["tool_choice"] == "render_a2ui"
|
||||
|
||||
|
||||
def test_build_operations_basic():
|
||||
args = {"surfaceId": "s1", "catalogId": "cat1", "components": [{"id": "root"}]}
|
||||
result = build_a2ui_operations_from_tool_call(args)
|
||||
ops = result["a2ui_operations"]
|
||||
assert len(ops) == 2 # create_surface + update_components
|
||||
|
||||
|
||||
def test_build_operations_with_data():
|
||||
args = {
|
||||
"surfaceId": "s1",
|
||||
"catalogId": "cat1",
|
||||
"components": [{"id": "root"}],
|
||||
"data": {"key": "val"},
|
||||
}
|
||||
result = build_a2ui_operations_from_tool_call(args)
|
||||
ops = result["a2ui_operations"]
|
||||
assert len(ops) == 3 # create_surface + update_components + update_data_model
|
||||
|
||||
|
||||
def test_build_operations_empty_components_warns(caplog):
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
build_a2ui_operations_from_tool_call(
|
||||
{"surfaceId": "s1", "catalogId": "cat1", "components": []}
|
||||
)
|
||||
assert "empty components" in caplog.text.lower()
|
||||
|
||||
|
||||
def test_context_entries_with_values_appear_in_system_prompt():
|
||||
entries = [
|
||||
{"value": "The user is viewing a sales dashboard."},
|
||||
{"value": "Current quarter is Q2 2026."},
|
||||
]
|
||||
result = generate_a2ui_impl(
|
||||
messages=[{"role": "user", "content": "hello"}], context_entries=entries
|
||||
)
|
||||
assert "sales dashboard" in result["system_prompt"]
|
||||
assert "Q2 2026" in result["system_prompt"]
|
||||
|
||||
|
||||
def test_context_entries_missing_or_empty_values_filtered():
|
||||
entries = [
|
||||
{"value": "Keep this"},
|
||||
{"value": ""},
|
||||
{"other_key": "no value field"},
|
||||
{"value": None},
|
||||
]
|
||||
result = generate_a2ui_impl(messages=[], context_entries=entries)
|
||||
assert "Keep this" in result["system_prompt"]
|
||||
# Empty/missing values should not produce extra content
|
||||
assert "None" not in result["system_prompt"]
|
||||
|
||||
|
||||
def test_messages_pass_through_unchanged():
|
||||
msgs = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
result = generate_a2ui_impl(messages=msgs)
|
||||
assert result["messages"] is msgs # exact same reference
|
||||
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
from tools import get_weather_impl
|
||||
|
||||
|
||||
def test_returns_all_required_fields():
|
||||
result = get_weather_impl("Tokyo")
|
||||
assert "city" in result
|
||||
assert "temperature" in result
|
||||
assert "humidity" in result
|
||||
assert "wind_speed" in result
|
||||
assert "feels_like" in result
|
||||
assert "conditions" in result
|
||||
|
||||
|
||||
def test_city_name_passed_through():
|
||||
result = get_weather_impl("San Francisco")
|
||||
assert result["city"] == "San Francisco"
|
||||
|
||||
|
||||
def test_deterministic_for_same_city():
|
||||
r1 = get_weather_impl("Tokyo")
|
||||
r2 = get_weather_impl("Tokyo")
|
||||
assert r1["temperature"] == r2["temperature"]
|
||||
assert r1["conditions"] == r2["conditions"]
|
||||
|
||||
|
||||
def test_different_cities_produce_different_results():
|
||||
r1 = get_weather_impl("Tokyo")
|
||||
r2 = get_weather_impl("London")
|
||||
# At least one field should differ (statistically guaranteed with seeded RNG)
|
||||
assert r1 != r2
|
||||
|
||||
|
||||
def test_temperature_in_valid_range():
|
||||
result = get_weather_impl("TestCity")
|
||||
assert 20 <= result["temperature"] <= 95
|
||||
|
||||
|
||||
def test_humidity_in_valid_range():
|
||||
result = get_weather_impl("TestCity")
|
||||
assert 30 <= result["humidity"] <= 90
|
||||
|
||||
|
||||
def test_case_insensitivity():
|
||||
r_lower = get_weather_impl("tokyo")
|
||||
r_upper = get_weather_impl("TOKYO")
|
||||
r_mixed = get_weather_impl("Tokyo")
|
||||
assert r_lower["temperature"] == r_upper["temperature"] == r_mixed["temperature"]
|
||||
assert r_lower["conditions"] == r_upper["conditions"] == r_mixed["conditions"]
|
||||
|
||||
|
||||
def test_feels_like_within_five_of_temperature():
|
||||
result = get_weather_impl("TestCity")
|
||||
assert abs(result["feels_like"] - result["temperature"]) <= 5
|
||||
|
||||
|
||||
def test_wind_speed_in_valid_range():
|
||||
result = get_weather_impl("TestCity")
|
||||
assert 2 <= result["wind_speed"] <= 30
|
||||
@@ -0,0 +1,62 @@
|
||||
import pytest
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
from tools import query_data_impl
|
||||
|
||||
|
||||
def test_returns_list():
|
||||
result = query_data_impl("any query")
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
def test_returns_nonempty():
|
||||
result = query_data_impl("revenue breakdown")
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_rows_have_expected_columns():
|
||||
result = query_data_impl("test")
|
||||
row = result[0]
|
||||
assert "date" in row
|
||||
assert "category" in row
|
||||
assert "subcategory" in row
|
||||
assert "amount" in row
|
||||
assert "type" in row
|
||||
|
||||
|
||||
def test_query_param_doesnt_filter():
|
||||
r1 = query_data_impl("revenue")
|
||||
r2 = query_data_impl("expenses")
|
||||
assert len(r1) == len(r2) # same data regardless of query
|
||||
|
||||
|
||||
def test_all_six_columns_present():
|
||||
"""Verify all 6 expected columns including 'notes'."""
|
||||
result = query_data_impl("test")
|
||||
row = result[0]
|
||||
for col in ("date", "category", "subcategory", "amount", "type", "notes"):
|
||||
assert col in row, f"Missing column: {col}"
|
||||
|
||||
|
||||
def test_amount_is_string():
|
||||
"""Amount should be a string (CSV DictReader returns strings, mock data also uses strings)."""
|
||||
result = query_data_impl("test")
|
||||
for row in result:
|
||||
assert isinstance(row["amount"], str), (
|
||||
f"amount should be str, got {type(row['amount'])}"
|
||||
)
|
||||
|
||||
|
||||
def test_csv_fallback_uses_mock_data(caplog):
|
||||
"""When CSV path doesn't exist, module falls back to mock data with a warning."""
|
||||
# We can't easily re-trigger module-level loading, but we can verify the
|
||||
# mock data structure matches expectations — the _MOCK_DATA is what gets
|
||||
# used when CSV is missing.
|
||||
from tools.query_data import _MOCK_DATA
|
||||
|
||||
assert len(_MOCK_DATA) == 3
|
||||
for row in _MOCK_DATA:
|
||||
assert "date" in row
|
||||
assert "category" in row
|
||||
assert "notes" in row
|
||||
assert isinstance(row["amount"], str)
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Tests for the render_mode middleware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure the shared python package is importable.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from middleware.render_mode import (
|
||||
get_render_mode,
|
||||
get_output_schema,
|
||||
apply_render_mode_prompt,
|
||||
JSONL_RENDER_INSTRUCTION,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_render_mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetRenderMode:
|
||||
def test_default_when_empty(self):
|
||||
"""No context entries -> default to 'tool-based'."""
|
||||
assert get_render_mode([]) == "tool-based"
|
||||
|
||||
def test_default_when_no_match(self):
|
||||
"""Context entries exist but none with description 'render_mode'."""
|
||||
ctx = [{"description": "other", "value": "foo"}]
|
||||
assert get_render_mode(ctx) == "tool-based"
|
||||
|
||||
def test_hashbrown(self):
|
||||
"""Context with render_mode='hashbrown' is extracted."""
|
||||
ctx = [
|
||||
{"description": "something_else", "value": "x"},
|
||||
{"description": "render_mode", "value": "hashbrown"},
|
||||
]
|
||||
assert get_render_mode(ctx) == "hashbrown"
|
||||
|
||||
def test_a2ui(self):
|
||||
ctx = [{"description": "render_mode", "value": "a2ui"}]
|
||||
assert get_render_mode(ctx) == "a2ui"
|
||||
|
||||
def test_json_render(self):
|
||||
ctx = [{"description": "render_mode", "value": "json-render"}]
|
||||
assert get_render_mode(ctx) == "json-render"
|
||||
|
||||
def test_missing_value_defaults(self):
|
||||
"""Entry exists but value key is absent -> 'tool-based'."""
|
||||
ctx = [{"description": "render_mode"}]
|
||||
assert get_render_mode(ctx) == "tool-based"
|
||||
|
||||
# --- Additional tests ---
|
||||
|
||||
def test_render_mode_not_first_in_context(self):
|
||||
"""render_mode is the last of multiple context entries."""
|
||||
ctx = [
|
||||
{"description": "user_id", "value": "user-123"},
|
||||
{"description": "session_id", "value": "sess-456"},
|
||||
{"description": "locale", "value": "en-US"},
|
||||
{"description": "render_mode", "value": "a2ui"},
|
||||
]
|
||||
assert get_render_mode(ctx) == "a2ui"
|
||||
|
||||
def test_render_mode_in_middle_of_context(self):
|
||||
"""render_mode is sandwiched between other entries."""
|
||||
ctx = [
|
||||
{"description": "theme", "value": "dark"},
|
||||
{"description": "render_mode", "value": "json-render"},
|
||||
{"description": "feature_flags", "value": "beta"},
|
||||
]
|
||||
assert get_render_mode(ctx) == "json-render"
|
||||
|
||||
def test_invalid_render_mode_value_passes_through(self):
|
||||
"""An unrecognized render_mode value is returned as-is.
|
||||
|
||||
The middleware does not validate the value -- that is the
|
||||
responsibility of callers. This test documents that behavior.
|
||||
"""
|
||||
ctx = [{"description": "render_mode", "value": "not-a-real-mode"}]
|
||||
assert get_render_mode(ctx) == "not-a-real-mode"
|
||||
|
||||
def test_first_render_mode_entry_wins(self):
|
||||
"""When multiple render_mode entries exist, the first one wins."""
|
||||
ctx = [
|
||||
{"description": "render_mode", "value": "hashbrown"},
|
||||
{"description": "render_mode", "value": "a2ui"},
|
||||
]
|
||||
assert get_render_mode(ctx) == "hashbrown"
|
||||
|
||||
def test_tool_based_explicit(self):
|
||||
"""Explicit tool-based value is returned."""
|
||||
ctx = [{"description": "render_mode", "value": "tool-based"}]
|
||||
assert get_render_mode(ctx) == "tool-based"
|
||||
|
||||
def test_empty_string_value(self):
|
||||
"""Empty string value is returned (falsy but still a string)."""
|
||||
ctx = [{"description": "render_mode", "value": ""}]
|
||||
assert get_render_mode(ctx) == ""
|
||||
|
||||
def test_none_value_defaults(self):
|
||||
"""None value triggers the default via .get fallback."""
|
||||
ctx = [{"description": "render_mode", "value": None}]
|
||||
# .get("value", "tool-based") returns None (key exists), not default
|
||||
assert get_render_mode(ctx) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_output_schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetOutputSchema:
|
||||
def test_none_when_empty(self):
|
||||
assert get_output_schema([]) is None
|
||||
|
||||
def test_none_when_no_match(self):
|
||||
ctx = [{"description": "render_mode", "value": "hashbrown"}]
|
||||
assert get_output_schema(ctx) is None
|
||||
|
||||
def test_parses_json_string(self):
|
||||
schema = {"type": "object", "properties": {"temp": {"type": "number"}}}
|
||||
ctx = [{"description": "output_schema", "value": json.dumps(schema)}]
|
||||
result = get_output_schema(ctx)
|
||||
assert result == schema
|
||||
|
||||
def test_returns_dict_directly(self):
|
||||
schema = {"type": "object", "properties": {"name": {"type": "string"}}}
|
||||
ctx = [{"description": "output_schema", "value": schema}]
|
||||
result = get_output_schema(ctx)
|
||||
assert result == schema
|
||||
|
||||
def test_invalid_json_returns_none(self):
|
||||
ctx = [{"description": "output_schema", "value": "not-json{{{"}]
|
||||
assert get_output_schema(ctx) is None
|
||||
|
||||
# --- Additional tests ---
|
||||
|
||||
def test_json_string_vs_dict_both_work(self):
|
||||
"""Both JSON string and native dict should return the same result."""
|
||||
schema = {"type": "object", "properties": {"x": {"type": "integer"}}}
|
||||
ctx_str = [{"description": "output_schema", "value": json.dumps(schema)}]
|
||||
ctx_dict = [{"description": "output_schema", "value": schema}]
|
||||
assert get_output_schema(ctx_str) == get_output_schema(ctx_dict)
|
||||
|
||||
def test_complex_nested_schema(self):
|
||||
"""A deeply nested schema is handled correctly."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"value": {"type": "number"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx = [{"description": "output_schema", "value": json.dumps(schema)}]
|
||||
result = get_output_schema(ctx)
|
||||
assert result == schema
|
||||
|
||||
def test_output_schema_not_first_in_context(self):
|
||||
"""output_schema is found even when not the first entry."""
|
||||
schema = {"type": "object"}
|
||||
ctx = [
|
||||
{"description": "render_mode", "value": "hashbrown"},
|
||||
{"description": "user_id", "value": "u-1"},
|
||||
{"description": "output_schema", "value": schema},
|
||||
]
|
||||
result = get_output_schema(ctx)
|
||||
assert result == schema
|
||||
|
||||
def test_missing_value_key_returns_none(self):
|
||||
"""Entry with description=output_schema but no value key returns None."""
|
||||
ctx = [{"description": "output_schema"}]
|
||||
result = get_output_schema(ctx)
|
||||
assert result is None
|
||||
|
||||
def test_empty_dict_schema(self):
|
||||
"""An empty dict schema is still returned."""
|
||||
ctx = [{"description": "output_schema", "value": {}}]
|
||||
result = get_output_schema(ctx)
|
||||
assert result == {}
|
||||
|
||||
def test_integer_value_is_returned(self):
|
||||
"""Non-dict, non-string values are returned as-is."""
|
||||
ctx = [{"description": "output_schema", "value": 42}]
|
||||
result = get_output_schema(ctx)
|
||||
assert result == 42
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apply_render_mode_prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApplyRenderModePrompt:
|
||||
BASE = "You are a helpful agent."
|
||||
|
||||
def test_tool_based_unchanged(self):
|
||||
result = apply_render_mode_prompt(self.BASE, "tool-based")
|
||||
assert result == self.BASE
|
||||
|
||||
def test_a2ui_unchanged(self):
|
||||
result = apply_render_mode_prompt(self.BASE, "a2ui")
|
||||
assert result == self.BASE
|
||||
|
||||
def test_json_render_appends_jsonl_instruction(self):
|
||||
result = apply_render_mode_prompt(self.BASE, "json-render")
|
||||
assert result.startswith(self.BASE)
|
||||
assert JSONL_RENDER_INSTRUCTION in result
|
||||
assert "```spec" in result
|
||||
assert "JSONL" in result
|
||||
|
||||
def test_unknown_mode_unchanged(self):
|
||||
result = apply_render_mode_prompt(self.BASE, "future-mode")
|
||||
assert result == self.BASE
|
||||
|
||||
# --- Additional tests ---
|
||||
|
||||
def test_json_render_contains_op_field_instruction(self):
|
||||
"""JSONL instruction mentions op field for patch objects."""
|
||||
result = apply_render_mode_prompt(self.BASE, "json-render")
|
||||
assert '"op"' in result
|
||||
assert "add" in result
|
||||
assert "replace" in result
|
||||
assert "remove" in result
|
||||
|
||||
def test_json_render_contains_path_field_instruction(self):
|
||||
"""JSONL instruction mentions path field (JSON-Pointer)."""
|
||||
result = apply_render_mode_prompt(self.BASE, "json-render")
|
||||
assert '"path"' in result or "path" in result
|
||||
|
||||
def test_hashbrown_unchanged(self):
|
||||
"""HashBrown mode does not modify the prompt (structured output is via response_format)."""
|
||||
result = apply_render_mode_prompt(self.BASE, "hashbrown")
|
||||
assert result == self.BASE
|
||||
|
||||
def test_empty_base_prompt_still_works(self):
|
||||
"""An empty base prompt gets the instruction appended."""
|
||||
result = apply_render_mode_prompt("", "json-render")
|
||||
assert JSONL_RENDER_INSTRUCTION in result
|
||||
|
||||
def test_prompt_injection_content_preserved(self):
|
||||
"""Base prompt with special characters is preserved verbatim."""
|
||||
tricky_base = "You are an agent. Do NOT output ```json blocks."
|
||||
result = apply_render_mode_prompt(tricky_base, "json-render")
|
||||
assert result.startswith(tricky_base)
|
||||
assert JSONL_RENDER_INSTRUCTION in result
|
||||
|
||||
def test_json_render_instruction_is_exact_constant(self):
|
||||
"""The appended instruction is exactly the JSONL_RENDER_INSTRUCTION constant."""
|
||||
result = apply_render_mode_prompt(self.BASE, "json-render")
|
||||
assert result == self.BASE + JSONL_RENDER_INSTRUCTION
|
||||
|
||||
def test_empty_string_mode_unchanged(self):
|
||||
"""Empty string as mode returns prompt unchanged."""
|
||||
result = apply_render_mode_prompt(self.BASE, "")
|
||||
assert result == self.BASE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HashBrown mode with missing output_schema (should not crash)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHashBrownMissingSchema:
|
||||
def test_no_output_schema_entry_returns_none(self):
|
||||
"""HashBrown mode with no output_schema in context returns None from get_output_schema."""
|
||||
ctx = [{"description": "render_mode", "value": "hashbrown"}]
|
||||
assert get_output_schema(ctx) is None
|
||||
|
||||
def test_hashbrown_mode_with_no_schema_does_not_modify_prompt(self):
|
||||
"""HashBrown mode does not add prompt instructions even without a schema."""
|
||||
base = "System prompt."
|
||||
result = apply_render_mode_prompt(base, "hashbrown")
|
||||
assert result == base
|
||||
|
||||
def test_hashbrown_mode_with_null_schema_value(self):
|
||||
"""output_schema entry with None value returns None."""
|
||||
ctx = [
|
||||
{"description": "render_mode", "value": "hashbrown"},
|
||||
{"description": "output_schema", "value": None},
|
||||
]
|
||||
assert get_output_schema(ctx) is None
|
||||
|
||||
def test_hashbrown_mode_with_empty_string_schema(self):
|
||||
"""output_schema with empty string returns None (invalid JSON)."""
|
||||
ctx = [
|
||||
{"description": "render_mode", "value": "hashbrown"},
|
||||
{"description": "output_schema", "value": ""},
|
||||
]
|
||||
# Empty string -> json.loads raises -> returns None
|
||||
assert get_output_schema(ctx) is None
|
||||
@@ -0,0 +1,60 @@
|
||||
import pytest
|
||||
from tools import manage_sales_todos_impl, get_sales_todos_impl, INITIAL_TODOS
|
||||
|
||||
|
||||
def test_initial_todos_have_fixed_ids():
|
||||
assert INITIAL_TODOS[0]["id"] == "st-001"
|
||||
assert INITIAL_TODOS[1]["id"] == "st-002"
|
||||
assert INITIAL_TODOS[2]["id"] == "st-003"
|
||||
|
||||
|
||||
def test_initial_todos_count():
|
||||
assert len(INITIAL_TODOS) == 3
|
||||
|
||||
|
||||
def test_manage_assigns_id_to_missing():
|
||||
result = manage_sales_todos_impl([{"title": "New deal"}])
|
||||
assert result[0]["id"] # should have an ID assigned
|
||||
assert len(result[0]["id"]) > 0
|
||||
|
||||
|
||||
def test_manage_preserves_existing_id():
|
||||
result = manage_sales_todos_impl([{"id": "keep-me", "title": "Deal"}])
|
||||
assert result[0]["id"] == "keep-me"
|
||||
|
||||
|
||||
def test_manage_provides_defaults():
|
||||
result = manage_sales_todos_impl([{"title": "Minimal"}])
|
||||
assert result[0]["stage"] == "prospect"
|
||||
assert result[0]["value"] == 0
|
||||
assert result[0]["dueDate"] == ""
|
||||
assert result[0]["assignee"] == ""
|
||||
assert result[0]["completed"] == False
|
||||
|
||||
|
||||
def test_get_returns_initial_when_none():
|
||||
result = get_sales_todos_impl(None)
|
||||
assert len(result) == 3
|
||||
assert result[0]["id"] == "st-001"
|
||||
|
||||
|
||||
def test_get_returns_empty_when_empty_list():
|
||||
result = get_sales_todos_impl([])
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_get_returns_provided_todos():
|
||||
todos = [
|
||||
{
|
||||
"id": "1",
|
||||
"title": "Test",
|
||||
"stage": "prospect",
|
||||
"value": 100,
|
||||
"dueDate": "",
|
||||
"assignee": "",
|
||||
"completed": False,
|
||||
}
|
||||
]
|
||||
result = get_sales_todos_impl(todos)
|
||||
assert len(result) == 1
|
||||
assert result[0]["title"] == "Test"
|
||||
@@ -0,0 +1,22 @@
|
||||
import pytest
|
||||
from tools import schedule_meeting_impl
|
||||
|
||||
|
||||
def test_returns_pending_status():
|
||||
result = schedule_meeting_impl("discuss roadmap")
|
||||
assert result["status"] == "pending_approval"
|
||||
|
||||
|
||||
def test_includes_reason():
|
||||
result = schedule_meeting_impl("quarterly review")
|
||||
assert result["reason"] == "quarterly review"
|
||||
|
||||
|
||||
def test_includes_duration_minutes():
|
||||
result = schedule_meeting_impl("sync", 45)
|
||||
assert result["duration_minutes"] == 45
|
||||
|
||||
|
||||
def test_default_duration():
|
||||
result = schedule_meeting_impl("sync")
|
||||
assert result["duration_minutes"] == 30
|
||||
@@ -0,0 +1,85 @@
|
||||
import pytest
|
||||
from tools import search_flights_impl
|
||||
from tools.search_flights import SURFACE_ID, CATALOG_ID
|
||||
|
||||
_FULL_FLIGHT = {
|
||||
"airline": "Test Air",
|
||||
"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",
|
||||
"airlineLogo": "https://example.com/logo.png",
|
||||
}
|
||||
|
||||
|
||||
def test_returns_a2ui_operations():
|
||||
result = search_flights_impl([_FULL_FLIGHT])
|
||||
assert "a2ui_operations" in result
|
||||
|
||||
|
||||
def test_operations_structure():
|
||||
flights = [{"airline": "Test"}]
|
||||
result = search_flights_impl(flights)
|
||||
ops = result["a2ui_operations"]
|
||||
assert any(op["type"] == "create_surface" for op in ops)
|
||||
assert any(op["type"] == "update_components" for op in ops)
|
||||
|
||||
|
||||
def test_all_three_operation_types_present():
|
||||
result = search_flights_impl([_FULL_FLIGHT])
|
||||
ops = result["a2ui_operations"]
|
||||
types = [op["type"] for op in ops]
|
||||
assert "create_surface" in types
|
||||
assert "update_components" in types
|
||||
assert "update_data_model" in types
|
||||
|
||||
|
||||
def test_surface_and_catalog_ids():
|
||||
result = search_flights_impl([_FULL_FLIGHT])
|
||||
ops = result["a2ui_operations"]
|
||||
create_op = next(op for op in ops if op["type"] == "create_surface")
|
||||
assert create_op["surfaceId"] == SURFACE_ID
|
||||
assert create_op["catalogId"] == CATALOG_ID
|
||||
|
||||
|
||||
def test_flight_data_embedded_in_data_model():
|
||||
flights = [_FULL_FLIGHT, {"airline": "Second Air"}]
|
||||
result = search_flights_impl(flights)
|
||||
ops = result["a2ui_operations"]
|
||||
data_op = next(op for op in ops if op["type"] == "update_data_model")
|
||||
assert data_op["data"]["flights"] == flights
|
||||
|
||||
|
||||
def test_empty_flights_list():
|
||||
result = search_flights_impl([])
|
||||
ops = result["a2ui_operations"]
|
||||
assert len(ops) == 3
|
||||
data_op = next(op for op in ops if op["type"] == "update_data_model")
|
||||
assert data_op["data"]["flights"] == []
|
||||
|
||||
|
||||
def test_properly_formed_flight_objects():
|
||||
result = search_flights_impl([_FULL_FLIGHT])
|
||||
ops = result["a2ui_operations"]
|
||||
data_op = next(op for op in ops if op["type"] == "update_data_model")
|
||||
flight = data_op["data"]["flights"][0]
|
||||
for key in (
|
||||
"airline",
|
||||
"flightNumber",
|
||||
"origin",
|
||||
"destination",
|
||||
"date",
|
||||
"departureTime",
|
||||
"arrivalTime",
|
||||
"duration",
|
||||
"status",
|
||||
"price",
|
||||
):
|
||||
assert key in flight
|
||||
@@ -0,0 +1,49 @@
|
||||
"""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
|
||||
from .roll_dice import roll_dice_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",
|
||||
# Dice roll
|
||||
"roll_dice_impl",
|
||||
]
|
||||
@@ -0,0 +1,200 @@
|
||||
"""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 json
|
||||
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 _unstringify_json_fields(component: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Parse JSON-string fields back to Python values where the schema
|
||||
expects structured data.
|
||||
|
||||
Gemini's structured-output sometimes emits `"data": "[{...}]"` (a JSON
|
||||
string) instead of `"data": [...]` (the actual array) for fields
|
||||
declared with an "any" type in the schema. The React A2UI renderer
|
||||
expects real arrays/objects on data props — strings render as
|
||||
"No data available" on charts. We round-trip those known structured
|
||||
fields through json.loads so the renderer sees the right type.
|
||||
|
||||
Returns a new dict (does not mutate the input).
|
||||
"""
|
||||
out = dict(component)
|
||||
for field in ("data", "value", "children"):
|
||||
v = out.get(field)
|
||||
if isinstance(v, str) and v.strip().startswith(("[", "{")):
|
||||
try:
|
||||
out[field] = json.loads(v)
|
||||
except (ValueError, TypeError):
|
||||
# Leave the raw string in place if it doesn't parse — the
|
||||
# renderer will still receive a defined value rather than
|
||||
# nothing, and downstream code can decide what to do.
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _sanitize_a2ui_components(raw: Any) -> list[dict[str, Any]]:
|
||||
"""Drop entries that aren't dicts or are missing `id`/`component`,
|
||||
then unstringify any JSON-as-string fields the model emitted.
|
||||
|
||||
Mirrors `langgraph-python/src/agents/_a2ui_utils.py:sanitize_a2ui_components`
|
||||
with an added pass for Gemini's stringified `data` quirk.
|
||||
"""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [
|
||||
_unstringify_json_fields(c)
|
||||
for c in raw
|
||||
if isinstance(c, dict) and c.get("id") and c.get("component")
|
||||
]
|
||||
|
||||
|
||||
def _has_root_component(components: list[dict[str, Any]]) -> bool:
|
||||
"""True iff `components` contains an entry with `id == "root"`.
|
||||
|
||||
Mirrors `langgraph-python/src/agents/_a2ui_utils.py:has_root_component`.
|
||||
"""
|
||||
return any(c.get("id") == "root" for c in components)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Emits the v0.9 NESTED operation shape that
|
||||
`@ag-ui/a2ui-middleware`'s `getOperationSurfaceId` and the React
|
||||
A2UI renderer recognize:
|
||||
|
||||
{ "version": "v0.9", "createSurface": { surfaceId, catalogId } }
|
||||
{ "version": "v0.9", "updateComponents": { surfaceId, components } }
|
||||
{ "version": "v0.9", "updateDataModel": { surfaceId, path, value } }
|
||||
|
||||
The legacy flat shape (`{type: "create_surface", surfaceId, ...}`)
|
||||
looked plausible but the middleware's matcher only walks the nested
|
||||
`createSurface` / `updateComponents` / `updateDataModel` keys; when
|
||||
those were absent it grouped every op under the fallback `"default"`
|
||||
surface and the renderer never received the schema. Mirrors
|
||||
`copilotkit.a2ui.create_surface` / `update_components` /
|
||||
`update_data_model` from the langgraph-python north-star.
|
||||
"""
|
||||
surface_id = args.get("surfaceId", "dynamic-surface")
|
||||
catalog_id = args.get("catalogId", CUSTOM_CATALOG_ID)
|
||||
# Drop empty/malformed component entries before forwarding. Without
|
||||
# this, the renderer errors on the first `undefined` id.
|
||||
components = _sanitize_a2ui_components(args.get("components", []))
|
||||
if not components:
|
||||
_logger.warning(
|
||||
"build_a2ui_operations_from_tool_call: all components were "
|
||||
"dropped by sanitization (LLM emitted empty {} entries)"
|
||||
)
|
||||
elif not _has_root_component(components):
|
||||
_logger.warning(
|
||||
"build_a2ui_operations_from_tool_call: no component with id "
|
||||
"'root' — the renderer will error with 'no root component'"
|
||||
)
|
||||
data = args.get("data")
|
||||
|
||||
ops: list[dict[str, Any]] = [
|
||||
{
|
||||
"version": "v0.9",
|
||||
"createSurface": {"surfaceId": surface_id, "catalogId": catalog_id},
|
||||
},
|
||||
{
|
||||
"version": "v0.9",
|
||||
"updateComponents": {"surfaceId": surface_id, "components": components},
|
||||
},
|
||||
]
|
||||
if data:
|
||||
ops.append(
|
||||
{
|
||||
"version": "v0.9",
|
||||
"updateDataModel": {
|
||||
"surfaceId": surface_id,
|
||||
"path": "/",
|
||||
"value": 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,60 @@
|
||||
"""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,13 @@
|
||||
"""Dice-rolling tool implementation."""
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
|
||||
def roll_dice_impl(sides: int) -> dict[str, Any]:
|
||||
"""Roll a die with the given number of sides.
|
||||
|
||||
Returns a dict with the requested ``sides`` and the rolled ``result``
|
||||
(a random integer in ``[1, sides]``).
|
||||
"""
|
||||
return {"sides": sides, "result": random.randint(1, sides)}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""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,122 @@
|
||||
"""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": [
|
||||
{
|
||||
"version": "v0.9",
|
||||
"createSurface": {"surfaceId": SURFACE_ID, "catalogId": CATALOG_ID},
|
||||
},
|
||||
{
|
||||
"version": "v0.9",
|
||||
"updateComponents": {
|
||||
"surfaceId": SURFACE_ID,
|
||||
"components": _flight_schema,
|
||||
},
|
||||
},
|
||||
{
|
||||
"version": "v0.9",
|
||||
"updateDataModel": {
|
||||
"surfaceId": SURFACE_ID,
|
||||
"path": "/",
|
||||
"value": {"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
|
||||
Reference in New Issue
Block a user