diff --git a/lib/crewai/src/crewai/a2a/extensions/a2ui/__init__.py b/lib/crewai/src/crewai/a2a/extensions/a2ui/__init__.py new file mode 100644 index 000000000..724a3bd9b --- /dev/null +++ b/lib/crewai/src/crewai/a2a/extensions/a2ui/__init__.py @@ -0,0 +1,68 @@ +"""A2UI (Agent to UI) declarative UI protocol support for CrewAI.""" + +from crewai.a2a.extensions.a2ui.catalog import ( + AudioPlayer, + Button, + Card, + CheckBox, + Column, + DateTimeInput, + Divider, + Icon, + Image, + List, + Modal, + MultipleChoice, + Row, + Slider, + Tabs, + Text, + TextField, + Video, +) +from crewai.a2a.extensions.a2ui.client_extension import A2UIClientExtension +from crewai.a2a.extensions.a2ui.models import ( + A2UIEvent, + A2UIMessage, + A2UIResponse, + BeginRendering, + DataModelUpdate, + DeleteSurface, + SurfaceUpdate, + UserAction, +) +from crewai.a2a.extensions.a2ui.server_extension import A2UIServerExtension +from crewai.a2a.extensions.a2ui.validator import validate_a2ui_message + + +__all__ = [ + "A2UIClientExtension", + "A2UIEvent", + "A2UIMessage", + "A2UIResponse", + "A2UIServerExtension", + "AudioPlayer", + "BeginRendering", + "Button", + "Card", + "CheckBox", + "Column", + "DataModelUpdate", + "DateTimeInput", + "DeleteSurface", + "Divider", + "Icon", + "Image", + "List", + "Modal", + "MultipleChoice", + "Row", + "Slider", + "SurfaceUpdate", + "Tabs", + "Text", + "TextField", + "UserAction", + "Video", + "validate_a2ui_message", +] diff --git a/lib/crewai/src/crewai/a2a/extensions/a2ui/catalog.py b/lib/crewai/src/crewai/a2a/extensions/a2ui/catalog.py new file mode 100644 index 000000000..8a6e70ca4 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/extensions/a2ui/catalog.py @@ -0,0 +1,381 @@ +"""Typed helpers for A2UI standard catalog components. + +These models provide optional type safety for standard catalog components. +Agents can also use raw dicts validated against the JSON schema. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class StringBinding(BaseModel): + """A string value: literal or data-model path.""" + + literal_string: str | None = Field(None, alias="literalString") + path: str | None = None + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class NumberBinding(BaseModel): + """A numeric value: literal or data-model path.""" + + literal_number: float | None = Field(None, alias="literalNumber") + path: str | None = None + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class BooleanBinding(BaseModel): + """A boolean value: literal or data-model path.""" + + literal_boolean: bool | None = Field(None, alias="literalBoolean") + path: str | None = None + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class ArrayBinding(BaseModel): + """An array value: literal or data-model path.""" + + literal_array: list[str] | None = Field(None, alias="literalArray") + path: str | None = None + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class ChildrenDef(BaseModel): + """Children definition for layout components.""" + + explicit_list: list[str] | None = Field(None, alias="explicitList") + template: ChildTemplate | None = None + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class ChildTemplate(BaseModel): + """Template for generating dynamic children from a data model list.""" + + component_id: str = Field(alias="componentId") + data_binding: str = Field(alias="dataBinding") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class ActionContextEntry(BaseModel): + """A key-value pair in an action context payload.""" + + key: str + value: ActionBoundValue + + model_config = {"extra": "forbid"} + + +class ActionBoundValue(BaseModel): + """A value in an action context: literal or data-model path.""" + + path: str | None = None + literal_string: str | None = Field(None, alias="literalString") + literal_number: float | None = Field(None, alias="literalNumber") + literal_boolean: bool | None = Field(None, alias="literalBoolean") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class Action(BaseModel): + """Client-side action dispatched by interactive components.""" + + name: str + context: list[ActionContextEntry] | None = None + + model_config = {"extra": "forbid"} + + +class TabItem(BaseModel): + """A single tab definition.""" + + title: StringBinding + child: str + + model_config = {"extra": "forbid"} + + +class MultipleChoiceOption(BaseModel): + """A single option in a MultipleChoice component.""" + + label: StringBinding + value: str + + model_config = {"extra": "forbid"} + + +class Text(BaseModel): + """Displays text content.""" + + text: StringBinding + usage_hint: Literal["h1", "h2", "h3", "h4", "h5", "caption", "body"] | None = Field( + None, alias="usageHint" + ) + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class Image(BaseModel): + """Displays an image.""" + + url: StringBinding + fit: Literal["contain", "cover", "fill", "none", "scale-down"] | None = None + usage_hint: ( + Literal[ + "icon", "avatar", "smallFeature", "mediumFeature", "largeFeature", "header" + ] + | None + ) = Field(None, alias="usageHint") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +IconName = Literal[ + "accountCircle", + "add", + "arrowBack", + "arrowForward", + "attachFile", + "calendarToday", + "call", + "camera", + "check", + "close", + "delete", + "download", + "edit", + "event", + "error", + "favorite", + "favoriteOff", + "folder", + "help", + "home", + "info", + "locationOn", + "lock", + "lockOpen", + "mail", + "menu", + "moreVert", + "moreHoriz", + "notificationsOff", + "notifications", + "payment", + "person", + "phone", + "photo", + "print", + "refresh", + "search", + "send", + "settings", + "share", + "shoppingCart", + "star", + "starHalf", + "starOff", + "upload", + "visibility", + "visibilityOff", + "warning", +] + + +class IconBinding(BaseModel): + """Icon name: literal enum or data-model path.""" + + literal_string: IconName | None = Field(None, alias="literalString") + path: str | None = None + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class Icon(BaseModel): + """Displays a named icon.""" + + name: IconBinding + + model_config = {"extra": "forbid"} + + +class Video(BaseModel): + """Displays a video player.""" + + url: StringBinding + + model_config = {"extra": "forbid"} + + +class AudioPlayer(BaseModel): + """Displays an audio player.""" + + url: StringBinding + description: StringBinding | None = None + + model_config = {"extra": "forbid"} + + +class Row(BaseModel): + """Horizontal layout container.""" + + children: ChildrenDef + distribution: ( + Literal["center", "end", "spaceAround", "spaceBetween", "spaceEvenly", "start"] + | None + ) = None + alignment: Literal["start", "center", "end", "stretch"] | None = None + + model_config = {"extra": "forbid"} + + +class Column(BaseModel): + """Vertical layout container.""" + + children: ChildrenDef + distribution: ( + Literal["start", "center", "end", "spaceBetween", "spaceAround", "spaceEvenly"] + | None + ) = None + alignment: Literal["center", "end", "start", "stretch"] | None = None + + model_config = {"extra": "forbid"} + + +class List(BaseModel): + """Scrollable list container.""" + + children: ChildrenDef + direction: Literal["vertical", "horizontal"] | None = None + alignment: Literal["start", "center", "end", "stretch"] | None = None + + model_config = {"extra": "forbid"} + + +class Card(BaseModel): + """Card container wrapping a single child.""" + + child: str + + model_config = {"extra": "forbid"} + + +class Tabs(BaseModel): + """Tabbed navigation container.""" + + tab_items: list[TabItem] = Field(alias="tabItems") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class Divider(BaseModel): + """A visual divider line.""" + + axis: Literal["horizontal", "vertical"] | None = None + + model_config = {"extra": "forbid"} + + +class Modal(BaseModel): + """A modal dialog with an entry point trigger and content.""" + + entry_point_child: str = Field(alias="entryPointChild") + content_child: str = Field(alias="contentChild") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class Button(BaseModel): + """An interactive button with an action.""" + + child: str + primary: bool | None = None + action: Action + + model_config = {"extra": "forbid"} + + +class CheckBox(BaseModel): + """A checkbox input.""" + + label: StringBinding + value: BooleanBinding + + model_config = {"extra": "forbid"} + + +class TextField(BaseModel): + """A text input field.""" + + label: StringBinding + text: StringBinding | None = None + text_field_type: ( + Literal["date", "longText", "number", "shortText", "obscured"] | None + ) = Field(None, alias="textFieldType") + validation_regexp: str | None = Field(None, alias="validationRegexp") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class DateTimeInput(BaseModel): + """A date and/or time picker.""" + + value: StringBinding + enable_date: bool | None = Field(None, alias="enableDate") + enable_time: bool | None = Field(None, alias="enableTime") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class MultipleChoice(BaseModel): + """A multiple-choice selection component.""" + + selections: ArrayBinding + options: list[MultipleChoiceOption] + max_allowed_selections: int | None = Field(None, alias="maxAllowedSelections") + variant: Literal["checkbox", "chips"] | None = None + filterable: bool | None = None + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class Slider(BaseModel): + """A numeric slider input.""" + + value: NumberBinding + min_value: float | None = Field(None, alias="minValue") + max_value: float | None = Field(None, alias="maxValue") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +STANDARD_CATALOG_COMPONENTS: frozenset[str] = frozenset( + { + "Text", + "Image", + "Icon", + "Video", + "AudioPlayer", + "Row", + "Column", + "List", + "Card", + "Tabs", + "Divider", + "Modal", + "Button", + "CheckBox", + "TextField", + "DateTimeInput", + "MultipleChoice", + "Slider", + } +) diff --git a/lib/crewai/src/crewai/a2a/extensions/a2ui/client_extension.py b/lib/crewai/src/crewai/a2a/extensions/a2ui/client_extension.py new file mode 100644 index 000000000..3edc75475 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/extensions/a2ui/client_extension.py @@ -0,0 +1,184 @@ +"""A2UI client extension for the A2A protocol.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +import logging +from typing import TYPE_CHECKING, Any + +from crewai.a2a.extensions.a2ui.models import extract_a2ui_json_objects +from crewai.a2a.extensions.a2ui.prompt import build_a2ui_system_prompt +from crewai.a2a.extensions.a2ui.server_extension import A2UI_MIME_TYPE +from crewai.a2a.extensions.a2ui.validator import ( + A2UIValidationError, + validate_a2ui_message, +) + + +if TYPE_CHECKING: + from a2a.types import Message + + from crewai.agent.core import Agent + + +logger = logging.getLogger(__name__) + + +@dataclass +class A2UIConversationState: + """Tracks active A2UI surfaces and data models across a conversation.""" + + active_surfaces: dict[str, dict[str, Any]] = field(default_factory=dict) + data_models: dict[str, list[dict[str, Any]]] = field(default_factory=dict) + last_a2ui_messages: list[dict[str, Any]] = field(default_factory=list) + + def is_ready(self) -> bool: + """Return True when at least one surface is active.""" + return bool(self.active_surfaces) + + +class A2UIClientExtension: + """A2A client extension that adds A2UI support to agents. + + Implements the ``A2AExtension`` protocol to inject A2UI prompt + instructions, track UI state across conversations, and validate + A2UI messages in responses. + + Example:: + + A2AClientConfig( + endpoint="...", + extensions=["https://a2ui.org/a2a-extension/a2ui/v0.8"], + client_extensions=[A2UIClientExtension()], + ) + """ + + def __init__( + self, + catalog_id: str | None = None, + allowed_components: list[str] | None = None, + ) -> None: + """Initialize the A2UI client extension. + + Args: + catalog_id: Catalog identifier to use for prompt generation. + allowed_components: Subset of component names to expose to the agent. + """ + self._catalog_id = catalog_id + self._allowed_components = allowed_components + + def inject_tools(self, agent: Agent) -> None: + """No-op — A2UI uses prompt augmentation rather than tool injection.""" + + def extract_state_from_history( + self, conversation_history: Sequence[Message] + ) -> A2UIConversationState | None: + """Scan conversation history for A2UI DataParts and track surface state.""" + state = A2UIConversationState() + + for message in conversation_history: + if not _has_parts(message): + continue + for part in message.parts: + root = part.root + if root.kind != "data": + continue + metadata = getattr(root, "metadata", None) or {} + mime_type = metadata.get("mimeType", "") + if mime_type != A2UI_MIME_TYPE: + continue + + data = root.data + if not isinstance(data, dict): + continue + + surface_id = _get_surface_id(data) + if not surface_id: + continue + + if "deleteSurface" in data: + state.active_surfaces.pop(surface_id, None) + state.data_models.pop(surface_id, None) + elif "beginRendering" in data: + state.active_surfaces[surface_id] = data["beginRendering"] + elif "surfaceUpdate" in data: + state.active_surfaces[surface_id] = data["surfaceUpdate"] + elif "dataModelUpdate" in data: + contents = data["dataModelUpdate"].get("contents", []) + state.data_models.setdefault(surface_id, []).extend(contents) + + if not state.active_surfaces and not state.data_models: + return None + return state + + def augment_prompt( + self, + base_prompt: str, + conversation_state: A2UIConversationState | None, + ) -> str: + """Append A2UI system prompt instructions to the base prompt.""" + a2ui_prompt = build_a2ui_system_prompt( + catalog_id=self._catalog_id, + allowed_components=self._allowed_components, + ) + return f"{base_prompt}\n\n{a2ui_prompt}" + + def process_response( + self, + agent_response: Any, + conversation_state: A2UIConversationState | None, + ) -> Any: + """Extract and validate A2UI JSON from agent output. + + Stores extracted A2UI messages on the conversation state and returns + the original response unchanged to preserve the AgentResponseProtocol + contract. + """ + text = ( + agent_response if isinstance(agent_response, str) else str(agent_response) + ) + a2ui_messages = _extract_and_validate(text) + + if a2ui_messages and conversation_state is not None: + conversation_state.last_a2ui_messages = a2ui_messages + + return agent_response + + +def _has_parts(message: Any) -> bool: + """Check if a message has a parts attribute.""" + return isinstance(getattr(message, "parts", None), list) + + +def _get_surface_id(data: dict[str, Any]) -> str | None: + """Extract surfaceId from any A2UI message type.""" + for key in ("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface"): + inner = data.get(key) + if isinstance(inner, dict): + sid = inner.get("surfaceId") + if isinstance(sid, str): + return sid + return None + + +def _extract_and_validate(text: str) -> list[dict[str, Any]]: + """Extract A2UI JSON objects from text and validate them.""" + return [ + dumped + for candidate in extract_a2ui_json_objects(text) + if (dumped := _try_validate(candidate)) is not None + ] + + +def _try_validate(candidate: dict[str, Any]) -> dict[str, Any] | None: + """Validate a single A2UI candidate, returning None on failure.""" + try: + msg = validate_a2ui_message(candidate) + except A2UIValidationError: + logger.debug( + "Skipping invalid A2UI candidate in agent output", + exc_info=True, + ) + return None + return msg.model_dump(by_alias=True, exclude_none=True) diff --git a/lib/crewai/src/crewai/a2a/extensions/a2ui/models.py b/lib/crewai/src/crewai/a2a/extensions/a2ui/models.py new file mode 100644 index 000000000..5a1d05001 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/extensions/a2ui/models.py @@ -0,0 +1,212 @@ +"""Pydantic models for A2UI server-to-client messages and client-to-server events.""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from pydantic import BaseModel, Field, model_validator + + +class BoundValue(BaseModel): + """A value that can be a literal or a data-model path reference.""" + + literal_string: str | None = Field(None, alias="literalString") + literal_number: float | None = Field(None, alias="literalNumber") + literal_boolean: bool | None = Field(None, alias="literalBoolean") + literal_array: list[str] | None = Field(None, alias="literalArray") + path: str | None = None + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class MapEntry(BaseModel): + """A single entry in a valueMap adjacency list, supporting recursive nesting.""" + + key: str + value_string: str | None = Field(None, alias="valueString") + value_number: float | None = Field(None, alias="valueNumber") + value_boolean: bool | None = Field(None, alias="valueBoolean") + value_map: list[MapEntry] | None = Field(None, alias="valueMap") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class DataEntry(BaseModel): + """A data model entry with a key and exactly one typed value.""" + + key: str + value_string: str | None = Field(None, alias="valueString") + value_number: float | None = Field(None, alias="valueNumber") + value_boolean: bool | None = Field(None, alias="valueBoolean") + value_map: list[MapEntry] | None = Field(None, alias="valueMap") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +_HEX_COLOR_PATTERN: re.Pattern[str] = re.compile(r"^#[0-9a-fA-F]{6}$") + + +class Styles(BaseModel): + """Surface styling information.""" + + font: str | None = None + primary_color: str | None = Field( + None, alias="primaryColor", pattern=_HEX_COLOR_PATTERN.pattern + ) + + model_config = {"populate_by_name": True, "extra": "allow"} + + +class ComponentEntry(BaseModel): + """A single component in a UI widget tree. + + The ``component`` dict must contain exactly one key — the component type + name (e.g. ``"Text"``, ``"Button"``) — whose value holds the component + properties. Component internals are left as ``dict[str, Any]`` because + they are catalog-dependent; use the typed helpers in ``catalog.py`` for + the standard catalog. + """ + + id: str + weight: float | None = None + component: dict[str, Any] + + model_config = {"extra": "forbid"} + + +class BeginRendering(BaseModel): + """Signals the client to begin rendering a surface.""" + + surface_id: str = Field(alias="surfaceId") + root: str + catalog_id: str | None = Field(None, alias="catalogId") + styles: Styles | None = None + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class SurfaceUpdate(BaseModel): + """Updates a surface with a new set of components.""" + + surface_id: str = Field(alias="surfaceId") + components: list[ComponentEntry] = Field(min_length=1) + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class DataModelUpdate(BaseModel): + """Updates the data model for a surface.""" + + surface_id: str = Field(alias="surfaceId") + path: str | None = None + contents: list[DataEntry] + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class DeleteSurface(BaseModel): + """Signals the client to delete a surface.""" + + surface_id: str = Field(alias="surfaceId") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + +class A2UIMessage(BaseModel): + """Union wrapper for the four server-to-client A2UI message types. + + Exactly one of the fields must be set. + """ + + begin_rendering: BeginRendering | None = Field(None, alias="beginRendering") + surface_update: SurfaceUpdate | None = Field(None, alias="surfaceUpdate") + data_model_update: DataModelUpdate | None = Field(None, alias="dataModelUpdate") + delete_surface: DeleteSurface | None = Field(None, alias="deleteSurface") + + model_config = {"populate_by_name": True, "extra": "forbid"} + + @model_validator(mode="after") + def _check_exactly_one(self) -> A2UIMessage: + """Enforce the spec's exactly-one-of constraint.""" + fields = [ + self.begin_rendering, + self.surface_update, + self.data_model_update, + self.delete_surface, + ] + count = sum(f is not None for f in fields) + if count != 1: + raise ValueError(f"Exactly one A2UI message type must be set, got {count}") + return self + + +class UserAction(BaseModel): + """Reports a user-initiated action from a component.""" + + name: str + surface_id: str = Field(alias="surfaceId") + source_component_id: str = Field(alias="sourceComponentId") + timestamp: str + context: dict[str, Any] + + model_config = {"populate_by_name": True} + + +class ClientError(BaseModel): + """Reports a client-side error.""" + + model_config = {"extra": "allow"} + + +class A2UIEvent(BaseModel): + """Union wrapper for client-to-server events.""" + + user_action: UserAction | None = Field(None, alias="userAction") + error: ClientError | None = None + + model_config = {"populate_by_name": True} + + @model_validator(mode="after") + def _check_exactly_one(self) -> A2UIEvent: + """Enforce the spec's exactly-one-of constraint.""" + fields = [self.user_action, self.error] + count = sum(f is not None for f in fields) + if count != 1: + raise ValueError(f"Exactly one A2UI event type must be set, got {count}") + return self + + +class A2UIResponse(BaseModel): + """Typed wrapper for responses containing A2UI messages.""" + + text: str + a2ui_parts: list[dict[str, Any]] = Field(default_factory=list) + a2ui_messages: list[dict[str, Any]] = Field(default_factory=list) + + +_A2UI_KEYS = {"beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface"} + + +def extract_a2ui_json_objects(text: str) -> list[dict[str, Any]]: + """Extract JSON objects containing A2UI keys from text. + + Uses ``json.JSONDecoder.raw_decode`` for robust parsing that correctly + handles braces inside string literals. + """ + decoder = json.JSONDecoder() + results: list[dict[str, Any]] = [] + idx = 0 + while idx < len(text): + idx = text.find("{", idx) + if idx == -1: + break + try: + obj, end_idx = decoder.raw_decode(text, idx) + if isinstance(obj, dict) and _A2UI_KEYS & obj.keys(): + results.append(obj) + idx = end_idx + except json.JSONDecodeError: + idx += 1 + return results diff --git a/lib/crewai/src/crewai/a2a/extensions/a2ui/prompt.py b/lib/crewai/src/crewai/a2a/extensions/a2ui/prompt.py new file mode 100644 index 000000000..4d9ddf0dd --- /dev/null +++ b/lib/crewai/src/crewai/a2a/extensions/a2ui/prompt.py @@ -0,0 +1,75 @@ +"""System prompt generation for A2UI-capable agents.""" + +from __future__ import annotations + +import json + +from crewai.a2a.extensions.a2ui.catalog import STANDARD_CATALOG_COMPONENTS +from crewai.a2a.extensions.a2ui.schema import load_schema + + +def build_a2ui_system_prompt( + catalog_id: str | None = None, + allowed_components: list[str] | None = None, +) -> str: + """Build a system prompt fragment instructing the LLM to produce A2UI output. + + The prompt describes the A2UI message format, available components, and + data binding rules. It includes the resolved schema so the LLM can + generate structured output. + + Args: + catalog_id: Catalog identifier to reference. Defaults to the + standard catalog for A2UI v0.8. + allowed_components: Subset of component names to expose. When + ``None``, all standard catalog components are available. + + Returns: + A system prompt string to append to the agent's instructions. + """ + components = sorted( + allowed_components + if allowed_components is not None + else STANDARD_CATALOG_COMPONENTS + ) + + catalog_label = catalog_id or "standard (v0.8)" + + resolved_schema = load_schema("server_to_client_with_standard_catalog") + schema_json = json.dumps(resolved_schema, indent=2) + + return f"""\ + +You can generate rich, declarative UI by emitting A2UI JSON messages. + +CATALOG: {catalog_label} +AVAILABLE COMPONENTS: {", ".join(components)} + +MESSAGE TYPES (emit exactly ONE per message): +- beginRendering: Initialize a new surface with a root component and optional styles. +- surfaceUpdate: Send/update components for a surface. Each component has a unique id \ +and a "component" wrapper containing exactly one component-type key. +- dataModelUpdate: Update the data model for a surface. Data entries have a key and \ +one typed value (valueString, valueNumber, valueBoolean, valueMap). +- deleteSurface: Remove a surface. + +DATA BINDING: +- Use {{"literalString": "..."}} for inline string values. +- Use {{"literalNumber": ...}} for inline numeric values. +- Use {{"literalBoolean": ...}} for inline boolean values. +- Use {{"literalArray": ["...", "..."]}} for inline array values. +- Use {{"path": "/data/model/path"}} to bind to data model values. + +ACTIONS: +- Interactive components (Button, etc.) have an "action" with a "name" and optional \ +"context" array of key/value pairs. +- Values in action context can use data binding (path or literal). + +OUTPUT FORMAT: +Emit each A2UI message as a valid JSON object. When generating UI, produce a \ +beginRendering message first, then surfaceUpdate messages with components, and \ +optionally dataModelUpdate messages to populate data-bound values. + +SCHEMA: +{schema_json} +""" diff --git a/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/__init__.py b/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/__init__.py new file mode 100644 index 000000000..1ab73d373 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/__init__.py @@ -0,0 +1,48 @@ +"""Schema loading utilities for vendored A2UI JSON schemas.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +_SCHEMA_DIR = Path(__file__).parent / "v0_8" + +_SCHEMA_CACHE: dict[str, dict[str, Any]] = {} + +SCHEMA_NAMES: frozenset[str] = frozenset( + { + "server_to_client", + "client_to_server", + "standard_catalog_definition", + "server_to_client_with_standard_catalog", + } +) + + +def load_schema(name: str) -> dict[str, Any]: + """Load a vendored A2UI JSON schema by name. + + Args: + name: Schema name without extension (e.g. ``"server_to_client"``). + + Returns: + Parsed JSON schema dict. + + Raises: + ValueError: If the schema name is not recognized. + FileNotFoundError: If the schema file is missing from the package. + """ + if name not in SCHEMA_NAMES: + raise ValueError(f"Unknown schema {name!r}. Available: {sorted(SCHEMA_NAMES)}") + + if name in _SCHEMA_CACHE: + return _SCHEMA_CACHE[name] + + path = _SCHEMA_DIR / f"{name}.json" + with path.open() as f: + schema: dict[str, Any] = json.load(f) + + _SCHEMA_CACHE[name] = schema + return schema diff --git a/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/v0_8/client_to_server.json b/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/v0_8/client_to_server.json new file mode 100644 index 000000000..f4f964a24 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/v0_8/client_to_server.json @@ -0,0 +1,53 @@ +{ + "title": "A2UI (Agent to UI) Client-to-Server Event Schema", + "description": "Describes a JSON payload for a client-to-server event message.", + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "properties": { + "userAction": { + "type": "object", + "description": "Reports a user-initiated action from a component.", + "properties": { + "name": { + "type": "string", + "description": "The name of the action, taken from the component's action.name property." + }, + "surfaceId": { + "type": "string", + "description": "The id of the surface where the event originated." + }, + "sourceComponentId": { + "type": "string", + "description": "The id of the component that triggered the event." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "An ISO 8601 timestamp of when the event occurred." + }, + "context": { + "type": "object", + "description": "A JSON object containing the key-value pairs from the component's action.context, after resolving all data bindings.", + "additionalProperties": true + } + }, + "required": [ + "name", + "surfaceId", + "sourceComponentId", + "timestamp", + "context" + ] + }, + "error": { + "type": "object", + "description": "Reports a client-side error. The content is flexible.", + "additionalProperties": true + } + }, + "oneOf": [ + { "required": ["userAction"] }, + { "required": ["error"] } + ] +} diff --git a/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/v0_8/server_to_client.json b/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/v0_8/server_to_client.json new file mode 100644 index 000000000..3b73b754f --- /dev/null +++ b/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/v0_8/server_to_client.json @@ -0,0 +1,148 @@ +{ + "title": "A2UI Message Schema", + "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.", + "type": "object", + "additionalProperties": false, + "properties": { + "beginRendering": { + "type": "object", + "description": "Signals the client to begin rendering a surface with a root component and specific styles.", + "additionalProperties": false, + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be rendered." + }, + "catalogId": { + "type": "string", + "description": "The identifier of the component catalog to use for this surface. If omitted, the client MUST default to the standard catalog for this A2UI version (https://a2ui.org/specification/v0_8/standard_catalog_definition.json)." + }, + "root": { + "type": "string", + "description": "The ID of the root component to render." + }, + "styles": { + "type": "object", + "description": "Styling information for the UI.", + "additionalProperties": true + } + }, + "required": ["root", "surfaceId"] + }, + "surfaceUpdate": { + "type": "object", + "description": "Updates a surface with a new set of components.", + "additionalProperties": false, + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown." + }, + "components": { + "type": "array", + "description": "A list containing all UI components for the surface.", + "minItems": 1, + "items": { + "type": "object", + "description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for this component." + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + }, + "component": { + "type": "object", + "description": "A wrapper object that MUST contain exactly one key, which is the name of the component type. The value is an object containing the properties for that specific component.", + "additionalProperties": true + } + }, + "required": ["id", "component"] + } + } + }, + "required": ["surfaceId", "components"] + }, + "dataModelUpdate": { + "type": "object", + "description": "Updates the data model for a surface.", + "additionalProperties": false, + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." + }, + "path": { + "type": "string", + "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced." + }, + "contents": { + "type": "array", + "description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.", + "items": { + "type": "object", + "description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "The key for this data entry." + }, + "valueString": { + "type": "string" + }, + "valueNumber": { + "type": "number" + }, + "valueBoolean": { + "type": "boolean" + }, + "valueMap": { + "description": "Represents a map as an adjacency list.", + "type": "array", + "items": { + "type": "object", + "description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "valueString": { + "type": "string" + }, + "valueNumber": { + "type": "number" + }, + "valueBoolean": { + "type": "boolean" + } + }, + "required": ["key"] + } + } + }, + "required": ["key"] + } + } + }, + "required": ["contents", "surfaceId"] + }, + "deleteSurface": { + "type": "object", + "description": "Signals the client to delete the surface identified by 'surfaceId'.", + "additionalProperties": false, + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." + } + }, + "required": ["surfaceId"] + } + } +} diff --git a/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/v0_8/server_to_client_with_standard_catalog.json b/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/v0_8/server_to_client_with_standard_catalog.json new file mode 100644 index 000000000..df6a8b5ff --- /dev/null +++ b/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/v0_8/server_to_client_with_standard_catalog.json @@ -0,0 +1,823 @@ +{ + "title": "A2UI Message Schema", + "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.", + "type": "object", + "additionalProperties": false, + "properties": { + "beginRendering": { + "type": "object", + "description": "Signals the client to begin rendering a surface with a root component and specific styles.", + "additionalProperties": false, + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be rendered." + }, + "root": { + "type": "string", + "description": "The ID of the root component to render." + }, + "styles": { + "type": "object", + "description": "Styling information for the UI.", + "additionalProperties": false, + "properties": { + "font": { + "type": "string", + "description": "The primary font for the UI." + }, + "primaryColor": { + "type": "string", + "description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + } + } + } + }, + "required": ["root", "surfaceId"] + }, + "surfaceUpdate": { + "type": "object", + "description": "Updates a surface with a new set of components.", + "additionalProperties": false, + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown." + }, + "components": { + "type": "array", + "description": "A list containing all UI components for the surface.", + "minItems": 1, + "items": { + "type": "object", + "description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for this component." + }, + "weight": { + "type": "number", + "description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column." + }, + "component": { + "type": "object", + "description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.", + "additionalProperties": false, + "properties": { + "Text": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": { + "type": "object", + "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "usageHint": { + "type": "string", + "description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": ["text"] + }, + "Image": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "object", + "description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "fit": { + "type": "string", + "description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.", + "enum": [ + "contain", + "cover", + "fill", + "none", + "scale-down" + ] + }, + "usageHint": { + "type": "string", + "description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.", + "enum": [ + "icon", + "avatar", + "smallFeature", + "mediumFeature", + "largeFeature", + "header" + ] + } + }, + "required": ["url"] + }, + "Icon": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "object", + "description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string", + "enum": [ + "accountCircle", + "add", + "arrowBack", + "arrowForward", + "attachFile", + "calendarToday", + "call", + "camera", + "check", + "close", + "delete", + "download", + "edit", + "event", + "error", + "favorite", + "favoriteOff", + "folder", + "help", + "home", + "info", + "locationOn", + "lock", + "lockOpen", + "mail", + "menu", + "moreVert", + "moreHoriz", + "notificationsOff", + "notifications", + "payment", + "person", + "phone", + "photo", + "print", + "refresh", + "search", + "send", + "settings", + "share", + "shoppingCart", + "star", + "starHalf", + "starOff", + "upload", + "visibility", + "visibilityOff", + "warning" + ] + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["name"] + }, + "Video": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "object", + "description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["url"] + }, + "AudioPlayer": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "object", + "description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "description": { + "type": "object", + "description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["url"] + }, + "Row": { + "type": "object", + "additionalProperties": false, + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "additionalProperties": false, + "properties": { + "explicitList": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "additionalProperties": false, + "properties": { + "componentId": { + "type": "string" + }, + "dataBinding": { + "type": "string" + } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "distribution": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.", + "enum": [ + "center", + "end", + "spaceAround", + "spaceBetween", + "spaceEvenly", + "start" + ] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.", + "enum": ["start", "center", "end", "stretch"] + } + }, + "required": ["children"] + }, + "Column": { + "type": "object", + "additionalProperties": false, + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "additionalProperties": false, + "properties": { + "explicitList": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "additionalProperties": false, + "properties": { + "componentId": { + "type": "string" + }, + "dataBinding": { + "type": "string" + } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "distribution": { + "type": "string", + "description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly" + ] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.", + "enum": ["center", "end", "start", "stretch"] + } + }, + "required": ["children"] + }, + "List": { + "type": "object", + "additionalProperties": false, + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "additionalProperties": false, + "properties": { + "explicitList": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + "additionalProperties": false, + "properties": { + "componentId": { + "type": "string" + }, + "dataBinding": { + "type": "string" + } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "direction": { + "type": "string", + "description": "The direction in which the list items are laid out.", + "enum": ["vertical", "horizontal"] + }, + "alignment": { + "type": "string", + "description": "Defines the alignment of children along the cross axis.", + "enum": ["start", "center", "end", "stretch"] + } + }, + "required": ["children"] + }, + "Card": { + "type": "object", + "additionalProperties": false, + "properties": { + "child": { + "type": "string", + "description": "The ID of the component to be rendered inside the card." + } + }, + "required": ["child"] + }, + "Tabs": { + "type": "object", + "additionalProperties": false, + "properties": { + "tabItems": { + "type": "array", + "description": "An array of objects, where each object defines a tab with a title and a child component.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "object", + "description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "child": { + "type": "string" + } + }, + "required": ["title", "child"] + } + } + }, + "required": ["tabItems"] + }, + "Divider": { + "type": "object", + "additionalProperties": false, + "properties": { + "axis": { + "type": "string", + "description": "The orientation of the divider.", + "enum": ["horizontal", "vertical"] + } + } + }, + "Modal": { + "type": "object", + "additionalProperties": false, + "properties": { + "entryPointChild": { + "type": "string", + "description": "The ID of the component that opens the modal when interacted with (e.g., a button)." + }, + "contentChild": { + "type": "string", + "description": "The ID of the component to be displayed inside the modal." + } + }, + "required": ["entryPointChild", "contentChild"] + }, + "Button": { + "type": "object", + "additionalProperties": false, + "properties": { + "child": { + "type": "string", + "description": "The ID of the component to display in the button, typically a Text component." + }, + "primary": { + "type": "boolean", + "description": "Indicates if this button should be styled as the primary action." + }, + "action": { + "type": "object", + "description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "context": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "object", + "description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').", + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "literalString": { + "type": "string" + }, + "literalNumber": { + "type": "number" + }, + "literalBoolean": { + "type": "boolean" + } + } + } + }, + "required": ["key", "value"] + } + } + }, + "required": ["name"] + } + }, + "required": ["child", "action"] + }, + "CheckBox": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "object", + "description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "value": { + "type": "object", + "description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').", + "additionalProperties": false, + "properties": { + "literalBoolean": { + "type": "boolean" + }, + "path": { + "type": "string" + } + } + } + }, + "required": ["label", "value"] + }, + "TextField": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "object", + "description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "text": { + "type": "object", + "description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "textFieldType": { + "type": "string", + "description": "The type of input field to display.", + "enum": [ + "date", + "longText", + "number", + "shortText", + "obscured" + ] + }, + "validationRegexp": { + "type": "string", + "description": "A regular expression used for client-side validation of the input." + } + }, + "required": ["label"] + }, + "DateTimeInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "type": "object", + "description": "The selected date and/or time value in ISO 8601 format. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "enableDate": { + "type": "boolean", + "description": "If true, allows the user to select a date." + }, + "enableTime": { + "type": "boolean", + "description": "If true, allows the user to select a time." + } + }, + "required": ["value"] + }, + "MultipleChoice": { + "type": "object", + "additionalProperties": false, + "properties": { + "selections": { + "type": "object", + "description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').", + "additionalProperties": false, + "properties": { + "literalArray": { + "type": "array", + "items": { + "type": "string" + } + }, + "path": { + "type": "string" + } + } + }, + "options": { + "type": "array", + "description": "An array of available options for the user to choose from.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "object", + "description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "value": { + "type": "string", + "description": "The value to be associated with this option when selected." + } + }, + "required": ["label", "value"] + } + }, + "maxAllowedSelections": { + "type": "integer", + "description": "The maximum number of options that the user is allowed to select." + } + }, + "required": ["selections", "options"] + }, + "Slider": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "type": "object", + "description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').", + "additionalProperties": false, + "properties": { + "literalNumber": { + "type": "number" + }, + "path": { + "type": "string" + } + } + }, + "minValue": { + "type": "number", + "description": "The minimum value of the slider." + }, + "maxValue": { + "type": "number", + "description": "The maximum value of the slider." + } + }, + "required": ["value"] + } + } + } + }, + "required": ["id", "component"] + } + } + }, + "required": ["surfaceId", "components"] + }, + "dataModelUpdate": { + "type": "object", + "description": "Updates the data model for a surface.", + "additionalProperties": false, + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." + }, + "path": { + "type": "string", + "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced." + }, + "contents": { + "type": "array", + "description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.", + "items": { + "type": "object", + "description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "The key for this data entry." + }, + "valueString": { + "type": "string" + }, + "valueNumber": { + "type": "number" + }, + "valueBoolean": { + "type": "boolean" + }, + "valueMap": { + "description": "Represents a map as an adjacency list.", + "type": "array", + "items": { + "type": "object", + "description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.", + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "valueString": { + "type": "string" + }, + "valueNumber": { + "type": "number" + }, + "valueBoolean": { + "type": "boolean" + } + }, + "required": ["key"] + } + } + }, + "required": ["key"] + } + } + }, + "required": ["contents", "surfaceId"] + }, + "deleteSurface": { + "type": "object", + "description": "Signals the client to delete the surface identified by 'surfaceId'.", + "additionalProperties": false, + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." + } + }, + "required": ["surfaceId"] + } + } +} \ No newline at end of file diff --git a/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/v0_8/standard_catalog_definition.json b/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/v0_8/standard_catalog_definition.json new file mode 100644 index 000000000..8b5c0a06c --- /dev/null +++ b/lib/crewai/src/crewai/a2a/extensions/a2ui/schema/v0_8/standard_catalog_definition.json @@ -0,0 +1,459 @@ +{ + "components": { + "Text": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": { + "type": "object", + "description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "usageHint": { + "type": "string", + "description": "A hint for the base text style.", + "enum": ["h1", "h2", "h3", "h4", "h5", "caption", "body"] + } + }, + "required": ["text"] + }, + "Image": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "object", + "description": "The URL of the image to display.", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "fit": { + "type": "string", + "description": "Specifies how the image should be resized to fit its container.", + "enum": ["contain", "cover", "fill", "none", "scale-down"] + }, + "usageHint": { + "type": "string", + "description": "A hint for the image size and style.", + "enum": ["icon", "avatar", "smallFeature", "mediumFeature", "largeFeature", "header"] + } + }, + "required": ["url"] + }, + "Icon": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "object", + "description": "The name of the icon to display.", + "additionalProperties": false, + "properties": { + "literalString": { + "type": "string", + "enum": [ + "accountCircle", "add", "arrowBack", "arrowForward", "attachFile", + "calendarToday", "call", "camera", "check", "close", "delete", + "download", "edit", "event", "error", "favorite", "favoriteOff", + "folder", "help", "home", "info", "locationOn", "lock", "lockOpen", + "mail", "menu", "moreVert", "moreHoriz", "notificationsOff", + "notifications", "payment", "person", "phone", "photo", "print", + "refresh", "search", "send", "settings", "share", "shoppingCart", + "star", "starHalf", "starOff", "upload", "visibility", + "visibilityOff", "warning" + ] + }, + "path": { "type": "string" } + } + } + }, + "required": ["name"] + }, + "Video": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "object", + "description": "The URL of the video to display.", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + } + }, + "required": ["url"] + }, + "AudioPlayer": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "object", + "description": "The URL of the audio to be played.", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "description": { + "type": "object", + "description": "A description of the audio, such as a title or summary.", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + } + }, + "required": ["url"] + }, + "Row": { + "type": "object", + "additionalProperties": false, + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "additionalProperties": false, + "properties": { + "explicitList": { "type": "array", "items": { "type": "string" } }, + "template": { + "type": "object", + "additionalProperties": false, + "properties": { + "componentId": { "type": "string" }, + "dataBinding": { "type": "string" } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "distribution": { + "type": "string", + "enum": ["center", "end", "spaceAround", "spaceBetween", "spaceEvenly", "start"] + }, + "alignment": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + } + }, + "required": ["children"] + }, + "Column": { + "type": "object", + "additionalProperties": false, + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "additionalProperties": false, + "properties": { + "explicitList": { "type": "array", "items": { "type": "string" } }, + "template": { + "type": "object", + "additionalProperties": false, + "properties": { + "componentId": { "type": "string" }, + "dataBinding": { "type": "string" } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "distribution": { + "type": "string", + "enum": ["start", "center", "end", "spaceBetween", "spaceAround", "spaceEvenly"] + }, + "alignment": { + "type": "string", + "enum": ["center", "end", "start", "stretch"] + } + }, + "required": ["children"] + }, + "List": { + "type": "object", + "additionalProperties": false, + "properties": { + "children": { + "type": "object", + "description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.", + "additionalProperties": false, + "properties": { + "explicitList": { "type": "array", "items": { "type": "string" } }, + "template": { + "type": "object", + "additionalProperties": false, + "properties": { + "componentId": { "type": "string" }, + "dataBinding": { "type": "string" } + }, + "required": ["componentId", "dataBinding"] + } + } + }, + "direction": { + "type": "string", + "enum": ["vertical", "horizontal"] + }, + "alignment": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + } + }, + "required": ["children"] + }, + "Card": { + "type": "object", + "additionalProperties": false, + "properties": { + "child": { + "type": "string", + "description": "The ID of the component to be rendered inside the card." + } + }, + "required": ["child"] + }, + "Tabs": { + "type": "object", + "additionalProperties": false, + "properties": { + "tabItems": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "object", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "child": { "type": "string" } + }, + "required": ["title", "child"] + } + } + }, + "required": ["tabItems"] + }, + "Divider": { + "type": "object", + "additionalProperties": false, + "properties": { + "axis": { + "type": "string", + "enum": ["horizontal", "vertical"] + } + } + }, + "Modal": { + "type": "object", + "additionalProperties": false, + "properties": { + "entryPointChild": { + "type": "string", + "description": "The ID of the component that opens the modal when interacted with." + }, + "contentChild": { + "type": "string", + "description": "The ID of the component to be displayed inside the modal." + } + }, + "required": ["entryPointChild", "contentChild"] + }, + "Button": { + "type": "object", + "additionalProperties": false, + "properties": { + "child": { + "type": "string", + "description": "The ID of the component to display in the button." + }, + "primary": { + "type": "boolean", + "description": "Indicates if this button should be styled as the primary action." + }, + "action": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "context": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { "type": "string" }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { "type": "string" }, + "literalString": { "type": "string" }, + "literalNumber": { "type": "number" }, + "literalBoolean": { "type": "boolean" } + } + } + }, + "required": ["key", "value"] + } + } + }, + "required": ["name"] + } + }, + "required": ["child", "action"] + }, + "CheckBox": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "object", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "literalBoolean": { "type": "boolean" }, + "path": { "type": "string" } + } + } + }, + "required": ["label", "value"] + }, + "TextField": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "object", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "text": { + "type": "object", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "textFieldType": { + "type": "string", + "enum": ["date", "longText", "number", "shortText", "obscured"] + }, + "validationRegexp": { "type": "string" } + }, + "required": ["label"] + }, + "DateTimeInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "enableDate": { "type": "boolean" }, + "enableTime": { "type": "boolean" } + }, + "required": ["value"] + }, + "MultipleChoice": { + "type": "object", + "additionalProperties": false, + "properties": { + "selections": { + "type": "object", + "additionalProperties": false, + "properties": { + "literalArray": { "type": "array", "items": { "type": "string" } }, + "path": { "type": "string" } + } + }, + "options": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "object", + "additionalProperties": false, + "properties": { + "literalString": { "type": "string" }, + "path": { "type": "string" } + } + }, + "value": { "type": "string" } + }, + "required": ["label", "value"] + } + }, + "maxAllowedSelections": { "type": "integer" }, + "variant": { + "type": "string", + "enum": ["checkbox", "chips"] + }, + "filterable": { "type": "boolean" } + }, + "required": ["selections", "options"] + }, + "Slider": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "literalNumber": { "type": "number" }, + "path": { "type": "string" } + } + }, + "minValue": { "type": "number" }, + "maxValue": { "type": "number" } + }, + "required": ["value"] + } + }, + "styles": { + "font": { + "type": "string", + "description": "The primary font for the UI." + }, + "primaryColor": { + "type": "string", + "description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + } + } +} diff --git a/lib/crewai/src/crewai/a2a/extensions/a2ui/server_extension.py b/lib/crewai/src/crewai/a2a/extensions/a2ui/server_extension.py new file mode 100644 index 000000000..8cc74a524 --- /dev/null +++ b/lib/crewai/src/crewai/a2a/extensions/a2ui/server_extension.py @@ -0,0 +1,125 @@ +"""A2UI server extension for the A2A protocol.""" + +from __future__ import annotations + +import logging +from typing import Any + +from crewai.a2a.extensions.a2ui.models import A2UIResponse, extract_a2ui_json_objects +from crewai.a2a.extensions.a2ui.validator import ( + A2UIValidationError, + validate_a2ui_message, +) +from crewai.a2a.extensions.server import ExtensionContext, ServerExtension + + +logger = logging.getLogger(__name__) + +A2UI_MIME_TYPE = "application/json+a2ui" +A2UI_EXTENSION_URI = "https://a2ui.org/a2a-extension/a2ui/v0.8" + + +class A2UIServerExtension(ServerExtension): + """A2A server extension that enables A2UI declarative UI generation. + + When activated by a client that declares A2UI v0.8 support, + this extension: + + * Negotiates catalog preferences during ``on_request``. + * Wraps A2UI messages in the agent response as A2A DataParts with + ``application/json+a2ui`` MIME type during ``on_response``. + + Example:: + + A2AServerConfig( + server_extensions=[A2UIServerExtension()], + default_output_modes=["text/plain", "application/json+a2ui"], + ) + """ + + uri: str = A2UI_EXTENSION_URI + required: bool = False + description: str = "A2UI declarative UI generation" + + def __init__( + self, + catalog_ids: list[str] | None = None, + accept_inline_catalogs: bool = False, + ) -> None: + """Initialize the A2UI server extension. + + Args: + catalog_ids: Catalog identifiers this server supports. + accept_inline_catalogs: Whether inline catalog definitions are accepted. + """ + self._catalog_ids = catalog_ids or [] + self._accept_inline_catalogs = accept_inline_catalogs + + @property + def params(self) -> dict[str, Any]: + """Extension parameters advertised in the AgentCard.""" + result: dict[str, Any] = {} + if self._catalog_ids: + result["supportedCatalogIds"] = self._catalog_ids + result["acceptsInlineCatalogs"] = self._accept_inline_catalogs + return result + + async def on_request(self, context: ExtensionContext) -> None: + """Extract A2UI catalog preferences from the client request. + + Stores the negotiated catalog in ``context.state`` under + ``"a2ui_catalog_id"`` for downstream use. + """ + if not self.is_active(context): + return + + catalog_id = context.get_extension_metadata(self.uri, "catalogId") + if isinstance(catalog_id, str): + context.state["a2ui_catalog_id"] = catalog_id + elif self._catalog_ids: + context.state["a2ui_catalog_id"] = self._catalog_ids[0] + + context.state["a2ui_active"] = True + + async def on_response(self, context: ExtensionContext, result: Any) -> Any: + """Wrap A2UI messages in the result as A2A DataParts. + + Scans the result for A2UI JSON payloads and converts them into + DataParts with ``application/json+a2ui`` MIME type and A2UI metadata. + """ + if not context.state.get("a2ui_active"): + return result + + if not isinstance(result, str): + return result + + a2ui_messages = extract_a2ui_json_objects(result) + if not a2ui_messages: + return result + + data_parts = [ + part + for part in (_build_data_part(msg_data) for msg_data in a2ui_messages) + if part is not None + ] + + if not data_parts: + return result + + return A2UIResponse(text=result, a2ui_parts=data_parts) + + +def _build_data_part(msg_data: dict[str, Any]) -> dict[str, Any] | None: + """Validate a single A2UI message and wrap it as a DataPart dict.""" + try: + validated = validate_a2ui_message(msg_data) + except A2UIValidationError: + logger.warning("Skipping invalid A2UI message in response", exc_info=True) + return None + return { + "kind": "data", + "data": validated.model_dump(by_alias=True, exclude_none=True), + "metadata": { + "mimeType": A2UI_MIME_TYPE, + }, + } diff --git a/lib/crewai/src/crewai/a2a/extensions/a2ui/validator.py b/lib/crewai/src/crewai/a2a/extensions/a2ui/validator.py new file mode 100644 index 000000000..c8ebdf4df --- /dev/null +++ b/lib/crewai/src/crewai/a2a/extensions/a2ui/validator.py @@ -0,0 +1,59 @@ +"""Validate A2UI message dicts via Pydantic models.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import ValidationError + +from crewai.a2a.extensions.a2ui.models import A2UIEvent, A2UIMessage + + +class A2UIValidationError(Exception): + """Raised when an A2UI message fails validation.""" + + def __init__(self, message: str, errors: list[Any] | None = None) -> None: + super().__init__(message) + self.errors = errors or [] + + +def validate_a2ui_message(data: dict[str, Any]) -> A2UIMessage: + """Parse and validate an A2UI server-to-client message. + + Args: + data: Raw message dict (JSON-decoded). + + Returns: + Validated ``A2UIMessage`` instance. + + Raises: + A2UIValidationError: If the data does not conform to the A2UI schema. + """ + try: + return A2UIMessage.model_validate(data) + except ValidationError as exc: + raise A2UIValidationError( + f"Invalid A2UI message: {exc.error_count()} validation error(s)", + errors=exc.errors(), + ) from exc + + +def validate_a2ui_event(data: dict[str, Any]) -> A2UIEvent: + """Parse and validate an A2UI client-to-server event. + + Args: + data: Raw event dict (JSON-decoded). + + Returns: + Validated ``A2UIEvent`` instance. + + Raises: + A2UIValidationError: If the data does not conform to the A2UI event schema. + """ + try: + return A2UIEvent.model_validate(data) + except ValidationError as exc: + raise A2UIValidationError( + f"Invalid A2UI event: {exc.error_count()} validation error(s)", + errors=exc.errors(), + ) from exc