Skip to main content

ag_ui_a2ui/toolkit/
negotiate.rs

1//! Agreeing with the renderer on which catalog to speak.
2//!
3//! An agent knows several catalogs; a renderer supports some subset, and may
4//! ship its own inline definitions for components only it has. Before the agent
5//! can prompt a model it has to settle which catalog the surface will use, since
6//! `createSurface` fixes that choice for the surface's lifetime.
7//!
8//! [`select_catalog`] is that negotiation. The renderer's preference order wins
9//! — it is the one that has to draw the result — and inline catalogs are merged
10//! into the selection so a renderer can extend a standard catalog rather than
11//! replace it.
12//!
13//! ```
14//! use ag_ui_a2ui::toolkit::negotiate::{select_catalog_schema, ClientCapabilities};
15//! use serde_json::json;
16//!
17//! let mine = vec![json!({"catalogId": "basic"}), json!({"catalogId": "fancy"})];
18//! let renderer = ClientCapabilities {
19//!     supported_catalog_ids: vec!["fancy".into(), "basic".into()],
20//!     inline_catalogs: vec![],
21//! };
22//!
23//! let chosen = select_catalog_schema(&mine, &renderer, false).unwrap();
24//! assert_eq!(chosen["catalogId"], "fancy");
25//! ```
26
27use std::path::Path;
28
29use serde::{Deserialize, Serialize};
30use serde_json::{Map, Value};
31
32use crate::catalog::Catalog;
33use crate::error::{Error, Result};
34use crate::toolkit::schema::SchemaBundle;
35
36/// What a renderer says it can draw.
37///
38/// Carried in transport metadata as `a2uiClientCapabilities`.
39#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct ClientCapabilities {
42    /// Catalog ids the renderer supports, most preferred first.
43    #[serde(default)]
44    pub supported_catalog_ids: Vec<String>,
45    /// Catalog documents the renderer supplies itself.
46    #[serde(default)]
47    pub inline_catalogs: Vec<Value>,
48}
49
50impl ClientCapabilities {
51    /// Capabilities naming a preference order and nothing else.
52    pub fn supporting<I, S>(ids: I) -> Self
53    where
54        I: IntoIterator<Item = S>,
55        S: Into<String>,
56    {
57        Self {
58            supported_catalog_ids: ids.into_iter().map(Into::into).collect(),
59            inline_catalogs: Vec::new(),
60        }
61    }
62}
63
64/// Picks the catalog document both sides can speak.
65///
66/// The renderer's order is the preference order. When it supplies inline
67/// catalogs and `accepts_inline` is set, their components are merged into the
68/// selection — keeping the selected catalog's `catalogId`, because that id is
69/// what the two sides negotiated on and what `createSurface` will carry.
70///
71/// # Errors
72///
73/// Returns [`Error::Catalog`] when the renderer supports none of the agent's
74/// catalogs, when it offers inline catalogs the agent does not accept, or when
75/// the agent has no catalogs at all.
76pub fn select_catalog_schema(
77    supported: &[Value],
78    capabilities: &ClientCapabilities,
79    accepts_inline: bool,
80) -> Result<Value> {
81    if !capabilities.inline_catalogs.is_empty() && !accepts_inline {
82        return Err(Error::catalog(
83            "the renderer supplied inline catalogs but the agent does not accept inline catalogs",
84        ));
85    }
86
87    let matched = match_by_preference(supported, &capabilities.supported_catalog_ids);
88    let base = match matched {
89        Some(catalog) => catalog,
90        // Falling back to the agent's default is only reasonable when inline
91        // definitions are coming; otherwise the renderer cannot draw the result.
92        None if !capabilities.inline_catalogs.is_empty() => supported
93            .first()
94            .ok_or_else(|| Error::catalog("the agent has no catalogs to offer"))?,
95        None if capabilities.supported_catalog_ids.is_empty() => supported
96            .first()
97            .ok_or_else(|| Error::catalog("the agent has no catalogs to offer"))?,
98        None => {
99            return Err(Error::catalog(format!(
100                "No client-supported catalog found: the renderer supports [{}], the agent offers \
101                 [{}]",
102                capabilities.supported_catalog_ids.join(", "),
103                supported
104                    .iter()
105                    .filter_map(|catalog| catalog.get("catalogId").and_then(Value::as_str))
106                    .collect::<Vec<_>>()
107                    .join(", ")
108            )));
109        }
110    };
111
112    let mut selected = base.clone();
113    for inline in &capabilities.inline_catalogs {
114        merge_components(&mut selected, inline);
115    }
116    Ok(selected)
117}
118
119/// [`select_catalog_schema`], parsed into a typed [`Catalog`].
120///
121/// # Errors
122///
123/// See [`select_catalog_schema`]; also returns [`Error::Catalog`] if the chosen
124/// document is not a usable catalog.
125pub fn select_catalog(
126    supported: &[Value],
127    capabilities: &ClientCapabilities,
128    accepts_inline: bool,
129) -> Result<Catalog> {
130    Catalog::from_schema(&select_catalog_schema(
131        supported,
132        capabilities,
133        accepts_inline,
134    )?)
135}
136
137fn match_by_preference<'a>(supported: &'a [Value], preferred: &[String]) -> Option<&'a Value> {
138    preferred.iter().find_map(|id| {
139        supported
140            .iter()
141            .find(|catalog| catalog.get("catalogId").and_then(Value::as_str) == Some(id.as_str()))
142    })
143}
144
145/// Folds one catalog's components and functions into another.
146fn merge_components(target: &mut Value, source: &Value) {
147    let Some(target) = target.as_object_mut() else {
148        return;
149    };
150    for section in ["components", "functions"] {
151        let Some(Value::Object(extra)) = source.get(section) else {
152            continue;
153        };
154        let entry = target
155            .entry(section.to_string())
156            .or_insert_with(|| Value::Object(Map::new()));
157        if let Some(existing) = entry.as_object_mut() {
158            for (name, definition) in extra {
159                existing.insert(name.clone(), definition.clone());
160            }
161        }
162    }
163}
164
165/// The catalogs an agent knows about.
166///
167/// Names are how the application refers to a catalog; ids are what goes on the
168/// wire. They are usually different — a registry entry called `"standard"` may
169/// carry `catalogId: "https://a2ui.org/..."` — so the registry keeps both and
170/// [`CatalogRegistry::supported_catalog_ids`] reports the wire ids.
171#[derive(Debug, Clone, Default)]
172pub struct CatalogRegistry {
173    entries: Vec<RegistryEntry>,
174}
175
176/// One catalog in a [`CatalogRegistry`].
177#[derive(Debug, Clone, PartialEq)]
178pub struct RegistryEntry {
179    /// The application's name for this catalog.
180    pub name: String,
181    /// The catalog document.
182    pub schema: Value,
183}
184
185impl RegistryEntry {
186    /// The wire id: the document's `catalogId`, or the local name if it has none.
187    pub fn catalog_id(&self) -> &str {
188        self.schema
189            .get("catalogId")
190            .and_then(Value::as_str)
191            .unwrap_or(&self.name)
192    }
193}
194
195impl CatalogRegistry {
196    /// An empty registry.
197    pub fn new() -> Self {
198        Self::default()
199    }
200
201    /// Registers a catalog document under a local name.
202    pub fn insert(&mut self, name: impl Into<String>, schema: Value) {
203        self.entries.push(RegistryEntry {
204            name: name.into(),
205            schema,
206        });
207    }
208
209    /// Registers a catalog document read from disk.
210    ///
211    /// # Errors
212    ///
213    /// Returns [`Error::Catalog`] if the file cannot be read or is not JSON.
214    pub fn load(&mut self, name: impl Into<String>, path: impl AsRef<Path>) -> Result<()> {
215        let path = path.as_ref();
216        let text = std::fs::read_to_string(path)
217            .map_err(|e| Error::catalog(format!("cannot read catalog {}: {e}", path.display())))?;
218        let schema = serde_json::from_str(&text)
219            .map_err(|e| Error::catalog(format!("catalog {} is not JSON: {e}", path.display())))?;
220        self.insert(name, schema);
221        Ok(())
222    }
223
224    /// Applies `additionalProperties` relaxation to every registered catalog.
225    ///
226    /// Structured-output APIs reject schemas that forbid extra properties, so
227    /// this is usually applied at load time when the catalogs feed one.
228    pub fn relax_strict_validation(&mut self) {
229        for entry in &mut self.entries {
230            crate::toolkit::schema::remove_strict_validation(&mut entry.schema);
231        }
232    }
233
234    /// The registered catalogs, in registration order.
235    pub fn entries(&self) -> &[RegistryEntry] {
236        &self.entries
237    }
238
239    /// The catalog documents, for [`select_catalog_schema`].
240    pub fn schemas(&self) -> Vec<Value> {
241        self.entries
242            .iter()
243            .map(|entry| entry.schema.clone())
244            .collect()
245    }
246
247    /// The wire ids of every registered catalog.
248    pub fn supported_catalog_ids(&self) -> Vec<String> {
249        self.entries
250            .iter()
251            .map(|entry| entry.catalog_id().to_string())
252            .collect()
253    }
254
255    /// Looks a catalog up by wire id or by local name.
256    pub fn get(&self, id: &str) -> Option<&RegistryEntry> {
257        self.entries
258            .iter()
259            .find(|entry| entry.catalog_id() == id || entry.name == id)
260    }
261
262    /// Negotiates a catalog from this registry.
263    ///
264    /// # Errors
265    ///
266    /// See [`select_catalog_schema`].
267    pub fn select(
268        &self,
269        capabilities: &ClientCapabilities,
270        accepts_inline: bool,
271    ) -> Result<SchemaBundle> {
272        let schema = select_catalog_schema(&self.schemas(), capabilities, accepts_inline)?;
273        Ok(SchemaBundle::from_catalog(schema))
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use serde_json::json;
281
282    fn agent_catalogs() -> Vec<Value> {
283        vec![
284            json!({"catalogId": "id_basic", "components": {}}),
285            json!({"catalogId": "id_custom1", "components": {}}),
286            json!({"catalogId": "id_custom2", "components": {}}),
287        ]
288    }
289
290    #[test]
291    fn no_preference_takes_the_agents_default() {
292        let chosen =
293            select_catalog_schema(&agent_catalogs(), &ClientCapabilities::default(), false)
294                .unwrap();
295        assert_eq!(chosen["catalogId"], "id_basic");
296    }
297
298    #[test]
299    fn the_renderers_order_decides() {
300        let chosen = select_catalog_schema(
301            &agent_catalogs(),
302            &ClientCapabilities::supporting(["id_custom2", "id_custom1"]),
303            false,
304        )
305        .unwrap();
306        assert_eq!(chosen["catalogId"], "id_custom2");
307
308        let chosen = select_catalog_schema(
309            &agent_catalogs(),
310            &ClientCapabilities::supporting(["id_custom1", "id_custom2"]),
311            false,
312        )
313        .unwrap();
314        assert_eq!(chosen["catalogId"], "id_custom1");
315    }
316
317    #[test]
318    fn no_overlap_is_an_error() {
319        let error = select_catalog_schema(
320            &agent_catalogs(),
321            &ClientCapabilities::supporting(["id_not_exists"]),
322            false,
323        )
324        .unwrap_err();
325        assert!(
326            error
327                .to_string()
328                .contains("No client-supported catalog found")
329        );
330    }
331
332    #[test]
333    fn inline_catalogs_extend_the_selection() {
334        let capabilities = ClientCapabilities {
335            supported_catalog_ids: vec![],
336            inline_catalogs: vec![json!({"catalogId": "id_inline", "components": {"Button": {}}})],
337        };
338        let chosen = select_catalog_schema(
339            &[json!({"catalogId": "id_basic", "components": {"Text": {}}})],
340            &capabilities,
341            true,
342        )
343        .unwrap();
344        // The negotiated id survives; only the components are added.
345        assert_eq!(
346            chosen,
347            json!({"catalogId": "id_basic", "components": {"Text": {}, "Button": {}}})
348        );
349    }
350
351    #[test]
352    fn several_inline_catalogs_merge_in_order() {
353        let capabilities = ClientCapabilities {
354            supported_catalog_ids: vec![],
355            inline_catalogs: vec![
356                json!({"catalogId": "id_basic", "components": {"Button": {}}}),
357                json!({"catalogId": "id_basic", "components": {"Icon": {}}}),
358            ],
359        };
360        let chosen = select_catalog_schema(
361            &[json!({"catalogId": "id_basic", "components": {"Text": {}}})],
362            &capabilities,
363            true,
364        )
365        .unwrap();
366        assert_eq!(
367            chosen,
368            json!({"catalogId": "id_basic", "components": {"Text": {}, "Button": {}, "Icon": {}}})
369        );
370    }
371
372    #[test]
373    fn inline_catalogs_rescue_a_failed_match() {
374        let capabilities = ClientCapabilities {
375            supported_catalog_ids: vec!["id_not_exists".to_string()],
376            inline_catalogs: vec![json!({"catalogId": "id_basic", "components": {"Button": {}}})],
377        };
378        let chosen = select_catalog_schema(
379            &[
380                json!({"catalogId": "id_basic", "components": {"Text": {}}}),
381                json!({"catalogId": "id_custom1", "components": {}}),
382            ],
383            &capabilities,
384            true,
385        )
386        .unwrap();
387        assert_eq!(
388            chosen,
389            json!({"catalogId": "id_basic", "components": {"Text": {}, "Button": {}}})
390        );
391    }
392
393    #[test]
394    fn inline_catalogs_are_refused_when_the_agent_says_so() {
395        let capabilities = ClientCapabilities {
396            supported_catalog_ids: vec![],
397            inline_catalogs: vec![json!({"catalogId": "id_inline"})],
398        };
399        let error =
400            select_catalog_schema(&[json!({"catalogId": "id_basic"})], &capabilities, false)
401                .unwrap_err();
402        assert!(
403            error
404                .to_string()
405                .contains("the agent does not accept inline catalogs")
406        );
407    }
408
409    #[test]
410    fn a_registry_reports_wire_ids_not_local_names() {
411        let mut registry = CatalogRegistry::new();
412        registry.insert("standard", json!({"catalogId": "basic"}));
413        registry.insert("Custom", json!({"components": {}}));
414        assert_eq!(
415            registry.supported_catalog_ids(),
416            vec!["basic".to_string(), "Custom".to_string()]
417        );
418        assert!(registry.get("basic").is_some());
419        assert!(registry.get("standard").is_some());
420        assert!(registry.get("nope").is_none());
421    }
422
423    #[test]
424    fn relaxing_a_registry_strips_strict_validation() {
425        let mut registry = CatalogRegistry::new();
426        registry.insert(
427            "strict",
428            json!({"catalogId": "s", "components": {"Text": {"additionalProperties": false}}}),
429        );
430        registry.relax_strict_validation();
431        assert_eq!(
432            registry.entries()[0].schema,
433            json!({"catalogId": "s", "components": {"Text": {}}})
434        );
435    }
436}