Skip to content

feat(dart): stub the remaining a2ui_agent API for protocol v0.9 - #2890

Merged
polina-c merged 6 commits into
mainfrom
09-remaining-api
Sep 30, 2026
Merged

polina-c merged 6 commits into
mainfrom
09-remaining-api

Conversation

@polina-c

Copy link
Copy Markdown
Collaborator

Contributes to #2356. Follows #2747 and #2757.

Prompt

Create stubbed API to enable the 0.9 protocol, following the agent SDK blueprint. Previous PR: #2747.

Summary

#2747 added the part of the a2ui_agent API that one Express turn needs, and #2757 implemented it. This PR declares the rest of the agent SDK blueprint API in Dart, still limited to protocol v0.9. As in #2747, a member that is not trivial to implement throws UnimplementedError, so follow-up PRs can fill in bodies without changing signatures.

The layout follows section 2 of the blueprint: catalog_transformers/, processor/catalog_providers.dart, utils/catalog_resolver.dart, inference_formats/direct_json/ and inference_formats/express/decompiler.dart are new.

Implemented

Each of these is a few lines over code that already existed:

  • CatalogTransformer, ComponentPruningTransformer and FunctionPruningTransformer. Pruning is Catalog.copyWith with a filter.
  • CatalogConfig.transformers, and CatalogConfig.transformedCatalog, which applies them in order.
  • resolveCatalogs, which now holds the negotiation that used to be inside A2uiGenerator.createProcessor. It returns the transformed catalogs.
  • examples on A2uiGenerator, A2uiRequestProcessor, PromptGenerator and InferenceFormatFactory.createFormat. The processor checks each example with the existing validatePayloads when it is created, so an example that uses a pruned component fails at creation with A2uiValidationError.
  • An inferenceFormatFactory argument on A2uiGenerator.createProcessor, which overrides the generator's format for one processor.
  • Parser.supportsStreaming, false by default, and Parser.parseChunk, which throws UnsupportedError unless a parser overrides it.
  • ExpressParser.hasFormatContent and ExpressParser.wrap, using the tag scanning unwrap already had.

Stubbed

These throw UnimplementedError:

  • FileSystemCatalogProvider.load and InMemoryCatalogProvider.load, and so CatalogConfig.fromPath.
  • The direct JSON format: DirectJsonPromptGenerator.generate and every DirectJsonParser method, including parseChunk and the default progressiveKeys. DirectJsonFormatFactory, DirectJsonFormat (with allowedMessages) and DirectJsonParser.supportsStreaming are real.
  • ExpressDecompiler.decompile, which ExpressParser.decompile calls.
  • ExpressPromptGenerator.generate when examples are given.
  • resolveCatalogs(acceptsInlineCatalogs: true) when the renderer sends inline catalogs.

A2uiProtocolVersion has only v0_9, so the providers cannot be given another version. resolveCatalogs keeps the check that the renderer declared v0.9 capabilities. The stub docs say that providers and DirectJsonParser.compile reject other and missing versions.

Decisions

  • A2uiGenerator.inferenceFormatFactory and A2uiRequestProcessor.formatFactory now default to DirectJsonFormatFactory, as the blueprint says, instead of being required as in feat(dart): minimal a2ui_agent API for the Express format, protocol v0.9 #2747. Until direct JSON is implemented, an agent that omits the format gets a processor whose promptSnippet and parseResponse throw UnimplementedError. The example and the e2e test pass ExpressFormatFactory and are unaffected. Keeping the format required until direct JSON works is a small revert if we prefer it.
  • Each example is checked on its own, on surfaces that start empty, the same way parseResponse checks a response. An example that only updates a surface created in an earlier turn is therefore rejected.
  • PromptGenerator holds catalogs and examples in the base class, as in the blueprint, so subclasses pass them to super.
  • The direct JSON classes are exported, because allowedMessages and customProgressiveKeys can only be set through their constructors. Express still exports only ExpressFormatFactory, since everything it takes goes through the factory.
  • Following the blueprint, a provider has no fallback version. A v0.9 catalog document declares none, so loading one will need protocolVersion: A2uiProtocolVersion.v0_9.
  • RawResponsePart keeps the Implement express, v0.9, in dart agent. #2757 shape, with isFinal on RawA2uiPart rather than a wrapper around the part.

These change the API for custom formats: Parser subclasses must implement hasFormatContent, wrap and decompile, and InferenceFormatFactory.createFormat takes examples.

Tests

  • test/generator_test.dart covers transformed catalogs, the format override, the direct JSON default, examples reaching the format, an example rejected because its component was pruned, and resolveCatalogs ignoring inline catalogs by default.
  • test/express_test.dart covers hasFormatContent, including a close tag inside a string, and that Express does not stream.
  • test/conformance/catalog_transformer_conformance_test.dart runs conformance/agent/catalog_transformer.yaml. The suite loading code it shares with the Express harness moved to test/conformance/suites.dart.
  • The Express conformance harness now runs the wrap cases and applies transformers.

Skipped conformance cases went from 23 to 18. Still skipped, with the reason in the test output: 12 decompile cases, 2 prompt example cases, 1 message allowlist case, 1 callRendererFunction case, and two new ones:

  • test_function_pruning_on_catalog_without_functions loads a catalog document with no catalogId, which only a catalog provider could supply.
  • test_express_snippet_omits_a_pruned_component fails because the Express syntax rules end with the example root = Card(...), so Card( is in the snippet even when Card is pruned. The transformer skip hid this before. The fix is to change that example in the rules, which is left out of this PR because it changes the prompt.

Verification

  • flutter analyze --fatal-infos at the repository root reports no issues, and dart format makes no changes to the files in this PR.
  • dart test in dart/a2ui_agent: 120 tests pass and 18 are skipped. On main it is 90 and 23.
  • The e2e test was not run. The calls it makes did not change.
  • a2ui_agent is bumped to 0.0.1-wip005, with a changelog entry.

Declares the rest of the agent SDK blueprint API in dart/a2ui_agent,
limited to protocol v0.9. Catalog transformers, resolveCatalogs,
prompt example plumbing and Express wrap/hasFormatContent are
implemented. Catalog providers, the direct JSON format, Express
decompilation, prompt examples in the Express prompt and inline
catalogs throw UnimplementedError.
@github-project-automation github-project-automation Bot moved this to Todo in A2UI Sep 29, 2026
@polina-c
polina-c marked this pull request as ready for review September 29, 2026 22:59
@polina-c polina-c mentioned this pull request Sep 29, 2026
24 tasks done

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request expands the A2UI agent SDK for protocol v0.9 by declaring the remaining blueprint APIs (such as CatalogProvider and the direct JSON format) as stubs, introducing catalog transformers (ComponentPruningTransformer and FunctionPruningTransformer), and adding support for prompt examples. It also updates the Parser interface with new members like hasFormatContent and wrap, which are implemented for the Express parser. Feedback on the changes suggests that ExpressParser.hasFormatContent should explicitly return false if a direct JSON payload tag is present to avoid unexpected parse errors.

@polina-c
polina-c requested a review from nan-yu September 29, 2026 23:23

@jacobsimionato jacobsimionato left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great overall. Approval so you can submit in your timezone if you feel happy enough!

/// Writes [a2uiPayload] in this format's notation, without sentinel tags.
///
/// The inverse of [compile].
String decompile(List<AgentToRendererMessage> a2uiPayload);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yay, symmetry and type safety!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@override
InferenceFormat createFormat(
List<SchemaCatalog> catalogs, {
List<List<AgentToRendererMessage>> examples = const [],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to expose allowedMessages here too? And possibly plumb through customProgressiveKeys to the parser?

I think we should either provide full plumbing as above, or else consider dropping directly JSON streaming and just implementing it in the express compiler. Currently, JSON supports streaming and Express does not, but it makes more sense for it to be the other way around, seeing as Express is the format people will choose when they care about latency.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the plumbing. As written, neither option could be reached through A2uiGenerator: it only calls createFormat(catalogs, examples: ...), so allowedMessages was only settable if you built a DirectJsonFormat yourself, and progressive keys couldn't be set at all.

createFormat is the shared interface and the SDK calls it once per request, so the options go on the factory instead: DirectJsonFormatFactory({allowedMessages, progressiveKeys}). The factory passes them to each DirectJsonFormat, which passes progressiveKeys to each parser it creates. The factory is still const, so the A2uiGenerator default doesn't change. The blueprint had the same gap, so I updated §4.A in this PR to match.

On streaming, I'd rather not change it in this PR. The blueprint says Express doesn't stream on purpose: an Express block resolves references across its whole body, so a partial block names components that aren't defined yet (§4.B). Making Express stream means redesigning the format (for example, streaming statement by statement once each statement's references resolve), not just the parser. I agree that latency is the reason people pick Express, so I'll open an issue to discuss Express streaming and whether direct JSON streaming is still worth having.


/// The string properties whose value a streamed payload may show before
/// the value is complete, such as the text of a `Text` component.
Set<String> get progressiveKeys =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should just have progressiveKeys passed as an argument, rather than having a default and then an override (customProgressiveKeys).

Any default value is probably tied to a specific catalog, e.g. the basic catalog, so we want to avoid it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Any default set would be a guess about one catalog (text in basic), and inference formats have to stay catalog-agnostic. The schema has no marker for which properties hold prose, so we can't derive a set from it either.

The parser now takes progressiveKeys directly, defaulting to the empty set. customProgressiveKeys and the getter that threw UnimplementedError are gone. The conformance suite already treats an empty set as "healing off" (response_streaming.yaml), so the default is defined behaviour and not tied to any catalog. I kept an empty default rather than making it required, so the default DirectJsonFormatFactory() still works. Streaming then just holds a message back until its strings are complete. I'm happy to make it required if you'd rather callers always choose. I made the same change to the blueprint (§4.A).

/// one request.
abstract class PromptGenerator {
const PromptGenerator();
const PromptGenerator(this.catalogs, {this.examples = const []});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the abstract class need to expose Catalogs, Examples, Constructor etc? Could it just expose generate() and then each inference format implementation can have whatever constructor it wants? I don't expect application code to need to access catalogs, examples etc from the PromptGenerator. If anything, I'd expect them exposed on InferenceFormat.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. App code only calls generate(), and each format builds its generator from different inputs (direct JSON also takes allowedMessages). PromptGenerator is back to const PromptGenerator() plus String generate(), as it is on main. ExpressPromptGenerator and DirectJsonPromptGenerator now own catalogs and examples. This also drops the breaking change for custom PromptGenerator subclasses from the CHANGELOG.

The blueprint had catalogs and examples on the base class (§3.B), so I updated it too. The rule that examples render in order and carry no label is now prose that applies to every format's generator. Dart is the first SDK to implement this part of the blueprint (the Python SDK still has the older template-method PromptGenerator), so nothing else needs to change.

I didn't add them to InferenceFormat, since nothing needs them there yet. The processor already holds its catalogs and examples.


@override
InferenceFormat createFormat(
List<SchemaCatalog> catalogs, {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can we call SchemaCatalog CatalogApi instead? This would mirror ComponentApi and FunctionApi.

I know it's not in this PR specifically, but I think it would be more consistent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, CatalogApi is the better name. Catalog<ComponentApi, FunctionApi> is a catalog made only of APIs, so it belongs with ComponentApi and FunctionApi. SchemaCatalog names it after how it's stored instead. It's also clearer next to FunctionImplementation: a renderer's catalog carries implementations, an agent's catalog carries APIs.

I'm keeping it out of this PR because it's bigger than a2ui_agent:

  • SchemaCatalog is defined and exported by a2ui_core, which is published at 0.2.x, and about 30 files across a2ui_core and a2ui_agent use it. Renaming it needs CatalogApi added, all uses moved over, and SchemaCatalog kept as a @Deprecated alias for a release.
  • The core blueprint doesn't name this type at all; it only writes Catalog<TComponent, TFunction>. The TypeScript design doc uses SchemaCatalog, and the other SDKs write the generic type out. If only Dart renamed it, the languages would drift further apart. The name should go in the blueprint first so every SDK uses the same one.

Filed #2900. It defines CatalogApi in a2ui_core.blueprint.md and uses it in a2ui_agent.blueprint.md, then updates Dart (with the deprecated alias), TypeScript, Python, Swift and Kotlin, plus the docs.

@polina-c
polina-c merged commit 868968f into main Sep 30, 2026
37 checks passed
@polina-c
polina-c deleted the 09-remaining-api branch September 30, 2026 04:10
@polina-c polina-c mentioned this pull request Oct 15, 2026
gspencergoog added a commit to gspencergoog/A2UI that referenced this pull request Oct 16, 2026
Prepares the first publish of a2ui_core since 0.2.2 and of a2ui_agent
since 0.0.1-wip004. Neither 0.3.0 nor 0.0.1-wip005 reached pub.dev when
their pubspecs were bumped (a2ui-project#3192, a2ui-project#2890), so this folds each package's
`## Unreleased` section into that version's section instead of opening a
third one.

While folding, the entries were checked against lib/ and corrected:

- a2ui_core: `PayloadValidator.protocolVersion`, `commonTypesSchema`,
  `checkVersion`, `forVersion` and `MessageProcessor.commonTypesSchema`
  were described as removed but still exist. `DataContext.isV10`,
  `A2uiCompileError`, `Catalog.protocolVersion` as a `String?` and the
  flattened `ComponentApi.schema` were never in a published version, so
  they are described in their final form rather than as renames or
  removals. `DataContext.dispose` does not exist. The new `intl` and
  `logging` dependencies are listed. Notes about tests and tooling are
  dropped and overlong lines are wrapped.
- a2ui_agent: payload checks go through `MessageProcessor.forAgent`, not
  `Catalog.withFunctions`. Entries that revised unpublished wip005
  entries are merged into them, including the v1.0 entries from a2ui-project#3242.

No code changes. The versions in pubspec.yaml were already set.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants