awaken_ext_generative_ui/a2ui/
plugin.rs1use std::sync::Arc;
2
3use async_trait::async_trait;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use awaken_contract::PluginConfigKey;
8use awaken_contract::StateError;
9use awaken_contract::contract::context_message::ContextMessage;
10use awaken_contract::model::Phase;
11use awaken_contract::registry_spec::AgentSpec;
12
13use awaken_runtime::agent::state::AddContextMessage;
14use awaken_runtime::plugins::{ConfigSchema, Plugin, PluginDescriptor, PluginRegistrar};
15use awaken_runtime::state::MutationBatch;
16use awaken_runtime::{PhaseContext, PhaseHook, StateCommand};
17
18use super::tool::A2uiRenderTool;
19use super::{A2UI_PLUGIN_ID, A2UI_TOOL_ID};
20
21pub const DEFAULT_A2UI_CATALOG_ID: &str =
22 "https://a2ui.org/specification/v0_8/standard_catalog_definition.json";
23
24const A2UI_INSTRUCTION_CONTEXT_KEY: &str = "generative_ui.instructions";
25
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
32#[serde(default)]
33pub struct A2uiPromptConfig {
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub catalog_id: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub examples: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub instructions: Option<String>,
44}
45
46impl A2uiPromptConfig {
47 #[must_use]
48 pub fn with_catalog_id(mut self, catalog_id: impl Into<String>) -> Self {
49 self.catalog_id = Some(catalog_id.into());
50 self
51 }
52
53 #[must_use]
54 pub fn with_examples(mut self, examples: impl Into<String>) -> Self {
55 self.examples = Some(examples.into());
56 self
57 }
58
59 #[must_use]
60 pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
61 self.instructions = Some(instructions.into());
62 self
63 }
64
65 #[must_use]
66 pub fn is_empty(&self) -> bool {
67 self.catalog_id.is_none() && self.examples.is_none() && self.instructions.is_none()
68 }
69
70 fn overlay(&self, overrides: Self) -> Self {
71 Self {
72 catalog_id: overrides.catalog_id.or_else(|| self.catalog_id.clone()),
73 examples: overrides.examples.or_else(|| self.examples.clone()),
74 instructions: overrides.instructions.or_else(|| self.instructions.clone()),
75 }
76 }
77
78 fn instructions_text(&self) -> String {
79 if let Some(instructions) = &self.instructions {
80 return instructions.clone();
81 }
82
83 build_instructions(
84 self.catalog_id
85 .as_deref()
86 .unwrap_or(DEFAULT_A2UI_CATALOG_ID),
87 self.examples.as_deref(),
88 )
89 }
90}
91
92pub struct A2uiPromptConfigKey;
94
95impl PluginConfigKey for A2uiPromptConfigKey {
96 const KEY: &'static str = A2UI_PLUGIN_ID;
97 type Config = A2uiPromptConfig;
98}
99
100#[derive(Clone)]
101pub(crate) struct A2uiInstructionHook {
102 defaults: A2uiPromptConfig,
103}
104
105impl A2uiInstructionHook {
106 pub(crate) fn new(defaults: A2uiPromptConfig) -> Self {
107 Self { defaults }
108 }
109
110 pub(crate) fn instructions_for(&self, agent_spec: &AgentSpec) -> Result<String, StateError> {
111 let overrides = agent_spec.config::<A2uiPromptConfigKey>()?;
112 Ok(self.defaults.overlay(overrides).instructions_text())
113 }
114}
115
116#[async_trait]
117impl PhaseHook for A2uiInstructionHook {
118 async fn run(&self, ctx: &PhaseContext) -> Result<StateCommand, StateError> {
119 let instructions = self.instructions_for(ctx.agent_spec.as_ref())?;
120 if instructions.trim().is_empty() {
121 return Ok(StateCommand::new());
122 }
123
124 let mut cmd = StateCommand::new();
125 cmd.schedule_action::<AddContextMessage>(ContextMessage::system(
126 A2UI_INSTRUCTION_CONTEXT_KEY,
127 instructions,
128 ))?;
129 Ok(cmd)
130 }
131}
132
133pub struct A2uiPlugin {
135 default_prompt_config: A2uiPromptConfig,
136 instructions: String,
137}
138
139impl A2uiPlugin {
140 pub fn new() -> Self {
142 Self::with_prompt_config(A2uiPromptConfig::default())
143 }
144
145 pub fn with_prompt_config(prompt_config: A2uiPromptConfig) -> Self {
148 let instructions = prompt_config.instructions_text();
149 Self {
150 default_prompt_config: prompt_config,
151 instructions,
152 }
153 }
154
155 pub fn with_catalog_id(catalog_id: &str) -> Self {
157 Self::with_prompt_config(A2uiPromptConfig::default().with_catalog_id(catalog_id))
158 }
159
160 pub fn with_catalog_and_examples(catalog_id: &str, examples: &str) -> Self {
162 Self::with_prompt_config(
163 A2uiPromptConfig::default()
164 .with_catalog_id(catalog_id)
165 .with_examples(examples),
166 )
167 }
168
169 pub fn with_custom_instructions(instructions: String) -> Self {
171 Self::with_prompt_config(A2uiPromptConfig::default().with_instructions(instructions))
172 }
173
174 pub fn prompt_config(&self) -> &A2uiPromptConfig {
177 &self.default_prompt_config
178 }
179
180 pub fn instructions(&self) -> &str {
183 &self.instructions
184 }
185}
186
187impl Default for A2uiPlugin {
188 fn default() -> Self {
189 Self::new()
190 }
191}
192
193impl Plugin for A2uiPlugin {
194 fn descriptor(&self) -> PluginDescriptor {
195 PluginDescriptor {
196 name: A2UI_PLUGIN_ID,
197 }
198 }
199
200 fn register(&self, registrar: &mut PluginRegistrar) -> Result<(), StateError> {
201 registrar.register_phase_hook(
202 A2UI_PLUGIN_ID,
203 Phase::BeforeInference,
204 A2uiInstructionHook::new(self.default_prompt_config.clone()),
205 )?;
206 registrar.register_tool(A2UI_TOOL_ID, Arc::new(A2uiRenderTool::new()))?;
207 Ok(())
208 }
209
210 fn config_schemas(&self) -> Vec<ConfigSchema> {
211 vec![ConfigSchema::for_key::<A2uiPromptConfigKey>()]
212 }
213
214 fn on_activate(
215 &self,
216 _agent_spec: &AgentSpec,
217 _patch: &mut MutationBatch,
218 ) -> Result<(), StateError> {
219 Ok(())
220 }
221}
222
223fn build_instructions(catalog_id: &str, examples: Option<&str>) -> String {
224 let mut s = String::from(A2UI_SCHEMA_INSTRUCTIONS);
225 s = s.replace("{{CATALOG_ID}}", catalog_id);
226 if let Some(examples) = examples {
227 s.push_str("\n\n---BEGIN A2UI EXAMPLES---\n");
228 s.push_str(examples);
229 s.push_str("\n---END A2UI EXAMPLES---");
230 }
231 s
232}
233
234const A2UI_SCHEMA_INSTRUCTIONS: &str = r#"
235## A2UI Declarative UI
236
237You have access to the `render_a2ui` tool. Pass ONE A2UI message key directly
238as the tool argument. Use the official A2UI v0.8 message format and do NOT add
239wrapper objects beyond the protocol itself.
240
241Call the tool once per message in this order: surfaceUpdate → dataModelUpdate → beginRendering.
242
243### 1. surfaceUpdate
244```json
245{"surfaceUpdate": {"surfaceId": "ops_request", "components": [
246 {"id": "root", "component": {"Card": {"child": "content"}}},
247 {"id": "content", "component": {"Column": {"children": {"explicitList": ["title", "requester", "notes", "priority_label", "priority", "submit_label", "submit_button"]}}}},
248 {"id": "title", "component": {"Text": {"usageHint": "h2", "text": {"literalString": "Operations Request"}}}},
249 {"id": "requester", "component": {"TextField": {"label": {"literalString": "Requester"}, "text": {"path": "/request/requester"}, "textFieldType": "shortText"}}},
250 {"id": "notes", "component": {"TextField": {"label": {"literalString": "Request details"}, "text": {"path": "/request/notes"}, "textFieldType": "longText"}}},
251 {"id": "priority_label", "component": {"Text": {"usageHint": "caption", "text": {"literalString": "Priority"}}}},
252 {"id": "priority", "component": {"MultipleChoice": {"selections": {"path": "/request/priority"}, "options": [{"label": {"literalString": "Standard"}, "value": "standard"}, {"label": {"literalString": "Urgent"}, "value": "urgent"}], "maxAllowedSelections": 1}}},
253 {"id": "submit_label", "component": {"Text": {"text": {"literalString": "Submit request"}}}},
254 {"id": "submit_button", "component": {"Button": {"child": "submit_label", "primary": true, "action": {"name": "ops_request.submit", "context": [{"key": "requester", "value": {"path": "/request/requester"}}, {"key": "notes", "value": {"path": "/request/notes"}}, {"key": "priority", "value": {"path": "/request/priority"}}]}}}}
255]}}
256```
257
258### 2. dataModelUpdate
259```json
260{"dataModelUpdate": {"surfaceId": "ops_request", "path": "/request", "contents": [
261 {"key": "requester", "valueString": ""},
262 {"key": "notes", "valueString": ""},
263 {"key": "priority", "valueMap": [{"key": "0", "valueString": "standard"}]}
264]}}
265```
266
267### 3. beginRendering
268```json
269{"beginRendering": {"surfaceId": "ops_request", "root": "root"}}
270```
271
272### Rules
273- The client defaults to the standard v0.8 catalog: `{{CATALOG_ID}}`.
274- Components are a flat list. Relationships are expressed by component IDs inside nested component props.
275- Each component must have `"id"` and `"component"`, where `"component"` is an object like `{"Text": {...}}`.
276- `Text` components must always nest copy under the `text` field, for example `{"Text":{"text":{"literalString":"Submit"}}}`.
277- For container children, use `{"children": {"explicitList": ["id1", "id2"]}}`.
278- For text input binding, v0.8 `TextField` uses the `text` property, not `value`.
279- For selection inputs, v0.8 `MultipleChoice` uses `options`, `selections`, and `maxAllowedSelections`.
280- Each `MultipleChoice.options` item must look like `{"label":{"literalString":"High"},"value":"high"}`.
281- `MultipleChoice` does not have a `label` field. If you need a visible label, render a nearby `Text` component.
282- `MultipleChoice.selections` binds to an array path. Initialize defaults with `valueMap`, for example `{"key":"priority","valueMap":[{"key":"0","valueString":"standard"}]}`.
283- For `DateTimeInput` defaults, use browser-compatible local strings like `2026-04-10T09:00`. Do not include a trailing `Z` or timezone offset.
284- For buttons, use `"action": {"name": "...", "context": [{"key":"field","value":{"path":"/request/field"}}]}`; do not use the v0.9 `event` wrapper.
285- `dataModelUpdate.contents` must be an array of `{key, valueString|valueNumber|valueBoolean|valueMap}` entries.
286- Call `beginRendering` only after the referenced root component already exists in `surfaceUpdate`.
287- Use `deleteSurface` when the workflow is complete or should be dismissed.
288- When updating an existing surface after a user interaction, resend the current values for any bound fields that remain visible on the surface. Do not send only the newly changed status fields if the next UI still displays older inputs.
289- If the conversation includes an `A2UI action:` payload, treat its `context` object as the authoritative source for the user's current bound field values unless the user explicitly changed them in the new turn.
290
291### Update reminder
292If a follow-up state still shows the original form fields, the next `dataModelUpdate` should carry both the retained field values and the new status fields, for example:
293```json
294{"dataModelUpdate":{"surfaceId":"surface","path":"/request","contents":[
295 {"key":"requester","valueString":"Jordan Patel"},
296 {"key":"department","valueString":"Operations"},
297 {"key":"status","valueString":"submitted"}
298]}}
299```
300"#;