From 327feb50b5b308a6a588bbc69a0ce95a58743ba9 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 26 Oct 2026 10:49:25 -0700 Subject: [PATCH 1/4] fix(py-agent): compile Event userMessage and nested action slots in Express `Event("reply", userMessage="...")` silently dropped `userMessage`, and a function call used as the action of a list item (for example `Links([{label: "Docs", action: openUrl("...")}])`) compiled to a bare `{"@call": ...}` that the processor rejects. - `Event` now accepts `userMessage=` and `context=` keyword arguments. `userMessage` compiles to the v1.0 `event.userMessage` field and raises `ExpressValidationError` before v1.0; any other keyword on `Event` is rejected rather than dropped. - `_compile_value` carries the catalog schema at the current position, so an `Action`-typed sub-schema inside array items or object properties wraps function calls in `functionCall` like a top-level action slot does. `CatalogSchemaHelper` gains `resolve_subschema` and the shared `is_action_slot` helper to walk the schema. - `ExpressDecompiler` writes `userMessage=` back so events round-trip. Fixes points 1 and 2 of #3598 for the Python SDK. --- python/a2ui_agent/CHANGELOG.md | 1 + .../inference_formats/_shared/__init__.py | 3 +- .../_shared/schema_helper.py | 98 +++++++++ .../inference_formats/express/compiler.py | 172 +++++++++++---- .../inference_formats/express/decompiler.py | 22 +- .../a2ui_agent/tests/express/test_compiler.py | 208 ++++++++++++++++++ .../tests/express/test_parser_decompile.py | 86 ++++++++ 7 files changed, 541 insertions(+), 49 deletions(-) diff --git a/python/a2ui_agent/CHANGELOG.md b/python/a2ui_agent/CHANGELOG.md index 49c7245767..e4793f0e3e 100644 --- a/python/a2ui_agent/CHANGELOG.md +++ b/python/a2ui_agent/CHANGELOG.md @@ -42,6 +42,7 @@ standalone. [#3232](https://github.com/a2ui-project/a2ui/issues/3232), [#3575](https://github.com/a2ui-project/a2ui/pull/3575) +- The Express compiler accepts `userMessage=` and `context=` keyword arguments on `Event(...)`, compiling `userMessage` to the v1.0 `event.userMessage` field, and raises `ExpressValidationError` for `userMessage` before v1.0 and for any keyword `Event` does not define, where it used to drop them. A function call in an action slot nested inside a list item or object property (such as `items: [{label, action: openUrl(...)}]`) is now compiled as a `functionCall` action. `ExpressDecompiler` writes `userMessage=` back ([#3598](https://github.com/a2ui-project/a2ui/issues/3598)). - The Express compiler keeps a map literal as written, quoted keys or not, when one of its keys shares a name with an Express construct (`variable`, `path`, `call`, `check`, `skipped`); before, such a map was read as a variable diff --git a/python/a2ui_agent/src/a2ui/inference_formats/_shared/__init__.py b/python/a2ui_agent/src/a2ui/inference_formats/_shared/__init__.py index 11e05ed1f6..a13d1a9fb1 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/_shared/__init__.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/_shared/__init__.py @@ -33,7 +33,7 @@ surface_catalog_id, ) from .coalesce import CoalescedMessage, coalesce_surface_messages -from .schema_helper import CatalogSchemaHelper +from .schema_helper import CatalogSchemaHelper, is_action_slot __all__ = [ "CatalogSchemaHelper", @@ -44,6 +44,7 @@ "check_catalogs", "check_mixed_catalogs", "coalesce_surface_messages", + "is_action_slot", "normalize_prompt_example_messages", "surface_catalog_id", "to_message_dicts", diff --git a/python/a2ui_agent/src/a2ui/inference_formats/_shared/schema_helper.py b/python/a2ui_agent/src/a2ui/inference_formats/_shared/schema_helper.py index 8e55114831..d26ed585b5 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/_shared/schema_helper.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/_shared/schema_helper.py @@ -35,6 +35,37 @@ _COMMON_DEF_RE = re.compile(r"(?:^|/)common_types\.json#/(?:\$defs|definitions)/(\w+)$") +def _common_def_name(ref: Any) -> str | None: + """Returns the common type a `$ref` names, or None if it is not one.""" + if not isinstance(ref, str): + return None + m = _COMMON_DEF_RE.search(ref) + return m.group(1) if m else None + + +def is_action_slot(schema: Any) -> bool: + """Checks whether a property schema is an Action slot. + + A property is an action slot when its schema references the common `Action` + def, directly or through `oneOf`/`anyOf`/`allOf`. + + Args: + schema: The property JSON schema to inspect. + + Returns: + True if the schema references the common `Action` type. + """ + if not isinstance(schema, dict): + return False + if _common_def_name(schema.get("$ref")) == "Action": + return True + for key in ("allOf", "oneOf", "anyOf"): + subs = schema.get(key) + if isinstance(subs, list) and any(is_action_slot(sub) for sub in subs): + return True + return False + + def _find_enum(s: Any) -> list[Any] | None: """Returns the first `enum` list found in a schema or its combinators.""" if isinstance(s, dict): @@ -462,3 +493,70 @@ def _check(s: Any, in_common_types: bool) -> bool: return False return _check(schema, False) + + def resolve_subschema(self, schema: Any, key: str) -> dict[str, Any] | None: + """Resolves the schema of an array item or an object property. + + Follows `$ref` (into both the common types and the catalog document), + `allOf`, `oneOf` and `anyOf` to find the schema declared at that + position. + + Args: + schema: The parent JSON schema to inspect. + key: `items` for array items, or the property name for object + properties. A property name falls back to + `additionalProperties` when it is not declared. + + Returns: + The resolved sub-schema, or None if none is declared. + """ + visited: set[int] = set() + + def _resolve(s: Any, in_common_types: bool) -> dict[str, Any] | None: + if not isinstance(s, dict) or id(s) in visited: + return None + visited.add(id(s)) + + if key == "items": + items = s.get("items") + if isinstance(items, dict): + return items + else: + props = s.get("properties") + prop = props.get(key) if isinstance(props, dict) else None + if isinstance(prop, dict): + return prop + additional = s.get("additionalProperties") + if isinstance(additional, dict): + return additional + + ref = s.get("$ref") + if isinstance(ref, str): + def_name = _common_def_name(ref) + if def_name: + found = _resolve(self._lookup_common_def(def_name), True) + elif in_common_types and ( + ref.startswith("#/$defs/") or ref.startswith("#/definitions/") + ): + name = ref[8:] if ref.startswith("#/$defs/") else ref[14:] + found = _resolve(self._lookup_common_def(name), True) + elif ref.startswith("#/"): + found = _resolve( + self._resolve_json_pointer(self.catalog, ref), False + ) + else: + found = None + if found: + return found + + for combinator in ("allOf", "oneOf", "anyOf"): + subs = s.get(combinator) + if isinstance(subs, list): + for sub in subs: + found = _resolve(sub, in_common_types) + if found: + return found + + return None + + return _resolve(schema, False) diff --git a/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py b/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py index 873305a5bf..6e7f0b18ef 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py @@ -37,6 +37,7 @@ catalogs_defining, catalogs_protocol_version, check_mixed_catalogs, + is_action_slot, surface_catalog_id, to_message_models, ) @@ -70,6 +71,29 @@ _SEND_DATA_MODEL_KEY = "sendDataModel" _UPDATE_KEY = "update" +# Keyword arguments accepted by the reserved `Event(...)` signature. +_EVENT_KEYWORDS = ("userMessage", "context") + + +@dataclass(frozen=True) +class _ValueSlot: + """The catalog schema declared at a value's position, with the helper that owns it.""" + + schema: dict[str, Any] + helper: CatalogSchemaHelper + + +def _sub_slot(slot: _ValueSlot | None, key: str) -> _ValueSlot | None: + """Descends a value slot into an array item (`items`) or an object property. + + Returns: + The nested slot, or None when the schema is unknown at that position. + """ + if slot is None: + return None + schema = slot.helper.resolve_subschema(slot.schema, key) + return _ValueSlot(schema, slot.helper) if schema else None + def _component_id(var_name: str, ast: Any, *, at_least_v10: bool = False) -> str: """Returns the ID of the component a variable holds. @@ -1216,15 +1240,19 @@ def _compile_ast_node( comp_dict[prop_name] = None continue + prop_schema = comp_helper.get_property_schema(comp_name, prop_name) mapped_val = self._compile_value( arg, raw_symbols, ctx, - is_action=(prop_name in ["action", "submitAction"]), + is_action=( + is_action_slot(prop_schema) + or prop_name in ["action", "submitAction"] + ), parent_id=comp_id, parent_prop=prop_name, + slot=_ValueSlot(prop_schema, comp_helper) if prop_schema else None, ) - prop_schema = comp_helper.get_property_schema(comp_name, prop_name) if prop_schema and not _schema_allows_databinding(prop_schema): if _has_databinding(arg, raw_symbols): raise ExpressForbiddenDatabindingError(comp_name, prop_name) @@ -1368,6 +1396,86 @@ def _compile_check( condition["args"] = compiled_args return condition, message_val + def _compile_event_call( + self, + val: CallNode, + raw_symbols: dict[str, Any], + ctx: _CompileContext, + is_action: bool, + ) -> dict[str, Any]: + """Compiles the reserved `Event(name, context?, userMessage=?, context=?)` signature. + + `userMessage` is a v1.0 Action field. It is rejected when targeting an + earlier protocol version, and so is any other keyword the signature does + not define, rather than being dropped from the compiled action. + + Raises: + ExpressValidationError: If there are more than two positional + arguments, `userMessage` is used before v1.0, or `userMessage` + is neither a string nor a `$path` data binding. + ExpressInvalidParamError: If a keyword other than `userMessage` or + `context` is given. + ExpressDuplicateParamError: If the context is given both + positionally and by keyword. + """ + fn_args = val.args + fn_kwargs = val.kwargs + + if len(fn_args) > 2: + raise ExpressValidationError( + "Event accepts at most 2 positional arguments (name, context)," + f" got {len(fn_args)}." + ) + for key in fn_kwargs: + if key not in _EVENT_KEYWORDS: + raise ExpressInvalidParamError("Event", key, list(_EVENT_KEYWORDS)) + if len(fn_args) == 2 and "context" in fn_kwargs: + raise ExpressDuplicateParamError("Event", "context") + + compiled_event_name = ( + self._compile_value(fn_args[0], raw_symbols, ctx, is_action) + if fn_args + else "" + ) + event_dict: dict[str, Any] = {"name": compiled_event_name} + + if "userMessage" in fn_kwargs: + if not ctx.at_least_v10: + raise ExpressValidationError( + "Event userMessage requires protocol v1.0 or later; the" + f" target version {ctx.version} has no userMessage field." + ) + user_message = self._compile_value( + fn_kwargs["userMessage"], raw_symbols, ctx, is_action + ) + if not isinstance(user_message, str) and not ( + isinstance(user_message, dict) and ctx.path_key in user_message + ): + raise ExpressValidationError( + "Event userMessage must be a string or a $path data binding," + f" got: {fn_kwargs['userMessage']!r}" + ) + event_dict["userMessage"] = user_message + + if len(fn_args) == 2: + context_node = fn_args[1] + elif "context" in fn_kwargs: + context_node = fn_kwargs["context"] + else: + return {"event": event_dict} + + raw_context = self._compile_value(context_node, raw_symbols, ctx, is_action) + # A context written in the DSL is kept even when it is empty. + compiled_context: dict[str, Any] = {} + if isinstance(raw_context, dict): + compiled_context.update(raw_context) + elif isinstance(raw_context, list): + for item in raw_context: + if isinstance(item, dict): + compiled_context.update(item) + event_dict["context"] = compiled_context + return {"event": event_dict} + def _compile_value( self, val: Any, @@ -1377,6 +1485,7 @@ def _compile_value( parent_id: str | None = None, parent_prop: str | None = None, list_index: int | None = None, + slot: _ValueSlot | None = None, ) -> Any: """Compiles an individual AST node value into valid A2UI equivalents. @@ -1388,6 +1497,10 @@ def _compile_value( parent_id: The parent component ID if compiling a component property. parent_prop: The parent component property name. list_index: The index within an array property if applicable. + slot: The catalog schema declared at this position, when known. + Array items and map entries resolve their own sub-schema from + it, so an `Action` slot nested inside a list item (e.g. + `items: [{label, action}]`) is compiled as an action too. Returns: The semantically correct A2UI JSON structure. @@ -1420,6 +1533,7 @@ def _compile_value( raw_symbols, ctx, is_action, + slot=slot, ) finally: ctx.resolving.discard(ref_name) @@ -1441,21 +1555,32 @@ def _compile_value( # A dict is always a map literal, whatever its keys are. if isinstance(val, dict): - return { - k: self._compile_value(v, raw_symbols, ctx, is_action) - for k, v in val.items() - } + dict_res: dict[str, Any] = {} + for k, v in val.items(): + entry_slot = _sub_slot(slot, k) + entry_is_action = is_action or ( + entry_slot is not None and is_action_slot(entry_slot.schema) + ) + dict_res[k] = self._compile_value( + v, raw_symbols, ctx, entry_is_action, slot=entry_slot + ) + return dict_res if isinstance(val, list): + item_slot = _sub_slot(slot, "items") + item_is_action = is_action or ( + item_slot is not None and is_action_slot(item_slot.schema) + ) return [ self._compile_value( item, raw_symbols, ctx, - is_action, + item_is_action, parent_id=parent_id, parent_prop=parent_prop, list_index=idx, + slot=item_slot, ) for idx, item in enumerate(val) ] @@ -1495,38 +1620,7 @@ def _compile_call( return {"path": template_path, "componentId": comp_id_val} if fn_name == "Event": - compiled_event_name = ( - self._compile_value( - fn_args[0], - raw_symbols, - ctx, - is_action, - ) - if len(fn_args) > 0 - else "" - ) - raw_context = ( - self._compile_value( - fn_args[1], - raw_symbols, - ctx, - is_action, - ) - if len(fn_args) > 1 - else {} - ) - compiled_context = {} - if isinstance(raw_context, dict): - compiled_context.update(raw_context) - elif isinstance(raw_context, list): - for item in raw_context: - if isinstance(item, dict): - compiled_context.update(item) - event_dict: dict[str, Any] = {"name": compiled_event_name} - # A context written in the DSL is kept even when it is empty. - if compiled_context or len(fn_args) > 1: - event_dict["context"] = compiled_context - return {"event": event_dict} + return self._compile_event_call(val, raw_symbols, ctx, is_action) call_cat_id = raw_kwargs.pop("catalogId", None) if self._defines_component(fn_name, call_cat_id): diff --git a/python/a2ui_agent/src/a2ui/inference_formats/express/decompiler.py b/python/a2ui_agent/src/a2ui/inference_formats/express/decompiler.py index edf8beb83d..945dec6484 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/express/decompiler.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/express/decompiler.py @@ -835,17 +835,21 @@ def _decompile_value( if isinstance(val.get("event"), dict): evt = val["event"] - name = _decompile_string(evt.get("name", "")) - ctx = evt.get("context", {}) - ctx_reprs = [ - f"{_decompile_key(k)}:" - f" {self._decompile_value(v, var_names, False, surface_cat_id)}" - for k, v in ctx.items() - ] + event_args = [_decompile_string(evt.get("name", ""))] # An empty context is written out so that it compiles back. if "context" in evt: - return f"Event({name}, {{{', '.join(ctx_reprs)}}})" - return f"Event({name})" + ctx_reprs = [ + f"{_decompile_key(k)}:" + f" {self._decompile_value(v, var_names, False, surface_cat_id)}" + for k, v in evt.get("context", {}).items() + ] + event_args.append(f"{{{', '.join(ctx_reprs)}}}") + if evt.get("userMessage") is not None: + user_message = self._decompile_value( + evt["userMessage"], var_names, False, surface_cat_id + ) + event_args.append(f"userMessage={user_message}") + return f"Event({', '.join(event_args)})" if isinstance(val.get("functionCall"), dict): return self._decompile_call( diff --git a/python/a2ui_agent/tests/express/test_compiler.py b/python/a2ui_agent/tests/express/test_compiler.py index 0bcd42696c..a95806001f 100644 --- a/python/a2ui_agent/tests/express/test_compiler.py +++ b/python/a2ui_agent/tests/express/test_compiler.py @@ -42,6 +42,7 @@ ExpressParser, ExpressUndefinedRootError, ExpressUnknownPropertyError, + ExpressValidationError, ) from ..repo_paths import find_repo_root @@ -321,5 +322,212 @@ def test_number_literal_parse_errors(self): compiler.compile('root = Column([t], weight=.)\nt = Text("Hi")') +def _links_catalog(protocol_version: str) -> Catalog: + """Builds a catalog whose `Links` component nests an action inside each list item. + + `items[].action` and `primary` are `Action` slots, `tags[]` is a plain + string list, and `openUrl`/`concat` are catalog functions. + """ + call_key = "@call" if protocol_version == "1.0" else "call" + return Catalog.from_json( + { + "catalogId": "test/links", + "components": { + "Links": { + "type": "object", + "properties": { + "component": {"const": "Links"}, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "action": { + "$ref": "common_types.json#/$defs/Action" + }, + }, + "required": ["label", "action"], + }, + }, + "primary": {"$ref": "common_types.json#/$defs/Action"}, + "tags": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["component", "items"], + }, + }, + "functions": { + "openUrl": { + "type": "object", + "returnType": "void", + "properties": { + call_key: {"const": "openUrl"}, + "args": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + }, + }, + "required": [call_key, "args"], + }, + "concat": { + "type": "object", + "returnType": "string", + "properties": { + call_key: {"const": "concat"}, + "args": { + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"}, + }, + "required": ["a", "b"], + }, + }, + "required": [call_key, "args"], + }, + }, + }, + protocol_version=protocol_version, + ) + + +class TestExpressEventAndNestedActionSlots(unittest.TestCase): + """Covers the `Event(...)` keyword signature and action slots nested in list items.""" + + def setUp(self): + self.catalog = _links_catalog("1.0") + self.compiler = ExpressCompiler([self.catalog], version="v1.0") + + def _root(self, dsl: str) -> dict: + messages = to_message_dicts(self.compiler.compile(dsl)) + comps = {c["id"]: c for c in messages[0]["createSurface"]["components"]} + return comps["root"] + + def test_event_user_message_compiles_to_v1_field(self): + root = self._root( + 'root = Links([{label: "Reply", action: Event("reply",' + ' userMessage="Now")}])' + ) + self.assertEqual( + root["items"][0]["action"], + {"event": {"name": "reply", "userMessage": "Now"}}, + ) + + def test_event_user_message_binding_with_positional_context(self): + root = self._root( + 'root = Links([{label: "Reply", action: Event("reply", {threadId: "t1"},' + " userMessage=$/draft/message)}])" + ) + self.assertEqual( + root["items"][0]["action"], + { + "event": { + "name": "reply", + "userMessage": {"@path": "/draft/message"}, + "context": {"threadId": "t1"}, + } + }, + ) + + def test_event_context_keyword(self): + root = self._root( + 'root = Links([{label: "Go", action: Event("submit", context={itemId:' + ' "42"})}])' + ) + self.assertEqual( + root["items"][0]["action"], + {"event": {"name": "submit", "context": {"itemId": "42"}}}, + ) + + def test_event_duplicate_context_is_a_validation_error(self): + with self.assertRaises(ExpressDuplicateParamError) as ctx: + self.compiler.compile( + 'root = Links([{label: "Go", action: Event("submit", {a: 1},' + " context={b: 2})}])" + ) + self.assertIsInstance(ctx.exception, ExpressValidationError) + + def test_event_unknown_keyword_is_a_validation_error(self): + with self.assertRaises(ExpressInvalidParamError) as ctx: + self.compiler.compile( + 'root = Links([{label: "Reply", action: Event("reply",' + ' message="Now")}])' + ) + self.assertIsInstance(ctx.exception, ExpressValidationError) + self.assertIn("message", str(ctx.exception)) + + def test_event_non_string_user_message_is_a_validation_error(self): + with self.assertRaises(ExpressValidationError): + self.compiler.compile( + 'root = Links([{label: "Reply", action: Event("reply",' + " userMessage=42)}])" + ) + + def test_event_too_many_positional_args_is_a_validation_error(self): + with self.assertRaises(ExpressValidationError): + self.compiler.compile( + 'root = Links([{label: "Reply", action: Event("reply", {}, "extra")}])' + ) + + def test_event_user_message_before_v1_is_a_validation_error(self): + compiler = ExpressCompiler([_links_catalog("0.9")], version="v0.9") + with self.assertRaises(ExpressValidationError) as ctx: + compiler.compile( + 'root = Links([{label: "Reply", action: Event("reply",' + ' userMessage="Now")}])' + ) + self.assertIn("v1.0", str(ctx.exception)) + + def test_function_call_in_list_item_action_slot_is_wrapped(self): + root = self._root( + 'root = Links([{label: "Docs", action: openUrl("https://a2ui.org")}],' + ' primary=openUrl("https://a2ui.org/start"))' + ) + self.assertEqual( + root["items"][0]["action"], + { + "functionCall": { + "@call": "openUrl", + "args": {"url": "https://a2ui.org"}, + } + }, + ) + self.assertEqual( + root["primary"], + { + "functionCall": { + "@call": "openUrl", + "args": {"url": "https://a2ui.org/start"}, + } + }, + ) + + def test_function_call_in_list_item_action_slot_through_variable(self): + root = self._root( + 'docs = {label: "Docs", action: openUrl("https://a2ui.org")}\n' + "root = Links([docs])" + ) + self.assertEqual( + root["items"][0]["action"], + { + "functionCall": { + "@call": "openUrl", + "args": {"url": "https://a2ui.org"}, + } + }, + ) + + def test_function_call_in_non_action_nested_slot_stays_a_plain_call(self): + root = self._root( + 'root = Links([{label: "Go", action: Event("go")}], tags=[concat("a",' + ' "b")])' + ) + self.assertEqual( + root["tags"], + [{"@call": "concat", "args": {"a": "a", "b": "b"}}], + ) + + if __name__ == "__main__": unittest.main() diff --git a/python/a2ui_agent/tests/express/test_parser_decompile.py b/python/a2ui_agent/tests/express/test_parser_decompile.py index b3b0068df5..158273fb45 100644 --- a/python/a2ui_agent/tests/express/test_parser_decompile.py +++ b/python/a2ui_agent/tests/express/test_parser_decompile.py @@ -258,5 +258,91 @@ def test_decompiled_numbers_round_trip(self): self.assertEqual(comps["root"]["weight"], 1e-7) +class TestExpressDecompileEventAndNestedActions(unittest.TestCase): + """Covers writing `Event` keyword arguments and nested action slots back out.""" + + def setUp(self): + self.catalog = Catalog.from_json( + { + "catalogId": "test/links", + "components": { + "Links": { + "type": "object", + "properties": { + "component": {"const": "Links"}, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "action": { + "$ref": "common_types.json#/$defs/Action" + }, + }, + "required": ["label", "action"], + }, + }, + }, + "required": ["component", "items"], + }, + }, + "functions": { + "openUrl": { + "type": "object", + "returnType": "void", + "properties": { + "@call": {"const": "openUrl"}, + "args": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + }, + }, + "required": ["@call", "args"], + }, + }, + }, + protocol_version="1.0", + ) + self.compiler = ExpressCompiler([self.catalog], version="v1.0") + self.decompiler = ExpressParser([self.catalog]) + + def _round_trip(self, dsl: str) -> str: + """Decompiles the compiled DSL and checks the text compiles back the same.""" + compiled = self.compiler.compile(dsl) + decompiled = self.decompiler.decompile(compiled) + self.assertEqual( + to_message_dicts(self.compiler.compile(decompiled)), + to_message_dicts(compiled), + ) + return decompiled + + def test_event_user_message_is_written_as_keyword(self): + decompiled = self._round_trip( + 'root = Links([{label: "Reply", action: Event("reply",' + ' userMessage="Now")}])' + ) + self.assertIn('Event("reply", userMessage="Now")', decompiled) + + def test_event_user_message_binding_follows_context(self): + decompiled = self._round_trip( + 'root = Links([{label: "Reply", action: Event("reply", {threadId: "t1"},' + " userMessage=$/draft/message)}])" + ) + self.assertIn( + 'Event("reply", {threadId: "t1"}, userMessage=$/draft/message)', decompiled + ) + + def test_function_call_in_list_item_action_slot_is_written_as_a_call(self): + decompiled = self._round_trip( + 'root = Links([{label: "Docs", action: openUrl("https://a2ui.org")}])' + ) + self.assertIn( + 'root = Links([{label: "Docs", action: openUrl("https://a2ui.org")}])', + decompiled, + ) + + if __name__ == "__main__": unittest.main() From 706146ec1de54203789eb3e76459365c429d13b6 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 26 Oct 2026 14:14:52 -0700 Subject: [PATCH 2/4] fix(py-agent): compile the values of an Event as data, not actions The event's name, userMessage and context entries are DynamicValues, so a function call among them is a plain call. The action flag of the slot the event sits in no longer reaches them, which also matches the Dart compiler. --- .../a2ui/inference_formats/express/compiler.py | 15 ++++++++------- python/a2ui_agent/tests/express/test_compiler.py | 10 ++++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py b/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py index 6e7f0b18ef..d317e734e8 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py @@ -1401,7 +1401,6 @@ def _compile_event_call( val: CallNode, raw_symbols: dict[str, Any], ctx: _CompileContext, - is_action: bool, ) -> dict[str, Any]: """Compiles the reserved `Event(name, context?, userMessage=?, context=?)` signature. @@ -1409,6 +1408,10 @@ def _compile_event_call( earlier protocol version, and so is any other keyword the signature does not define, rather than being dropped from the compiled action. + The event's name, message and context entries are `DynamicValue`s, not + actions, so a function call among them stays a plain call whatever slot + the event itself sits in. + Raises: ExpressValidationError: If there are more than two positional arguments, `userMessage` is used before v1.0, or `userMessage` @@ -1433,9 +1436,7 @@ def _compile_event_call( raise ExpressDuplicateParamError("Event", "context") compiled_event_name = ( - self._compile_value(fn_args[0], raw_symbols, ctx, is_action) - if fn_args - else "" + self._compile_value(fn_args[0], raw_symbols, ctx) if fn_args else "" ) event_dict: dict[str, Any] = {"name": compiled_event_name} @@ -1446,7 +1447,7 @@ def _compile_event_call( f" target version {ctx.version} has no userMessage field." ) user_message = self._compile_value( - fn_kwargs["userMessage"], raw_symbols, ctx, is_action + fn_kwargs["userMessage"], raw_symbols, ctx ) if not isinstance(user_message, str) and not ( isinstance(user_message, dict) and ctx.path_key in user_message @@ -1464,7 +1465,7 @@ def _compile_event_call( else: return {"event": event_dict} - raw_context = self._compile_value(context_node, raw_symbols, ctx, is_action) + raw_context = self._compile_value(context_node, raw_symbols, ctx) # A context written in the DSL is kept even when it is empty. compiled_context: dict[str, Any] = {} if isinstance(raw_context, dict): @@ -1620,7 +1621,7 @@ def _compile_call( return {"path": template_path, "componentId": comp_id_val} if fn_name == "Event": - return self._compile_event_call(val, raw_symbols, ctx, is_action) + return self._compile_event_call(val, raw_symbols, ctx) call_cat_id = raw_kwargs.pop("catalogId", None) if self._defines_component(fn_name, call_cat_id): diff --git a/python/a2ui_agent/tests/express/test_compiler.py b/python/a2ui_agent/tests/express/test_compiler.py index a95806001f..be11a43933 100644 --- a/python/a2ui_agent/tests/express/test_compiler.py +++ b/python/a2ui_agent/tests/express/test_compiler.py @@ -414,6 +414,16 @@ def test_event_user_message_compiles_to_v1_field(self): {"event": {"name": "reply", "userMessage": "Now"}}, ) + def test_event_context_keeps_a_function_call_a_plain_call(self): + root = self._root( + 'root = Links([{label: "Go", action: Event("go",' + ' {label: concat("a", "b")})}])' + ) + self.assertEqual( + root["items"][0]["action"]["event"]["context"]["label"], + {"@call": "concat", "args": {"a": "a", "b": "b"}}, + ) + def test_event_user_message_binding_with_positional_context(self): root = self._root( 'root = Links([{label: "Reply", action: Event("reply", {threadId: "t1"},' From 90fafd530671af17744c3868600957de62677373 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 26 Oct 2026 16:36:18 -0700 Subject: [PATCH 3/4] fix(py-agent): accept a function call as an Event userMessage Action.event.userMessage is a DynamicString: a string, a data binding or a function call. The compiler accepted only the first two, so an event the decompiler wrote with a call did not compile back. The error for any other value now names what the model wrote instead of printing the node. Whether a property is an action slot now comes from its catalog schema alone, as in TypeScript; the fallback on the property names `action` and `submitAction` is gone, and the inline test catalogs that relied on it declare the common `Action` type. --- .../inference_formats/express/compiler.py | 52 +++++++++++++++---- .../express/test_catalogs_and_updates.py | 9 +++- .../a2ui_agent/tests/express/test_compiler.py | 33 +++++++++++- .../tests/express/test_parser_decompile.py | 9 ++++ .../test_multi_catalog_inference_formats.py | 4 +- 5 files changed, 92 insertions(+), 15 deletions(-) diff --git a/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py b/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py index d317e734e8..ebcca31c21 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py @@ -23,6 +23,7 @@ from collections.abc import Sequence from dataclasses import dataclass, field, replace +import json import re from typing import Any, Literal @@ -254,6 +255,38 @@ def _schema_allows_databinding(schema: Any) -> bool: return False +def _describe_express_value(val: Any) -> str: + """Names a parsed value for an error message in the terms the model wrote it in. + + Returns: + A short phrase such as `a map literal` or `the number 42`, rather than + the node's repr. + """ + if isinstance(val, CallNode): + return f"a call to {val.name}(...)" + if isinstance(val, CheckNode): + return f"a check (?{val.name})" + if isinstance(val, VariableNode): + return f"the variable {val.name}" + if isinstance(val, PathNode): + return f"the data binding ${val.path}" + if isinstance(val, SkippedNode): + return "'_'" + if isinstance(val, dict): + return "a map literal" + if isinstance(val, list): + return "a list" + if val is None: + return "null" + if isinstance(val, bool): + return f"the boolean {str(val).lower()}" + if isinstance(val, (int, float)): + return f"the number {json.dumps(val)}" + if isinstance(val, str): + return f"the string {json.dumps(val)}" + return f"a {type(val).__name__}" + + def _binding_path(v: Any, path_key: str) -> str | None: """Returns the path of a compiled data binding, or None if `v` is not one. @@ -1245,10 +1278,7 @@ def _compile_ast_node( arg, raw_symbols, ctx, - is_action=( - is_action_slot(prop_schema) - or prop_name in ["action", "submitAction"] - ), + is_action=is_action_slot(prop_schema), parent_id=comp_id, parent_prop=prop_name, slot=_ValueSlot(prop_schema, comp_helper) if prop_schema else None, @@ -1446,15 +1476,19 @@ def _compile_event_call( "Event userMessage requires protocol v1.0 or later; the" f" target version {ctx.version} has no userMessage field." ) + # `userMessage` is a DynamicString: a string, a data binding or a + # function call. user_message = self._compile_value( fn_kwargs["userMessage"], raw_symbols, ctx ) - if not isinstance(user_message, str) and not ( - isinstance(user_message, dict) and ctx.path_key in user_message - ): + is_binding_or_call = isinstance(user_message, dict) and ( + ctx.path_key in user_message or ctx.call_key in user_message + ) + if not isinstance(user_message, str) and not is_binding_or_call: raise ExpressValidationError( - "Event userMessage must be a string or a $path data binding," - f" got: {fn_kwargs['userMessage']!r}" + "Event userMessage must be a string, a $path data binding or" + " a function call, got" + f" {_describe_express_value(fn_kwargs['userMessage'])}." ) event_dict["userMessage"] = user_message diff --git a/python/a2ui_agent/tests/express/test_catalogs_and_updates.py b/python/a2ui_agent/tests/express/test_catalogs_and_updates.py index 8ad4769598..cb19ad8561 100644 --- a/python/a2ui_agent/tests/express/test_catalogs_and_updates.py +++ b/python/a2ui_agent/tests/express/test_catalogs_and_updates.py @@ -76,7 +76,12 @@ def _primary(version: str = "v1.0") -> Catalog: "Input": { "allOf": [ _CHECKABLE, - {"properties": {"value": _DYNAMIC_STRING, "action": {}}}, + { + "properties": { + "value": _DYNAMIC_STRING, + "action": {"$ref": "common_types.json#/$defs/Action"}, + } + }, ] }, }, @@ -99,7 +104,7 @@ def _secondary(version: str = "v1.0") -> Catalog: "properties": { "headline": _DYNAMIC_STRING, "content": {"$ref": "common_types.json#/$defs/ComponentId"}, - "action": {}, + "action": {"$ref": "common_types.json#/$defs/Action"}, } }, ] diff --git a/python/a2ui_agent/tests/express/test_compiler.py b/python/a2ui_agent/tests/express/test_compiler.py index be11a43933..84652dcf92 100644 --- a/python/a2ui_agent/tests/express/test_compiler.py +++ b/python/a2ui_agent/tests/express/test_compiler.py @@ -467,12 +467,41 @@ def test_event_unknown_keyword_is_a_validation_error(self): self.assertIsInstance(ctx.exception, ExpressValidationError) self.assertIn("message", str(ctx.exception)) - def test_event_non_string_user_message_is_a_validation_error(self): - with self.assertRaises(ExpressValidationError): + def test_event_user_message_accepts_a_function_call(self): + root = self._root( + 'root = Links([{label: "Reply", action: Event("reply",' + ' userMessage=concat("a", "b"))}])' + ) + self.assertEqual( + root["items"][0]["action"], + { + "event": { + "name": "reply", + "userMessage": { + "@call": "concat", + "args": {"a": "a", "b": "b"}, + }, + } + }, + ) + + def test_event_user_message_that_is_not_a_dynamic_string_is_an_error(self): + with self.assertRaises(ExpressValidationError) as ctx: self.compiler.compile( 'root = Links([{label: "Reply", action: Event("reply",' " userMessage=42)}])" ) + self.assertEqual( + str(ctx.exception), + "Event userMessage must be a string, a $path data binding or a" + " function call, got the number 42.", + ) + with self.assertRaises(ExpressValidationError) as ctx: + self.compiler.compile( + 'root = Links([{label: "Reply", action: Event("reply",' + " userMessage={a: 1})}])" + ) + self.assertIn("got a map literal.", str(ctx.exception)) def test_event_too_many_positional_args_is_a_validation_error(self): with self.assertRaises(ExpressValidationError): diff --git a/python/a2ui_agent/tests/express/test_parser_decompile.py b/python/a2ui_agent/tests/express/test_parser_decompile.py index 158273fb45..26e0d7c803 100644 --- a/python/a2ui_agent/tests/express/test_parser_decompile.py +++ b/python/a2ui_agent/tests/express/test_parser_decompile.py @@ -334,6 +334,15 @@ def test_event_user_message_binding_follows_context(self): 'Event("reply", {threadId: "t1"}, userMessage=$/draft/message)', decompiled ) + def test_event_user_message_function_call_round_trips(self): + decompiled = self._round_trip( + 'root = Links([{label: "Reply", action: Event("reply",' + ' userMessage=openUrl("https://a2ui.org"))}])' + ) + self.assertIn( + 'Event("reply", userMessage=openUrl("https://a2ui.org"))', decompiled + ) + def test_function_call_in_list_item_action_slot_is_written_as_a_call(self): decompiled = self._round_trip( 'root = Links([{label: "Docs", action: openUrl("https://a2ui.org")}])' diff --git a/python/a2ui_agent/tests/test_multi_catalog_inference_formats.py b/python/a2ui_agent/tests/test_multi_catalog_inference_formats.py index e483c4acc5..6a0bab714c 100644 --- a/python/a2ui_agent/tests/test_multi_catalog_inference_formats.py +++ b/python/a2ui_agent/tests/test_multi_catalog_inference_formats.py @@ -48,7 +48,7 @@ def primary_catalog() -> Catalog: { "properties": { "primaryArg": {"type": "string"}, - "action": {"type": "object"}, + "action": {"$ref": "common_types.json#/$defs/Action"}, } }, ] @@ -104,7 +104,7 @@ def secondary_catalog() -> Catalog: { "properties": { "secondaryArg": {"type": "string"}, - "action": {"type": "object"}, + "action": {"$ref": "common_types.json#/$defs/Action"}, } }, ] From 92e225a4bb9e2462e2c547591c9af9432f9243c0 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 26 Oct 2026 18:27:58 -0700 Subject: [PATCH 4/4] fix(py-agent): keep a property named items apart from array items resolve_subschema took the key "items" to mean the array items, so an object property named items was never looked up among the properties. It is now two methods, resolve_item_schema and resolve_property_schema, and the item slot is resolved once per list rather than once per element. Before v1.0 the decompiler writes an event carrying a userMessage as a map literal, since that version's compiler rejects userMessage= and the literal compiles back as written. --- .../_shared/schema_helper.py | 41 ++++++++--- .../inference_formats/express/compiler.py | 25 +++++-- .../inference_formats/express/decompiler.py | 7 +- .../a2ui_agent/tests/express/test_compiler.py | 72 +++++++++++++++++++ .../tests/express/test_parser_decompile.py | 32 +++++++++ 5 files changed, 159 insertions(+), 18 deletions(-) diff --git a/python/a2ui_agent/src/a2ui/inference_formats/_shared/schema_helper.py b/python/a2ui_agent/src/a2ui/inference_formats/_shared/schema_helper.py index d26ed585b5..ee73175c6e 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/_shared/schema_helper.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/_shared/schema_helper.py @@ -494,22 +494,41 @@ def _check(s: Any, in_common_types: bool) -> bool: return _check(schema, False) - def resolve_subschema(self, schema: Any, key: str) -> dict[str, Any] | None: - """Resolves the schema of an array item or an object property. + def resolve_item_schema(self, schema: Any) -> dict[str, Any] | None: + """Resolves the schema of an array's items. Follows `$ref` (into both the common types and the catalog document), `allOf`, `oneOf` and `anyOf` to find the schema declared at that position. Args: - schema: The parent JSON schema to inspect. - key: `items` for array items, or the property name for object - properties. A property name falls back to - `additionalProperties` when it is not declared. + schema: The array's JSON schema. Returns: - The resolved sub-schema, or None if none is declared. + The resolved item schema, or None if none is declared. """ + return self._resolve_at(schema, None) + + def resolve_property_schema(self, schema: Any, name: str) -> dict[str, Any] | None: + """Resolves the schema of an object's property. + + Follows `$ref` (into both the common types and the catalog document), + `allOf`, `oneOf` and `anyOf` to find the schema declared at that + position, and falls back to `additionalProperties` when the property + is not declared. A property named `items` is looked up as a property, + not as the array items. + + Args: + schema: The object's JSON schema. + name: The property name. + + Returns: + The resolved property schema, or None if none is declared. + """ + return self._resolve_at(schema, name) + + def _resolve_at(self, schema: Any, prop: str | None) -> dict[str, Any] | None: + """Resolves the schema at a property, or at the array items when `prop` is None.""" visited: set[int] = set() def _resolve(s: Any, in_common_types: bool) -> dict[str, Any] | None: @@ -517,15 +536,15 @@ def _resolve(s: Any, in_common_types: bool) -> dict[str, Any] | None: return None visited.add(id(s)) - if key == "items": + if prop is None: items = s.get("items") if isinstance(items, dict): return items else: props = s.get("properties") - prop = props.get(key) if isinstance(props, dict) else None - if isinstance(prop, dict): - return prop + declared = props.get(prop) if isinstance(props, dict) else None + if isinstance(declared, dict): + return declared additional = s.get("additionalProperties") if isinstance(additional, dict): return additional diff --git a/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py b/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py index ebcca31c21..23b02a1953 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py @@ -84,15 +84,27 @@ class _ValueSlot: helper: CatalogSchemaHelper -def _sub_slot(slot: _ValueSlot | None, key: str) -> _ValueSlot | None: - """Descends a value slot into an array item (`items`) or an object property. +def _item_slot(slot: _ValueSlot | None) -> _ValueSlot | None: + """Descends a value slot into the items of an array. Returns: - The nested slot, or None when the schema is unknown at that position. + The item slot, or None when the schema is unknown at that position. """ if slot is None: return None - schema = slot.helper.resolve_subschema(slot.schema, key) + schema = slot.helper.resolve_item_schema(slot.schema) + return _ValueSlot(schema, slot.helper) if schema else None + + +def _property_slot(slot: _ValueSlot | None, name: str) -> _ValueSlot | None: + """Descends a value slot into a property of an object, whatever its name. + + Returns: + The property's slot, or None when the schema is unknown at that position. + """ + if slot is None: + return None + schema = slot.helper.resolve_property_schema(slot.schema, name) return _ValueSlot(schema, slot.helper) if schema else None @@ -1592,7 +1604,7 @@ def _compile_value( if isinstance(val, dict): dict_res: dict[str, Any] = {} for k, v in val.items(): - entry_slot = _sub_slot(slot, k) + entry_slot = _property_slot(slot, k) entry_is_action = is_action or ( entry_slot is not None and is_action_slot(entry_slot.schema) ) @@ -1602,7 +1614,8 @@ def _compile_value( return dict_res if isinstance(val, list): - item_slot = _sub_slot(slot, "items") + # Resolved once for the list, since every item shares the schema. + item_slot = _item_slot(slot) item_is_action = is_action or ( item_slot is not None and is_action_slot(item_slot.schema) ) diff --git a/python/a2ui_agent/src/a2ui/inference_formats/express/decompiler.py b/python/a2ui_agent/src/a2ui/inference_formats/express/decompiler.py index 945dec6484..6511c6176f 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/express/decompiler.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/express/decompiler.py @@ -833,7 +833,12 @@ def _decompile_value( if isinstance(bound_path, str) and len(val) == 1: return _decompile_path(bound_path) - if isinstance(val.get("event"), dict): + # Before v1.0 an event has no userMessage, and the compiler rejects + # `userMessage=`, so such an object is written as a map literal that + # compiles back as written. + if isinstance(val.get("event"), dict) and ( + self._v1 or val["event"].get("userMessage") is None + ): evt = val["event"] event_args = [_decompile_string(evt.get("name", ""))] # An empty context is written out so that it compiles back. diff --git a/python/a2ui_agent/tests/express/test_compiler.py b/python/a2ui_agent/tests/express/test_compiler.py index 84652dcf92..ecd0c52cce 100644 --- a/python/a2ui_agent/tests/express/test_compiler.py +++ b/python/a2ui_agent/tests/express/test_compiler.py @@ -503,6 +503,78 @@ def test_event_user_message_that_is_not_a_dynamic_string_is_an_error(self): ) self.assertIn("got a map literal.", str(ctx.exception)) + def test_property_named_items_is_a_property_not_the_array_items(self): + """A map key `items` is an object property; its item schema is reached through a local $ref.""" + catalog = Catalog.from_json( + { + "catalogId": "test/menu", + "components": { + "Menu": { + "type": "object", + "properties": { + "component": {"const": "Menu"}, + "menu": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"$ref": "#/$defs/MenuItem"}, + } + }, + }, + }, + "required": ["component", "menu"], + } + }, + "functions": { + "openUrl": { + "type": "object", + "returnType": "void", + "properties": { + "@call": {"const": "openUrl"}, + "args": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + }, + }, + "required": ["@call", "args"], + } + }, + "$defs": { + "MenuItem": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "action": {"$ref": "common_types.json#/$defs/Action"}, + }, + "required": ["label", "action"], + } + }, + }, + protocol_version="1.0", + ) + compiler = ExpressCompiler([catalog], version="v1.0") + messages = to_message_dicts( + compiler.compile( + 'root = Menu({items: [{label: "Docs", action:' + ' openUrl("https://a2ui.org")}]})' + ) + ) + root = messages[0]["createSurface"]["components"][0] + self.assertEqual( + root["menu"]["items"], + [{ + "label": "Docs", + "action": { + "functionCall": { + "@call": "openUrl", + "args": {"url": "https://a2ui.org"}, + } + }, + }], + ) + def test_event_too_many_positional_args_is_a_validation_error(self): with self.assertRaises(ExpressValidationError): self.compiler.compile( diff --git a/python/a2ui_agent/tests/express/test_parser_decompile.py b/python/a2ui_agent/tests/express/test_parser_decompile.py index 26e0d7c803..a3e9318c7c 100644 --- a/python/a2ui_agent/tests/express/test_parser_decompile.py +++ b/python/a2ui_agent/tests/express/test_parser_decompile.py @@ -343,6 +343,38 @@ def test_event_user_message_function_call_round_trips(self): 'Event("reply", userMessage=openUrl("https://a2ui.org"))', decompiled ) + def test_event_with_user_message_is_a_map_literal_before_v1(self): + """v0.9 has no userMessage and its compiler rejects `userMessage=`, so the object stays data.""" + catalog = Catalog.from_json( + { + "catalogId": "test/v09", + "components": { + "Card": { + "type": "object", + "properties": { + "component": {"const": "Card"}, + "child": {"type": "object"}, + }, + "required": ["component", "child"], + } + }, + }, + protocol_version="0.9", + ) + decompiled = ExpressParser([catalog], version="v0.9").decompile([{ + "version": "v0.9", + "updateComponents": { + "surfaceId": "s1", + "components": [{ + "id": "root", + "component": "Card", + "child": {"event": {"name": "go", "userMessage": "hi"}}, + }], + }, + }]) + self.assertNotIn("Event(", decompiled) + self.assertIn("{event: {name:", decompiled) + def test_function_call_in_list_item_action_slot_is_written_as_a_call(self): decompiled = self._round_trip( 'root = Links([{label: "Docs", action: openUrl("https://a2ui.org")}])'