Skip to main content

ag_ui_a2ui/
message.rs

1//! A2UI protocol envelopes.
2//!
3//! A2UI is a stream of JSON objects in two directions. Each object carries a
4//! `version` discriminator plus exactly one payload key:
5//!
6//! | Direction | Payload keys |
7//! |---|---|
8//! | agent → renderer | `createSurface`, `updateComponents`, `updateDataModel`, `deleteSurface`, `callRendererFunction`, `agentFunctionResponse` |
9//! | renderer → agent | `action`, `callAgentFunction`, `rendererFunctionResponse`, `error` |
10//!
11//! # The adjacency-list component model
12//!
13//! Components are sent as a **flat list**. Parent/child links are ID references,
14//! never nesting — a `Card` names its child by id, a `Column` holds an array of
15//! ids. The renderer stores every component in a map and rebuilds the tree at
16//! render time, which is what lets the agent stream definitions in any order and
17//! lets the renderer start painting as soon as `root` arrives.
18//!
19//! ```
20//! use ag_ui_a2ui::message::{AgentMessage, Component};
21//! use serde_json::json;
22//!
23//! let msg = AgentMessage::update_components(
24//!     "profile",
25//!     vec![
26//!         Component::new("root", "Column").with("children", json!(["name"])),
27//!         Component::new("name", "Text").with("text", json!("Ada")),
28//!     ],
29//! );
30//! let wire = serde_json::to_value(&msg).unwrap();
31//! assert_eq!(wire["version"], "v0.9");
32//! assert_eq!(wire["updateComponents"]["components"][0]["component"], "Column");
33//! ```
34
35use serde::{Deserialize, Serialize};
36use serde_json::{Map, Value};
37
38use crate::constants::PROTOCOL_VERSION;
39use crate::error::{Error, Result};
40
41fn default_version() -> String {
42    PROTOCOL_VERSION.to_string()
43}
44
45/// One agent → renderer message.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct AgentMessage {
48    /// Protocol version stamped on the wire; defaults to
49    /// [`PROTOCOL_VERSION`].
50    #[serde(default = "default_version")]
51    pub version: String,
52    /// The single payload key that gives this message its type.
53    #[serde(flatten)]
54    pub payload: AgentPayload,
55}
56
57impl AgentMessage {
58    /// Wraps a payload with the current protocol version.
59    pub fn new(payload: AgentPayload) -> Self {
60        Self {
61            version: default_version(),
62            payload,
63        }
64    }
65
66    /// `createSurface`: allocate a surface and fix its `catalogId`.
67    ///
68    /// Re-creating a `surfaceId` that already exists is an error per spec; see
69    #[cfg_attr(
70        feature = "toolkit",
71        doc = "[`crate::toolkit::ops::assemble_ops`], which omits this message when the"
72    )]
73    #[cfg_attr(
74        not(feature = "toolkit"),
75        doc = "`toolkit::ops::assemble_ops` (behind the `toolkit` feature), which omits this message when the"
76    )]
77    /// intent is to update an existing surface.
78    pub fn create_surface(surface_id: impl Into<String>, catalog_id: impl Into<String>) -> Self {
79        Self::new(AgentPayload::CreateSurface(CreateSurface {
80            surface_id: surface_id.into(),
81            catalog_id: catalog_id.into(),
82            theme: None,
83            send_data_model: None,
84        }))
85    }
86
87    /// `updateComponents`: add or replace components on an existing surface.
88    pub fn update_components(surface_id: impl Into<String>, components: Vec<Component>) -> Self {
89        Self::new(AgentPayload::UpdateComponents(UpdateComponents {
90            surface_id: surface_id.into(),
91            components,
92        }))
93    }
94
95    /// `updateDataModel`: upsert `value` at `path` (JSON Pointer, `/` = whole model).
96    pub fn update_data_model(
97        surface_id: impl Into<String>,
98        path: impl Into<String>,
99        value: Value,
100    ) -> Self {
101        Self::new(AgentPayload::UpdateDataModel(UpdateDataModel {
102            surface_id: surface_id.into(),
103            path: path.into(),
104            value,
105        }))
106    }
107
108    /// `deleteSurface`: drop a surface and everything under it.
109    pub fn delete_surface(surface_id: impl Into<String>) -> Self {
110        Self::new(AgentPayload::DeleteSurface(DeleteSurface {
111            surface_id: surface_id.into(),
112        }))
113    }
114
115    /// The `surfaceId` this message targets, if it targets one.
116    ///
117    /// Function-call messages are addressed by `functionCallId` rather than by
118    /// surface, so they return `None`.
119    pub fn surface_id(&self) -> Option<&str> {
120        match &self.payload {
121            AgentPayload::CreateSurface(m) => Some(&m.surface_id),
122            AgentPayload::UpdateComponents(m) => Some(&m.surface_id),
123            AgentPayload::UpdateDataModel(m) => Some(&m.surface_id),
124            AgentPayload::DeleteSurface(m) => Some(&m.surface_id),
125            AgentPayload::CallRendererFunction(_) | AgentPayload::AgentFunctionResponse(_) => None,
126        }
127    }
128}
129
130/// The payload of an [`AgentMessage`], externally tagged by its wire key.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub enum AgentPayload {
134    /// Create a surface and fix its catalog.
135    CreateSurface(CreateSurface),
136    /// Add or replace components on a surface.
137    UpdateComponents(UpdateComponents),
138    /// Upsert part of a surface's data model.
139    UpdateDataModel(UpdateDataModel),
140    /// Remove a surface entirely.
141    DeleteSurface(DeleteSurface),
142    /// Ask the renderer to run one of its local functions.
143    CallRendererFunction(CallRendererFunction),
144    /// Answer a renderer-initiated [`RendererPayload::CallAgentFunction`].
145    AgentFunctionResponse(FunctionResponse),
146}
147
148/// Payload of a `createSurface` message.
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct CreateSurface {
152    /// Globally unique surface identifier, for the renderer's lifetime.
153    pub surface_id: String,
154    /// Opaque identifier of the component catalog this surface speaks.
155    ///
156    /// Fixed for the life of the surface: changing it means deleting and
157    /// recreating the surface.
158    pub catalog_id: String,
159    /// Catalog-defined theme parameters (`primaryColor`, `iconUrl`, ...).
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub theme: Option<Value>,
162    /// Ask the renderer to echo this surface's whole data model back with every
163    /// message it sends to the creating agent.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub send_data_model: Option<bool>,
166}
167
168/// Payload of an `updateComponents` message.
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct UpdateComponents {
172    /// Surface to update. It must already have been created.
173    pub surface_id: String,
174    /// Flat adjacency list of component definitions.
175    pub components: Vec<Component>,
176}
177
178/// Payload of an `updateDataModel` message.
179///
180/// Upsert semantics: an existing path is replaced, a missing path is created,
181/// and a `null` value deletes the key.
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct UpdateDataModel {
185    /// Surface whose data model is being updated.
186    pub surface_id: String,
187    /// JSON Pointer into the data model. Defaults to `/`, the whole model.
188    #[serde(default = "root_pointer")]
189    pub path: String,
190    /// The new value. `null` deletes the key at `path`.
191    #[serde(default)]
192    pub value: Value,
193}
194
195fn root_pointer() -> String {
196    "/".to_string()
197}
198
199impl UpdateDataModel {
200    /// Applies this update to a surface data model in place.
201    ///
202    /// - `path` of `/` or `""` replaces the whole model (or clears it to `null`).
203    /// - Missing intermediate objects are created.
204    /// - A `null` value removes the key (or, in an array, sets the slot to
205    ///   `null` so the array keeps its length, per spec).
206    ///
207    /// # Errors
208    ///
209    /// Returns [`Error::Pointer`] when the pointer is malformed, when it walks
210    /// through a scalar, or when an array index is not a number within bounds.
211    pub fn apply(&self, model: &mut Value) -> Result<()> {
212        apply_data_model_update(model, &self.path, &self.value)
213    }
214}
215
216/// Applies one `updateDataModel` operation to `model`.
217///
218/// Split out from [`UpdateDataModel::apply`] so callers holding a loose
219/// path/value pair (a replayed history entry, say) can reuse the semantics.
220///
221/// # Errors
222///
223/// See [`UpdateDataModel::apply`].
224pub fn apply_data_model_update(model: &mut Value, path: &str, value: &Value) -> Result<()> {
225    let trimmed = path.trim();
226    if trimmed.is_empty() || trimmed == "/" {
227        *model = value.clone();
228        return Ok(());
229    }
230    if !trimmed.starts_with('/') {
231        return Err(Error::pointer(
232            trimmed,
233            "updateDataModel path must be an absolute JSON Pointer starting with '/'",
234        ));
235    }
236
237    let tokens = crate::binding::pointer_tokens(trimmed);
238    let Some((last, parents)) = tokens.split_last() else {
239        *model = value.clone();
240        return Ok(());
241    };
242
243    let mut cursor = model;
244    for token in parents {
245        cursor = descend_or_create(cursor, token, trimmed)?;
246    }
247
248    match cursor {
249        Value::Object(map) => {
250            if value.is_null() {
251                map.remove(last.as_str());
252            } else {
253                map.insert(last.clone(), value.clone());
254            }
255        }
256        Value::Array(items) => {
257            let idx = parse_index(last, trimmed)?;
258            if idx < items.len() {
259                // A null clears the slot without shortening the array.
260                items[idx] = value.clone();
261            } else if idx == items.len() {
262                items.push(value.clone());
263            } else {
264                return Err(Error::pointer(
265                    trimmed,
266                    format!("array index {idx} is out of bounds (len {})", items.len()),
267                ));
268            }
269        }
270        Value::Null => {
271            let mut map = Map::new();
272            if !value.is_null() {
273                map.insert(last.clone(), value.clone());
274            }
275            *cursor = Value::Object(map);
276        }
277        _ => {
278            return Err(Error::pointer(trimmed, "path walks through a scalar value"));
279        }
280    }
281    Ok(())
282}
283
284fn descend_or_create<'a>(
285    cursor: &'a mut Value,
286    token: &str,
287    full_path: &str,
288) -> Result<&'a mut Value> {
289    if cursor.is_null() {
290        *cursor = Value::Object(Map::new());
291    }
292    match cursor {
293        Value::Object(map) => Ok(map.entry(token.to_string()).or_insert(Value::Null)),
294        Value::Array(items) => {
295            let idx = parse_index(token, full_path)?;
296            let len = items.len();
297            if idx == len {
298                items.push(Value::Null);
299            }
300            items.get_mut(idx).ok_or_else(|| {
301                Error::pointer(
302                    full_path,
303                    format!("array index {idx} is out of bounds (len {len})"),
304                )
305            })
306        }
307        _ => Err(Error::pointer(
308            full_path,
309            "path walks through a scalar value",
310        )),
311    }
312}
313
314fn parse_index(token: &str, full_path: &str) -> Result<usize> {
315    token.parse::<usize>().map_err(|_| {
316        Error::pointer(
317            full_path,
318            format!("expected an array index, found segment {token:?}"),
319        )
320    })
321}
322
323/// Payload of a `deleteSurface` message.
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
325#[serde(rename_all = "camelCase")]
326pub struct DeleteSurface {
327    /// Surface to remove.
328    pub surface_id: String,
329}
330
331/// Payload of a `callRendererFunction` message.
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(rename_all = "camelCase")]
334pub struct CallRendererFunction {
335    /// Correlation id; the renderer copies it into its response.
336    pub function_call_id: String,
337    /// The function to invoke, with its arguments.
338    pub call_function: FunctionCall,
339}
340
341/// Payload of a `callAgentFunction` message.
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343#[serde(rename_all = "camelCase")]
344pub struct CallAgentFunction {
345    /// Surface the call originated from.
346    pub surface_id: String,
347    /// Correlation id; the agent copies it into its response.
348    pub function_call_id: String,
349    /// The function to invoke, with its arguments.
350    pub call_function: FunctionCall,
351}
352
353/// A named function invocation, used both in component properties and in the
354/// two `call*Function` messages.
355#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357pub struct FunctionCall {
358    /// Function name, e.g. `formatString`, `required`, `@index`.
359    pub call: String,
360    /// Named arguments. Values may themselves be bindings or nested calls.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub args: Option<Map<String, Value>>,
363    /// Catalog the function is drawn from, when it is not the surface default.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub catalog_id: Option<String>,
366    /// Expected return type, used to disambiguate overloads on the wire.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub return_type: Option<String>,
369}
370
371/// The result of a `call*Function`, sent back by whichever side ran it.
372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
373#[serde(rename_all = "camelCase")]
374pub struct FunctionResponse {
375    /// Correlation id copied from the originating call.
376    pub function_call_id: String,
377    /// Whatever the function returned.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub result: Option<Value>,
380    /// Set instead of `result` when the call failed.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub error: Option<String>,
383}
384
385/// One renderer → agent message.
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387pub struct RendererMessage {
388    /// Protocol version stamped on the wire.
389    #[serde(default = "default_version")]
390    pub version: String,
391    /// The single payload key that gives this message its type.
392    #[serde(flatten)]
393    pub payload: RendererPayload,
394}
395
396impl RendererMessage {
397    /// Wraps a payload with the current protocol version.
398    pub fn new(payload: RendererPayload) -> Self {
399        Self {
400            version: default_version(),
401            payload,
402        }
403    }
404
405    /// The `surfaceId` this message relates to, if any.
406    pub fn surface_id(&self) -> Option<&str> {
407        match &self.payload {
408            RendererPayload::Action(a) => Some(&a.surface_id),
409            RendererPayload::CallAgentFunction(c) => Some(&c.surface_id),
410            RendererPayload::RendererFunctionResponse(_) => None,
411            RendererPayload::Error(e) => e.surface_id.as_deref(),
412        }
413    }
414}
415
416/// The payload of a [`RendererMessage`], externally tagged by its wire key.
417#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
418#[serde(rename_all = "camelCase")]
419pub enum RendererPayload {
420    /// A user interacted with a component that declares an `action`.
421    Action(Action),
422    /// The renderer wants the agent to run a function on its behalf.
423    CallAgentFunction(CallAgentFunction),
424    /// Result of an agent-initiated [`AgentPayload::CallRendererFunction`].
425    RendererFunctionResponse(FunctionResponse),
426    /// The renderer is reporting a problem, typically a failed validation.
427    Error(RendererError),
428}
429
430/// Payload of an `action` message.
431#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432#[serde(rename_all = "camelCase")]
433pub struct Action {
434    /// Action name, taken from the component's `action.event.name`.
435    pub name: String,
436    /// Surface the interaction happened on.
437    pub surface_id: String,
438    /// Component that triggered it.
439    pub source_component_id: String,
440    /// ISO 8601 timestamp of the interaction.
441    pub timestamp: String,
442    /// The component's `action.event.context` with all bindings resolved.
443    #[serde(default)]
444    pub context: Map<String, Value>,
445    /// Human-readable description of what the user did, if the component
446    /// supplied one.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub user_message: Option<String>,
449}
450
451/// Payload of an `error` message from the renderer.
452#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
453#[serde(rename_all = "camelCase")]
454pub struct RendererError {
455    /// Machine-readable code, e.g. `VALIDATION_FAILED`, `UNALLOWED_PARENT`.
456    pub code: String,
457    /// One or two sentences the agent (or its model) can act on.
458    pub message: String,
459    /// Surface the error relates to, for surface-scoped errors.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub surface_id: Option<String>,
462    /// JSON Pointer to the offending field, for validation errors.
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub path: Option<String>,
465    /// Correlation id, for errors raised while running a function call.
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub function_call_id: Option<String>,
468}
469
470/// One node of the flat component adjacency list.
471///
472/// `id` and `component` are the only fixed fields; everything else is
473/// catalog-defined and lives in [`Component::props`] as raw JSON. The struct
474/// flattens back to the wire shape `{"id": ..., "component": "Text", "text": ...}`.
475#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
476pub struct Component {
477    /// Unique id within the surface; other components reference it by this.
478    pub id: String,
479    /// Component type name, resolved against the surface's catalog.
480    pub component: String,
481    /// Catalog-defined properties, verbatim.
482    #[serde(flatten)]
483    pub props: Map<String, Value>,
484}
485
486impl Component {
487    /// Creates a component with no properties yet.
488    pub fn new(id: impl Into<String>, component: impl Into<String>) -> Self {
489        Self {
490            id: id.into(),
491            component: component.into(),
492            props: Map::new(),
493        }
494    }
495
496    /// Sets a property, builder style.
497    #[must_use]
498    pub fn with(mut self, key: impl Into<String>, value: Value) -> Self {
499        self.props.insert(key.into(), value);
500        self
501    }
502
503    /// Borrows a property by name.
504    pub fn prop(&self, key: &str) -> Option<&Value> {
505        self.props.get(key)
506    }
507}
508
509/// How a component declares its children.
510///
511/// Either a static list of ids, or a template: one component id instantiated
512/// once per item of the array at `path`. The template form is what creates a
513/// collection scope for relative data bindings (see [`crate::binding`]).
514#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
515#[serde(untagged)]
516pub enum ChildList {
517    /// A fixed set of child component ids.
518    Ids(Vec<String>),
519    /// A template instantiated once per element of a bound array.
520    Template(ChildTemplate),
521}
522
523/// The template form of a [`ChildList`].
524#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
525#[serde(rename_all = "camelCase")]
526pub struct ChildTemplate {
527    /// Id of the component to instantiate per item.
528    pub component_id: String,
529    /// JSON Pointer to the array to iterate.
530    pub path: String,
531}
532
533impl ChildList {
534    /// Parses a raw `children` property value, if it is a well-formed child list.
535    pub fn from_value(value: &Value) -> Option<Self> {
536        serde_json::from_value(value.clone()).ok()
537    }
538
539    /// Every component id this child list references.
540    pub fn referenced_ids(&self) -> Vec<&str> {
541        match self {
542            ChildList::Ids(ids) => ids.iter().map(String::as_str).collect(),
543            ChildList::Template(t) => vec![t.component_id.as_str()],
544        }
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use serde_json::json;
552
553    #[test]
554    fn agent_message_round_trips_through_the_wire_shape() {
555        let msg = AgentMessage::create_surface("s1", "cat");
556        let wire = serde_json::to_value(&msg).unwrap();
557        assert_eq!(
558            wire,
559            json!({"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "cat"}})
560        );
561        let back: AgentMessage = serde_json::from_value(wire).unwrap();
562        assert_eq!(back, msg);
563    }
564
565    #[test]
566    fn components_keep_catalog_props_flat() {
567        let wire = json!({"id": "t", "component": "Text", "text": "hi", "variant": "h1"});
568        let c: Component = serde_json::from_value(wire.clone()).unwrap();
569        assert_eq!(c.prop("variant"), Some(&json!("h1")));
570        assert_eq!(serde_json::to_value(&c).unwrap(), wire);
571    }
572
573    #[test]
574    fn update_data_model_defaults_path_to_root() {
575        let msg: AgentMessage = serde_json::from_value(json!({
576            "version": "v0.9",
577            "updateDataModel": {"surfaceId": "s", "value": {"a": 1}}
578        }))
579        .unwrap();
580        let AgentPayload::UpdateDataModel(m) = msg.payload else {
581            panic!("expected updateDataModel");
582        };
583        assert_eq!(m.path, "/");
584    }
585
586    #[test]
587    fn upsert_creates_missing_intermediates_and_null_deletes() {
588        let mut model = json!({});
589        apply_data_model_update(&mut model, "/user/name", &json!("Ada")).unwrap();
590        assert_eq!(model, json!({"user": {"name": "Ada"}}));
591
592        apply_data_model_update(&mut model, "/user/name", &Value::Null).unwrap();
593        assert_eq!(model, json!({"user": {}}));
594
595        apply_data_model_update(&mut model, "/", &json!({"replaced": true})).unwrap();
596        assert_eq!(model, json!({"replaced": true}));
597    }
598
599    #[test]
600    fn upsert_into_arrays_preserves_length_on_delete() {
601        let mut model = json!({"items": [1, 2, 3]});
602        apply_data_model_update(&mut model, "/items/1", &Value::Null).unwrap();
603        assert_eq!(model, json!({"items": [1, null, 3]}));
604
605        apply_data_model_update(&mut model, "/items/3", &json!(4)).unwrap();
606        assert_eq!(model, json!({"items": [1, null, 3, 4]}));
607
608        let err = apply_data_model_update(&mut model, "/items/9", &json!(0)).unwrap_err();
609        assert!(matches!(err, Error::Pointer { .. }));
610    }
611
612    #[test]
613    fn escaped_pointer_tokens_are_decoded() {
614        let mut model = json!({});
615        apply_data_model_update(&mut model, "/a~1b", &json!(1)).unwrap();
616        assert_eq!(model, json!({"a/b": 1}));
617    }
618
619    #[test]
620    fn child_list_parses_both_forms() {
621        assert_eq!(
622            ChildList::from_value(&json!(["a", "b"]))
623                .unwrap()
624                .referenced_ids(),
625            vec!["a", "b"]
626        );
627        assert_eq!(
628            ChildList::from_value(&json!({"componentId": "tpl", "path": "/items"}))
629                .unwrap()
630                .referenced_ids(),
631            vec!["tpl"]
632        );
633        assert!(ChildList::from_value(&json!("nope")).is_none());
634    }
635
636    #[test]
637    fn renderer_messages_round_trip() {
638        let wire = json!({
639            "version": "v0.9",
640            "action": {
641                "name": "submit",
642                "surfaceId": "s1",
643                "sourceComponentId": "btn",
644                "timestamp": "2026-01-01T00:00:00Z",
645                "context": {"email": "a@b.c"}
646            }
647        });
648        let msg: RendererMessage = serde_json::from_value(wire.clone()).unwrap();
649        assert_eq!(msg.surface_id(), Some("s1"));
650        assert_eq!(serde_json::to_value(&msg).unwrap(), wire);
651    }
652}