Compare commits

...

7 Commits

Author SHA1 Message Date
Greyson LaLonde
aa01d3130f Merge branch 'main' into gl/feat/a2ui-extension 2026-03-14 03:07:44 -04:00
Greyson Lalonde
e6fa652364 fix: replace getattr with direct metadata access on DataPart 2026-03-14 02:55:35 -04:00
Greyson Lalonde
d653a40394 docs: register A2UI guide in docs navigation 2026-03-14 02:48:09 -04:00
Greyson Lalonde
b8f47aaa6b test: add A2UI schema conformance tests 2026-03-14 02:47:09 -04:00
Greyson Lalonde
4ee32fc7d1 feat: add A2UI content type to A2A utils 2026-03-14 02:43:49 -04:00
Greyson Lalonde
c8d776f62a docs: add A2UI extension guide 2026-03-14 02:42:36 -04:00
Greyson Lalonde
b2a2902f00 feat: add A2UI v0.8 extension for declarative UI generation 2026-03-14 02:41:48 -04:00
16 changed files with 3314 additions and 2 deletions

View File

@@ -351,7 +351,9 @@
"en/learn/using-annotations",
"en/learn/execution-hooks",
"en/learn/llm-hooks",
"en/learn/tool-hooks"
"en/learn/tool-hooks",
"en/learn/a2a-agent-delegation",
"en/learn/a2ui"
]
},
{
@@ -810,7 +812,9 @@
"en/learn/using-annotations",
"en/learn/execution-hooks",
"en/learn/llm-hooks",
"en/learn/tool-hooks"
"en/learn/tool-hooks",
"en/learn/a2a-agent-delegation",
"en/learn/a2ui"
]
},
{

344
docs/en/learn/a2ui.mdx Normal file
View File

@@ -0,0 +1,344 @@
---
title: Agent-to-UI (A2UI) Protocol
description: Enable agents to generate declarative UI surfaces for rich client rendering via the A2UI extension.
icon: window-restore
mode: "wide"
---
## A2UI Overview
A2UI is a declarative UI protocol extension for [A2A](/en/learn/a2a-agent-delegation) that lets agents emit structured JSON messages describing interactive surfaces. Clients receive these messages and render them as rich UI components — forms, cards, lists, modals, and more — without the agent needing to know anything about the client's rendering stack.
A2UI is built on the A2A extension mechanism and identified by the URI `https://a2ui.org/a2a-extension/a2ui/v0.8`.
<Note>
A2UI requires the `a2a-sdk` package. Install with: `uv add 'crewai[a2a]'` or `pip install 'crewai[a2a]'`
</Note>
## How It Works
1. The **server extension** scans agent output for A2UI JSON objects
2. Valid messages are wrapped as `DataPart` entries with the `application/json+a2ui` MIME type
3. The **client extension** augments the agent's system prompt with A2UI instructions and the component catalog
4. The client tracks surface state (active surfaces and data models) across conversation turns
## Server Setup
Add `A2UIServerExtension` to your `A2AServerConfig` to enable A2UI output:
```python Code
from crewai import Agent
from crewai.a2a import A2AServerConfig
from crewai.a2a.extensions.a2ui import A2UIServerExtension
agent = Agent(
role="Dashboard Agent",
goal="Present data through interactive UI surfaces",
backstory="Expert at building clear, actionable dashboards",
llm="gpt-4o",
a2a=A2AServerConfig(
url="https://your-server.com",
extensions=[A2UIServerExtension()],
),
)
```
### Server Extension Options
<ParamField path="catalog_ids" type="list[str] | None" default="None">
Component catalog identifiers the server supports. When set, only these catalogs are advertised to clients.
</ParamField>
<ParamField path="accept_inline_catalogs" type="bool" default="False">
Whether to accept inline catalog definitions from clients in addition to named catalogs.
</ParamField>
## Client Setup
Add `A2UIClientExtension` to your `A2AClientConfig` to enable A2UI rendering:
```python Code
from crewai import Agent
from crewai.a2a import A2AClientConfig
from crewai.a2a.extensions.a2ui import A2UIClientExtension
agent = Agent(
role="UI Coordinator",
goal="Coordinate tasks and render agent responses as rich UI",
backstory="Expert at presenting agent output in interactive formats",
llm="gpt-4o",
a2a=A2AClientConfig(
endpoint="https://dashboard-agent.example.com/.well-known/agent-card.json",
extensions=[A2UIClientExtension()],
),
)
```
### Client Extension Options
<ParamField path="catalog_id" type="str | None" default="None">
Preferred component catalog identifier. Defaults to `"standard (v0.8)"` when not set.
</ParamField>
<ParamField path="allowed_components" type="list[str] | None" default="None">
Restrict which components the agent may use. When `None`, all 18 standard catalog components are available.
</ParamField>
## Message Types
A2UI defines four server-to-client message types. Each message targets a **surface** identified by `surfaceId`.
<Tabs>
<Tab title="beginRendering">
Initializes a new surface with a root component and optional styles.
```json
{
"beginRendering": {
"surfaceId": "dashboard-1",
"root": "main-column",
"catalogId": "standard (v0.8)",
"styles": {
"primaryColor": "#EB6658"
}
}
}
```
</Tab>
<Tab title="surfaceUpdate">
Sends or updates one or more components on an existing surface.
```json
{
"surfaceUpdate": {
"surfaceId": "dashboard-1",
"components": [
{
"id": "main-column",
"component": {
"Column": {
"children": { "explicitList": ["title", "content"] },
"alignment": "start"
}
}
},
{
"id": "title",
"component": {
"Text": {
"text": { "literalString": "Dashboard" },
"usageHint": "h1"
}
}
}
]
}
}
```
</Tab>
<Tab title="dataModelUpdate">
Updates the data model bound to a surface, enabling dynamic content.
```json
{
"dataModelUpdate": {
"surfaceId": "dashboard-1",
"path": "/data/model",
"contents": [
{
"key": "userName",
"valueString": "Alice"
},
{
"key": "score",
"valueNumber": 42
}
]
}
}
```
</Tab>
<Tab title="deleteSurface">
Removes a surface and all its components.
```json
{
"deleteSurface": {
"surfaceId": "dashboard-1"
}
}
```
</Tab>
</Tabs>
## Component Catalog
A2UI ships with 18 standard components organized into three categories:
### Content
| Component | Description | Required Fields |
|-----------|-------------|-----------------|
| **Text** | Renders text with optional heading/body hints | `text` (StringBinding) |
| **Image** | Displays an image with fit and size options | `url` (StringBinding) |
| **Icon** | Renders a named icon from a set of 47 icons | `name` (IconBinding) |
| **Video** | Embeds a video player | `url` (StringBinding) |
| **AudioPlayer** | Embeds an audio player with optional description | `url` (StringBinding) |
### Layout
| Component | Description | Required Fields |
|-----------|-------------|-----------------|
| **Row** | Horizontal flex container | `children` (ChildrenDef) |
| **Column** | Vertical flex container | `children` (ChildrenDef) |
| **List** | Scrollable list (vertical or horizontal) | `children` (ChildrenDef) |
| **Card** | Elevated container for a single child | `child` (str) |
| **Tabs** | Tabbed container | `tabItems` (list of TabItem) |
| **Divider** | Visual separator (horizontal or vertical) | — |
| **Modal** | Overlay triggered by an entry point | `entryPointChild`, `contentChild` (str) |
### Interactive
| Component | Description | Required Fields |
|-----------|-------------|-----------------|
| **Button** | Clickable button that triggers an action | `child` (str), `action` (Action) |
| **CheckBox** | Boolean toggle with a label | `label` (StringBinding), `value` (BooleanBinding) |
| **TextField** | Text input with type and validation options | `label` (StringBinding) |
| **DateTimeInput** | Date and/or time picker | `value` (StringBinding) |
| **MultipleChoice** | Selection from a list of options | `selections` (ArrayBinding), `options` (list) |
| **Slider** | Numeric range slider | `value` (NumberBinding) |
## Data Binding
Components reference values through **bindings** rather than raw literals. This allows surfaces to update dynamically when the data model changes.
There are two ways to bind a value:
- **Literal values** — hardcoded directly in the component definition
- **Path references** — point to a key in the surface's data model
```json
{
"surfaceUpdate": {
"surfaceId": "profile-1",
"components": [
{
"id": "greeting",
"component": {
"Text": {
"text": { "path": "/data/model/userName" },
"usageHint": "h2"
}
}
},
{
"id": "status",
"component": {
"Text": {
"text": { "literalString": "Online" },
"usageHint": "caption"
}
}
}
]
}
}
```
In this example, `greeting` reads the user's name from the data model (updated via `dataModelUpdate`), while `status` uses a hardcoded literal.
## Handling User Actions
Interactive components like `Button` trigger `userAction` events that flow back to the server. Each action includes a `name`, the originating `surfaceId` and `sourceComponentId`, and an optional `context` with key-value pairs.
```json
{
"userAction": {
"name": "submitForm",
"surfaceId": "form-1",
"sourceComponentId": "submit-btn",
"timestamp": "2026-03-12T10:00:00Z",
"context": {
"selectedOption": "optionA"
}
}
}
```
Action context values can also use path bindings to send current data model values back to the server:
```json
{
"Button": {
"child": "confirm-label",
"action": {
"name": "confirm",
"context": [
{
"key": "currentScore",
"value": { "path": "/data/model/score" }
}
]
}
}
}
```
## Validation
Use `validate_a2ui_message` to validate server-to-client messages and `validate_a2ui_event` for client-to-server events:
```python Code
from crewai.a2a.extensions.a2ui import validate_a2ui_message
from crewai.a2a.extensions.a2ui.validator import (
validate_a2ui_event,
A2UIValidationError,
)
# Validate a server message
try:
msg = validate_a2ui_message({"beginRendering": {"surfaceId": "s1", "root": "r1"}})
except A2UIValidationError as exc:
print(exc.errors)
# Validate a client event
try:
event = validate_a2ui_event({
"userAction": {
"name": "click",
"surfaceId": "s1",
"sourceComponentId": "btn-1",
"timestamp": "2026-03-12T10:00:00Z",
}
})
except A2UIValidationError as exc:
print(exc.errors)
```
## Best Practices
<CardGroup cols={2}>
<Card title="Start Simple" icon="play">
Begin with a `beginRendering` message and a single `surfaceUpdate`. Add data binding and interactivity once the basic flow works.
</Card>
<Card title="Use Data Binding for Dynamic Content" icon="arrows-rotate">
Prefer path bindings over literal values for content that changes. Use `dataModelUpdate` to push new values without resending the full component tree.
</Card>
<Card title="Filter Components" icon="filter">
Use the `allowed_components` option on `A2UIClientExtension` to restrict which components the agent may emit, reducing prompt size and keeping output predictable.
</Card>
<Card title="Validate Messages" icon="check">
Use `validate_a2ui_message` and `validate_a2ui_event` to catch malformed payloads early, especially when building custom integrations.
</Card>
</CardGroup>
## Learn More
- [A2A Agent Delegation](/en/learn/a2a-agent-delegation) — configure agents for remote delegation via the A2A protocol
- [A2A Protocol Documentation](https://a2a-protocol.org) — official protocol specification

View File

@@ -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",
]

View File

@@ -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",
}
)

View File

@@ -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 = root.metadata 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)

View File

@@ -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

View File

@@ -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"""\
<A2UI_INSTRUCTIONS>
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}
</A2UI_INSTRUCTIONS>"""

View File

@@ -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

View File

@@ -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"] }
]
}

View File

@@ -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"]
}
}
}

View File

@@ -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"]
}
}
}

View File

@@ -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}$"
}
}
}

View File

@@ -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,
},
}

View File

@@ -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

View File

@@ -28,6 +28,7 @@ APPLICATION_PDF: Literal["application/pdf"] = "application/pdf"
APPLICATION_OCTET_STREAM: Literal["application/octet-stream"] = (
"application/octet-stream"
)
APPLICATION_A2UI_JSON: Literal["application/json+a2ui"] = "application/json+a2ui"
DEFAULT_CLIENT_INPUT_MODES: Final[list[Literal["text/plain", "application/json"]]] = [
TEXT_PLAIN,
@@ -311,6 +312,10 @@ def get_part_content_type(part: Part) -> str:
if root.kind == "text":
return TEXT_PLAIN
if root.kind == "data":
metadata = root.metadata or {}
mime = metadata.get("mimeType", "")
if mime == APPLICATION_A2UI_JSON:
return APPLICATION_A2UI_JSON
return APPLICATION_JSON
if root.kind == "file":
return root.file.mime_type or APPLICATION_OCTET_STREAM

View File

@@ -0,0 +1,324 @@
"""Cross-validate A2UI Pydantic models against vendored JSON schemas.
Ensures the two validation sources stay in sync: representative payloads
must be accepted or rejected consistently by both the Pydantic models and
the JSON schemas.
"""
from __future__ import annotations
from typing import Any
import jsonschema
import pytest
from crewai.a2a.extensions.a2ui.models import A2UIEvent, A2UIMessage
from crewai.a2a.extensions.a2ui.schema import load_schema
SERVER_SCHEMA = load_schema("server_to_client")
CLIENT_SCHEMA = load_schema("client_to_server")
CATALOG_SCHEMA = load_schema("standard_catalog_definition")
def _json_schema_valid(schema: dict[str, Any], instance: dict[str, Any]) -> bool:
"""Return True if *instance* validates against *schema*."""
try:
jsonschema.validate(instance, schema)
return True
except jsonschema.ValidationError:
return False
def _pydantic_valid_message(data: dict[str, Any]) -> bool:
"""Return True if *data* validates as an A2UIMessage."""
try:
A2UIMessage.model_validate(data)
return True
except Exception:
return False
def _pydantic_valid_event(data: dict[str, Any]) -> bool:
"""Return True if *data* validates as an A2UIEvent."""
try:
A2UIEvent.model_validate(data)
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# Valid server-to-client payloads
# ---------------------------------------------------------------------------
VALID_SERVER_MESSAGES: list[dict[str, Any]] = [
{
"beginRendering": {
"surfaceId": "s1",
"root": "root-col",
},
},
{
"beginRendering": {
"surfaceId": "s2",
"root": "root-col",
"catalogId": "standard (v0.8)",
"styles": {"primaryColor": "#FF0000", "font": "Roboto"},
},
},
{
"surfaceUpdate": {
"surfaceId": "s1",
"components": [
{
"id": "title",
"component": {
"Text": {"text": {"literalString": "Hello"}},
},
},
],
},
},
{
"surfaceUpdate": {
"surfaceId": "s1",
"components": [
{
"id": "weighted",
"weight": 2.0,
"component": {
"Column": {
"children": {"explicitList": ["a", "b"]},
},
},
},
],
},
},
{
"dataModelUpdate": {
"surfaceId": "s1",
"contents": [
{"key": "name", "valueString": "Alice"},
{"key": "score", "valueNumber": 42},
{"key": "active", "valueBoolean": True},
],
},
},
{
"dataModelUpdate": {
"surfaceId": "s1",
"path": "/user",
"contents": [
{
"key": "prefs",
"valueMap": [
{"key": "theme", "valueString": "dark"},
],
},
],
},
},
{
"deleteSurface": {"surfaceId": "s1"},
},
]
# ---------------------------------------------------------------------------
# Invalid server-to-client payloads
# ---------------------------------------------------------------------------
INVALID_SERVER_MESSAGES: list[dict[str, Any]] = [
{},
{"beginRendering": {"surfaceId": "s1"}},
{"surfaceUpdate": {"surfaceId": "s1", "components": []}},
{
"beginRendering": {"surfaceId": "s1", "root": "r"},
"deleteSurface": {"surfaceId": "s1"},
},
{"unknownType": {"surfaceId": "s1"}},
]
# ---------------------------------------------------------------------------
# Valid client-to-server payloads
# ---------------------------------------------------------------------------
VALID_CLIENT_EVENTS: list[dict[str, Any]] = [
{
"userAction": {
"name": "click",
"surfaceId": "s1",
"sourceComponentId": "btn-1",
"timestamp": "2026-03-12T10:00:00Z",
"context": {},
},
},
{
"userAction": {
"name": "submit",
"surfaceId": "s1",
"sourceComponentId": "btn-2",
"timestamp": "2026-03-12T10:00:00Z",
"context": {"field": "value"},
},
},
{
"error": {"message": "render failed", "code": 500},
},
]
# ---------------------------------------------------------------------------
# Invalid client-to-server payloads
# ---------------------------------------------------------------------------
INVALID_CLIENT_EVENTS: list[dict[str, Any]] = [
{},
{"userAction": {"name": "click"}},
{
"userAction": {
"name": "click",
"surfaceId": "s1",
"sourceComponentId": "btn-1",
"timestamp": "2026-03-12T10:00:00Z",
"context": {},
},
"error": {"message": "oops"},
},
]
# ---------------------------------------------------------------------------
# Catalog component payloads (validated structurally)
# ---------------------------------------------------------------------------
VALID_COMPONENTS: dict[str, dict[str, Any]] = {
"Text": {"text": {"literalString": "hello"}, "usageHint": "h1"},
"Image": {"url": {"path": "/img/url"}, "fit": "cover", "usageHint": "avatar"},
"Icon": {"name": {"literalString": "home"}},
"Video": {"url": {"literalString": "https://example.com/video.mp4"}},
"AudioPlayer": {"url": {"literalString": "https://example.com/audio.mp3"}},
"Row": {"children": {"explicitList": ["a", "b"]}, "distribution": "center"},
"Column": {"children": {"template": {"componentId": "c1", "dataBinding": "/list"}}},
"List": {"children": {"explicitList": ["x"]}, "direction": "horizontal"},
"Card": {"child": "inner"},
"Tabs": {"tabItems": [{"title": {"literalString": "Tab 1"}, "child": "content"}]},
"Divider": {"axis": "horizontal"},
"Modal": {"entryPointChild": "trigger", "contentChild": "body"},
"Button": {"child": "label", "action": {"name": "go"}},
"CheckBox": {"label": {"literalString": "Accept"}, "value": {"literalBoolean": False}},
"TextField": {"label": {"literalString": "Name"}},
"DateTimeInput": {"value": {"path": "/date"}},
"MultipleChoice": {
"selections": {"literalArray": ["a"]},
"options": [{"label": {"literalString": "A"}, "value": "a"}],
},
"Slider": {"value": {"literalNumber": 50}, "minValue": 0, "maxValue": 100},
}
class TestServerToClientConformance:
"""Pydantic models and JSON schema must agree on server-to-client messages."""
@pytest.mark.parametrize("payload", VALID_SERVER_MESSAGES)
def test_valid_accepted_by_both(self, payload: dict[str, Any]) -> None:
assert _json_schema_valid(SERVER_SCHEMA, payload), (
f"JSON schema rejected valid payload: {payload}"
)
assert _pydantic_valid_message(payload), (
f"Pydantic rejected valid payload: {payload}"
)
@pytest.mark.parametrize("payload", INVALID_SERVER_MESSAGES)
def test_invalid_rejected_by_pydantic(self, payload: dict[str, Any]) -> None:
assert not _pydantic_valid_message(payload), (
f"Pydantic accepted invalid payload: {payload}"
)
class TestClientToServerConformance:
"""Pydantic models and JSON schema must agree on client-to-server events."""
@pytest.mark.parametrize("payload", VALID_CLIENT_EVENTS)
def test_valid_accepted_by_both(self, payload: dict[str, Any]) -> None:
assert _json_schema_valid(CLIENT_SCHEMA, payload), (
f"JSON schema rejected valid payload: {payload}"
)
assert _pydantic_valid_event(payload), (
f"Pydantic rejected valid payload: {payload}"
)
@pytest.mark.parametrize("payload", INVALID_CLIENT_EVENTS)
def test_invalid_rejected_by_pydantic(self, payload: dict[str, Any]) -> None:
assert not _pydantic_valid_event(payload), (
f"Pydantic accepted invalid payload: {payload}"
)
class TestCatalogConformance:
"""Catalog component schemas and Pydantic models must define the same components."""
def test_catalog_component_names_match(self) -> None:
from crewai.a2a.extensions.a2ui.catalog import STANDARD_CATALOG_COMPONENTS
schema_components = set(CATALOG_SCHEMA["components"].keys())
assert schema_components == STANDARD_CATALOG_COMPONENTS
@pytest.mark.parametrize(
"name,props",
list(VALID_COMPONENTS.items()),
)
def test_valid_component_accepted_by_catalog_schema(
self, name: str, props: dict[str, Any]
) -> None:
component_schema = CATALOG_SCHEMA["components"][name]
assert _json_schema_valid(component_schema, props), (
f"Catalog schema rejected valid {name}: {props}"
)
@pytest.mark.parametrize(
"name,props",
list(VALID_COMPONENTS.items()),
)
def test_valid_component_accepted_by_pydantic(
self, name: str, props: dict[str, Any]
) -> None:
import crewai.a2a.extensions.a2ui.catalog as catalog_mod
model_cls = getattr(catalog_mod, name)
try:
model_cls.model_validate(props)
except Exception as exc:
pytest.fail(f"Pydantic {name} rejected valid props: {exc}")
def test_catalog_required_fields_match(self) -> None:
"""Required fields in the JSON schema match non-optional Pydantic fields."""
import crewai.a2a.extensions.a2ui.catalog as catalog_mod
for comp_name, comp_schema in CATALOG_SCHEMA["components"].items():
schema_required = set(comp_schema.get("required", []))
model_cls = getattr(catalog_mod, comp_name)
pydantic_required = {
info.alias or field_name
for field_name, info in model_cls.model_fields.items()
if info.is_required()
}
assert schema_required == pydantic_required, (
f"{comp_name}: schema requires {schema_required}, "
f"Pydantic requires {pydantic_required}"
)
def test_catalog_fields_match(self) -> None:
"""Field names in JSON schema match Pydantic model aliases."""
import crewai.a2a.extensions.a2ui.catalog as catalog_mod
for comp_name, comp_schema in CATALOG_SCHEMA["components"].items():
schema_fields = set(comp_schema.get("properties", {}).keys())
model_cls = getattr(catalog_mod, comp_name)
pydantic_fields = {
info.alias or field_name
for field_name, info in model_cls.model_fields.items()
}
assert schema_fields == pydantic_fields, (
f"{comp_name}: schema has {schema_fields}, "
f"Pydantic has {pydantic_fields}"
)