[dart] Extend a2ui_core for agent SDKs, limited to protocol v0.9 - #2439
Conversation
Prerequisite for the Dart a2ui_agent API (#2408). Everything here is a change to a2ui_core, or to a consumer of it, split out so the agent PR reviews as agent work only. Catalog and capabilities - `Catalog<C extends ComponentApi, F extends FunctionApi>` (breaking, 0.1.1 -> 0.2.0). Agents parameterise with `CatalogFunction` (signature only), renderers with `FunctionImplementation`. `SchemaCatalog` aliases the agent shape. - `Catalog.fromJson` / `catalogSchema` / `copyWith`, plus schema-only `CatalogComponent` and `CatalogFunction`, so catalog documents round trip through core and a narrowed catalog renders a narrowed document with `$defs/anyComponent` and `$defs/anyFunction` narrowed to match. - `A2uiRendererCapabilities`, mirroring `client_capabilities.json` and web_core's `A2uiClientCapabilities`. - `A2uiProtocolVersion`, and the `A2uiParseError` / `A2uiCompileError` / `A2uiCatalogError` / `A2uiIntegrityError` / `A2uiRecursionError` categories. - `A2uiValidator`, with the v0.9 version gate implemented and the structural and catalog-schema checks declared but stubbed. One bug fixed - `DataModel.set` silently dropped a write whose parent path resolved to a primitive (`/user/name/first` where `/user/name` is a string). It now throws `A2uiDataError`, matching web_core. The shared dataset surfaced this. Notification on an unchanged value is fixed the same way: the signal is handed a copy of a container rather than bypassing the equality check. Shared conformance data - `core/data_model.yaml` (new, `data_model` action): 37 cases migrated from `renderers/web_core/src/v0_9/state/data-model.test.ts`. - `core/message_processor.yaml` (new, `process_messages` action): the two surface-isolation cases the v1_0 branch's suite of the same name does not cover, written in that branch's case vocabulary so the two files concatenate rather than conflict when it lands. - `conformance_schema.json` gains the two actions and the `DataError` category; the file's existing formatting is preserved. web_core consumes the shared data - `tests/conformance/harness.ts` locates `conformance/` by walking up. - `data-model.conformance.test.ts` and `message-processor.conformance.test.ts` run the two suites. Both are additive: the hand-written `data-model.test.ts` and `message-processor.test.ts` are untouched. Also - `blueprints/modules/a2ui_core.blueprint.md`: package boundary and non-goals, catalog immutability and per-catalog protocol version, the version-keyed capabilities objects and their normative schemas, the complete exception hierarchy plus the rule that parsing wire JSON raises from it, and a conformance section (there was none). - `flutter_packages_test.yml` discovers packages under `dart/` as well as `samples/`. The `dart/` packages were not built, analyzed or tested by any workflow before this. - `dart/a2ui_agent` gets the two changes that exposure requires: its `a2ui_core` constraint follows the major bump, and the unnecessary `library;` directive its stub carried is removed.
# Conflicts: # conformance/conformance_schema.json # dart/a2ui_agent/lib/a2ui_agent.dart # dart/a2ui_agent/pubspec.yaml
There was a problem hiding this comment.
Code Review
This pull request updates the A2UI Core SDK by introducing the A2uiValidator for multi-stage payload validation, refactoring Catalog to support generic type parameters, and adding conformance test suites for the data model and message processor in both Dart and TypeScript. It also refines DataModel observer notifications and improves message parsing error handling. The review feedback highlights several critical areas in the Dart implementation where strict type checks (e.g., Map<String, dynamic>) could cause runtime TypeErrors when handling programmatically constructed maps (e.g., Map<dynamic, dynamic>). It is recommended to apply the suggested robust casting and pattern-matching patterns to prevent these failures.
A renderer can need a smaller catalog for a given use case and derives one the same way. What is agent-owned is the named transformer rules and the config pipeline that applies them, not narrowing itself -- which is the actual reason their conformance data belongs under agent/.
'a capabilities payload above all' said nothing precise; it is just an example, so name it as one. Also applies prettier, which the previous commit skipped.
The per-item prose restated what the a2ui_agent blueprint already documents. A list of what is not in core, with one pointer to where it is specified, carries the same boundary without the duplication.
| url: {type: string} | ||
| required: [component, url] | ||
| args: | ||
| allowed_components: [Text] |
There was a problem hiding this comment.
Why do we need allowed_components in the Catalog.catalog_schema method? It is supposed to be a concept in the agent when trying to remove some components to reduce input tokens for better inference performance.
There was a problem hiding this comment.
You're right, and it's removed. Pruning is an agent concern and it already has its own action — test_with_pruning_components_v09 covers exactly what the case did, including anyComponent narrowing down to the components that survive. Borrowing allowed_components into catalog_schema conflated two things and added no coverage.
catalog_schema now just parses a document and rebuilds it: same components and functions, metadata preserved, every local reference resolving, and rebuilding twice giving the same document. args is gone from CatalogSchemaTest too.
No library change either way — allowed_components was never a parameter of Catalog.catalogSchema; the case pruned through copyWith before rebuilding.
| }) : groupModel = SurfaceGroupModel<T>() { | ||
| }) : validator = | ||
| validator ?? | ||
| A2uiValidator<T, FunctionImplementation>(catalogs: catalogs), |
There was a problem hiding this comment.
This is problematic. It takes all catalogs to initialize the validator. However, we should use the particular catalog from the messages to run validation.
A message processor defines a list of catalogs to support all surfaces and components. However, a component is only from one particular catalog.
For example,
- component_1 in surface_1 uses catalog1.
- component_2 in surface_2 uses catalog2.
We should use catalog1 to validate component_1, and use catalog2 to validate component_2.
There was a problem hiding this comment.
The constructor takes all catalogs, but that map is not what components are validated against. Every call site passes the surface's own catalog: validateTheme(message.theme, catalog) on create, and validateComponent(compJson, surface.catalog) and validateComponentBatch(..., surface.catalog) on update. Each SurfaceModel stores the catalog named at createSurface, so your example already behaves as you describe.
I checked it with two catalogs and two surfaces:
s1 (cat1) + Alpha: ACCEPTED s2 (cat2) + Beta: ACCEPTED
s1 (cat1) + Beta: REJECTED s2 (cat2) + Alpha: REJECTED
Added that as a test, since nothing was pinning it.
But, you were right about the standalone validator, though. A2uiValidator has no surface state, and v0.9 puts catalogId on createSurface only, so for a payload that just updates a surface it could not tell which catalog applied. It picked the sole catalog when there was one and otherwise skipped those components and returned success. A bogus component was rejected with one catalog and silently accepted with two.
Fixed two ways: validate now takes an optional surfaceCatalogs map naming the catalog per surface id, and when it still cannot tell it throws A2uiCatalogError instead of skipping. Reporting a payload valid that nothing checked was the worse failure.
This is also a good argument for your other suggestion about agents driving MessageProcessor — the processor knows the mapping already, so the question does not arise there.
There was a problem hiding this comment.
Instead of passing a map of available catalogs to every validation method, what about scoping A2uiValidator to a single catalog?
Under this model, the MessageProcessor would instantiate a fresh validator instance scoped to the target catalog every time it processes incoming messages.
My reasoning comes from our message-handling lifecycle. While the validator is structurally designed to handle messages containing multiple surfaces, Line 293 in message-processor.ts reveals that the processor actively blocks the mixing of multiple update types within a single batch.
Since MessageProcessor already filters and splits messages down to specific, surface-isolated payloads before invoking the validation step, A2uiValidator only ever needs to validate against one active catalog context. Transitioning it to a single-catalog scope would significantly simplify our signature contracts and dependency passing.
There was a problem hiding this comment.
This requires updating MessageProcessor first to block mixed update types. As you mentioned, you'd like to get this PR in to unblock other work. I'm okay with addressing them as followups.
There was a problem hiding this comment.
Yes, will address.
There was a problem hiding this comment.
A2uiValidator is scoped to many catalogs in the blueprint.
Updated blueprint to scope it to single catalog and implemented: #2538
`dart format --set-exit-if-changed` over dart/ fails on validator_test.dart and message_processor_conformance_test.dart, which arrived with a2ui-project#2439. fix_format.sh formats samples/client/flutter and renderers/flutter, never dart/, so nothing has been holding these packages to a format until the job this PR adds. Pure `dart format` output, with the same result under --language-version=3.10, so it is not a language-version difference.
Contributes to #2356, #2373.
Prerequisite for #2408 (Dart
a2ui_agentAPI), split out of it in response to review feedback asking that the agent PR carry agent work only. Everything here is a change toa2ui_coreand necessary support in infrastructure.dart/a2ui_coreCatalog
Catalog<C extends ComponentApi, F extends FunctionApi>— breaking,0.1.1→0.2.0. Agents parameterise withCatalogFunction(signature only), renderers withFunctionImplementation.SchemaCatalogaliases the agent shape.Catalog.fromJson/catalogSchema/copyWith, plus schema-onlyCatalogComponentandCatalogFunction. Catalog documents round-trip through core, and a narrowed catalog renders a narrowed document with$defs/anyComponentand$defs/anyFunctionnarrowed to match — so the schema knowledge lives in one place and an agent's transformers stay trivial.Capabilities, versions, errors
A2uiRendererCapabilities, mirroringclient_capabilities.jsonand web_core'sA2uiClientCapabilities.A2uiProtocolVersionand theA2uiParseError/A2uiCompileError/A2uiCatalogError/A2uiIntegrityError/A2uiRecursionErrorcategories.A2uiValidator, with the v0.9 version gate implemented and the structural / catalog-schema checks declared but stubbed.One bug fixed
DataModel.setsilently dropped a write whose parent path resolved to a primitive (/user/name/firstwhere/user/nameis a string). It now throwsA2uiDataError, matching web_core. The shared dataset below surfaced it.Notification on an unchanged value is fixed the same way:
DataModelhands the signal a copy of a container rather than bypassing the equality check, so an observer whose own value did not change is no longer woken. Both behaviours moved into the shared suite rather than being excluded from it.conformance/— shared datacore/data_model.yaml(new,data_modelaction): 37 cases migrated fromrenderers/web_core/src/v0_9/state/data-model.test.ts.core/message_processor.yaml(new,process_messagesaction): the v1_0 branch's suite of the same name is the primary one, with 51 cases. Only the two it does not cover are here — component and data-model isolation between surfaces, which it opens two surfaces for twice and asserts in neither — written in the v1_0 case vocabulary (messages,catalogPaths,expect.surfaces) so the two files concatenate instead of conflicting when that branch lands.conformance_schema.jsongains the two actions and theDataErrorcategory; the file's existing formatting is preserved.renderers/web_core— consuming the shared datatests/conformance/harness.ts(new) locatesconformance/by walking up from the test file and loads a suite by name.data-model.conformance.test.tsandmessage-processor.conformance.test.tsrun the two suites. Both are additive: the hand-writtendata-model.test.tsandmessage-processor.test.tsare untouched.yamldevDependency.Each harness builds catalogs natively from the case's catalog id, because neither renderer builds a catalog from JSON Schema.
Divergences the migration surfaced
Comparing two live implementations turned up disagreements that are decisions, not formatting:
/items/999999999must be rejected; JavaScript arrays are sparse, so the same write is cheap and allowed. Sparse arrays protect web_core's heap but not its serialized size, which makes this an amplification vector whensendDataModelships the model back to the agent — filed as web_core: DataModel accepts unbounded list indices, amplifying the serialized client data model #2420 rather than changed here, since capping accepted input for a shipped renderer is a product decision, not a side effect of a test migration.null/undefinedpath arguments,undefinedversus a removed key, leading-zero indices.These want a maintainer decision.
blueprints/modules/a2ui_core.blueprint.mdThe gaps that produced the review findings on #2408, written down so the next generated implementation does not repeat them:
a2ui_agentdepends ona2ui_coreand never the reverse; core does not own catalog narrowing, prompt generation, response parsing or capability negotiation; a core change made to unblock an agent should be minimal and land as its own reviewable unit (this PR).protocolVersionis a property of each catalog rather than of the process.client_capabilities.json/server_capabilities.jsonin v0.9,renderer_capabilities.json/agent_capabilities.jsonin v1.0, a third spelling in v0.8).TypeError/ClassCastException.core/vsagent/split is about ownership, not subject matter, and an existing suite must be looked for on every active spec branch before a new one is authored.CI
flutter_packages_test.ymlnow discovers packages underdart/as well assamples/. Thedart/packages were not built, analyzed or tested by any workflow before this.dart/a2ui_agentTwo lines, both consequences of the above rather than agent work:
a2ui_coreconstraint follows the major bump (^0.1.1→^0.2.0);library;directive in the stub is removed, which the new CI coverage would otherwise fail on.Verification
All run locally:
a2ui_core: format,dart analyze --fatal-infos,flutter testa2ui_agent(stub):dart analyze --fatal-infos,flutter testweb_core:yarn test,yarn lintconformance:pytest(suite self-validation)yarn build:allprettier --check,validate_blueprints.py