use std::sync::Arc;
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use awaken_runtime_contract::PluginConfigKey;
use awaken_runtime_contract::StateError;
use awaken_runtime_contract::contract::context_message::ContextMessage;
use awaken_runtime_contract::model::Phase;
use awaken_runtime_contract::registry_spec::AgentSpec;
use awaken_runtime::agent::state::AddContextMessage;
use awaken_runtime::plugins::{ConfigSchema, Plugin, PluginDescriptor, PluginRegistrar};
use awaken_runtime::state::MutationBatch;
use awaken_runtime::{PhaseContext, PhaseHook, StateCommand};
use super::tool::A2uiRenderTool;
use super::{A2UI_PLUGIN_ID, A2UI_TOOL_ID};
pub const DEFAULT_A2UI_CATALOG_ID: &str =
"https://a2ui.org/specification/v0_8/standard_catalog_definition.json";
const A2UI_INSTRUCTION_CONTEXT_KEY: &str = "generative_ui.instructions";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct A2uiPromptConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub catalog_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub examples: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
impl A2uiPromptConfig {
#[must_use]
pub fn with_catalog_id(mut self, catalog_id: impl Into<String>) -> Self {
self.catalog_id = Some(catalog_id.into());
self
}
#[must_use]
pub fn with_examples(mut self, examples: impl Into<String>) -> Self {
self.examples = Some(examples.into());
self
}
#[must_use]
pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
self.instructions = Some(instructions.into());
self
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.catalog_id.is_none() && self.examples.is_none() && self.instructions.is_none()
}
fn overlay(&self, overrides: Self) -> Self {
Self {
catalog_id: overrides.catalog_id.or_else(|| self.catalog_id.clone()),
examples: overrides.examples.or_else(|| self.examples.clone()),
instructions: overrides.instructions.or_else(|| self.instructions.clone()),
}
}
fn instructions_text(&self) -> String {
if let Some(instructions) = &self.instructions {
return instructions.clone();
}
build_instructions(
self.catalog_id
.as_deref()
.unwrap_or(DEFAULT_A2UI_CATALOG_ID),
self.examples.as_deref(),
)
}
}
pub struct A2uiPromptConfigKey;
impl PluginConfigKey for A2uiPromptConfigKey {
const KEY: &'static str = A2UI_PLUGIN_ID;
type Config = A2uiPromptConfig;
}
#[derive(Clone)]
pub(crate) struct A2uiInstructionHook {
defaults: A2uiPromptConfig,
}
impl A2uiInstructionHook {
pub(crate) fn new(defaults: A2uiPromptConfig) -> Self {
Self { defaults }
}
pub(crate) fn instructions_for(&self, agent_spec: &AgentSpec) -> Result<String, StateError> {
let overrides = agent_spec.config::<A2uiPromptConfigKey>()?;
Ok(self.defaults.overlay(overrides).instructions_text())
}
}
#[async_trait]
impl PhaseHook for A2uiInstructionHook {
async fn run(&self, ctx: &PhaseContext) -> Result<StateCommand, StateError> {
let instructions = self.instructions_for(ctx.agent_spec.as_ref())?;
if instructions.trim().is_empty() {
return Ok(StateCommand::new());
}
let mut cmd = StateCommand::new();
cmd.schedule_action::<AddContextMessage>(ContextMessage::system(
A2UI_INSTRUCTION_CONTEXT_KEY,
instructions,
))?;
Ok(cmd)
}
}
pub struct A2uiPlugin {
default_prompt_config: A2uiPromptConfig,
instructions: String,
}
impl A2uiPlugin {
pub fn new() -> Self {
Self::with_prompt_config(A2uiPromptConfig::default())
}
pub fn with_prompt_config(prompt_config: A2uiPromptConfig) -> Self {
let instructions = prompt_config.instructions_text();
Self {
default_prompt_config: prompt_config,
instructions,
}
}
pub fn with_catalog_id(catalog_id: &str) -> Self {
Self::with_prompt_config(A2uiPromptConfig::default().with_catalog_id(catalog_id))
}
pub fn with_catalog_and_examples(catalog_id: &str, examples: &str) -> Self {
Self::with_prompt_config(
A2uiPromptConfig::default()
.with_catalog_id(catalog_id)
.with_examples(examples),
)
}
pub fn with_custom_instructions(instructions: String) -> Self {
Self::with_prompt_config(A2uiPromptConfig::default().with_instructions(instructions))
}
pub fn prompt_config(&self) -> &A2uiPromptConfig {
&self.default_prompt_config
}
pub fn instructions(&self) -> &str {
&self.instructions
}
}
impl Default for A2uiPlugin {
fn default() -> Self {
Self::new()
}
}
impl Plugin for A2uiPlugin {
fn descriptor(&self) -> PluginDescriptor {
PluginDescriptor {
name: A2UI_PLUGIN_ID,
}
}
fn register(&self, registrar: &mut PluginRegistrar) -> Result<(), StateError> {
registrar.register_phase_hook(
A2UI_PLUGIN_ID,
Phase::BeforeInference,
A2uiInstructionHook::new(self.default_prompt_config.clone()),
)?;
registrar.register_tool(A2UI_TOOL_ID, Arc::new(A2uiRenderTool::new()))?;
Ok(())
}
fn config_schemas(&self) -> Vec<ConfigSchema> {
vec![
ConfigSchema::for_key::<A2uiPromptConfigKey>()
.with_display_name("Generative UI")
.with_description("Prompt and catalog overrides for UI generation.")
.with_category("presentation")
.with_editor("generative-ui"),
]
}
fn on_activate(
&self,
_agent_spec: &AgentSpec,
_patch: &mut MutationBatch,
) -> Result<(), StateError> {
Ok(())
}
}
fn build_instructions(catalog_id: &str, examples: Option<&str>) -> String {
let mut s = String::from(A2UI_SCHEMA_INSTRUCTIONS);
s = s.replace("{{CATALOG_ID}}", catalog_id);
if let Some(examples) = examples {
s.push_str("\n\n---BEGIN A2UI EXAMPLES---\n");
s.push_str(examples);
s.push_str("\n---END A2UI EXAMPLES---");
}
s
}
const A2UI_SCHEMA_INSTRUCTIONS: &str = r#"
## A2UI Declarative UI
You have access to the `render_a2ui` tool. Pass ONE A2UI message key directly
as the tool argument. Use the official A2UI v0.8 message format and do NOT add
wrapper objects beyond the protocol itself.
Call the tool once per message in this order: surfaceUpdate → dataModelUpdate → beginRendering.
### 1. surfaceUpdate
```json
{"surfaceUpdate": {"surfaceId": "ops_request", "components": [
{"id": "root", "component": {"Card": {"child": "content"}}},
{"id": "content", "component": {"Column": {"children": {"explicitList": ["title", "requester", "notes", "priority_label", "priority", "submit_label", "submit_button"]}}}},
{"id": "title", "component": {"Text": {"usageHint": "h2", "text": {"literalString": "Operations Request"}}}},
{"id": "requester", "component": {"TextField": {"label": {"literalString": "Requester"}, "text": {"path": "/request/requester"}, "textFieldType": "shortText"}}},
{"id": "notes", "component": {"TextField": {"label": {"literalString": "Request details"}, "text": {"path": "/request/notes"}, "textFieldType": "longText"}}},
{"id": "priority_label", "component": {"Text": {"usageHint": "caption", "text": {"literalString": "Priority"}}}},
{"id": "priority", "component": {"MultipleChoice": {"selections": {"path": "/request/priority"}, "options": [{"label": {"literalString": "Standard"}, "value": "standard"}, {"label": {"literalString": "Urgent"}, "value": "urgent"}], "maxAllowedSelections": 1}}},
{"id": "submit_label", "component": {"Text": {"text": {"literalString": "Submit request"}}}},
{"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"}}]}}}}
]}}
```
### 2. dataModelUpdate
```json
{"dataModelUpdate": {"surfaceId": "ops_request", "path": "/request", "contents": [
{"key": "requester", "valueString": ""},
{"key": "notes", "valueString": ""},
{"key": "priority", "valueMap": [{"key": "0", "valueString": "standard"}]}
]}}
```
### 3. beginRendering
```json
{"beginRendering": {"surfaceId": "ops_request", "root": "root"}}
```
### Rules
- The client defaults to the standard v0.8 catalog: `{{CATALOG_ID}}`.
- Components are a flat list. Relationships are expressed by component IDs inside nested component props.
- Each component must have `"id"` and `"component"`, where `"component"` is an object like `{"Text": {...}}`.
- `Text` components must always nest copy under the `text` field, for example `{"Text":{"text":{"literalString":"Submit"}}}`.
- For container children, use `{"children": {"explicitList": ["id1", "id2"]}}`.
- For text input binding, v0.8 `TextField` uses the `text` property, not `value`.
- For selection inputs, v0.8 `MultipleChoice` uses `options`, `selections`, and `maxAllowedSelections`.
- Each `MultipleChoice.options` item must look like `{"label":{"literalString":"High"},"value":"high"}`.
- `MultipleChoice` does not have a `label` field. If you need a visible label, render a nearby `Text` component.
- `MultipleChoice.selections` binds to an array path. Initialize defaults with `valueMap`, for example `{"key":"priority","valueMap":[{"key":"0","valueString":"standard"}]}`.
- For `DateTimeInput` defaults, use browser-compatible local strings like `2026-04-10T09:00`. Do not include a trailing `Z` or timezone offset.
- For buttons, use `"action": {"name": "...", "context": [{"key":"field","value":{"path":"/request/field"}}]}`; do not use the v0.9 `event` wrapper.
- `dataModelUpdate.contents` must be an array of `{key, valueString|valueNumber|valueBoolean|valueMap}` entries.
- Call `beginRendering` only after the referenced root component already exists in `surfaceUpdate`.
- Use `deleteSurface` when the workflow is complete or should be dismissed.
- 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.
- 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.
### Update reminder
If 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:
```json
{"dataModelUpdate":{"surfaceId":"surface","path":"/request","contents":[
{"key":"requester","valueString":"Jordan Patel"},
{"key":"department","valueString":"Operations"},
{"key":"status","valueString":"submitted"}
]}}
```
"#;