Skip to main content

ag_ui_a2ui/toolkit/
tools.rs

1//! The two tool definitions an A2UI agent exposes.
2//!
3//! They sit at different levels and are easy to confuse:
4//!
5//! - [`generate_a2ui_tool`] is **planner-facing**. The orchestrating model calls
6//!   it to say "render this, as a new surface or as an edit to that one". Its
7//!   arguments are intent and description, not components.
8//! - [`render_a2ui_tool`] is the **inner structured-output** tool. The
9//!   generating model calls it to emit the actual surface: a flat component list
10//!   and a data model.
11//!
12//! Keeping them apart is what lets the planner stay out of the component
13//! catalog: it describes what it wants, and the inner call produces it.
14
15use serde_json::{Value, json};
16
17use crate::catalog::Catalog;
18use crate::constants::{
19    DEFAULT_SURFACE_ID, GENERATE_A2UI_TOOL_NAME, RENDER_A2UI_TOOL_NAME, ROOT_ID,
20};
21
22/// A provider-neutral tool definition.
23///
24/// `parameters` is a JSON Schema object; every major LLM API takes that shape,
25/// under whatever key it calls it.
26#[derive(Debug, Clone, PartialEq)]
27pub struct ToolDefinition {
28    /// The tool name the model calls.
29    pub name: &'static str,
30    /// What the tool does, as the model sees it.
31    pub description: String,
32    /// JSON Schema for the tool's arguments.
33    pub parameters: Value,
34}
35
36impl ToolDefinition {
37    /// Renders the definition in Anthropic's Messages API shape:
38    /// `{name, description, input_schema}`.
39    ///
40    /// Named for the provider because the key is: Anthropic calls the schema
41    /// `input_schema`, OpenAI nests it under `function.parameters`, and Gemini
42    /// wants `parameters` with a restricted subset of JSON Schema. The struct's
43    /// own fields are the provider-neutral form; reach for those, or for
44    /// [`ag_ui::Tool`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/tool/struct.Tool.html)
45    /// via `From` under the `ag-ui` feature, when the target is anything else.
46    pub fn to_anthropic_value(&self) -> Value {
47        json!({
48            "name": self.name,
49            "description": self.description,
50            "input_schema": self.parameters,
51        })
52    }
53}
54
55/// The planner-facing tool: ask for a surface.
56///
57/// The `intent` argument is the important one. `"update"` targets a surface that
58/// already exists and must not be re-created; `"create"` allocates a new
59/// `surface_id`. The description spells that out because the planner is the only
60/// one who knows which the user meant.
61pub fn generate_a2ui_tool() -> ToolDefinition {
62    ToolDefinition {
63        name: GENERATE_A2UI_TOOL_NAME,
64        description: "Render an interactive UI surface for the user. Use this instead of \
65                      describing a UI in prose. Set intent to 'create' for a new surface, or \
66                      'update' to change a surface already on screen — updating keeps the \
67                      existing surface_id and never re-creates it."
68            .to_string(),
69        parameters: json!({
70            "type": "object",
71            "properties": {
72                "intent": {
73                    "type": "string",
74                    "enum": ["create", "update"],
75                    "description": "'create' builds a new surface; 'update' edits the surface \
76                                    already on screen. Re-creating an existing surface is an \
77                                    error, so use 'update' whenever one exists.",
78                    "default": "create"
79                },
80                "surface_id": {
81                    "type": "string",
82                    "description": format!(
83                        "The surface to target. Required for 'update'; for 'create' it must be \
84                         unused. Defaults to '{DEFAULT_SURFACE_ID}'."
85                    )
86                },
87                "request": {
88                    "type": "string",
89                    "description": "What the UI should show or let the user do, in plain \
90                                    language. Include the concrete data to display."
91                },
92                "design_notes": {
93                    "type": "string",
94                    "description": "Optional layout or styling guidance, e.g. 'compact list, \
95                                    primary action at the bottom'."
96                }
97            },
98            "required": ["intent", "request"]
99        }),
100    }
101}
102
103/// The inner structured-output tool: emit the surface.
104///
105/// The schema restates the adjacency-list rules, because the model filling it in
106/// is the one that has to get them right: a flat list, ids not nesting, exactly
107/// one `root`.
108///
109/// Pass a catalog to name the permitted component types in the schema, which
110/// keeps the model inside them without relying on the prompt alone.
111pub fn render_a2ui_tool(catalog: Option<&Catalog>) -> ToolDefinition {
112    let component_type = match catalog {
113        Some(catalog) if !catalog.components.is_empty() => json!({
114            "type": "string",
115            "description": "The component type, from the catalog.",
116            "enum": catalog
117                .components_in_order()
118                .map(|d| d.name.clone())
119                .collect::<Vec<_>>()
120        }),
121        _ => json!({
122            "type": "string",
123            "description": "The component type, e.g. 'Text' or 'Column'."
124        }),
125    };
126
127    ToolDefinition {
128        name: RENDER_A2UI_TOOL_NAME,
129        description: format!(
130            "Emit the UI surface as a flat list of A2UI components. Components are never nested: \
131             a parent names its children by id. Exactly one component must have id '{ROOT_ID}', \
132             and it must come first, with every parent listed before its children."
133        ),
134        parameters: json!({
135            "type": "object",
136            "properties": {
137                "components": {
138                    "type": "array",
139                    "minItems": 1,
140                    "description": format!(
141                        "The flat component list. The first entry must be the '{ROOT_ID}' \
142                         component; parents come before their children."
143                    ),
144                    "items": {
145                        "type": "object",
146                        "properties": {
147                            "id": {
148                                "type": "string",
149                                "description": format!(
150                                    "Unique id within this surface. Exactly one component uses \
151                                     '{ROOT_ID}'."
152                                )
153                            },
154                            "component": component_type
155                        },
156                        "required": ["id", "component"],
157                        "additionalProperties": true
158                    }
159                },
160                "data_model": {
161                    "type": "object",
162                    "description": "Data the components bind to. Every {\"path\": \"/pointer\"} \
163                                    binding in the components must resolve here.",
164                    "additionalProperties": true
165                },
166                "message": {
167                    "type": "string",
168                    "description": "Optional short sentence to show the user alongside the \
169                                    surface."
170                }
171            },
172            "required": ["components"]
173        }),
174    }
175}
176
177/// Both tool definitions, planner-facing first.
178pub fn tool_definitions(catalog: Option<&Catalog>) -> Vec<ToolDefinition> {
179    vec![generate_a2ui_tool(), render_a2ui_tool(catalog)]
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn the_tool_names_are_the_wire_constants() {
188        assert_eq!(generate_a2ui_tool().name, "generate_a2ui");
189        assert_eq!(render_a2ui_tool(None).name, "render_a2ui");
190    }
191
192    #[test]
193    fn the_planner_tool_forces_an_explicit_intent() {
194        let tool = generate_a2ui_tool();
195        let intent = &tool.parameters["properties"]["intent"];
196        assert_eq!(intent["enum"], json!(["create", "update"]));
197        assert_eq!(tool.parameters["required"], json!(["intent", "request"]));
198        assert!(tool.description.contains("never re-creates"));
199    }
200
201    #[test]
202    fn the_render_tool_states_the_adjacency_rules() {
203        let tool = render_a2ui_tool(None);
204        assert!(tool.description.contains("never nested"));
205        assert!(tool.description.contains("'root'"));
206        assert_eq!(
207            tool.parameters["properties"]["components"]["items"]["required"],
208            json!(["id", "component"])
209        );
210        assert_eq!(tool.parameters["properties"]["components"]["minItems"], 1);
211    }
212
213    #[test]
214    fn a_catalog_constrains_the_component_enum() {
215        let catalog = Catalog::basic();
216        let tool = render_a2ui_tool(Some(&catalog));
217        let types = &tool.parameters["properties"]["components"]["items"]["properties"]["component"]
218            ["enum"];
219        assert_eq!(types[0], "Text");
220        assert_eq!(types.as_array().unwrap().len(), 18);
221
222        // No catalog means no enum, so custom catalogs are not blocked.
223        let open = render_a2ui_tool(None);
224        assert!(
225            open.parameters["properties"]["components"]["items"]["properties"]["component"]["enum"]
226                .is_null()
227        );
228        assert!(render_a2ui_tool(Some(&Catalog::empty("x"))).parameters["properties"]
229            ["components"]["items"]["properties"]["component"]["enum"]
230            .is_null());
231    }
232
233    #[test]
234    fn definitions_render_for_an_api_payload() {
235        let value = generate_a2ui_tool().to_anthropic_value();
236        assert_eq!(value["name"], "generate_a2ui");
237        assert_eq!(value["input_schema"]["type"], "object");
238        assert_eq!(tool_definitions(None).len(), 2);
239    }
240}