mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-03-14 15:58:14 +00:00
Compare commits
7 Commits
main
...
gl/feat/a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa01d3130f | ||
|
|
e6fa652364 | ||
|
|
d653a40394 | ||
|
|
b8f47aaa6b | ||
|
|
4ee32fc7d1 | ||
|
|
c8d776f62a | ||
|
|
b2a2902f00 |
@@ -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
344
docs/en/learn/a2ui.mdx
Normal 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
|
||||
68
lib/crewai/src/crewai/a2a/extensions/a2ui/__init__.py
Normal file
68
lib/crewai/src/crewai/a2a/extensions/a2ui/__init__.py
Normal 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",
|
||||
]
|
||||
381
lib/crewai/src/crewai/a2a/extensions/a2ui/catalog.py
Normal file
381
lib/crewai/src/crewai/a2a/extensions/a2ui/catalog.py
Normal 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",
|
||||
}
|
||||
)
|
||||
184
lib/crewai/src/crewai/a2a/extensions/a2ui/client_extension.py
Normal file
184
lib/crewai/src/crewai/a2a/extensions/a2ui/client_extension.py
Normal 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)
|
||||
212
lib/crewai/src/crewai/a2a/extensions/a2ui/models.py
Normal file
212
lib/crewai/src/crewai/a2a/extensions/a2ui/models.py
Normal 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
|
||||
75
lib/crewai/src/crewai/a2a/extensions/a2ui/prompt.py
Normal file
75
lib/crewai/src/crewai/a2a/extensions/a2ui/prompt.py
Normal 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>"""
|
||||
48
lib/crewai/src/crewai/a2a/extensions/a2ui/schema/__init__.py
Normal file
48
lib/crewai/src/crewai/a2a/extensions/a2ui/schema/__init__.py
Normal 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
|
||||
@@ -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"] }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
125
lib/crewai/src/crewai/a2a/extensions/a2ui/server_extension.py
Normal file
125
lib/crewai/src/crewai/a2a/extensions/a2ui/server_extension.py
Normal 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,
|
||||
},
|
||||
}
|
||||
59
lib/crewai/src/crewai/a2a/extensions/a2ui/validator.py
Normal file
59
lib/crewai/src/crewai/a2a/extensions/a2ui/validator.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
324
lib/crewai/tests/a2a/extensions/test_a2ui_schema_conformance.py
Normal file
324
lib/crewai/tests/a2a/extensions/test_a2ui_schema_conformance.py
Normal 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}"
|
||||
)
|
||||
Reference in New Issue
Block a user