From 8572646bfcf6c4f07b914508113f6412665989f1 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Mon, 10 Aug 2026 17:22:50 -0700 Subject: [PATCH 01/19] - --- packages/a2ui_agent/CHANGELOG.md | 3 +++ packages/a2ui_agent/LICENSE | 25 +++++++++++++++++++ packages/a2ui_agent/README.md | 3 +++ .../example/a2ui_agent_example.dart | 6 +++++ packages/a2ui_agent/lib/a2ui_agent.dart | 8 ++++++ .../a2ui_agent/lib/src/a2ui_agent_base.dart | 6 +++++ packages/a2ui_agent/pubspec.yaml | 14 +++++++++++ packages/a2ui_agent/test/a2ui_agent_test.dart | 16 ++++++++++++ pubspec.yaml | 1 + 9 files changed, 82 insertions(+) create mode 100644 packages/a2ui_agent/CHANGELOG.md create mode 100644 packages/a2ui_agent/LICENSE create mode 100644 packages/a2ui_agent/README.md create mode 100644 packages/a2ui_agent/example/a2ui_agent_example.dart create mode 100644 packages/a2ui_agent/lib/a2ui_agent.dart create mode 100644 packages/a2ui_agent/lib/src/a2ui_agent_base.dart create mode 100644 packages/a2ui_agent/pubspec.yaml create mode 100644 packages/a2ui_agent/test/a2ui_agent_test.dart diff --git a/packages/a2ui_agent/CHANGELOG.md b/packages/a2ui_agent/CHANGELOG.md new file mode 100644 index 000000000..b78d64c62 --- /dev/null +++ b/packages/a2ui_agent/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +- Initial version. diff --git a/packages/a2ui_agent/LICENSE b/packages/a2ui_agent/LICENSE new file mode 100644 index 000000000..33e1140da --- /dev/null +++ b/packages/a2ui_agent/LICENSE @@ -0,0 +1,25 @@ +Copyright 2025 The Flutter Authors. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/a2ui_agent/README.md b/packages/a2ui_agent/README.md new file mode 100644 index 000000000..0bbb0e5d8 --- /dev/null +++ b/packages/a2ui_agent/README.md @@ -0,0 +1,3 @@ +# A2UI Agent SDK + +TODO: add readme diff --git a/packages/a2ui_agent/example/a2ui_agent_example.dart b/packages/a2ui_agent/example/a2ui_agent_example.dart new file mode 100644 index 000000000..8c80989f7 --- /dev/null +++ b/packages/a2ui_agent/example/a2ui_agent_example.dart @@ -0,0 +1,6 @@ +import 'package:a2ui_agent/a2ui_agent.dart'; + +void main() { + var awesome = Awesome(); + print('awesome: ${awesome.isAwesome}'); +} diff --git a/packages/a2ui_agent/lib/a2ui_agent.dart b/packages/a2ui_agent/lib/a2ui_agent.dart new file mode 100644 index 000000000..e015e7ace --- /dev/null +++ b/packages/a2ui_agent/lib/a2ui_agent.dart @@ -0,0 +1,8 @@ +/// Support for doing something awesome. +/// +/// More dartdocs go here. +library; + +export 'src/a2ui_agent_base.dart'; + +// TODO: Export any libraries intended for clients of this package. diff --git a/packages/a2ui_agent/lib/src/a2ui_agent_base.dart b/packages/a2ui_agent/lib/src/a2ui_agent_base.dart new file mode 100644 index 000000000..e8a6f1590 --- /dev/null +++ b/packages/a2ui_agent/lib/src/a2ui_agent_base.dart @@ -0,0 +1,6 @@ +// TODO: Put public facing types in this file. + +/// Checks if you are awesome. Spoiler: you are. +class Awesome { + bool get isAwesome => true; +} diff --git a/packages/a2ui_agent/pubspec.yaml b/packages/a2ui_agent/pubspec.yaml new file mode 100644 index 000000000..21b0331ed --- /dev/null +++ b/packages/a2ui_agent/pubspec.yaml @@ -0,0 +1,14 @@ +name: a2ui_agent +description: The A2UI agent SDK. +version: 0.0.1-wip + +resolution: workspace + +environment: + sdk: ">=3.10.0 <4.0.0" + +dependencies: + a2ui_core: ^0.1.0 + +dev_dependencies: + test: ^1.25.6 diff --git a/packages/a2ui_agent/test/a2ui_agent_test.dart b/packages/a2ui_agent/test/a2ui_agent_test.dart new file mode 100644 index 000000000..5cb21adcc --- /dev/null +++ b/packages/a2ui_agent/test/a2ui_agent_test.dart @@ -0,0 +1,16 @@ +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:test/test.dart'; + +void main() { + group('A group of tests', () { + final awesome = Awesome(); + + setUp(() { + // Additional setup goes here. + }); + + test('First Test', () { + expect(awesome.isAwesome, isTrue); + }); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 2607aad4d..b450665de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ workspace: - examples/simple_chat - examples/verdure/client + - packages/a2ui_agent - packages/a2ui_core - packages/genui - packages/genui_a2a From d4c616caf9c51dec615768decf83b8bdc702f9e1 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Mon, 10 Aug 2026 17:25:36 -0700 Subject: [PATCH 02/19] - --- packages/a2ui_agent/example/a2ui_agent_example.dart | 4 ++++ packages/a2ui_agent/lib/a2ui_agent.dart | 4 ++++ packages/a2ui_agent/lib/src/a2ui_agent_base.dart | 4 ++++ packages/a2ui_agent/pubspec.yaml | 4 ++++ packages/a2ui_agent/test/a2ui_agent_test.dart | 4 ++++ 5 files changed, 20 insertions(+) diff --git a/packages/a2ui_agent/example/a2ui_agent_example.dart b/packages/a2ui_agent/example/a2ui_agent_example.dart index 8c80989f7..e070e568a 100644 --- a/packages/a2ui_agent/example/a2ui_agent_example.dart +++ b/packages/a2ui_agent/example/a2ui_agent_example.dart @@ -1,3 +1,7 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + import 'package:a2ui_agent/a2ui_agent.dart'; void main() { diff --git a/packages/a2ui_agent/lib/a2ui_agent.dart b/packages/a2ui_agent/lib/a2ui_agent.dart index e015e7ace..462ed7701 100644 --- a/packages/a2ui_agent/lib/a2ui_agent.dart +++ b/packages/a2ui_agent/lib/a2ui_agent.dart @@ -1,3 +1,7 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + /// Support for doing something awesome. /// /// More dartdocs go here. diff --git a/packages/a2ui_agent/lib/src/a2ui_agent_base.dart b/packages/a2ui_agent/lib/src/a2ui_agent_base.dart index e8a6f1590..b0fbcc1e8 100644 --- a/packages/a2ui_agent/lib/src/a2ui_agent_base.dart +++ b/packages/a2ui_agent/lib/src/a2ui_agent_base.dart @@ -1,3 +1,7 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + // TODO: Put public facing types in this file. /// Checks if you are awesome. Spoiler: you are. diff --git a/packages/a2ui_agent/pubspec.yaml b/packages/a2ui_agent/pubspec.yaml index 21b0331ed..8b5c4c30a 100644 --- a/packages/a2ui_agent/pubspec.yaml +++ b/packages/a2ui_agent/pubspec.yaml @@ -1,3 +1,7 @@ +# Copyright 2025 The Flutter Authors. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + name: a2ui_agent description: The A2UI agent SDK. version: 0.0.1-wip diff --git a/packages/a2ui_agent/test/a2ui_agent_test.dart b/packages/a2ui_agent/test/a2ui_agent_test.dart index 5cb21adcc..94bbcf5f8 100644 --- a/packages/a2ui_agent/test/a2ui_agent_test.dart +++ b/packages/a2ui_agent/test/a2ui_agent_test.dart @@ -1,3 +1,7 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + import 'package:a2ui_agent/a2ui_agent.dart'; import 'package:test/test.dart'; From 3cc5223b420e1864ab67fcaf418a23e1dd424e2c Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Mon, 10 Aug 2026 18:02:37 -0700 Subject: [PATCH 03/19] Update a2ui_agent_example.dart --- packages/a2ui_agent/example/a2ui_agent_example.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/a2ui_agent/example/a2ui_agent_example.dart b/packages/a2ui_agent/example/a2ui_agent_example.dart index e070e568a..a808ae23f 100644 --- a/packages/a2ui_agent/example/a2ui_agent_example.dart +++ b/packages/a2ui_agent/example/a2ui_agent_example.dart @@ -6,5 +6,5 @@ import 'package:a2ui_agent/a2ui_agent.dart'; void main() { var awesome = Awesome(); - print('awesome: ${awesome.isAwesome}'); + awesome.toString(); } From 9545b0c996013922fbf3f6096fe454f05cda916e Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Mon, 10 Aug 2026 18:05:01 -0700 Subject: [PATCH 04/19] Update CHANGELOG.md --- packages/a2ui_agent/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/a2ui_agent/CHANGELOG.md b/packages/a2ui_agent/CHANGELOG.md index b78d64c62..0b024e263 100644 --- a/packages/a2ui_agent/CHANGELOG.md +++ b/packages/a2ui_agent/CHANGELOG.md @@ -1,3 +1,3 @@ -## 0.0.1 +## 0.0.1-wip - Initial version. From fb8690d2b75abfbc19b14275284c138c9def727e Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Mon, 10 Aug 2026 18:11:53 -0700 Subject: [PATCH 05/19] Update pubspec.yaml --- packages/a2ui_agent/pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/a2ui_agent/pubspec.yaml b/packages/a2ui_agent/pubspec.yaml index 8b5c4c30a..cee4bf492 100644 --- a/packages/a2ui_agent/pubspec.yaml +++ b/packages/a2ui_agent/pubspec.yaml @@ -5,6 +5,7 @@ name: a2ui_agent description: The A2UI agent SDK. version: 0.0.1-wip +repository: https://github.com/flutter/genui/tree/main/packages/a2ui_agent resolution: workspace From 1ca909bb80719c249501b91888d359dc4c9c2e43 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Mon, 10 Aug 2026 18:12:26 -0700 Subject: [PATCH 06/19] Update pubspec.yaml --- packages/a2ui_agent/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/a2ui_agent/pubspec.yaml b/packages/a2ui_agent/pubspec.yaml index cee4bf492..fa606012f 100644 --- a/packages/a2ui_agent/pubspec.yaml +++ b/packages/a2ui_agent/pubspec.yaml @@ -16,4 +16,4 @@ dependencies: a2ui_core: ^0.1.0 dev_dependencies: - test: ^1.25.6 + test: ^1.26.2 From d128c2c349efadb83c3c4a0a3005cf957fb99185 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 11:11:22 -0700 Subject: [PATCH 07/19] - --- .../lib/src/catalog_transformers/base.dart | 17 + .../lib/src/catalog_transformers/pruning.dart | 58 ++++ .../a2ui_agent/lib/src/inference_format.dart | 33 ++ .../lib/src/parser/incremental_processor.dart | 105 ++++++ .../a2ui_agent/lib/src/parser/parser.dart | 105 ++++++ .../lib/src/parser/response_part.dart | 103 ++++++ .../lib/src/parser/sentinel_tokenizer.dart | 185 +++++++++++ .../a2ui_agent/lib/src/primitives/errors.dart | 35 ++ .../lib/src/primitives/protocol_version.dart | 48 +++ .../a2ui_agent/lib/src/prompt/generator.dart | 33 ++ .../lib/src/utils/catalog_document.dart | 313 ++++++++++++++++++ packages/a2ui_agent/pubspec.yaml | 1 + 12 files changed, 1036 insertions(+) create mode 100644 packages/a2ui_agent/lib/src/catalog_transformers/base.dart create mode 100644 packages/a2ui_agent/lib/src/catalog_transformers/pruning.dart create mode 100644 packages/a2ui_agent/lib/src/inference_format.dart create mode 100644 packages/a2ui_agent/lib/src/parser/incremental_processor.dart create mode 100644 packages/a2ui_agent/lib/src/parser/parser.dart create mode 100644 packages/a2ui_agent/lib/src/parser/response_part.dart create mode 100644 packages/a2ui_agent/lib/src/parser/sentinel_tokenizer.dart create mode 100644 packages/a2ui_agent/lib/src/primitives/errors.dart create mode 100644 packages/a2ui_agent/lib/src/primitives/protocol_version.dart create mode 100644 packages/a2ui_agent/lib/src/prompt/generator.dart create mode 100644 packages/a2ui_agent/lib/src/utils/catalog_document.dart diff --git a/packages/a2ui_agent/lib/src/catalog_transformers/base.dart b/packages/a2ui_agent/lib/src/catalog_transformers/base.dart new file mode 100644 index 000000000..768556ccf --- /dev/null +++ b/packages/a2ui_agent/lib/src/catalog_transformers/base.dart @@ -0,0 +1,17 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +/// A transformation rule applied to a catalog before prompt engineering and +/// payload validation. +/// +/// Transformers are pure: they take a pristine catalog and return a modified +/// copy of the same component type, leaving the original untouched. +abstract class CatalogTransformer { + const CatalogTransformer(); + + /// Transforms [catalog] into a modified catalog of the same component type. + Catalog transform(Catalog catalog); +} diff --git a/packages/a2ui_agent/lib/src/catalog_transformers/pruning.dart b/packages/a2ui_agent/lib/src/catalog_transformers/pruning.dart new file mode 100644 index 000000000..4ef635411 --- /dev/null +++ b/packages/a2ui_agent/lib/src/catalog_transformers/pruning.dart @@ -0,0 +1,58 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +import 'base.dart'; + +/// Prunes catalog component definitions down to an allowlist. +/// +/// Pruning happens before the prompt is rendered, so components left out are +/// invisible to the model, and before validation, so a model that names one +/// anyway is rejected. +class ComponentPruningTransformer extends CatalogTransformer { + /// The names of the components that survive the transformation. + final Set allowedComponents; + + ComponentPruningTransformer(Iterable allowedComponents) + : allowedComponents = Set.unmodifiable(allowedComponents); + + @override + Catalog transform(Catalog catalog) { + return Catalog( + id: catalog.id, + components: [ + for (final component in catalog.components.values) + if (allowedComponents.contains(component.name)) component, + ], + functions: catalog.functions.values.toList(), + themeSchema: catalog.themeSchema, + ); + } +} + +/// Prunes catalog function definitions down to an allowlist. +/// +/// Use this to restrict the client-side validation rules and logic functions a +/// model is allowed to reference. +class FunctionPruningTransformer extends CatalogTransformer { + /// The names of the functions that survive the transformation. + final Set allowedFunctions; + + FunctionPruningTransformer(Iterable allowedFunctions) + : allowedFunctions = Set.unmodifiable(allowedFunctions); + + @override + Catalog transform(Catalog catalog) { + return Catalog( + id: catalog.id, + components: catalog.components.values.toList(), + functions: [ + for (final function in catalog.functions.values) + if (allowedFunctions.contains(function.name)) function, + ], + themeSchema: catalog.themeSchema, + ); + } +} diff --git a/packages/a2ui_agent/lib/src/inference_format.dart b/packages/a2ui_agent/lib/src/inference_format.dart new file mode 100644 index 000000000..4a05b6a40 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_format.dart @@ -0,0 +1,33 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +import 'parser/parser.dart'; +import 'prompt/generator.dart'; + +/// Constructs [InferenceFormat] strategies bound to a set of active catalogs. +abstract class InferenceFormatFactory { + const InferenceFormatFactory(); + + /// Constructs an [InferenceFormat] bound to [catalogs]. + InferenceFormat createFormat( + List> catalogs, { + PromptExamples? examples, + }); +} + +/// Pairs the prompt generator (model input) and the parser (model output) of +/// one inference format. +abstract class InferenceFormat { + const InferenceFormat(); + + /// The generator that renders this format's system prompt instructions. + PromptGenerator get promptGenerator; + + /// Creates a fresh parser bound to this format. + /// + /// Parsers carry streaming state, so each model turn needs its own. + Parser createParser(); +} diff --git a/packages/a2ui_agent/lib/src/parser/incremental_processor.dart b/packages/a2ui_agent/lib/src/parser/incremental_processor.dart new file mode 100644 index 000000000..09e69a4c0 --- /dev/null +++ b/packages/a2ui_agent/lib/src/parser/incremental_processor.dart @@ -0,0 +1,105 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'response_part.dart'; +import 'sentinel_tokenizer.dart'; + +/// Shared streaming plumbing for format parsers. +/// +/// The processor owns the sentinel tokenizer and the raw buffer for the block +/// currently being streamed. Formats only implement [emitDelta], which is +/// handed the whole raw block accumulated so far and returns the messages that +/// have not been emitted yet. +abstract class IncrementalStreamProcessor { + final SentinelTokenizer _tokenizer; + final StringBuffer _block = StringBuffer(); + bool _inBlock = false; + + IncrementalStreamProcessor({ + required String openTag, + required String closeTag, + }) : _tokenizer = SentinelTokenizer(openTag: openTag, closeTag: closeTag); + + /// Returns the messages of [rawBlock] that have not been emitted yet. + /// + /// [rawBlock] is the complete raw content of the current block accumulated + /// so far, not a delta. [blockComplete] is true once the closing sentinel + /// tag has been seen, or once the stream ended, at which point the format + /// should salvage whatever it can from a truncated payload. + /// + /// Implementations must track their own emission state and must not emit the + /// same content twice. + List emitDelta( + String rawBlock, { + required bool blockComplete, + }); + + /// Discards per-block emission state, called when a new block starts. + void resetBlock(); + + /// Consumes a streamed [chunk]. + /// + /// When [wrapped] is false the chunk is treated as raw payload content with + /// no surrounding sentinel tags. + List add(String chunk, {bool wrapped = true}) { + if (!wrapped) { + if (!_inBlock) { + _inBlock = true; + resetBlock(); + } + _block.write(chunk); + return _emit(blockComplete: false); + } + + final parts = []; + for (final SentinelToken token in _tokenizer.add(chunk)) { + parts.addAll(_handle(token)); + } + return parts; + } + + /// Flushes buffered state at the end of the stream. + List flush({bool wrapped = true}) { + final parts = []; + if (wrapped) { + for (final SentinelToken token in _tokenizer.flush()) { + parts.addAll(_handle(token)); + } + } + if (_inBlock) { + parts.addAll(_emit(blockComplete: true)); + _inBlock = false; + _block.clear(); + } + return parts; + } + + List _handle(SentinelToken token) { + switch (token) { + case TextToken(text: final String text): + return text.isEmpty ? const [] : [TextPart(text)]; + case BlockStartToken(): + _inBlock = true; + _block.clear(); + resetBlock(); + return const []; + case BlockContentToken(content: final String content): + _block.write(content); + return _emit(blockComplete: false); + case BlockEndToken(): + final List parts = _emit(blockComplete: true); + _inBlock = false; + _block.clear(); + return parts; + } + } + + List _emit({required bool blockComplete}) { + final List messages = emitDelta( + _block.toString(), + blockComplete: blockComplete, + ); + return messages.isEmpty ? const [] : [A2uiPart(messages)]; + } +} diff --git a/packages/a2ui_agent/lib/src/parser/parser.dart b/packages/a2ui_agent/lib/src/parser/parser.dart new file mode 100644 index 000000000..5ac47a83e --- /dev/null +++ b/packages/a2ui_agent/lib/src/parser/parser.dart @@ -0,0 +1,105 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'response_part.dart'; +import 'sentinel_tokenizer.dart'; + +/// Base class for response parsers across all inference format strategies. +/// +/// A parser tokenizes LLM output, unwraps the format's sentinel tags, and +/// compiles the raw format expressions it finds into A2UI messages. +/// +/// Parsers are turn-scoped: [parseChunk] accumulates streaming state, so a +/// fresh parser must be created for each model turn (see +/// `InferenceFormat.createParser`). +abstract class Parser { + const Parser(); + + /// The tag that opens a raw payload block for this format. + String get openTag; + + /// The tag that closes a raw payload block for this format. + String get closeTag; + + /// Converts [blocks] back into a single string, re-adding the sentinel tags + /// around each raw A2UI section and concatenating conversational text. + String wrap(List blocks) { + final buffer = StringBuffer(); + for (final block in blocks) { + switch (block.part) { + case TextPart(text: final String text): + buffer.write(text); + case RawA2uiPart(a2uiRaw: final String a2uiRaw): + buffer + ..write(openTag) + ..write(a2uiRaw) + ..write(closeTag); + } + } + return buffer.toString(); + } + + /// Tokenizes an LLM response into an ordered list of [RawResponsePart]s. + /// + /// Conversational text and tagged payload blocks are returned in exactly the + /// order the model emitted them. + List unwrap(String content) => + SentinelTokenizer.unwrap(content, openTag: openTag, closeTag: closeTag); + + /// Compiles a raw format content string into A2UI messages. + /// + /// Throws [A2uiFormatError](../primitives/errors.dart) when the content + /// cannot be compiled. + List compile(String formatContent); + + /// Decompiles A2UI messages back into this format's raw notation. + String decompile(List a2uiPayload); + + /// Parses a complete, non-streamed response. + /// + /// When [wrapped] is true the content is unwrapped first and the + /// chronological order of text and payload blocks is preserved; otherwise + /// the whole of [content] is compiled as a single payload. + List parseResponse(String content, {bool wrapped = true}) { + if (!wrapped) return [A2uiPart(compile(content))]; + + final result = []; + for (final RawResponsePart rawPart in unwrap(content)) { + switch (rawPart.part) { + case TextPart part: + result.add(part); + case RawA2uiPart(a2uiRaw: final String a2uiRaw): + result.add(A2uiPart(compile(a2uiRaw))); + } + } + return result; + } + + /// Processes an incremental [chunk] of a streamed response. + /// + /// Returns only what became available since the previous call. Call [flush] + /// once the stream ends to release anything still buffered. + List parseChunk(String chunk, {bool wrapped = true}); + + /// Releases buffered streaming state at the end of a stream. + /// + /// A payload block left unterminated by the model is salvaged here if the + /// format can repair it. + List flush(); + + /// Parses a stream of response chunks into a stream of [ResponsePart]s. + /// + /// This is a convenience wrapper around [parseChunk] and [flush]. + Stream parseStream( + Stream chunks, { + bool wrapped = true, + }) async* { + await for (final chunk in chunks) { + yield* Stream.fromIterable( + parseChunk(chunk, wrapped: wrapped), + ); + } + yield* Stream.fromIterable(flush()); + } +} diff --git a/packages/a2ui_agent/lib/src/parser/response_part.dart b/packages/a2ui_agent/lib/src/parser/response_part.dart new file mode 100644 index 000000000..19bbd6df4 --- /dev/null +++ b/packages/a2ui_agent/lib/src/parser/response_part.dart @@ -0,0 +1,103 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +/// A message sent from an agent to a renderer. +/// +/// The A2UI specification names this concept `AgentToRendererMessage`; in Dart +/// it is modelled by [A2uiMessage] from `package:a2ui_core`. +typedef AgentToRendererMessage = A2uiMessage; + +/// A compiled slice of an LLM response, ready to hand to the caller. +/// +/// Either conversational [TextPart] or a compiled [A2uiPart]. +sealed class ResponsePart { + const ResponsePart(); +} + +/// An uncompiled slice of an LLM response, as emitted by the model. +/// +/// Either conversational [TextPart] or a still-raw [RawA2uiPart]. +sealed class RawPart { + const RawPart(); +} + +/// Conversational text extracted from an LLM response. +final class TextPart extends ResponsePart implements RawPart { + /// The conversational text content intended for user display. + final String text; + + const TextPart(this.text); + + @override + bool operator ==(Object other) => other is TextPart && other.text == text; + + @override + int get hashCode => text.hashCode; + + @override + String toString() => 'TextPart(${_preview(text)})'; +} + +/// An uncompiled A2UI format content block extracted from an LLM response. +final class RawA2uiPart extends RawPart { + /// The raw uncompiled format content (e.g. raw JSON or Express DSL), with + /// the enclosing sentinel tags removed. + final String a2uiRaw; + + const RawA2uiPart(this.a2uiRaw); + + @override + bool operator ==(Object other) => + other is RawA2uiPart && other.a2uiRaw == a2uiRaw; + + @override + int get hashCode => a2uiRaw.hashCode; + + @override + String toString() => 'RawA2uiPart(${_preview(a2uiRaw)})'; +} + +/// An uncompiled token from an LLM response stream. +final class RawResponsePart { + /// The underlying content: conversational [TextPart] or uncompiled + /// [RawA2uiPart]. + final RawPart part; + + /// Whether this part is complete, i.e. not truncated mid-stream. + /// + /// A [RawA2uiPart] is final once its closing sentinel tag has been seen; a + /// [TextPart] is final once a following sentinel tag or the end of the + /// response proves that no more text can be appended to it. + final bool isFinal; + + const RawResponsePart(this.part, {this.isFinal = true}); + + @override + String toString() => 'RawResponsePart($part, isFinal: $isFinal)'; +} + +/// Extracted and compiled A2UI payload messages. +final class A2uiPart extends ResponsePart { + /// The compiled messages to deliver to client renderers. + final List a2ui; + + const A2uiPart(this.a2ui); + + /// The messages serialized back to their JSON envelopes. + List> toJson() => [ + for (final AgentToRendererMessage message in a2ui) message.toJson(), + ]; + + @override + String toString() => 'A2uiPart(${a2ui.length} message(s))'; +} + +String _preview(String value) { + const maxLength = 40; + final String collapsed = value.replaceAll('\n', r'\n'); + if (collapsed.length <= maxLength) return "'$collapsed'"; + return "'${collapsed.substring(0, maxLength)}…'"; +} diff --git a/packages/a2ui_agent/lib/src/parser/sentinel_tokenizer.dart b/packages/a2ui_agent/lib/src/parser/sentinel_tokenizer.dart new file mode 100644 index 000000000..c3c280f34 --- /dev/null +++ b/packages/a2ui_agent/lib/src/parser/sentinel_tokenizer.dart @@ -0,0 +1,185 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'response_part.dart'; + +/// An event produced by [SentinelTokenizer]. +sealed class SentinelToken { + const SentinelToken(); +} + +/// Conversational text that appeared outside any sentinel-tagged block. +final class TextToken extends SentinelToken { + /// The newly available text. In streaming mode this is a delta, not the + /// accumulated run. + final String text; + + const TextToken(this.text); +} + +/// Marks the point where an opening sentinel tag was consumed. +final class BlockStartToken extends SentinelToken { + const BlockStartToken(); +} + +/// Raw content from inside a sentinel-tagged block. +final class BlockContentToken extends SentinelToken { + /// The newly available raw content. In streaming mode this is a delta. + final String content; + + const BlockContentToken(this.content); + + @override + String toString() => 'BlockContentToken($content)'; +} + +/// Marks the end of a sentinel-tagged block. +final class BlockEndToken extends SentinelToken { + /// Whether the block was terminated by its closing tag. + /// + /// This is `false` when the stream ended while a block was still open, which + /// happens when a model is cut off mid-payload. + final bool terminated; + + const BlockEndToken({required this.terminated}); +} + +/// Splits a response, or a stream of response chunks, into conversational text +/// and the raw content of sentinel-tagged blocks. +/// +/// The tokenizer never emits text that could still turn out to be the start of +/// a sentinel tag: a trailing partial tag is held back until the next chunk +/// resolves it, so ``. + final String openTag; + + /// The tag that closes a raw payload block, e.g. ``. + final String closeTag; + + final StringBuffer _buffer = StringBuffer(); + bool _inBlock = false; + + SentinelTokenizer({required this.openTag, required this.closeTag}); + + /// Whether the tokenizer is currently inside a sentinel-tagged block. + bool get inBlock => _inBlock; + + /// Consumes [chunk] and returns every token that became unambiguous. + List add(String chunk) { + _buffer.write(chunk); + final tokens = []; + var rest = _buffer.toString(); + _buffer.clear(); + + while (true) { + final String tag = _inBlock ? closeTag : openTag; + final int index = rest.indexOf(tag); + if (index < 0) { + // Hold back anything that might be the beginning of `tag`. + final int keep = _partialTagSuffixLength(rest, tag); + final String emit = rest.substring(0, rest.length - keep); + if (emit.isNotEmpty) { + tokens.add( + _inBlock ? BlockContentToken(emit) : TextToken(emit), + ); + } + _buffer.write(rest.substring(rest.length - keep)); + return tokens; + } + + final String emit = rest.substring(0, index); + if (emit.isNotEmpty) { + tokens.add(_inBlock ? BlockContentToken(emit) : TextToken(emit)); + } + tokens.add( + _inBlock ? const BlockEndToken(terminated: true) : const + BlockStartToken(), + ); + _inBlock = !_inBlock; + rest = rest.substring(index + tag.length); + } + } + + /// Flushes buffered content once the stream has ended. + /// + /// An unterminated block is closed with `BlockEndToken(terminated: false)` + /// so that callers can decide whether to salvage a truncated payload. + List flush() { + final tokens = []; + final rest = _buffer.toString(); + _buffer.clear(); + if (rest.isNotEmpty) { + tokens.add(_inBlock ? BlockContentToken(rest) : TextToken(rest)); + } + if (_inBlock) { + tokens.add(const BlockEndToken(terminated: false)); + _inBlock = false; + } + return tokens; + } + + /// Tokenizes a complete [content] string into ordered [RawResponsePart]s. + /// + /// Conversational text is trimmed and empty runs are dropped; raw block + /// content is preserved verbatim. + static List unwrap( + String content, { + required String openTag, + required String closeTag, + }) { + final tokenizer = SentinelTokenizer(openTag: openTag, closeTag: closeTag); + final List tokens = [ + ...tokenizer.add(content), + ...tokenizer.flush(), + ]; + + final parts = []; + final text = StringBuffer(); + final raw = StringBuffer(); + var inBlock = false; + + void flushText() { + final String value = text.toString().trim(); + text.clear(); + if (value.isNotEmpty) parts.add(RawResponsePart(TextPart(value))); + } + + for (final token in tokens) { + switch (token) { + case TextToken(text: final String chunk): + text.write(chunk); + case BlockStartToken(): + flushText(); + inBlock = true; + case BlockContentToken(content: final String content): + raw.write(content); + case BlockEndToken(terminated: final bool terminated): + parts.add( + RawResponsePart( + RawA2uiPart(raw.toString()), + isFinal: terminated, + ), + ); + raw.clear(); + inBlock = false; + } + } + if (!inBlock) flushText(); + return parts; + } + + /// The length of the longest suffix of [value] that is a proper prefix of + /// [tag]. + static int _partialTagSuffixLength(String value, String tag) { + final int max = value.length < tag.length - 1 + ? value.length + : tag.length - 1; + for (var length = max; length > 0; length--) { + if (value.endsWith(tag.substring(0, length))) return length; + } + return 0; + } +} diff --git a/packages/a2ui_agent/lib/src/primitives/errors.dart b/packages/a2ui_agent/lib/src/primitives/errors.dart new file mode 100644 index 000000000..ed08d34bb --- /dev/null +++ b/packages/a2ui_agent/lib/src/primitives/errors.dart @@ -0,0 +1,35 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +/// Thrown when raw inference-format content cannot be compiled into A2UI +/// messages. +/// +/// This covers lexical and syntactic failures (malformed JSON, invalid Express +/// statements) as well as catalog mismatches discovered while compiling. +class A2uiFormatError extends A2uiError { + /// The 1-based line of the raw content the failure was detected on, if + /// known. + final int? line; + + /// The raw fragment that failed to compile, if known. + final String? source; + + A2uiFormatError(String message, {this.line, this.source}) + : super(message, 'FORMAT_ERROR'); + + @override + String toString() { + final buffer = StringBuffer('$runtimeType [$code]: $message'); + if (line != null) buffer.write(' (line $line)'); + if (source != null) buffer.write('\n in: $source'); + return buffer.toString(); + } +} + +/// Thrown when catalogs cannot be resolved against renderer capabilities. +class A2uiCapabilityError extends A2uiError { + A2uiCapabilityError(String message) : super(message, 'CAPABILITY_ERROR'); +} diff --git a/packages/a2ui_agent/lib/src/primitives/protocol_version.dart b/packages/a2ui_agent/lib/src/primitives/protocol_version.dart new file mode 100644 index 000000000..bb18a684a --- /dev/null +++ b/packages/a2ui_agent/lib/src/primitives/protocol_version.dart @@ -0,0 +1,48 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// An A2UI protocol specification version. +/// +/// The agent SDK negotiates and validates against these versions. Note that +/// `package:a2ui_core` currently models the `v0.9` message envelopes only; the +/// other members exist so that catalog documents declaring another version can +/// be recognized and reported rather than silently mis-parsed. +enum ProtocolVersion { + /// Protocol `v0.8`. Predates `catalogId`, so catalog documents at this + /// version carry no identifier. + v08('v0.8'), + + /// Protocol `v0.9`, the version modelled by `package:a2ui_core`. + v09('v0.9'), + + /// Protocol `v0.9.1`. + v091('v0.9.1'), + + /// Protocol `v1.0`. The first version where catalog documents declare their + /// own `protocolVersion`. + v10('v1.0'); + + const ProtocolVersion(this.wireValue); + + /// The value used on the wire and in catalog documents, e.g. `v0.9`. + final String wireValue; + + /// The version emitted by `package:a2ui_core` message envelopes. + static const ProtocolVersion current = ProtocolVersion.v09; + + /// Parses [value] into a [ProtocolVersion]. + /// + /// Accepts both the `v`-prefixed wire form (`v0.9`) and the bare numeric + /// form (`0.9`). Returns `null` when [value] is not a known version. + static ProtocolVersion? tryParse(String value) { + final normalized = value.startsWith('v') ? value : 'v$value'; + for (final ProtocolVersion version in values) { + if (version.wireValue == normalized) return version; + } + return null; + } + + @override + String toString() => wireValue; +} diff --git a/packages/a2ui_agent/lib/src/prompt/generator.dart b/packages/a2ui_agent/lib/src/prompt/generator.dart new file mode 100644 index 000000000..fe91e780b --- /dev/null +++ b/packages/a2ui_agent/lib/src/prompt/generator.dart @@ -0,0 +1,33 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +import '../parser/response_part.dart'; + +/// A named set of example messages used as a few-shot prompt turn. +/// +/// The key of the surrounding map describes the example turn; the value is the +/// A2UI payload the model is expected to produce for it. +typedef PromptExamples = Map>; + +/// Base class for format-specific prompt generators. +/// +/// A generator renders the system instruction snippet describing how the model +/// must emit A2UI for one inference format, including the schemas of the +/// active catalogs. Callers prepend their own role and workflow preambles and +/// append their own suffixes. +abstract class PromptGenerator { + /// The active catalogs to describe in the system instructions. + final List> catalogs; + + /// Optional few-shot example turns, keyed by a description of the turn. + final PromptExamples? examples; + + const PromptGenerator(this.catalogs, {this.examples}); + + /// Renders the format-specific system prompt instructions and catalog + /// schemas. + String generate(); +} diff --git a/packages/a2ui_agent/lib/src/utils/catalog_document.dart b/packages/a2ui_agent/lib/src/utils/catalog_document.dart new file mode 100644 index 000000000..63b736778 --- /dev/null +++ b/packages/a2ui_agent/lib/src/utils/catalog_document.dart @@ -0,0 +1,313 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +import '../primitives/protocol_version.dart'; +import 'schema_utils.dart'; + +/// A [ComponentApi] backed by a schema loaded from a catalog document. +/// +/// Agents describe and validate components they never render, so a loaded +/// catalog needs no Dart implementation behind each component. +class JsonComponentApi extends ComponentApi { + @override + final String name; + + @override + final Schema schema; + + JsonComponentApi({required this.name, required this.schema}); +} + +/// A catalog function known only by its declaration. +/// +/// Client functions execute on the renderer, so the agent side carries the +/// signature but cannot run it. [execute] throws to make an accidental +/// server-side invocation loud instead of silently wrong. +class DeclaredFunction extends FunctionImplementation { + @override + final String name; + + @override + final A2uiReturnType returnType; + + @override + final Schema argumentSchema; + + DeclaredFunction({ + required this.name, + required this.returnType, + required this.argumentSchema, + }); + + @override + Object? execute( + Map args, + DataContext context, [ + CancellationSignal? cancellationSignal, + ]) { + throw UnsupportedError( + "Catalog function '$name' is declaration-only on the agent side; it is " + 'executed by the renderer.', + ); + } +} + +/// Serializes [catalog] to an A2UI catalog document. +/// +/// The result is the `{catalogId, components, functions, theme}` shape used by +/// inline catalogs and catalog files: each component schema is wrapped in the +/// standard component envelope, and `REF:` description markers used by +/// `package:a2ui_core` schemas are expanded back into JSON Schema `$ref`s. +Map catalogToDocument(Catalog catalog) { + final components = {}; + for (final MapEntry entry + in catalog.components.entries) { + final Map schema = entry.value.schema.toJsonMap(); + expandSchemaRefs(schema); + final Map flattened = _mergeAllOf(schema); + components[entry.key] = { + 'allOf': [ + {r'$ref': r'common_types.json#/$defs/ComponentCommon'}, + { + 'properties': { + 'component': {'const': entry.key}, + ...?(flattened['properties'] as Map?), + }, + 'required': ['component', ...?(flattened['required'] as List?)], + }, + ], + }; + } + + final functions = >[]; + for (final FunctionImplementation function in catalog.functions.values) { + final Map parameters = function.argumentSchema.toJsonMap(); + expandSchemaRefs(parameters); + functions.add({ + 'name': function.name, + 'returnType': function.returnType.jsonValue, + 'parameters': parameters, + }); + } + + Map? theme; + if (catalog.themeSchema != null) { + final Map themeSchema = catalog.themeSchema!.toJsonMap(); + expandSchemaRefs(themeSchema); + theme = themeSchema['properties'] as Map?; + } + + return { + 'catalogId': catalog.id, + 'components': components, + if (functions.isNotEmpty) 'functions': functions, + 'theme': ?theme, + }; +} + +/// Builds a [Catalog] from an A2UI catalog [document]. +/// +/// [protocolVersion] and [catalogId], when given, must agree with what the +/// document declares; a conflict throws [A2uiValidationError] rather than +/// silently loading a catalog the renderer did not ask for. +Catalog catalogFromDocument( + Map document, { + ProtocolVersion? protocolVersion, + String? catalogId, + String? source, +}) { + final Object? declaredId = document['catalogId']; + if (declaredId != null && declaredId is! String) { + throw A2uiValidationError( + "Catalog 'catalogId' must be a string.", + details: source, + ); + } + if (catalogId != null && declaredId is String && declaredId != catalogId) { + throw A2uiValidationError( + "Catalog id mismatch: expected '$catalogId' but the document declares " + "'$declaredId'.", + details: source, + ); + } + + final Object? declaredVersion = document['protocolVersion']; + if (protocolVersion != null && declaredVersion is String) { + final ProtocolVersion? parsed = ProtocolVersion.tryParse(declaredVersion); + if (parsed != protocolVersion) { + throw A2uiValidationError( + 'Protocol version mismatch: expected ${protocolVersion.wireValue} but ' + "the document declares '$declaredVersion'.", + details: source, + ); + } + } + + final String id = (declaredId as String?) ?? catalogId ?? ''; + if (id.isEmpty) { + throw A2uiValidationError( + 'Catalog document has no catalogId and none was supplied.', + details: source, + ); + } + + final Object? rawComponents = document['components']; + if (rawComponents is! Map) { + throw A2uiValidationError( + "Catalog document must contain a 'components' object.", + details: source, + ); + } + + final components = []; + for (final MapEntry entry in rawComponents.entries) { + final Object? name = entry.key; + final Object? schema = entry.value; + if (name is! String || schema is! Map) continue; + components.add( + JsonComponentApi( + name: name, + schema: Schema.fromMap( + _stripComponentEnvelope(schema.cast()), + ), + ), + ); + } + + final functions = []; + final Object? rawFunctions = document['functions']; + if (rawFunctions is List) { + for (final Object? entry in rawFunctions) { + if (entry is! Map) continue; + final Object? name = entry['name']; + if (name is! String) continue; + final Object? parameters = entry['parameters']; + functions.add( + DeclaredFunction( + name: name, + returnType: _parseReturnType(entry['returnType']), + argumentSchema: parameters is Map + ? Schema.fromMap(parameters.cast()) + : Schema.object(properties: const {}), + ), + ); + } + } + + final Object? theme = document['theme']; + return Catalog( + id: id, + components: components, + functions: functions, + themeSchema: theme is Map + ? Schema.object(properties: _themeProperties(theme)) + : null, + ); +} + +/// Rewrites `REF:|` markers into JSON Schema `$ref`s. +/// +/// `package:a2ui_core` encodes shared common-type references in schema +/// descriptions because its schema builder has no `$ref` constructor; catalog +/// documents use real references. +void expandSchemaRefs(Object? node) { + if (node is! Map) return; + + final Object? description = node['description']; + if (description is String && description.startsWith('REF:')) { + final List parts = description.substring(4).split('|'); + final String ref = parts.first; + final String? actual = parts.length > 1 ? parts[1] : null; + node.clear(); + node[r'$ref'] = ref; + if (actual != null) node['description'] = actual; + return; + } + + for (final Object? value in node.values) { + if (value is Map) { + expandSchemaRefs(value); + } else if (value is List) { + for (final Object? item in value) { + expandSchemaRefs(item); + } + } + } +} + +A2uiReturnType _parseReturnType(Object? value) { + if (value is! String) return A2uiReturnType.any; + if (value == A2uiReturnType.void_.jsonValue) return A2uiReturnType.void_; + for (final type in A2uiReturnType.values) { + if (type.name == value) return type; + } + return A2uiReturnType.any; +} + +Map _themeProperties(Map theme) { + final properties = {}; + for (final MapEntry entry in theme.entries) { + final Object? key = entry.key; + final Object? value = entry.value; + if (key is String && value is Map) { + properties[key] = Schema.fromMap(value.cast()); + } + } + return properties; +} + +/// Collapses `allOf` composition into a single properties/required pair. +Map _mergeAllOf(Map schema) { + final ({Map properties, Set required}) flat = + flattenSchemaProperties(Schema.fromMap(schema)); + return { + 'properties': { + for (final MapEntry entry in flat.properties.entries) + entry.key: entry.value.value, + }, + 'required': flat.required.toList(), + }; +} + +/// Removes the standard component envelope from a catalog document schema. +/// +/// Catalog documents wrap every component as +/// `allOf: [ComponentCommon, {properties: {component: const, ...}}]`. Agents +/// work with the inner component API, so the envelope is peeled off on load; +/// [catalogToDocument] puts it back. +Map _stripComponentEnvelope(Map schema) { + final Object? allOf = schema['allOf']; + if (allOf is! List) return schema; + + final properties = {}; + final required = []; + for (final Object? branch in allOf) { + if (branch is! Map) continue; + final Object? branchProperties = branch['properties']; + if (branchProperties is Map) { + for (final MapEntry entry + in branchProperties.entries) { + final Object? key = entry.key; + if (key is String && !envelopeKeys.contains(key)) { + properties[key] = entry.value; + } + } + } + final Object? branchRequired = branch['required']; + if (branchRequired is List) { + for (final Object? name in branchRequired) { + if (name is String && !envelopeKeys.contains(name)) required.add(name); + } + } + } + + return { + 'type': 'object', + 'properties': properties, + if (required.isNotEmpty) 'required': required, + }; +} diff --git a/packages/a2ui_agent/pubspec.yaml b/packages/a2ui_agent/pubspec.yaml index fa606012f..41c816fe5 100644 --- a/packages/a2ui_agent/pubspec.yaml +++ b/packages/a2ui_agent/pubspec.yaml @@ -14,6 +14,7 @@ environment: dependencies: a2ui_core: ^0.1.0 + json_schema_builder: ^0.1.3 dev_dependencies: test: ^1.26.2 From fdf246bb7d628432cbed806fdc03d0143cd4eba0 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 11:16:17 -0700 Subject: [PATCH 08/19] - --- .../direct_json/payload_fixer.dart | 129 ++++++ .../lib/src/utils/catalog_document.dart | 2 +- .../lib/src/utils/schema_utils.dart | 174 ++++++++ .../lib/src/validation/payload_validator.dart | 414 ++++++++++++++++++ 4 files changed, 718 insertions(+), 1 deletion(-) create mode 100644 packages/a2ui_agent/lib/src/inference_formats/direct_json/payload_fixer.dart create mode 100644 packages/a2ui_agent/lib/src/utils/schema_utils.dart create mode 100644 packages/a2ui_agent/lib/src/validation/payload_validator.dart diff --git a/packages/a2ui_agent/lib/src/inference_formats/direct_json/payload_fixer.dart b/packages/a2ui_agent/lib/src/inference_formats/direct_json/payload_fixer.dart new file mode 100644 index 000000000..fff1628e3 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/direct_json/payload_fixer.dart @@ -0,0 +1,129 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import '../../primitives/errors.dart'; +import '../../primitives/protocol_version.dart'; + +/// Repairs and parses the raw JSON a model emitted between the sentinel tags. +/// +/// Models reliably produce a handful of near-miss JSON forms. Rather than +/// discard an otherwise sound payload, these are repaired: markdown fences, +/// smart quotes, trailing commas, a single message emitted outside a list, and +/// a missing protocol `version` on the envelope. +abstract final class PayloadFixer { + /// Parses [payload] into A2UI message envelopes, repairing what it can. + /// + /// Throws [A2uiFormatError] when the content is not recoverable JSON. + static List> parseAndFix( + String payload, { + ProtocolVersion version = ProtocolVersion.current, + }) { + final String sanitized = normalizeSmartQuotes(stripMarkdownFence(payload)); + if (sanitized.trim().isEmpty) { + throw A2uiFormatError('A2UI payload block is empty.'); + } + + Object? decoded; + try { + decoded = jsonDecode(sanitized); + } on FormatException catch (error) { + try { + decoded = jsonDecode(removeTrailingCommas(sanitized)); + } on FormatException { + throw A2uiFormatError( + 'Failed to parse A2UI JSON payload: ${error.message}', + source: sanitized, + ); + } + } + + final List entries = decoded is List ? decoded : [decoded]; + final messages = >[]; + for (final Object? entry in entries) { + if (entry is! Map) { + throw A2uiFormatError( + 'A2UI payload entries must be JSON objects, got ' + '${entry.runtimeType}.', + source: sanitized, + ); + } + final Map message = entry.cast(); + messages.add( + message.containsKey('version') + ? message + : {'version': version.wireValue, ...message}, + ); + } + return messages; + } + + /// Removes a markdown code fence wrapped around [payload]. + static String stripMarkdownFence(String payload) { + String trimmed = payload.trim(); + if (trimmed.startsWith('```json')) { + trimmed = trimmed.substring('```json'.length); + } else if (trimmed.startsWith('```')) { + trimmed = trimmed.substring('```'.length); + } else { + return trimmed; + } + if (trimmed.endsWith('```')) { + trimmed = trimmed.substring(0, trimmed.length - '```'.length); + } + return trimmed.trim(); + } + + /// Replaces smart (curly) quotes with straight quotes. + static String normalizeSmartQuotes(String json) => json + .replaceAll('“', '"') + .replaceAll('”', '"') + .replaceAll('‘', "'") + .replaceAll('’', "'"); + + /// Removes commas that directly precede a closing bracket or brace. + /// + /// Commas inside string literals are left alone. + static String removeTrailingCommas(String json) { + final buffer = StringBuffer(); + var inString = false; + var escaped = false; + + for (var i = 0; i < json.length; i++) { + final String char = json[i]; + if (escaped) { + escaped = false; + buffer.write(char); + continue; + } + if (char == r'\' && inString) { + escaped = true; + buffer.write(char); + continue; + } + if (char == '"') { + inString = !inString; + buffer.write(char); + continue; + } + if (!inString && char == ',') { + final int next = _nextNonWhitespace(json, i + 1); + if (next < json.length && (json[next] == ']' || json[next] == '}')) { + continue; + } + } + buffer.write(char); + } + return buffer.toString(); + } + + static int _nextNonWhitespace(String value, int from) { + var index = from; + while (index < value.length && value[index].trim().isEmpty) { + index++; + } + return index; + } +} diff --git a/packages/a2ui_agent/lib/src/utils/catalog_document.dart b/packages/a2ui_agent/lib/src/utils/catalog_document.dart index 63b736778..d1898d74a 100644 --- a/packages/a2ui_agent/lib/src/utils/catalog_document.dart +++ b/packages/a2ui_agent/lib/src/utils/catalog_document.dart @@ -242,7 +242,7 @@ void expandSchemaRefs(Object? node) { A2uiReturnType _parseReturnType(Object? value) { if (value is! String) return A2uiReturnType.any; if (value == A2uiReturnType.void_.jsonValue) return A2uiReturnType.void_; - for (final type in A2uiReturnType.values) { + for (final A2uiReturnType type in A2uiReturnType.values) { if (type.name == value) return type; } return A2uiReturnType.any; diff --git a/packages/a2ui_agent/lib/src/utils/schema_utils.dart b/packages/a2ui_agent/lib/src/utils/schema_utils.dart new file mode 100644 index 000000000..d4d97d98e --- /dev/null +++ b/packages/a2ui_agent/lib/src/utils/schema_utils.dart @@ -0,0 +1,174 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +/// Structural keys that describe a component's envelope rather than its API. +const Set envelopeKeys = {'id', 'component'}; + +/// One positional parameter of a component or function signature. +class SignatureParameter { + /// The property name this parameter fills in. + final String name; + + /// The schema of the property. + final Schema schema; + + /// Whether the property is required by the component or function. + final bool isRequired; + + const SignatureParameter({ + required this.name, + required this.schema, + required this.isRequired, + }); + + /// The parameter rendered for a prompt signature, e.g. `variant?`. + String get label => isRequired ? name : '$name?'; + + @override + String toString() => label; +} + +/// The properties and required-property names declared by [schema]. +/// +/// `allOf` branches are flattened in declaration order so that a component +/// composed from shared fragments (for example the common `checks` fragment) +/// exposes one flat property list. The structural [envelopeKeys] are removed: +/// they describe the message envelope, not the component API. +({Map properties, Set required}) +flattenSchemaProperties(Schema schema) { + final properties = {}; + final required = {}; + + void visit(Schema current) { + final Object? declared = current['properties']; + if (declared is Map) { + for (final MapEntry entry in declared.entries) { + final Object? key = entry.key; + final Object? value = entry.value; + if (key is! String || envelopeKeys.contains(key)) continue; + if (value is! Map) continue; + properties.putIfAbsent( + key, + () => Schema.fromMap(value.cast()), + ); + } + } + + final Object? declaredRequired = current['required']; + if (declaredRequired is List) { + for (final Object? name in declaredRequired) { + if (name is String && !envelopeKeys.contains(name)) required.add(name); + } + } + + final Object? allOf = current['allOf']; + if (allOf is List) { + for (final Object? branch in allOf) { + if (branch is Map) { + visit(Schema.fromMap(branch.cast())); + } + } + } + } + + visit(schema); + return (properties: properties, required: required); +} + +/// The positional signature of an object [schema]. +/// +/// Required properties come first in declaration order, followed by the +/// optional ones, also in declaration order. Keeping required parameters at +/// the front lets a compact format omit every trailing optional argument, +/// which is the whole point of a positional syntax. +/// +/// Prompt generation and compilation both go through this function, so the +/// signature a model is shown is exactly the one its output is mapped against. +List signatureOf(Schema schema) { + final ({Map properties, Set required}) flat = + flattenSchemaProperties(schema); + return [ + for (final MapEntry entry in flat.properties.entries) + if (flat.required.contains(entry.key)) + SignatureParameter( + name: entry.key, + schema: entry.value, + isRequired: true, + ), + for (final MapEntry entry in flat.properties.entries) + if (!flat.required.contains(entry.key)) + SignatureParameter( + name: entry.key, + schema: entry.value, + isRequired: false, + ), + ]; +} + +/// The name of the A2UI common type [schema] refers to, e.g. `ComponentId`, +/// `ChildList`, `Action` or `DynamicString`. +/// +/// References appear either as a JSON Schema `$ref` or, in schemas built with +/// `package:a2ui_core`, as a `REF:|` marker. Returns +/// `null` when the schema refers to no common type. +String? schemaRefName(Schema schema) { + final Object? ref = schema[r'$ref']; + if (ref is String) return _lastPointerSegment(ref); + + final Object? description = schema['description']; + if (description is String && description.startsWith('REF:')) { + return _lastPointerSegment(description.substring(4).split('|').first); + } + return null; +} + +/// Whether [schema] accepts a plain JSON string. +/// +/// Composition branches are searched, so the A2UI `DynamicString` type — a +/// string, a data binding or a function call — counts as accepting a string. +bool schemaAcceptsString(Schema schema) { + final Object? type = schema['type']; + if (type == 'string') return true; + if (type is List && type.contains('string')) return true; + + for (final key in const ['anyOf', 'oneOf', 'allOf']) { + final Object? branches = schema[key]; + if (branches is! List) continue; + for (final Object? branch in branches) { + if (branch is Map && + schemaAcceptsString(Schema.fromMap(branch.cast()))) { + return true; + } + } + } + return false; +} + +/// The property names across [catalogs] whose values may be plain strings. +/// +/// A streaming parser can safely auto-close a truncated string value for these +/// keys, because a partially received string is still a valid value for the +/// property. Keys whose values are numbers, objects or lists are excluded: +/// healing those would fabricate structure the model never emitted. +Set progressiveStringKeys(Iterable> catalogs) { + final keys = {}; + for (final catalog in catalogs) { + for (final ComponentApi component in catalog.components.values) { + final ({Map properties, Set required}) flat = + flattenSchemaProperties(component.schema); + for (final MapEntry entry in flat.properties.entries) { + if (schemaAcceptsString(entry.value)) keys.add(entry.key); + } + } + } + return Set.unmodifiable(keys); +} + +String _lastPointerSegment(String pointer) { + final int index = pointer.lastIndexOf('/'); + return index < 0 ? pointer : pointer.substring(index + 1); +} diff --git a/packages/a2ui_agent/lib/src/validation/payload_validator.dart b/packages/a2ui_agent/lib/src/validation/payload_validator.dart new file mode 100644 index 000000000..1a2da71ee --- /dev/null +++ b/packages/a2ui_agent/lib/src/validation/payload_validator.dart @@ -0,0 +1,414 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +import '../parser/response_part.dart'; +import '../primitives/protocol_version.dart'; +import '../utils/schema_utils.dart'; + +/// A single problem found in an A2UI payload. +class A2uiValidationIssue { + /// A human-readable description of the problem. + final String message; + + /// The surface the offending message targets, when known. + final String? surfaceId; + + /// The component the problem was found in, when known. + final String? componentId; + + const A2uiValidationIssue(this.message, {this.surfaceId, this.componentId}); + + @override + String toString() { + final location = [ + if (surfaceId != null) "surface '$surfaceId'", + if (componentId != null) "component '$componentId'", + ]; + return location.isEmpty ? message : '$message (in ${location.join(', ')})'; + } +} + +/// Validates compiled A2UI payloads against the negotiated catalogs. +/// +/// This is the agent SDK's validation layer. The A2UI agent specification +/// delegates it to `A2uiValidator` in the core package; +/// `package:a2ui_core` does not ship one yet, so the checks live here and +/// operate on the `v0.9` envelopes that package models. +/// +/// The checks are: envelope version, catalog identity, component existence, +/// required and unknown properties, duplicate component ids, JSON Pointer +/// syntax, and — opt in via `checkReferences` — dangling child references and +/// reference cycles. +class A2uiPayloadValidator { + /// The catalogs the payload must conform to. + /// + /// An empty list disables catalog conformance checks; structural checks + /// still run. + final List> catalogs; + + /// The protocol version the payload must declare. + final ProtocolVersion protocolVersion; + + const A2uiPayloadValidator({ + required this.catalogs, + this.protocolVersion = ProtocolVersion.current, + }); + + /// Returns every problem found in [messages]. + /// + /// Set [partial] when validating a batch that is still streaming: checks + /// that depend on a component being fully received (required properties, + /// reference integrity) are skipped, because the missing pieces are still in + /// flight rather than genuinely absent. + List validate( + List messages, { + bool partial = false, + bool checkReferences = false, + }) { + final issues = []; + final definedComponents = {}; + final references = >{}; + + for (final message in messages) { + if (message.version != protocolVersion.wireValue) { + issues.add( + A2uiValidationIssue( + "Message declares version '${message.version}' but the session " + 'negotiated ${protocolVersion.wireValue}.', + ), + ); + } + + switch (message) { + case CreateSurfaceMessage(): + _validateCreateSurface(message, issues); + case UpdateComponentsMessage(): + _validateUpdateComponents( + message, + issues, + definedComponents, + references, + partial: partial, + ); + case UpdateDataModelMessage(): + _validateUpdateDataModel(message, issues); + case DeleteSurfaceMessage(): + if (message.surfaceId.isEmpty) { + issues.add( + const A2uiValidationIssue('deleteSurface has an empty surfaceId'), + ); + } + } + } + + if (checkReferences && !partial) { + _validateReferences(definedComponents, references, issues); + } + return issues; + } + + /// Validates [messages] and throws on the first problem found. + /// + /// All problems are reported in the error, not just the first one. + void validateOrThrow( + List messages, { + bool partial = false, + bool checkReferences = false, + }) { + final List issues = validate( + messages, + partial: partial, + checkReferences: checkReferences, + ); + if (issues.isEmpty) return; + throw A2uiValidationError( + 'A2UI payload failed validation:\n' + '${issues.map((issue) => ' - $issue').join('\n')}', + details: issues, + ); + } + + /// The component definition for [name] across the active catalogs. + ComponentApi? componentFor(String name) { + for (final Catalog catalog in catalogs) { + final ComponentApi? component = catalog.components[name]; + if (component != null) return component; + } + return null; + } + + void _validateCreateSurface( + CreateSurfaceMessage message, + List issues, + ) { + if (message.surfaceId.isEmpty) { + issues.add( + const A2uiValidationIssue('createSurface has an empty surfaceId'), + ); + } + if (catalogs.isEmpty) return; + if (!catalogs.any((catalog) => catalog.id == message.catalogId)) { + issues.add( + A2uiValidationIssue( + "createSurface references catalog '${message.catalogId}', which is " + 'not active for this session. Active catalogs: ' + '${catalogs.map((catalog) => catalog.id).join(', ')}.', + surfaceId: message.surfaceId, + ), + ); + } + } + + void _validateUpdateComponents( + UpdateComponentsMessage message, + List issues, + Map definedComponents, + Map> references, { + required bool partial, + }) { + final seenInBatch = {}; + for (final Map component in message.components) { + final Object? id = component['id']; + if (id is! String || id.isEmpty) { + issues.add( + A2uiValidationIssue( + "Component is missing a string 'id'.", + surfaceId: message.surfaceId, + ), + ); + continue; + } + if (!seenInBatch.add(id)) { + issues.add( + A2uiValidationIssue( + "Duplicate component id '$id' in one updateComponents message.", + surfaceId: message.surfaceId, + componentId: id, + ), + ); + } + + final Object? type = component['component']; + if (type is! String || type.isEmpty) { + if (!partial) { + issues.add( + A2uiValidationIssue( + "Component is missing a string 'component' type.", + surfaceId: message.surfaceId, + componentId: id, + ), + ); + } + continue; + } + definedComponents[id] = type; + + if (catalogs.isEmpty) continue; + final ComponentApi? api = componentFor(type); + if (api == null) { + issues.add( + A2uiValidationIssue( + "Unknown component '$type'. The active catalogs define: " + '${_knownComponentNames().join(', ')}.', + surfaceId: message.surfaceId, + componentId: id, + ), + ); + continue; + } + + _validateProperties( + component, + api, + message.surfaceId, + id, + issues, + references, + partial: partial, + ); + } + } + + void _validateProperties( + Map component, + ComponentApi api, + String surfaceId, + String id, + List issues, + Map> references, { + required bool partial, + }) { + final ({Map properties, Set required}) flat = + flattenSchemaProperties(api.schema); + + if (!partial) { + for (final String name in flat.required) { + if (!component.containsKey(name)) { + issues.add( + A2uiValidationIssue( + "Component '${api.name}' is missing required property '$name'.", + surfaceId: surfaceId, + componentId: id, + ), + ); + } + } + } + + for (final MapEntry entry in component.entries) { + final String name = entry.key; + if (envelopeKeys.contains(name)) continue; + final Schema? schema = flat.properties[name]; + if (schema == null) { + if (flat.properties.isNotEmpty) { + issues.add( + A2uiValidationIssue( + "Component '${api.name}' has no property '$name'. Declared " + 'properties: ${flat.properties.keys.join(', ')}.', + surfaceId: surfaceId, + componentId: id, + ), + ); + } + continue; + } + + _validateDataBindings(entry.value, name, surfaceId, id, issues); + references + .putIfAbsent(id, () => {}) + .addAll(_referencedIds(entry.value, schema)); + } + } + + void _validateUpdateDataModel( + UpdateDataModelMessage message, + List issues, + ) { + final String? path = message.path; + if (path != null && !isValidJsonPointer(path)) { + issues.add( + A2uiValidationIssue( + "updateDataModel path '$path' is not a valid JSON Pointer.", + surfaceId: message.surfaceId, + ), + ); + } + } + + void _validateDataBindings( + Object? value, + String property, + String surfaceId, + String id, + List issues, + ) { + if (value is Map) { + final Object? path = value['path']; + if (value.length == 1 && path is String && !isValidJsonPointer(path)) { + issues.add( + A2uiValidationIssue( + "Data binding on '$property' has an invalid JSON Pointer " + "'$path'.", + surfaceId: surfaceId, + componentId: id, + ), + ); + } + for (final Object? nested in value.values) { + _validateDataBindings(nested, property, surfaceId, id, issues); + } + } else if (value is List) { + for (final Object? nested in value) { + _validateDataBindings(nested, property, surfaceId, id, issues); + } + } + } + + void _validateReferences( + Map definedComponents, + Map> references, + List issues, + ) { + for (final MapEntry> entry in references.entries) { + for (final String child in entry.value) { + if (!definedComponents.containsKey(child)) { + issues.add( + A2uiValidationIssue( + "Component '${entry.key}' references undefined component " + "'$child'.", + componentId: entry.key, + ), + ); + } + } + } + + final visited = {}; + final onStack = {}; + + bool hasCycle(String id) { + if (onStack.contains(id)) return true; + if (!visited.add(id)) return false; + onStack.add(id); + for (final String child in references[id] ?? const {}) { + if (hasCycle(child)) return true; + } + onStack.remove(id); + return false; + } + + for (final String id in definedComponents.keys) { + if (hasCycle(id)) { + issues.add( + A2uiValidationIssue( + "Component '$id' takes part in a reference cycle.", + componentId: id, + ), + ); + break; + } + } + } + + /// The component ids [value] references, given its declared [schema]. + Set _referencedIds(Object? value, Schema schema) { + final String? ref = schemaRefName(schema); + if (ref == 'ComponentId' && value is String) return {value}; + if (ref == 'ChildList') { + if (value is List) { + return { + for (final Object? item in value) + if (item is String) item, + }; + } + if (value is Map) { + final Object? templateId = value['componentId']; + if (templateId is String) return {templateId}; + } + } + return const {}; + } + + List _knownComponentNames() => [ + for (final catalog in catalogs) ...catalog.components.keys, + ]; +} + +/// Whether [pointer] is syntactically a JSON Pointer (RFC 6901). +/// +/// A2UI also allows relative paths inside list templates, so a pointer that +/// does not start with `/` is accepted as long as its escape sequences are +/// well formed. +bool isValidJsonPointer(String pointer) { + for (var i = 0; i < pointer.length; i++) { + if (pointer[i] != '~') continue; + if (i + 1 >= pointer.length) return false; + final String next = pointer[i + 1]; + if (next != '0' && next != '1') return false; + } + return true; +} From f092604878c1030e875ef90868cc87a793806b08 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 11:18:26 -0700 Subject: [PATCH 09/19] - --- .../direct_json/constants.dart | 23 + .../inference_formats/direct_json/parser.dart | 101 +++++ .../direct_json/prompt_generator.dart | 121 ++++++ .../direct_json/streaming.dart | 401 ++++++++++++++++++ .../lib/src/parser/incremental_processor.dart | 9 +- 5 files changed, 653 insertions(+), 2 deletions(-) create mode 100644 packages/a2ui_agent/lib/src/inference_formats/direct_json/constants.dart create mode 100644 packages/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart create mode 100644 packages/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart create mode 100644 packages/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart diff --git a/packages/a2ui_agent/lib/src/inference_formats/direct_json/constants.dart b/packages/a2ui_agent/lib/src/inference_formats/direct_json/constants.dart new file mode 100644 index 000000000..62a5e94dd --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/direct_json/constants.dart @@ -0,0 +1,23 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// The tag that opens a Direct JSON payload block. +const String directJsonOpenTag = ''; + +/// The tag that closes a Direct JSON payload block. +const String directJsonCloseTag = ''; + +/// The tag that opens the catalog schema section of the system prompt. +const String a2uiSchemaOpenTag = ''; + +/// The tag that closes the catalog schema section of the system prompt. +const String a2uiSchemaCloseTag = ''; + +/// The A2UI message envelopes an agent may emit. +const List a2uiMessageEnvelopes = [ + 'createSurface', + 'updateComponents', + 'updateDataModel', + 'deleteSurface', +]; diff --git a/packages/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart b/packages/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart new file mode 100644 index 000000000..521112f24 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart @@ -0,0 +1,101 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:a2ui_core/a2ui_core.dart'; + +import '../../parser/parser.dart'; +import '../../parser/response_part.dart'; +import '../../primitives/protocol_version.dart'; +import '../../utils/schema_utils.dart'; +import '../../validation/payload_validator.dart'; +import 'constants.dart'; +import 'payload_fixer.dart'; +import 'streaming.dart'; + +/// Parses standard A2UI JSON payloads enclosed in `` tags. +class DirectJsonParser extends Parser { + /// The active catalogs compiled payloads are validated against. + final List> catalogs; + + /// Overrides the progressive keys derived from [catalogs]. + final Set? customProgressiveKeys; + + /// The protocol version payloads must declare. + final ProtocolVersion protocolVersion; + + /// Whether compiled payloads are checked for dangling child references and + /// reference cycles. + /// + /// Off by default because a turn may legitimately update components that a + /// previous turn defined, which this parser cannot see. + final bool checkReferences; + + Set? _progressiveKeys; + DirectJsonStreamProcessor? _stream; + + DirectJsonParser({ + required this.catalogs, + this.customProgressiveKeys, + this.protocolVersion = ProtocolVersion.current, + this.checkReferences = false, + }); + + @override + String get openTag => directJsonOpenTag; + + @override + String get closeTag => directJsonCloseTag; + + /// The string property keys that may be auto-closed when a streamed value is + /// cut off mid-token. + /// + /// Derived from the active catalogs unless [customProgressiveKeys] overrides + /// them. + Set get progressiveKeys => + customProgressiveKeys ?? + (_progressiveKeys ??= progressiveStringKeys(catalogs)); + + @override + List compile(String formatContent) { + final List> payload = PayloadFixer.parseAndFix( + formatContent, + version: protocolVersion, + ); + final List messages = [ + for (final Map json in payload) + A2uiMessage.fromJson(json), + ]; + A2uiPayloadValidator( + catalogs: catalogs, + protocolVersion: protocolVersion, + ).validateOrThrow(messages, checkReferences: checkReferences); + return messages; + } + + @override + String decompile(List a2uiPayload) { + return const JsonEncoder.withIndent(' ').convert([ + for (final AgentToRendererMessage message in a2uiPayload) + message.toJson(), + ]); + } + + @override + List parseChunk(String chunk, {bool wrapped = true}) => + _processor.add(chunk, wrapped: wrapped); + + @override + List flush() => _processor.flush(); + + DirectJsonStreamProcessor get _processor => + _stream ??= DirectJsonStreamProcessor( + catalogs: catalogs, + progressiveKeys: progressiveKeys, + protocolVersion: protocolVersion, + openTag: openTag, + closeTag: closeTag, + ); +} diff --git a/packages/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart b/packages/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart new file mode 100644 index 000000000..a3a1c0aa5 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart @@ -0,0 +1,121 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:a2ui_core/a2ui_core.dart'; + +import '../../parser/response_part.dart'; +import '../../prompt/generator.dart'; +import '../../utils/catalog_document.dart'; +import 'constants.dart'; + +/// Renders the system prompt for the Direct JSON inference format. +/// +/// The snippet describes the sentinel tags, the message envelopes the agent +/// may emit, the component ordering the streaming parser relies on, and the +/// schemas of the active catalogs. +class DirectJsonPromptGenerator extends PromptGenerator { + /// The message envelopes the model is allowed to emit. + /// + /// Defaults to every envelope in the protocol. Narrow it to, say, + /// `['createSurface', 'updateComponents']` for a chat agent that only ever + /// builds new surfaces. + final List? allowedMessages; + + const DirectJsonPromptGenerator( + super.catalogs, { + super.examples, + this.allowedMessages, + }); + + /// The envelopes this generator advertises. + List get messages => allowedMessages ?? a2uiMessageEnvelopes; + + @override + String generate() { + final buffer = StringBuffer() + ..writeln('# A2UI output format') + ..writeln() + ..writeln( + 'You build user interfaces by emitting A2UI protocol messages as ' + 'JSON.', + ) + ..writeln() + ..writeln('## Rules') + ..writeln() + ..writeln( + '- A response may contain any number of A2UI blocks, and ' + 'conversational text before, between or after them.', + ) + ..writeln( + '- Every A2UI block MUST be wrapped in `$directJsonOpenTag` and ' + '`$directJsonCloseTag` tags.', + ) + ..writeln( + '- The content of a block MUST be raw JSON: a list of A2UI messages. ' + 'Do not wrap it in a markdown code fence.', + ) + ..writeln( + '- Each message MUST validate against the schemas below and MUST ' + 'carry exactly one of these envelopes: ${messages.join(', ')}.', + ) + ..writeln( + '- Within the `components` list of a message, the `root` component ' + 'MUST come first and every parent MUST come before its children. The ' + 'renderer streams the UI in the order you emit it.', + ) + ..writeln( + '- Never invent a component or property that is not in the catalog ' + 'schemas below.', + ) + ..writeln() + ..writeln('## Catalog schemas') + ..writeln() + ..writeln(a2uiSchemaOpenTag) + ..writeln(_encode(_catalogDocuments())) + ..writeln(a2uiSchemaCloseTag); + + final String examplesSection = _renderExamples(); + if (examplesSection.isNotEmpty) { + buffer + ..writeln() + ..write(examplesSection); + } + return buffer.toString(); + } + + List> _catalogDocuments() => [ + for (final Catalog catalog in catalogs) + catalogToDocument(catalog), + ]; + + String _renderExamples() { + final PromptExamples? examples = this.examples; + if (examples == null || examples.isEmpty) return ''; + + final buffer = StringBuffer() + ..writeln('## Examples') + ..writeln(); + for (final MapEntry> entry + in examples.entries) { + buffer + ..writeln('### ${entry.key}') + ..writeln() + ..writeln(directJsonOpenTag) + ..writeln( + _encode([ + for (final AgentToRendererMessage message in entry.value) + message.toJson(), + ]), + ) + ..writeln(directJsonCloseTag) + ..writeln(); + } + return buffer.toString(); + } + + static String _encode(Object? value) => + const JsonEncoder.withIndent(' ').convert(value); +} diff --git a/packages/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart b/packages/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart new file mode 100644 index 000000000..dae9d0513 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart @@ -0,0 +1,401 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:a2ui_core/a2ui_core.dart'; + +import '../../parser/incremental_processor.dart'; +import '../../parser/response_part.dart'; +import '../../primitives/protocol_version.dart'; +import '../../validation/payload_validator.dart'; +import 'payload_fixer.dart'; + +/// One top-level message found in a partially received payload. +class MessageFragment { + /// The message text, healed if it was still being streamed. + final String text; + + /// Whether the message's closing brace was actually received. + final bool complete; + + const MessageFragment({required this.text, required this.complete}); +} + +/// Splits a partially received A2UI JSON payload into message fragments, +/// repairing the one still in flight. +/// +/// A model emits components top-down, so a payload that has only been half +/// received still describes a renderable prefix of the UI. The scanner finds +/// every message whose text is usable and closes the containers the model has +/// not written yet. +class JsonFragmentScanner { + /// The raw payload text received so far. + final String source; + + /// Property keys whose truncated string values may be closed early. + /// + /// Closing a string for any other key would invent a value; for these keys a + /// prefix of the final string is a legitimate value that simply grows as + /// more of the stream arrives. + final Set progressiveKeys; + + JsonFragmentScanner(this.source, {required this.progressiveKeys}); + + /// Returns the message fragments contained in [source]. + List scan() { + final fragments = []; + var index = _skipWhitespace(0); + if (index < source.length && source[index] == '[') index++; + + while (true) { + index = _skipWhitespace(index); + while (index < source.length && + (source[index] == ',' || source[index] == ']')) { + index = _skipWhitespace(index + 1); + } + if (index >= source.length) break; + if (source[index] != '{') break; + + final _WalkResult result = _walk(index); + if (result.end != null) { + fragments.add( + MessageFragment( + text: source.substring(index, result.end), + complete: true, + ), + ); + index = result.end!; + continue; + } + + final String? healed = _heal(index, result); + if (healed != null) { + fragments.add(MessageFragment(text: healed, complete: false)); + } + break; + } + return fragments; + } + + int _skipWhitespace(int from) { + var index = from; + while (index < source.length && source[index].trim().isEmpty) { + index++; + } + return index; + } + + _WalkResult _walk(int start) { + final stack = <_Frame>[]; + var inString = false; + var escaped = false; + var isKeyString = false; + String? pendingKey; + + for (var i = start; i < source.length; i++) { + final String char = source[i]; + + if (inString) { + if (escaped) { + escaped = false; + } else if (char == r'\') { + escaped = true; + } else if (char == '"') { + inString = false; + if (isKeyString) { + pendingKey = _decodeStringLiteral(source, i); + } else { + _completeValue(stack, i + 1); + } + } + continue; + } + + switch (char) { + case '"': + inString = true; + isKeyString = + stack.isNotEmpty && stack.last.isObject && stack.last.expectKey; + break; + case '{': + case '[': + stack.add( + _Frame( + isObject: char == '{', + completeUpTo: i + 1, + expectKey: char == '{', + ), + ); + break; + case '}': + case ']': + if (stack.isEmpty) return _WalkResult(end: null, stack: stack); + stack.removeLast(); + if (stack.isEmpty) { + return _WalkResult(end: i + 1, stack: stack); + } + _completeValue(stack, i + 1); + break; + case ':': + if (stack.isNotEmpty) { + stack.last + ..expectKey = false + ..currentKey = pendingKey; + } + break; + case ',': + if (stack.isNotEmpty && stack.last.isObject) { + stack.last.expectKey = true; + } + break; + default: + if (char.trim().isEmpty) break; + final int end = _literalEnd(i); + _completeValue(stack, end); + i = end - 1; + } + } + + return _WalkResult( + end: null, + stack: stack, + inString: inString, + isKeyString: isKeyString, + ); + } + + int _literalEnd(int start) { + var index = start; + while (index < source.length) { + final String char = source[index]; + if (char == ',' || + char == '}' || + char == ']' || + char.trim().isEmpty || + char == ':') { + break; + } + index++; + } + return index; + } + + void _completeValue(List<_Frame> stack, int end) { + if (stack.isEmpty) return; + stack.last.completeUpTo = end; + if (stack.last.isObject) stack.last.expectKey = true; + } + + String? _heal(int start, _WalkResult result) { + if (result.stack.isEmpty) return null; + + String text; + if (result.inString) { + final String? key = result.stack.last.isObject + ? result.stack.last.currentKey + : null; + final bool canClose = + !result.isKeyString && key != null && progressiveKeys.contains(key); + text = canClose + ? '${source.substring(start)}"' + : source.substring(start, result.stack.last.completeUpTo); + } else { + text = source.substring(start, result.stack.last.completeUpTo); + } + + final closers = StringBuffer(); + for (var i = result.stack.length - 1; i >= 0; i--) { + closers.write(result.stack[i].isObject ? '}' : ']'); + } + return '$text$closers'; + } + + /// Decodes the string literal ending at [end] (the index of its closing + /// quote), so that escape sequences in property keys are honoured. + static String? _decodeStringLiteral(String source, int end) { + var start = end - 1; + while (start >= 0) { + if (source[start] == '"') { + var backslashes = 0; + var index = start - 1; + while (index >= 0 && source[index] == r'\') { + backslashes++; + index--; + } + if (backslashes.isEven) break; + } + start--; + } + if (start < 0) return null; + try { + final Object? decoded = jsonDecode(source.substring(start, end + 1)); + return decoded is String ? decoded : null; + } on FormatException { + return null; + } + } +} + +class _Frame { + _Frame({ + required this.isObject, + required this.completeUpTo, + required this.expectKey, + }); + + final bool isObject; + + /// The index just past the last fully received member of this container. + int completeUpTo; + + /// Whether the next string in this object is a property key. + bool expectKey; + + /// The key whose value is currently being received. + String? currentKey; +} + +class _WalkResult { + _WalkResult({ + required this.end, + required this.stack, + this.inString = false, + this.isKeyString = false, + }); + + /// The index just past the closing brace, or null if still open. + final int? end; + final List<_Frame> stack; + final bool inString; + final bool isKeyString; +} + +/// Turns a stream of Direct JSON chunks into incremental A2UI messages. +/// +/// Emission is delta-only and idempotent per component: a component is emitted +/// as soon as it is usable and re-emitted only when its content actually +/// changed, which is what lets a renderer show text growing as it streams. +/// `updateComponents` is additive per component id on the renderer, so a +/// partial batch is a valid message rather than a promise of one. +class DirectJsonStreamProcessor extends IncrementalStreamProcessor { + /// The catalogs compiled payloads are validated against. + final List> catalogs; + + /// Property keys whose truncated string values may be closed early. + final Set progressiveKeys; + + /// The protocol version stamped on messages that omit one. + final ProtocolVersion protocolVersion; + + final A2uiPayloadValidator _validator; + + /// Component payloads already emitted, keyed by message index and id. + final Map _emittedComponents = {}; + + /// Indexes of atomic messages already emitted. + final Set _emittedMessages = {}; + + DirectJsonStreamProcessor({ + required this.catalogs, + required this.progressiveKeys, + required super.openTag, + required super.closeTag, + this.protocolVersion = ProtocolVersion.current, + }) : _validator = A2uiPayloadValidator( + catalogs: catalogs, + protocolVersion: protocolVersion, + ); + + @override + void resetBlock() { + _emittedComponents.clear(); + _emittedMessages.clear(); + } + + @override + List emitDelta( + String rawBlock, { + required bool blockComplete, + }) { + final String payload = PayloadFixer.stripMarkdownFence(rawBlock); + if (payload.trim().isEmpty) return const []; + + final List fragments = JsonFragmentScanner( + payload, + progressiveKeys: progressiveKeys, + ).scan(); + + final delta = []; + for (var index = 0; index < fragments.length; index++) { + final MessageFragment fragment = fragments[index]; + final Map? json = _decode(fragment.text); + if (json == null) continue; + + if (json.containsKey('updateComponents')) { + final AgentToRendererMessage? message = _componentDelta(index, json); + if (message != null) delta.add(message); + continue; + } + + if (!fragment.complete || !_emittedMessages.add(index)) continue; + delta.add(A2uiMessage.fromJson(json)); + } + + if (delta.isEmpty) return const []; + _validator.validateOrThrow(delta, partial: !blockComplete); + return delta; + } + + Map? _decode(String text) { + try { + final Object? decoded = jsonDecode( + PayloadFixer.normalizeSmartQuotes(text), + ); + if (decoded is! Map) return null; + final Map json = decoded.cast(); + return json.containsKey('version') + ? json + : {'version': protocolVersion.wireValue, ...json}; + } on FormatException { + return null; + } + } + + AgentToRendererMessage? _componentDelta( + int index, + Map json, + ) { + final Object? body = json['updateComponents']; + if (body is! Map) return null; + final Object? surfaceId = body['surfaceId']; + final Object? components = body['components']; + if (surfaceId is! String || components is! List) return null; + + final fresh = >[]; + for (final Object? entry in components) { + if (entry is! Map) continue; + final Map component = entry.cast(); + final Object? id = component['id']; + final Object? type = component['component']; + // A component without an id or a type is still arriving; it is not + // renderable and must not be emitted as though it were. + if (id is! String || id.isEmpty || type is! String || type.isEmpty) { + continue; + } + final String encoded = jsonEncode(component); + final String key = '$index$id'; + if (_emittedComponents[key] == encoded) continue; + _emittedComponents[key] = encoded; + fresh.add(component); + } + + if (fresh.isEmpty) return null; + return UpdateComponentsMessage( + version: json['version'] as String? ?? protocolVersion.wireValue, + surfaceId: surfaceId, + components: fresh, + ); + } +} diff --git a/packages/a2ui_agent/lib/src/parser/incremental_processor.dart b/packages/a2ui_agent/lib/src/parser/incremental_processor.dart index 09e69a4c0..35e42c473 100644 --- a/packages/a2ui_agent/lib/src/parser/incremental_processor.dart +++ b/packages/a2ui_agent/lib/src/parser/incremental_processor.dart @@ -15,6 +15,7 @@ abstract class IncrementalStreamProcessor { final SentinelTokenizer _tokenizer; final StringBuffer _block = StringBuffer(); bool _inBlock = false; + bool _wrapped = true; IncrementalStreamProcessor({ required String openTag, @@ -43,6 +44,7 @@ abstract class IncrementalStreamProcessor { /// When [wrapped] is false the chunk is treated as raw payload content with /// no surrounding sentinel tags. List add(String chunk, {bool wrapped = true}) { + _wrapped = wrapped; if (!wrapped) { if (!_inBlock) { _inBlock = true; @@ -60,9 +62,12 @@ abstract class IncrementalStreamProcessor { } /// Flushes buffered state at the end of the stream. - List flush({bool wrapped = true}) { + /// + /// [wrapped] defaults to whatever the last [add] call used, so a stream that + /// was parsed unwrapped is also flushed unwrapped. + List flush({bool? wrapped}) { final parts = []; - if (wrapped) { + if (wrapped ?? _wrapped) { for (final SentinelToken token in _tokenizer.flush()) { parts.addAll(_handle(token)); } From 3cd13c982d467f8cd7a94b81b4cf4a5501b31dc8 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 11:18:31 -0700 Subject: [PATCH 10/19] Create format.dart --- .../inference_formats/direct_json/format.dart | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 packages/a2ui_agent/lib/src/inference_formats/direct_json/format.dart diff --git a/packages/a2ui_agent/lib/src/inference_formats/direct_json/format.dart b/packages/a2ui_agent/lib/src/inference_formats/direct_json/format.dart new file mode 100644 index 000000000..c304b984e --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/direct_json/format.dart @@ -0,0 +1,69 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +import '../../inference_format.dart'; +import '../../prompt/generator.dart'; +import 'parser.dart'; +import 'prompt_generator.dart'; + +/// Creates [DirectJsonFormat] strategies bound to a set of active catalogs. +class DirectJsonFormatFactory extends InferenceFormatFactory { + /// The message envelopes the model is allowed to emit. + final List? allowedMessages; + + /// Overrides the streaming progressive keys derived from the catalogs. + final Set? customProgressiveKeys; + + const DirectJsonFormatFactory({ + this.allowedMessages, + this.customProgressiveKeys, + }); + + @override + DirectJsonFormat createFormat( + List> catalogs, { + PromptExamples? examples, + }) { + return DirectJsonFormat( + catalogs, + examples: examples, + allowedMessages: allowedMessages, + customProgressiveKeys: customProgressiveKeys, + ); + } +} + +/// Pairs [DirectJsonPromptGenerator] with [DirectJsonParser]. +/// +/// This is the baseline format: the model emits the A2UI wire JSON itself, +/// wrapped in `` tags. +class DirectJsonFormat extends InferenceFormat { + /// The active catalogs this format is bound to. + final List> catalogs; + + /// Overrides the streaming progressive keys derived from [catalogs]. + final Set? customProgressiveKeys; + + @override + final DirectJsonPromptGenerator promptGenerator; + + DirectJsonFormat( + this.catalogs, { + PromptExamples? examples, + List? allowedMessages, + this.customProgressiveKeys, + }) : promptGenerator = DirectJsonPromptGenerator( + catalogs, + examples: examples, + allowedMessages: allowedMessages, + ); + + @override + DirectJsonParser createParser() => DirectJsonParser( + catalogs: catalogs, + customProgressiveKeys: customProgressiveKeys, + ); +} From b0d9e3e10437fd4eb15363039216bf88dcdb5978 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 11:19:10 -0700 Subject: [PATCH 11/19] - --- .../inference_formats/direct_json/payload_fixer.dart | 2 +- .../src/inference_formats/direct_json/streaming.dart | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/a2ui_agent/lib/src/inference_formats/direct_json/payload_fixer.dart b/packages/a2ui_agent/lib/src/inference_formats/direct_json/payload_fixer.dart index fff1628e3..37c363424 100644 --- a/packages/a2ui_agent/lib/src/inference_formats/direct_json/payload_fixer.dart +++ b/packages/a2ui_agent/lib/src/inference_formats/direct_json/payload_fixer.dart @@ -42,7 +42,7 @@ abstract final class PayloadFixer { final List entries = decoded is List ? decoded : [decoded]; final messages = >[]; - for (final Object? entry in entries) { + for (final entry in entries) { if (entry is! Map) { throw A2uiFormatError( 'A2UI payload entries must be JSON objects, got ' diff --git a/packages/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart b/packages/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart index dae9d0513..0ab3f4e64 100644 --- a/packages/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart +++ b/packages/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart @@ -46,7 +46,7 @@ class JsonFragmentScanner { /// Returns the message fragments contained in [source]. List scan() { final fragments = []; - var index = _skipWhitespace(0); + int index = _skipWhitespace(0); if (index < source.length && source[index] == '[') index++; while (true) { @@ -206,7 +206,7 @@ class JsonFragmentScanner { } final closers = StringBuffer(); - for (var i = result.stack.length - 1; i >= 0; i--) { + for (int i = result.stack.length - 1; i >= 0; i--) { closers.write(result.stack[i].isObject ? '}' : ']'); } return '$text$closers'; @@ -215,11 +215,11 @@ class JsonFragmentScanner { /// Decodes the string literal ending at [end] (the index of its closing /// quote), so that escape sequences in property keys are honoured. static String? _decodeStringLiteral(String source, int end) { - var start = end - 1; + int start = end - 1; while (start >= 0) { if (source[start] == '"') { var backslashes = 0; - var index = start - 1; + int index = start - 1; while (index >= 0 && source[index] == r'\') { backslashes++; index--; @@ -385,7 +385,7 @@ class DirectJsonStreamProcessor extends IncrementalStreamProcessor { continue; } final String encoded = jsonEncode(component); - final String key = '$index$id'; + final key = '$index$id'; if (_emittedComponents[key] == encoded) continue; _emittedComponents[key] = encoded; fresh.add(component); From 209bbcae7a406629337ed544d55ce06540f4a019 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 11:22:57 -0700 Subject: [PATCH 12/19] - --- .../src/inference_formats/express/ast.dart | 145 ++++++ .../inference_formats/express/constants.dart | 38 ++ .../src/inference_formats/express/lexer.dart | 418 ++++++++++++++++++ .../express/syntax_parser.dart | 232 ++++++++++ 4 files changed, 833 insertions(+) create mode 100644 packages/a2ui_agent/lib/src/inference_formats/express/ast.dart create mode 100644 packages/a2ui_agent/lib/src/inference_formats/express/constants.dart create mode 100644 packages/a2ui_agent/lib/src/inference_formats/express/lexer.dart create mode 100644 packages/a2ui_agent/lib/src/inference_formats/express/syntax_parser.dart diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/ast.dart b/packages/a2ui_agent/lib/src/inference_formats/express/ast.dart new file mode 100644 index 000000000..c5d3c1008 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/express/ast.dart @@ -0,0 +1,145 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// An expression in an A2UI Express statement. +sealed class ExpressExpression { + /// The 1-based line the expression starts on. + final int line; + + const ExpressExpression(this.line); +} + +/// A primitive literal: string, number, boolean or null. +final class LiteralExpression extends ExpressExpression { + /// The decoded literal value. + final Object? value; + + /// Whether the literal was written as a raw string (`r"..."`). + /// + /// Raw strings survive a decompile round trip unescaped, which matters for + /// validation patterns full of backslashes. + final bool isRawString; + + const LiteralExpression(super.line, this.value, {this.isRawString = false}); +} + +/// A data binding path, written `$/absolute` or `$relative`. +final class PathExpression extends ExpressExpression { + /// The path with the `$` prefix removed. + final String path; + + const PathExpression(super.line, this.path); +} + +/// A reference to a variable defined elsewhere in the block. +final class VariableExpression extends ExpressExpression { + /// The variable name. + final String name; + + const VariableExpression(super.line, this.name); +} + +/// The `_` placeholder that skips an optional positional argument. +final class SkippedExpression extends ExpressExpression { + const SkippedExpression(super.line); +} + +/// A list of expressions, written `[a, b]`. +final class ArrayExpression extends ExpressExpression { + /// The list elements, in source order. + final List items; + + const ArrayExpression(super.line, this.items); +} + +/// A key-value structure, written `{key: value}`. +final class MapExpression extends ExpressExpression { + /// The entries, in source order. + final Map entries; + + const MapExpression(super.line, this.entries); +} + +/// A call to a component constructor, catalog function or reserved helper. +final class CallExpression extends ExpressExpression { + /// The name being called. + final String name; + + /// The arguments, positional and named, in source order. + final List arguments; + + const CallExpression(super.line, this.name, this.arguments); + + /// The positional arguments, in order. + List get positional => [ + for (final ExpressArgument argument in arguments) + if (argument.name == null) argument.value, + ]; + + /// The named arguments, keyed by parameter name. + Map get named => { + for (final ExpressArgument argument in arguments) + if (argument.name != null) argument.name!: argument.value, + }; +} + +/// A validation check, written `?required` or `?regex(pattern, message)`. +final class CheckExpression extends ExpressExpression { + /// The name of the check function in the catalog. + final String name; + + /// The check arguments, in source order. + final List arguments; + + const CheckExpression(super.line, this.name, this.arguments); +} + +/// One argument of a [CallExpression]. +class ExpressArgument { + /// The parameter name for `param=value` arguments, or null when positional. + final String? name; + + /// The argument value. + final ExpressExpression value; + + const ExpressArgument({this.name, required this.value}); +} + +/// A statement in an A2UI Express block. +sealed class ExpressStatement { + /// The 1-based line the statement starts on. + final int line; + + const ExpressStatement(this.line); +} + +/// Assigns a component or value to a variable, e.g. `root = Card(body)`. +final class VariableAssignment extends ExpressStatement { + /// The variable being defined. Doubles as the compiled component id. + final String name; + + /// The assigned expression. + final ExpressExpression value; + + const VariableAssignment(super.line, this.name, this.value); +} + +/// Populates a data model path, e.g. `$/title = "Hello"`. +final class DataAssignment extends ExpressStatement { + /// The target path, with the `$` prefix removed. + final String path; + + /// The assigned expression. + final ExpressExpression value; + + const DataAssignment(super.line, this.path, this.value); +} + +/// A standalone call, e.g. `surface("main")` or `deleteSurface("main")`. +final class CallStatement extends ExpressStatement { + /// The call being made. + final CallExpression call; + + const CallStatement(super.line, this.call); +} diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/constants.dart b/packages/a2ui_agent/lib/src/inference_formats/express/constants.dart new file mode 100644 index 000000000..ad005585c --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/express/constants.dart @@ -0,0 +1,38 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// The tag that opens an Express payload block. +const String expressOpenTag = ''; + +/// The tag that closes an Express payload block. +const String expressCloseTag = ''; + +/// The variable that names the entry point of the component tree. +const String expressRootVariable = 'root'; + +/// The surface used when a block does not call `surface(...)`. +const String expressDefaultSurfaceId = 'default_surface'; + +/// The helper that binds a child slot to a data-driven list template. +const String expressTemplateHelper = '_template'; + +/// The call that declares a server-side event action. +const String expressEventCall = 'Event'; + +/// The call that targets a surface. +const String expressSurfaceCall = 'surface'; + +/// The call that deletes a surface. +const String expressDeleteSurfaceCall = 'deleteSurface'; + +/// The placeholder for a skipped optional positional argument. +const String expressSkipPlaceholder = '_'; + +/// Names that the compiler reserves and never resolves against a catalog. +const Set expressReservedNames = { + expressTemplateHelper, + expressEventCall, + expressSurfaceCall, + expressDeleteSurfaceCall, +}; diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/lexer.dart b/packages/a2ui_agent/lib/src/inference_formats/express/lexer.dart new file mode 100644 index 000000000..f0f27d6b3 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/express/lexer.dart @@ -0,0 +1,418 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../../primitives/errors.dart'; + +/// The kinds of token the Express lexer produces. +enum ExpressTokenType { + identifier, + path, + check, + number, + string, + boolean, + nullLiteral, + assign, + leftParen, + rightParen, + leftBracket, + rightBracket, + leftBrace, + rightBrace, + comma, + colon, + underscore, + eof, +} + +/// A lexical token of the Express DSL. +class ExpressToken { + /// What kind of token this is. + final ExpressTokenType type; + + /// The token text as written, with quotes and prefixes removed for strings. + final String lexeme; + + /// The decoded value for literal tokens. + final Object? value; + + /// The 1-based source line the token starts on. + final int line; + + /// Whether a string token was written as a raw string. + final bool isRawString; + + const ExpressToken({ + required this.type, + required this.lexeme, + required this.line, + this.value, + this.isRawString = false, + }); + + @override + String toString() => '${type.name}($lexeme)'; +} + +/// Turns Express source text into tokens. +/// +/// Comments (`#`, `//`, `/* */`), semicolons and whitespace are skipped, as in +/// the reference grammar. Identifiers follow the Unicode identifier shape the +/// specification requires: an ASCII letter, an underscore or any non-ASCII +/// character to start, then the same plus digits. +class ExpressLexer { + /// The source being scanned. + final String source; + + int _offset = 0; + int _line = 1; + + ExpressLexer(this.source); + + /// Scans [source] into a token list terminated by an + /// [ExpressTokenType.eof] token. + /// + /// Throws [A2uiFormatError] on an unterminated string or an unexpected + /// character. + List tokenize() { + final tokens = []; + while (true) { + final ExpressToken token = _next(); + tokens.add(token); + if (token.type == ExpressTokenType.eof) return tokens; + } + } + + ExpressToken _next() { + _skipIgnored(); + if (_offset >= source.length) { + return ExpressToken( + type: ExpressTokenType.eof, + lexeme: '', + line: _line, + ); + } + + final int startLine = _line; + final String char = source[_offset]; + + switch (char) { + case '=': + _offset++; + return ExpressToken( + type: ExpressTokenType.assign, + lexeme: '=', + line: startLine, + ); + case '(': + _offset++; + return ExpressToken( + type: ExpressTokenType.leftParen, + lexeme: '(', + line: startLine, + ); + case ')': + _offset++; + return ExpressToken( + type: ExpressTokenType.rightParen, + lexeme: ')', + line: startLine, + ); + case '[': + _offset++; + return ExpressToken( + type: ExpressTokenType.leftBracket, + lexeme: '[', + line: startLine, + ); + case ']': + _offset++; + return ExpressToken( + type: ExpressTokenType.rightBracket, + lexeme: ']', + line: startLine, + ); + case '{': + _offset++; + return ExpressToken( + type: ExpressTokenType.leftBrace, + lexeme: '{', + line: startLine, + ); + case '}': + _offset++; + return ExpressToken( + type: ExpressTokenType.rightBrace, + lexeme: '}', + line: startLine, + ); + case ',': + _offset++; + return ExpressToken( + type: ExpressTokenType.comma, + lexeme: ',', + line: startLine, + ); + case ':': + _offset++; + return ExpressToken( + type: ExpressTokenType.colon, + lexeme: ':', + line: startLine, + ); + case r'$': + return _readPath(startLine); + case '?': + return _readCheck(startLine); + case '"': + return _readString(startLine, raw: false); + } + + if ((char == 'r' || char == 'R') && + _offset + 1 < source.length && + source[_offset + 1] == '"') { + _offset++; + return _readString(startLine, raw: true); + } + + if (_isDigit(char) || + (char == '-' && + _offset + 1 < source.length && + _isDigit(source[_offset + 1]))) { + return _readNumber(startLine); + } + + if (_isIdentifierStart(char)) return _readIdentifier(startLine); + + throw A2uiFormatError( + "Unexpected character '$char' in Express source.", + line: startLine, + ); + } + + void _skipIgnored() { + while (_offset < source.length) { + final String char = source[_offset]; + if (char == '\n') { + _line++; + _offset++; + continue; + } + if (char == ' ' || char == '\t' || char == '\r' || char == ';') { + _offset++; + continue; + } + if (char == '#' || source.startsWith('//', _offset)) { + while (_offset < source.length && source[_offset] != '\n') { + _offset++; + } + continue; + } + if (source.startsWith('/*', _offset)) { + final int end = source.indexOf('*/', _offset + 2); + final int stop = end < 0 ? source.length : end + 2; + _line += '\n'.allMatches(source.substring(_offset, stop)).length; + _offset = stop; + continue; + } + return; + } + } + + ExpressToken _readPath(int line) { + final int start = _offset; + _offset++; // consume '$' + while (_offset < source.length && _isPathChar(source[_offset])) { + _offset++; + } + final String lexeme = source.substring(start, _offset); + return ExpressToken( + type: ExpressTokenType.path, + lexeme: lexeme, + value: lexeme.substring(1), + line: line, + ); + } + + ExpressToken _readCheck(int line) { + _offset++; // consume '?' + final int start = _offset; + if (_offset >= source.length || !_isIdentifierStart(source[_offset])) { + throw A2uiFormatError( + "A check must be written '?name'.", + line: line, + ); + } + while (_offset < source.length && _isIdentifierPart(source[_offset])) { + _offset++; + } + return ExpressToken( + type: ExpressTokenType.check, + lexeme: source.substring(start, _offset), + line: line, + ); + } + + ExpressToken _readNumber(int line) { + final int start = _offset; + if (source[_offset] == '-') _offset++; + while (_offset < source.length && _isDigit(source[_offset])) { + _offset++; + } + if (_offset < source.length && + source[_offset] == '.' && + _offset + 1 < source.length && + _isDigit(source[_offset + 1])) { + _offset++; + while (_offset < source.length && _isDigit(source[_offset])) { + _offset++; + } + } + final String lexeme = source.substring(start, _offset); + return ExpressToken( + type: ExpressTokenType.number, + lexeme: lexeme, + value: num.parse(lexeme), + line: line, + ); + } + + ExpressToken _readIdentifier(int line) { + final int start = _offset; + while (_offset < source.length && _isIdentifierPart(source[_offset])) { + _offset++; + } + final String lexeme = source.substring(start, _offset); + + switch (lexeme) { + case 'true': + case 'false': + return ExpressToken( + type: ExpressTokenType.boolean, + lexeme: lexeme, + value: lexeme == 'true', + line: line, + ); + case 'null': + return ExpressToken( + type: ExpressTokenType.nullLiteral, + lexeme: lexeme, + line: line, + ); + case '_': + return ExpressToken( + type: ExpressTokenType.underscore, + lexeme: lexeme, + line: line, + ); + } + + return ExpressToken( + type: ExpressTokenType.identifier, + lexeme: lexeme, + line: line, + ); + } + + ExpressToken _readString(int line, {required bool raw}) { + final bool triple = source.startsWith('"""', _offset); + final delimiter = triple ? '"""' : '"'; + _offset += delimiter.length; + + final buffer = StringBuffer(); + while (true) { + if (_offset >= source.length) { + throw A2uiFormatError('Unterminated string literal.', line: line); + } + if (source.startsWith(delimiter, _offset)) { + _offset += delimiter.length; + break; + } + final String char = source[_offset]; + if (char == '\n') { + if (!triple && !raw) { + throw A2uiFormatError('Unterminated string literal.', line: line); + } + _line++; + } + if (char == r'\' && !raw) { + _offset++; + if (_offset >= source.length) { + throw A2uiFormatError('Unterminated escape sequence.', line: line); + } + buffer.write(_unescape(source[_offset], line)); + _offset++; + continue; + } + buffer.write(char); + _offset++; + } + + return ExpressToken( + type: ExpressTokenType.string, + lexeme: buffer.toString(), + value: buffer.toString(), + line: line, + isRawString: raw, + ); + } + + String _unescape(String char, int line) { + switch (char) { + case 'n': + return '\n'; + case 't': + return '\t'; + case 'r': + return '\r'; + case 'b': + return '\b'; + case 'f': + return '\f'; + case '"': + return '"'; + case r'\': + return r'\'; + case '/': + return '/'; + case 'u': + final int start = _offset + 1; + if (start + 4 > source.length) { + throw A2uiFormatError('Truncated unicode escape.', line: line); + } + final String hex = source.substring(start, start + 4); + final int? code = int.tryParse(hex, radix: 16); + if (code == null) { + throw A2uiFormatError( + "Invalid unicode escape '\\u$hex'.", + line: line, + ); + } + _offset += 4; + return String.fromCharCode(code); + default: + return char; + } + } + + static bool _isDigit(String char) => + char.compareTo('0') >= 0 && char.compareTo('9') <= 0; + + static bool _isPathChar(String char) => + _isDigit(char) || + char == '/' || + char == '_' || + _isAsciiLetter(char) || + char.codeUnitAt(0) > 0x7f; + + static bool _isAsciiLetter(String char) => + (char.compareTo('a') >= 0 && char.compareTo('z') <= 0) || + (char.compareTo('A') >= 0 && char.compareTo('Z') <= 0); + + static bool _isIdentifierStart(String char) => + _isAsciiLetter(char) || char == '_' || char.codeUnitAt(0) > 0x7f; + + static bool _isIdentifierPart(String char) => + _isIdentifierStart(char) || _isDigit(char); +} diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/syntax_parser.dart b/packages/a2ui_agent/lib/src/inference_formats/express/syntax_parser.dart new file mode 100644 index 000000000..b4e69ad82 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/express/syntax_parser.dart @@ -0,0 +1,232 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../../primitives/errors.dart'; +import 'ast.dart'; +import 'lexer.dart'; + +/// Builds the Express abstract syntax tree from tokens. +/// +/// The grammar is the reference `Express.g4`: a program is a sequence of +/// variable assignments, data-path assignments and standalone calls, with +/// expressions covering literals, paths, arrays, maps, checks, calls and +/// variable references. +class ExpressSyntaxParser { + /// The tokens to parse, terminated by an end-of-file token. + final List tokens; + + int _position = 0; + + ExpressSyntaxParser(this.tokens); + + /// Tokenizes and parses [source]. + factory ExpressSyntaxParser.fromSource(String source) => + ExpressSyntaxParser(ExpressLexer(source).tokenize()); + + /// Parses the whole program. + List parseProgram() { + final statements = []; + while (!_isAtEnd) { + statements.add(_parseStatement()); + } + return statements; + } + + bool get _isAtEnd => _peek.type == ExpressTokenType.eof; + + ExpressToken get _peek => tokens[_position]; + + ExpressToken get _previous => tokens[_position - 1]; + + ExpressToken _advance() => tokens[_position++]; + + bool _check(ExpressTokenType type) => _peek.type == type; + + bool _match(ExpressTokenType type) { + if (!_check(type)) return false; + _position++; + return true; + } + + ExpressToken _expect(ExpressTokenType type, String description) { + if (_check(type)) return _advance(); + throw A2uiFormatError( + 'Expected $description but found ' + "'${_peek.lexeme.isEmpty ? 'end of input' : _peek.lexeme}'.", + line: _peek.line, + ); + } + + ExpressStatement _parseStatement() { + final ExpressToken token = _peek; + + if (token.type == ExpressTokenType.path && + tokens[_position + 1].type == ExpressTokenType.assign) { + _advance(); + _advance(); + return DataAssignment( + token.line, + token.value! as String, + _parseExpression(), + ); + } + + if (token.type == ExpressTokenType.identifier && + tokens[_position + 1].type == ExpressTokenType.assign) { + _advance(); + _advance(); + return VariableAssignment(token.line, token.lexeme, _parseExpression()); + } + + final ExpressExpression expression = _parseExpression(); + if (expression is! CallExpression) { + throw A2uiFormatError( + 'A standalone statement must be a call, such as surface("main").', + line: token.line, + ); + } + return CallStatement(token.line, expression); + } + + ExpressExpression _parseExpression() { + final ExpressToken token = _peek; + switch (token.type) { + case ExpressTokenType.leftBracket: + return _parseArray(); + case ExpressTokenType.leftBrace: + return _parseMap(); + case ExpressTokenType.path: + _advance(); + return PathExpression(token.line, token.value! as String); + case ExpressTokenType.check: + return _parseCheck(); + case ExpressTokenType.identifier: + return _parseIdentifierExpression(); + case ExpressTokenType.underscore: + _advance(); + return SkippedExpression(token.line); + case ExpressTokenType.string: + _advance(); + return LiteralExpression( + token.line, + token.value, + isRawString: token.isRawString, + ); + case ExpressTokenType.number: + case ExpressTokenType.boolean: + _advance(); + return LiteralExpression(token.line, token.value); + case ExpressTokenType.nullLiteral: + _advance(); + return LiteralExpression(token.line, null); + default: + throw A2uiFormatError( + "Unexpected token '${token.lexeme}' where a value was expected.", + line: token.line, + ); + } + } + + ExpressExpression _parseIdentifierExpression() { + final ExpressToken name = _advance(); + if (!_check(ExpressTokenType.leftParen)) { + return VariableExpression(name.line, name.lexeme); + } + return CallExpression(name.line, name.lexeme, _parseArguments()); + } + + List _parseArguments() { + _expect(ExpressTokenType.leftParen, "'('"); + final arguments = []; + if (_match(ExpressTokenType.rightParen)) return arguments; + + while (true) { + if (_check(ExpressTokenType.identifier) && + tokens[_position + 1].type == ExpressTokenType.assign) { + final ExpressToken name = _advance(); + _advance(); + arguments.add( + ExpressArgument(name: name.lexeme, value: _parseExpression()), + ); + } else { + arguments.add(ExpressArgument(value: _parseExpression())); + } + + if (_match(ExpressTokenType.comma)) { + if (_match(ExpressTokenType.rightParen)) return arguments; + continue; + } + _expect(ExpressTokenType.rightParen, "')' or ','"); + return arguments; + } + } + + ExpressExpression _parseArray() { + final ExpressToken open = _advance(); + final items = []; + if (_match(ExpressTokenType.rightBracket)) { + return ArrayExpression(open.line, items); + } + + while (true) { + items.add(_parseExpression()); + if (_match(ExpressTokenType.comma)) { + if (_match(ExpressTokenType.rightBracket)) { + return ArrayExpression(open.line, items); + } + continue; + } + _expect(ExpressTokenType.rightBracket, "']' or ','"); + return ArrayExpression(open.line, items); + } + } + + ExpressExpression _parseMap() { + final ExpressToken open = _advance(); + final entries = {}; + if (_match(ExpressTokenType.rightBrace)) { + return MapExpression(open.line, entries); + } + + while (true) { + if (!_match(ExpressTokenType.identifier) && + !_match(ExpressTokenType.string)) { + throw A2uiFormatError( + 'A map key must be an identifier or a string.', + line: _peek.line, + ); + } + final ExpressToken key = _previous; + _expect(ExpressTokenType.colon, "':'"); + entries[key.lexeme] = _parseExpression(); + + if (_match(ExpressTokenType.comma)) { + if (_match(ExpressTokenType.rightBrace)) { + return MapExpression(open.line, entries); + } + continue; + } + _expect(ExpressTokenType.rightBrace, "'}' or ','"); + return MapExpression(open.line, entries); + } + } + + ExpressExpression _parseCheck() { + final ExpressToken name = _advance(); + final arguments = []; + if (!_check(ExpressTokenType.leftParen)) { + return CheckExpression(name.line, name.lexeme, arguments); + } + for (final ExpressArgument argument in _parseArguments()) { + if (argument.name != null) { + throw A2uiFormatError( + 'A check does not take named arguments.', + line: name.line, + ); + } + arguments.add(argument.value); + } + return CheckExpression(name.line, name.lexeme, arguments); + } +} From 1e94e1b68f59a5ec9a1a588c4abca0ba706f2f79 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 11:29:25 -0700 Subject: [PATCH 13/19] - --- .../inference_formats/express/compiler.dart | 680 ++++++++++++++++++ .../inference_formats/express/decompiler.dart | 278 +++++++ .../src/inference_formats/express/format.dart | 71 ++ .../src/inference_formats/express/parser.dart | 159 ++++ .../express/prompt_generator.dart | 224 ++++++ .../express/statement_splitter.dart | 94 +++ .../lib/src/processor/catalog_config.dart | 66 ++ .../lib/src/processor/catalog_providers.dart | 142 ++++ .../src/processor/renderer_capabilities.dart | 79 ++ 9 files changed, 1793 insertions(+) create mode 100644 packages/a2ui_agent/lib/src/inference_formats/express/compiler.dart create mode 100644 packages/a2ui_agent/lib/src/inference_formats/express/decompiler.dart create mode 100644 packages/a2ui_agent/lib/src/inference_formats/express/format.dart create mode 100644 packages/a2ui_agent/lib/src/inference_formats/express/parser.dart create mode 100644 packages/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart create mode 100644 packages/a2ui_agent/lib/src/inference_formats/express/statement_splitter.dart create mode 100644 packages/a2ui_agent/lib/src/processor/catalog_config.dart create mode 100644 packages/a2ui_agent/lib/src/processor/catalog_providers.dart create mode 100644 packages/a2ui_agent/lib/src/processor/renderer_capabilities.dart diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/compiler.dart b/packages/a2ui_agent/lib/src/inference_formats/express/compiler.dart new file mode 100644 index 000000000..9d9812937 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/express/compiler.dart @@ -0,0 +1,680 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +import '../../parser/response_part.dart'; +import '../../primitives/errors.dart'; +import '../../primitives/protocol_version.dart'; +import '../../utils/schema_utils.dart'; +import 'ast.dart'; +import 'constants.dart'; +import 'syntax_parser.dart'; + +/// Compiles A2UI Express statements into A2UI protocol messages. +/// +/// A compiler instance is a session: it remembers the targeted surface, which +/// surfaces it has already created, and which component ids it has handed out, +/// so a document can be compiled all at once or statement by statement as it +/// streams in. +/// +/// The compiler holds no knowledge of any particular catalog. Positional +/// arguments are mapped through [signatureOf], the same function the prompt +/// generator uses to describe components to the model. +/// +/// Because `package:a2ui_core` models the `v0.9` envelopes, a compiled block +/// becomes `createSurface` (once per surface), then `updateDataModel` for data +/// assignments, then a single `updateComponents` carrying the flattened +/// adjacency list. +class ExpressCompiler { + /// The catalogs component and function signatures are resolved against. + final List> catalogs; + + /// The surface used when a block does not call `surface(...)`. + final String defaultSurfaceId; + + /// The protocol version stamped on emitted messages. + final ProtocolVersion protocolVersion; + + final Set _announcedSurfaces; + final Set _usedIds = {}; + + String? _surfaceId; + String? _catalogId; + + ExpressCompiler({ + required this.catalogs, + this.defaultSurfaceId = expressDefaultSurfaceId, + this.protocolVersion = ProtocolVersion.current, + Set existingSurfaceIds = const {}, + }) : _announcedSurfaces = {...existingSurfaceIds}; + + /// The surface the compiler is currently targeting. + String get surfaceId => _surfaceId ?? defaultSurfaceId; + + /// Compiles a complete Express document. + List compile(String source) => + compileStatements(ExpressSyntaxParser.fromSource(source).parseProgram()); + + /// Compiles [statements], continuing the current session. + List compileStatements( + List statements, + ) { + final messages = []; + final components = >[]; + + void flushComponents() { + if (components.isEmpty) return; + _ensureSurface(messages); + messages.add( + UpdateComponentsMessage( + version: protocolVersion.wireValue, + surfaceId: surfaceId, + components: List>.from(components), + ), + ); + components.clear(); + } + + for (final statement in statements) { + switch (statement) { + case CallStatement(call: final CallExpression call): + _compileCallStatement(call, messages, flushComponents); + case DataAssignment( + path: final String path, + value: final ExpressExpression value, + ): + _ensureSurface(messages); + messages.add( + UpdateDataModelMessage( + version: protocolVersion.wireValue, + surfaceId: surfaceId, + path: path, + value: _compileDataValue(value), + ), + ); + case VariableAssignment( + name: final String name, + value: final ExpressExpression value, + ): + components.addAll(_compileAssignment(name, value)); + } + } + + flushComponents(); + return messages; + } + + void _compileCallStatement( + CallExpression call, + List messages, + void Function() flushComponents, + ) { + switch (call.name) { + case expressSurfaceCall: + final List args = call.positional; + if (args.isEmpty) { + throw A2uiFormatError( + 'surface() needs a surface id.', + line: call.line, + ); + } + flushComponents(); + _surfaceId = _requireString(args.first, 'surface id'); + if (args.length > 1) { + _catalogId = _requireString(args[1], 'catalog id'); + } + case expressDeleteSurfaceCall: + final List args = call.positional; + flushComponents(); + final String target = args.isEmpty + ? surfaceId + : _requireString(args.first, 'surface id'); + messages.add( + DeleteSurfaceMessage( + version: protocolVersion.wireValue, + surfaceId: target, + ), + ); + _announcedSurfaces.remove(target); + default: + throw A2uiFormatError( + "Standalone call '${call.name}()' compiles to a callFunction RPC " + 'message, which the v0.9 protocol modelled by package:a2ui_core ' + 'does not define. Use surface(), deleteSurface(), or move the call ' + 'into a component property.', + line: call.line, + ); + } + } + + void _ensureSurface(List messages) { + final String id = surfaceId; + if (!_announcedSurfaces.add(id)) return; + messages.add( + CreateSurfaceMessage( + version: protocolVersion.wireValue, + surfaceId: id, + catalogId: _catalogId ?? _defaultCatalogId(), + ), + ); + } + + String _defaultCatalogId() { + if (catalogs.isEmpty) { + throw A2uiFormatError( + 'Cannot compile Express without a catalog: the compiler needs ' + 'component signatures to map positional arguments, and a catalog id ' + 'for createSurface.', + ); + } + return catalogs.first.id; + } + + List> _compileAssignment( + String name, + ExpressExpression value, + ) { + if (value is! CallExpression) { + throw A2uiFormatError( + "'$name' must be assigned a component, for example " + '$name = Text("Hello").', + line: value.line, + ); + } + _usedIds.add(name); + final sink = >[]; + final Map component = _compileComponent( + name, + value, + sink, + ); + return [component, ...sink]; + } + + /// Compiles [call] into a component map, appending inline children to + /// [sink]. + /// + /// The parent is returned rather than appended so that callers can keep + /// parents ahead of their children: the renderer builds the tree in the + /// order it receives it. + Map _compileComponent( + String id, + CallExpression call, + List> sink, + ) { + final ComponentApi api = _requireComponent(call.name, call.line); + final List parameters = signatureOf(api.schema); + final properties = {}; + + final List positional = call.positional; + if (positional.length > parameters.length) { + throw A2uiFormatError( + '${api.name} takes ${parameters.length} argument(s) but ' + '${positional.length} were given. Signature: ' + '${api.name}(${parameters.map((p) => p.label).join(', ')}).', + line: call.line, + ); + } + + for (var index = 0; index < positional.length; index++) { + final ExpressExpression argument = positional[index]; + if (argument is SkippedExpression) continue; + final SignatureParameter parameter = parameters[index]; + properties[parameter.name] = _compileValue( + argument, + parameter.schema, + parentId: id, + property: parameter.name, + sink: sink, + ); + } + + for (final MapEntry entry + in call.named.entries) { + final SignatureParameter? parameter = _parameterNamed( + parameters, + entry.key, + ); + if (parameter == null) { + throw A2uiFormatError( + "${api.name} has no parameter '${entry.key}'. Signature: " + '${api.name}(${parameters.map((p) => p.label).join(', ')}).', + line: entry.value.line, + ); + } + properties[parameter.name] = _compileValue( + entry.value, + parameter.schema, + parentId: id, + property: parameter.name, + sink: sink, + ); + } + + for (final parameter in parameters) { + if (parameter.isRequired && !properties.containsKey(parameter.name)) { + throw A2uiFormatError( + "${api.name} is missing required argument '${parameter.name}'. " + 'Signature: ' + '${api.name}(${parameters.map((p) => p.label).join(', ')}).', + line: call.line, + ); + } + } + + return {'id': id, 'component': api.name, ...properties}; + } + + Object? _compileValue( + ExpressExpression expression, + Schema schema, { + required String parentId, + required String property, + required List> sink, + }) { + final String? ref = schemaRefName(schema); + + switch (expression) { + case SkippedExpression(): + return null; + case LiteralExpression(value: final Object? value): + return value; + case PathExpression(path: final String path): + return {'path': path}; + case VariableExpression(name: final String name): + if (ref == 'ChildList') return [name]; + return name; + case ArrayExpression(items: final List items): + if (ref == 'ChildList') { + return [ + for (var index = 0; index < items.length; index++) + _childId( + items[index], + parentId: parentId, + property: property, + index: index, + sink: sink, + ), + ]; + } + final Schema itemSchema = _itemSchema(schema); + return [ + for (final ExpressExpression item in items) + _compileValue( + item, + itemSchema, + parentId: parentId, + property: property, + sink: sink, + ), + ]; + case MapExpression(entries: final Map entries): + return { + for (final MapEntry entry + in entries.entries) + entry.key: _compileValue( + entry.value, + _propertySchema(schema, entry.key), + parentId: parentId, + property: property, + sink: sink, + ), + }; + case CheckExpression(): + return _compileCheck(expression); + case CallExpression(): + return _compileCall( + expression, + ref: ref, + parentId: parentId, + property: property, + sink: sink, + ); + } + } + + Object? _compileCall( + CallExpression call, { + required String? ref, + required String parentId, + required String property, + required List> sink, + }) { + if (call.name == expressTemplateHelper) { + final List args = call.positional; + if (args.length != 2) { + throw A2uiFormatError( + '$expressTemplateHelper(path, component) takes exactly two ' + 'arguments.', + line: call.line, + ); + } + final ExpressExpression pathArgument = args.first; + if (pathArgument is! PathExpression) { + throw A2uiFormatError( + 'The first argument of $expressTemplateHelper must be a data path, ' + r'for example $/items.', + line: call.line, + ); + } + return { + 'componentId': _childId( + args[1], + parentId: parentId, + property: property, + index: 0, + sink: sink, + ), + 'path': pathArgument.path, + }; + } + + if (call.name == expressEventCall) { + final List args = call.positional; + if (args.isEmpty) { + throw A2uiFormatError( + '$expressEventCall() needs an event name.', + line: call.line, + ); + } + final context = {}; + if (args.length > 1) { + final ExpressExpression contextArgument = args[1]; + if (contextArgument is! MapExpression) { + throw A2uiFormatError( + 'The second argument of $expressEventCall must be a map, for ' + r'example {rep: $/form/rep}.', + line: call.line, + ); + } + for (final MapEntry entry + in contextArgument.entries.entries) { + context[entry.key] = _compileDataValue(entry.value); + } + } + return { + 'event': { + 'name': _requireString(args.first, 'event name'), + 'context': context, + }, + }; + } + + final ComponentApi? component = _componentApi(call.name); + if (component != null) { + final String id = _generateId(parentId, property); + _appendInline(id, call, sink); + return ref == 'ChildList' ? [id] : id; + } + + final FunctionImplementation function = _requireFunction( + call.name, + call.line, + ); + return _compileFunctionCall(call, function); + } + + Map _compileFunctionCall( + CallExpression call, + FunctionImplementation function, + ) { + return { + 'call': function.name, + 'args': _mapFunctionArguments(call, function), + 'returnType': function.returnType.jsonValue, + }; + } + + Map _mapFunctionArguments( + CallExpression call, + FunctionImplementation function, { + int dropTrailing = 0, + }) { + final List parameters = signatureOf( + function.argumentSchema, + ); + final List positional = call.positional; + final int count = positional.length - dropTrailing; + if (count > parameters.length) { + throw A2uiFormatError( + '${function.name} takes ${parameters.length} argument(s) but $count ' + 'were given. Signature: ' + '${function.name}(${parameters.map((p) => p.label).join(', ')}).', + line: call.line, + ); + } + + final args = {}; + for (var index = 0; index < count; index++) { + final ExpressExpression argument = positional[index]; + if (argument is SkippedExpression) continue; + args[parameters[index].name] = _compileDataValue(argument); + } + for (final MapEntry entry + in call.named.entries) { + if (_parameterNamed(parameters, entry.key) == null) { + throw A2uiFormatError( + "${function.name} has no parameter '${entry.key}'.", + line: entry.value.line, + ); + } + args[entry.key] = _compileDataValue(entry.value); + } + return args; + } + + /// Compiles `?name(args...)` into a `Checkable` entry. + /// + /// A check carries both a condition and the message shown when it fails. + /// Arguments map onto the catalog function's own signature; one extra + /// trailing argument is taken as the failure message, which is what + /// `?regex("^[0-9]{5}$", "Must be a zip code")` means. + Map _compileCheck(CheckExpression check) { + final FunctionImplementation function = _requireFunction( + check.name, + check.line, + ); + final List parameters = signatureOf( + function.argumentSchema, + ); + final bool hasMessage = check.arguments.length > parameters.length; + final String message = hasMessage + ? _requireString(check.arguments.last, 'check message') + : 'Failed check: ${check.name}'; + + final call = CallExpression(check.line, check.name, [ + for (final ExpressExpression argument in check.arguments) + ExpressArgument(value: argument), + ]); + + return { + 'condition': { + 'call': function.name, + 'args': _mapFunctionArguments( + call, + function, + dropTrailing: hasMessage ? 1 : 0, + ), + 'returnType': A2uiReturnType.boolean.jsonValue, + }, + 'message': message, + }; + } + + /// Compiles an expression used as plain data rather than as a component + /// property. + Object? _compileDataValue(ExpressExpression expression) { + switch (expression) { + case LiteralExpression(value: final Object? value): + return value; + case PathExpression(path: final String path): + return {'path': path}; + case SkippedExpression(): + return null; + case VariableExpression(name: final String name): + return name; + case ArrayExpression(items: final List items): + return [ + for (final ExpressExpression item in items) _compileDataValue(item), + ]; + case MapExpression(entries: final Map entries): + return { + for (final MapEntry entry + in entries.entries) + entry.key: _compileDataValue(entry.value), + }; + case CheckExpression(): + return _compileCheck(expression); + case CallExpression(): + if (expression.name == expressEventCall) { + return _compileCall( + expression, + ref: null, + parentId: '', + property: '', + sink: >[], + ); + } + return _compileFunctionCall( + expression, + _requireFunction(expression.name, expression.line), + ); + } + } + + String _childId( + ExpressExpression expression, { + required String parentId, + required String property, + required int index, + required List> sink, + }) { + switch (expression) { + case VariableExpression(name: final String name): + return name; + case LiteralExpression(value: final Object? value) when value is String: + return value; + case CallExpression(): + final String id = _generateId(parentId, property, index: index); + _appendInline(id, expression, sink); + return id; + default: + throw A2uiFormatError( + 'A child must be a component variable or an inline component.', + line: expression.line, + ); + } + } + + /// Compiles an inline component and appends it to [sink] ahead of its own + /// inline descendants. + /// + /// Descendants are gathered into a local sink first so the finished + /// component can be placed before them: the streaming renderer requires + /// every parent to arrive before its children. + void _appendInline( + String id, + CallExpression call, + List> sink, + ) { + final descendants = >[]; + final Map compiled = _compileComponent( + id, + call, + descendants, + ); + sink + ..add(compiled) + ..addAll(descendants); + } + + + String _generateId(String parentId, String property, {int? index}) { + final base = index == null + ? '${parentId}_$property' + : '${parentId}_$property$index'; + var candidate = base; + var suffix = 2; + while (!_usedIds.add(candidate)) { + candidate = '$base$suffix'; + suffix++; + } + return candidate; + } + + SignatureParameter? _parameterNamed( + List parameters, + String name, + ) { + for (final parameter in parameters) { + if (parameter.name == name) return parameter; + } + return null; + } + + Schema _itemSchema(Schema schema) { + final Object? items = schema['items']; + if (items is Map) return Schema.fromMap(items.cast()); + return Schema.fromMap(const {}); + } + + Schema _propertySchema(Schema schema, String property) { + final ({Map properties, Set required}) flat = + flattenSchemaProperties(schema); + return flat.properties[property] ?? Schema.fromMap(const {}); + } + + ComponentApi? _componentApi(String name) { + if (expressReservedNames.contains(name)) return null; + for (final Catalog catalog in catalogs) { + final ComponentApi? component = catalog.components[name]; + if (component != null) return component; + } + return null; + } + + ComponentApi _requireComponent(String name, int line) { + final ComponentApi? component = _componentApi(name); + if (component != null) return component; + throw A2uiFormatError( + "Unknown component '$name'. The active catalogs define: " + '${_componentNames().join(', ')}.', + line: line, + ); + } + + FunctionImplementation _requireFunction(String name, int line) { + for (final Catalog catalog in catalogs) { + final FunctionImplementation? function = catalog.functions[name]; + if (function != null) return function; + } + throw A2uiFormatError( + "Unknown function '$name'. The active catalogs define: " + '${_functionNames().join(', ')}.', + line: line, + ); + } + + String _requireString(ExpressExpression expression, String what) { + if (expression is LiteralExpression && expression.value is String) { + return expression.value! as String; + } + throw A2uiFormatError( + 'Expected a string for the $what.', + line: expression.line, + ); + } + + List _componentNames() => [ + for (final Catalog catalog in catalogs) + ...catalog.components.keys, + ]; + + List _functionNames() => [ + for (final Catalog catalog in catalogs) + ...catalog.functions.keys, + ]; +} diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/decompiler.dart b/packages/a2ui_agent/lib/src/inference_formats/express/decompiler.dart new file mode 100644 index 000000000..4f00b7697 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/express/decompiler.dart @@ -0,0 +1,278 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +import '../../parser/response_part.dart'; +import '../../primitives/errors.dart'; +import '../../utils/schema_utils.dart'; +import 'constants.dart'; +import 'lexer.dart'; + +/// Converts A2UI messages back into A2UI Express source. +/// +/// Decompilation is the inverse of [ExpressCompiler](compiler.dart): the same +/// catalog signatures decide argument order, so a payload compiled from +/// Express decompiles to equivalent Express, which is what makes it practical +/// to author few-shot examples as ordinary A2UI JSON and show them to the +/// model in the compact syntax. +class ExpressDecompiler { + /// The catalogs whose signatures drive argument ordering. + final List> catalogs; + + const ExpressDecompiler({required this.catalogs}); + + /// Renders [messages] as Express source. + String decompile(List messages) { + final lines = []; + for (final message in messages) { + switch (message) { + case CreateSurfaceMessage(): + lines.add( + '$expressSurfaceCall(${_string(message.surfaceId)}, ' + '${_string(message.catalogId)})', + ); + case DeleteSurfaceMessage(): + lines.add( + '$expressDeleteSurfaceCall(${_string(message.surfaceId)})', + ); + case UpdateDataModelMessage(): + lines.add( + r'$' + '${message.path ?? '/'} = ${_plainValue(message.value)}', + ); + case UpdateComponentsMessage(): + for (final Map component in message.components) { + lines.add(_component(component)); + } + } + } + return lines.join('\n'); + } + + String _component(Map component) { + final Object? id = component['id']; + final Object? type = component['component']; + if (id is! String || type is! String) { + throw A2uiFormatError( + "Cannot decompile a component without an 'id' and a 'component' type.", + ); + } + if (!_isIdentifier(id)) { + throw A2uiFormatError( + "Component id '$id' is not a valid Express identifier, so it cannot " + 'be written as a variable assignment.', + ); + } + + final ComponentApi? api = _componentApi(type); + if (api == null) { + throw A2uiFormatError( + "Unknown component '$type'; it is not in the active catalogs.", + ); + } + + final List parameters = signatureOf(api.schema); + final positional = []; + final named = []; + + for (final parameter in parameters) { + if (!component.containsKey(parameter.name)) { + positional.add(expressSkipPlaceholder); + continue; + } + positional.add( + _value(component[parameter.name], parameter.schema), + ); + } + while (positional.isNotEmpty && + positional.last == expressSkipPlaceholder) { + positional.removeLast(); + } + + for (final MapEntry entry in component.entries) { + if (envelopeKeys.contains(entry.key)) continue; + if (parameters.any((p) => p.name == entry.key)) continue; + named.add('${entry.key}=${_plainValue(entry.value)}'); + } + + return '$id = $type(${[...positional, ...named].join(', ')})'; + } + + /// Renders a component property value, using [schema] to tell component + /// references from ordinary strings. + String _value(Object? value, Schema schema) { + final String? ref = schemaRefName(schema); + + if (ref == 'ComponentId' && value is String) return value; + + if (ref == 'ChildList') { + if (value is List) { + final Iterable children = value.map( + (child) => child is String ? child : _plainValue(child), + ); + return '[${children.join(', ')}]'; + } + if (value is Map) { + final Object? componentId = value['componentId']; + final Object? path = value['path']; + if (componentId is String && path is String) { + return '$expressTemplateHelper(\$$path, $componentId)'; + } + } + } + + if (schema['items'] is Map && value is List) { + final itemSchema = Schema.fromMap( + (schema['items']! as Map).cast(), + ); + return '[${value.map((item) => _value(item, itemSchema)).join(', ')}]'; + } + + if (value is Map) { + final String? check = _check(value); + if (check != null) return check; + } + + return _plainValue(value); + } + + /// Renders a value that carries no schema context. + String _plainValue(Object? value) { + if (value == null) return 'null'; + if (value is String) return _string(value); + if (value is num || value is bool) return '$value'; + if (value is List) { + return '[${value.map(_plainValue).join(', ')}]'; + } + if (value is Map) { + final Map map = value.cast(); + + final Object? path = map['path']; + if (map.length == 1 && path is String) return '\$$path'; + + final Object? event = map['event']; + if (map.length == 1 && event is Map) { + final Object? name = event['name']; + final Object? context = event['context']; + final String arguments = context is Map && context.isNotEmpty + ? '${_string(name is String ? name : '')}, ${_plainValue(context)}' + : _string(name is String ? name : ''); + return '$expressEventCall($arguments)'; + } + + final Object? functionCall = map['functionCall']; + if (map.length == 1 && functionCall is Map) { + return _plainValue(functionCall); + } + + final Object? call = map['call']; + if (call is String) return _functionCall(call, map['args']); + + final Iterable entries = map.entries.map( + (entry) => '${_key(entry.key)}: ${_plainValue(entry.value)}', + ); + return '{${entries.join(', ')}}'; + } + return _string('$value'); + } + + /// Renders a `Checkable` entry as `?name(args, "message")`. + String? _check(Map value) { + final Object? condition = value['condition']; + final Object? message = value['message']; + if (condition is! Map || value.length != 2) return null; + final Object? call = condition['call']; + if (call is! String) return null; + + final List arguments = _orderedArguments(call, condition['args']); + if (message is String) arguments.add(_string(message)); + return arguments.isEmpty ? '?$call' : '?$call(${arguments.join(', ')})'; + } + + String _functionCall(String name, Object? args) => + '$name(${_orderedArguments(name, args).join(', ')})'; + + /// Orders a function call's arguments by the catalog signature. + List _orderedArguments(String name, Object? args) { + if (args is! Map) return []; + final Map map = args.cast(); + final FunctionImplementation? function = _function(name); + if (function == null) { + return [ + for (final MapEntry entry in map.entries) + '${entry.key}=${_plainValue(entry.value)}', + ]; + } + + final List parameters = signatureOf( + function.argumentSchema, + ); + final rendered = []; + for (final parameter in parameters) { + if (!map.containsKey(parameter.name)) { + rendered.add(expressSkipPlaceholder); + continue; + } + rendered.add(_plainValue(map[parameter.name])); + } + while (rendered.isNotEmpty && rendered.last == expressSkipPlaceholder) { + rendered.removeLast(); + } + + for (final MapEntry entry in map.entries) { + if (parameters.any((p) => p.name == entry.key)) continue; + rendered.add('${entry.key}=${_plainValue(entry.value)}'); + } + return rendered; + } + + String _key(String key) => _isIdentifier(key) ? key : _string(key); + + /// Quotes [value], preferring a raw string when escaping would obscure it. + String _string(String value) { + if (value.contains(r'\') && + !value.contains('"') && + !value.contains('\n')) { + return 'r"$value"'; + } + final String escaped = value + .replaceAll(r'\', r'\\') + .replaceAll('"', r'\"') + .replaceAll('\n', r'\n') + .replaceAll('\t', r'\t') + .replaceAll('\r', r'\r'); + return '"$escaped"'; + } + + ComponentApi? _componentApi(String name) { + for (final Catalog catalog in catalogs) { + final ComponentApi? component = catalog.components[name]; + if (component != null) return component; + } + return null; + } + + FunctionImplementation? _function(String name) { + for (final Catalog catalog in catalogs) { + final FunctionImplementation? function = catalog.functions[name]; + if (function != null) return function; + } + return null; + } + + static bool _isIdentifier(String value) { + if (value.isEmpty) return false; + final List tokens; + try { + tokens = ExpressLexer(value).tokenize(); + } on A2uiFormatError { + return false; + } + return tokens.length == 2 && + tokens.first.type == ExpressTokenType.identifier && + tokens.first.lexeme == value; + } +} diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/format.dart b/packages/a2ui_agent/lib/src/inference_formats/express/format.dart new file mode 100644 index 000000000..621758047 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/express/format.dart @@ -0,0 +1,71 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +import '../../inference_format.dart'; +import '../../prompt/generator.dart'; +import 'constants.dart'; +import 'parser.dart'; +import 'prompt_generator.dart'; + +/// Creates [ExpressFormat] strategies bound to a set of active catalogs. +class ExpressFormatFactory extends InferenceFormatFactory { + /// The surface used when a block does not call `surface(...)`. + final String defaultSurfaceId; + + /// Surfaces the renderer already has, which must not be created again. + final Set existingSurfaceIds; + + const ExpressFormatFactory({ + this.defaultSurfaceId = expressDefaultSurfaceId, + this.existingSurfaceIds = const {}, + }); + + @override + ExpressFormat createFormat( + List> catalogs, { + PromptExamples? examples, + }) { + return ExpressFormat( + catalogs, + examples: examples, + defaultSurfaceId: defaultSurfaceId, + existingSurfaceIds: existingSurfaceIds, + ); + } +} + +/// Pairs [ExpressPromptGenerator] with [ExpressParser]. +/// +/// Express trades the verbosity of raw A2UI JSON for a positional DSL, which +/// cuts output tokens substantially — the reason it exists — at the cost of +/// requiring a catalog on the agent side to map arguments onto properties. +class ExpressFormat extends InferenceFormat { + /// The active catalogs this format is bound to. + final List> catalogs; + + /// The surface used when a block does not call `surface(...)`. + final String defaultSurfaceId; + + /// Surfaces the renderer already has, which must not be created again. + final Set existingSurfaceIds; + + @override + final ExpressPromptGenerator promptGenerator; + + ExpressFormat( + this.catalogs, { + PromptExamples? examples, + this.defaultSurfaceId = expressDefaultSurfaceId, + this.existingSurfaceIds = const {}, + }) : promptGenerator = ExpressPromptGenerator(catalogs, examples: examples); + + @override + ExpressParser createParser() => ExpressParser( + catalogs: catalogs, + defaultSurfaceId: defaultSurfaceId, + existingSurfaceIds: existingSurfaceIds, + ); +} diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/parser.dart b/packages/a2ui_agent/lib/src/inference_formats/express/parser.dart new file mode 100644 index 000000000..404846c84 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/express/parser.dart @@ -0,0 +1,159 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +import '../../parser/incremental_processor.dart'; +import '../../parser/parser.dart'; +import '../../parser/response_part.dart'; +import '../../primitives/errors.dart'; +import '../../primitives/protocol_version.dart'; +import '../../validation/payload_validator.dart'; +import 'compiler.dart'; +import 'constants.dart'; +import 'decompiler.dart'; +import 'statement_splitter.dart'; +import 'syntax_parser.dart'; + +/// Parses A2UI Express payloads enclosed in `` tags. +class ExpressParser extends Parser { + /// The active catalogs signatures are resolved against. + final List> catalogs; + + /// The surface used when a block does not call `surface(...)`. + final String defaultSurfaceId; + + /// The protocol version emitted messages declare. + final ProtocolVersion protocolVersion; + + /// Surfaces the renderer already has, which must not be created again. + final Set existingSurfaceIds; + + /// Whether compiled payloads are checked for dangling child references and + /// reference cycles. + final bool checkReferences; + + ExpressStreamProcessor? _stream; + + ExpressParser({ + required this.catalogs, + this.defaultSurfaceId = expressDefaultSurfaceId, + this.protocolVersion = ProtocolVersion.current, + this.existingSurfaceIds = const {}, + this.checkReferences = false, + }); + + @override + String get openTag => expressOpenTag; + + @override + String get closeTag => expressCloseTag; + + @override + List compile(String formatContent) { + final List messages = _newCompiler().compile( + formatContent, + ); + _validator.validateOrThrow(messages, checkReferences: checkReferences); + return messages; + } + + @override + String decompile(List a2uiPayload) => + ExpressDecompiler(catalogs: catalogs).decompile(a2uiPayload); + + @override + List parseChunk(String chunk, {bool wrapped = true}) => + _processor.add(chunk, wrapped: wrapped); + + @override + List flush() => _processor.flush(); + + ExpressCompiler _newCompiler() => ExpressCompiler( + catalogs: catalogs, + defaultSurfaceId: defaultSurfaceId, + protocolVersion: protocolVersion, + existingSurfaceIds: existingSurfaceIds, + ); + + A2uiPayloadValidator get _validator => A2uiPayloadValidator( + catalogs: catalogs, + protocolVersion: protocolVersion, + ); + + ExpressStreamProcessor get _processor => + _stream ??= ExpressStreamProcessor( + createCompiler: _newCompiler, + validator: _validator, + openTag: openTag, + closeTag: closeTag, + ); +} + +/// Compiles Express statements as they stream in. +/// +/// Express is line oriented, so a block can be compiled statement by statement +/// rather than re-parsed from the top on every chunk: each completed statement +/// is handed to a long-lived [ExpressCompiler] session that carries the +/// surface, the catalog and the ids handed out so far. +class ExpressStreamProcessor extends IncrementalStreamProcessor { + /// Creates the compiler session used for a block. + final ExpressCompiler Function() createCompiler; + + /// Validates the messages produced for each statement. + final A2uiPayloadValidator validator; + + ExpressCompiler? _compiler; + int _consumed = 0; + + ExpressStreamProcessor({ + required this.createCompiler, + required this.validator, + required super.openTag, + required super.closeTag, + }); + + @override + void resetBlock() { + _compiler = null; + _consumed = 0; + } + + @override + List emitDelta( + String rawBlock, { + required bool blockComplete, + }) { + if (_consumed > rawBlock.length) return const []; + final String pending = rawBlock.substring(_consumed); + + if (blockComplete && pending.trim().isNotEmpty) { + try { + return _compile(pending, rawBlock.length); + } on A2uiFormatError { + // The stream stopped mid-statement. Salvage the statements that did + // arrive in full rather than losing the whole block. + final int prefix = completeStatementPrefixLength(pending); + if (prefix == 0) return const []; + return _compile(pending.substring(0, prefix), _consumed + prefix); + } + } + + final int prefix = completeStatementPrefixLength(pending); + if (prefix == 0) return const []; + return _compile(pending.substring(0, prefix), _consumed + prefix); + } + + List _compile(String source, int consumed) { + _consumed = consumed; + if (source.trim().isEmpty) return const []; + + final ExpressCompiler compiler = _compiler ??= createCompiler(); + final List messages = compiler.compileStatements( + ExpressSyntaxParser.fromSource(source).parseProgram(), + ); + if (messages.isNotEmpty) validator.validateOrThrow(messages); + return messages; + } +} diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart b/packages/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart new file mode 100644 index 000000000..31ff14906 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart @@ -0,0 +1,224 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +import '../../parser/response_part.dart'; +import '../../prompt/generator.dart'; +import '../../utils/schema_utils.dart'; +import 'constants.dart'; +import 'decompiler.dart'; + +/// Renders the system prompt for the A2UI Express inference format. +/// +/// Catalogs are described as positional signatures rather than JSON Schema, +/// which is the point of the format: the model spends its output tokens on +/// content instead of structural keys and quotes. +class ExpressPromptGenerator extends PromptGenerator { + const ExpressPromptGenerator(super.catalogs, {super.examples}); + + @override + String generate() { + final buffer = StringBuffer() + ..writeln('# A2UI Express output format') + ..writeln() + ..writeln( + 'You build user interfaces by writing A2UI Express: a compact, ' + 'line-oriented syntax that the host compiles into A2UI protocol ' + 'messages.', + ) + ..writeln() + ..writeln('## Rules') + ..writeln() + ..writeln( + '- Every Express block MUST be wrapped in `$expressOpenTag` and ' + '`$expressCloseTag` tags. Conversational text goes outside the tags.', + ) + ..writeln( + '- Each statement is a variable assignment on its own line: ' + '`name = Component(arguments)`. A statement may span lines while its ' + 'brackets are open.', + ) + ..writeln( + '- `$expressRootVariable` is the reserved entry point of the ' + 'component tree. Define it first, then its children.', + ) + ..writeln( + '- Arguments are positional, in the order given by the signatures ' + 'below. `name=value` also works and can be mixed in. Trailing ' + 'optional arguments may be omitted; use `$expressSkipPlaceholder` to ' + 'skip an optional argument that comes before one you need.', + ) + ..writeln( + '- A child is either a variable holding a component, or an inline ' + 'component: `Card(Text("Hi"))`. Lists use brackets: `[header, body]`.', + ) + ..writeln( + r'- Bind to the data model with `$/absolute/path`, or `$relative` ' + 'inside a list template.', + ) + ..writeln( + r'- Populate the data model with `$/path = value`, for example ' + r'`$/title = "Inbox"`.', + ) + ..writeln( + '- Bind a child slot to a data-driven list with ' + '`$expressTemplateHelper(\$/items, itemComponent)`.', + ) + ..writeln( + '- Trigger a server event with ' + '`$expressEventCall("name", {key: \$/path})`. The context map is ' + 'optional.', + ) + ..writeln( + '- Write validation checks with `?name`, e.g. `?required` or ' + r'`?regex(r"^[0-9]{5}$", "Must be a zip code")`. Group them in a ' + 'list: `[?required, ?email]`. The last argument is the message shown ' + 'when the check fails.', + ) + ..writeln( + '- Strings use double quotes. `r"..."` is a raw string where ' + 'backslashes are literal, which is what regex patterns need. ' + '`"""..."""` spans lines. Numbers, `true`, `false` and `null` are ' + 'written plainly.', + ) + ..writeln( + '- Target a surface with `$expressSurfaceCall("id")` before any ' + 'component. Remove one with `$expressDeleteSurfaceCall("id")`.', + ) + ..writeln('- Comments start with `#` or `//`.') + ..writeln( + '- Never invent a component, function or parameter that is not listed ' + 'below.', + ) + ..writeln() + ..write(_signatures()); + + final String examplesSection = _renderExamples(); + if (examplesSection.isNotEmpty) { + buffer + ..writeln() + ..write(examplesSection); + } + return buffer.toString(); + } + + String _signatures() { + final buffer = StringBuffer() + ..writeln('## Component signatures') + ..writeln() + ..writeln('```'); + for (final Catalog catalog in catalogs) { + for (final ComponentApi component in catalog.components.values) { + buffer.writeln(componentSignature(component)); + } + } + buffer + ..writeln('```') + ..writeln(); + + final List functions = [ + for (final Catalog catalog in catalogs) + ...catalog.functions.values, + ]; + if (functions.isNotEmpty) { + buffer + ..writeln('## Functions') + ..writeln() + ..writeln('```'); + for (final function in functions) { + buffer.writeln(functionSignature(function)); + } + buffer + ..writeln('```') + ..writeln(); + } + + buffer + ..writeln('## Types') + ..writeln() + ..writeln( + r'- `DynamicString` / `DynamicBoolean`: a literal, a `$path` binding, ' + 'or a function call.', + ) + ..writeln( + '- `ChildList`: `[child, child]` or ' + '`$expressTemplateHelper(\$/path, child)`.', + ) + ..writeln('- `ComponentId`: a variable holding a component.') + ..writeln( + '- `Action`: `$expressEventCall("name", {...})` or a call to one of ' + 'the functions above.', + ); + return buffer.toString(); + } + + /// The positional signature of [component], e.g. `Text(text, variant?)`. + static String componentSignature(ComponentApi component) { + final List parameters = signatureOf(component.schema); + final Iterable rendered = parameters.map( + (parameter) => '${parameter.label}: ${typeHint(parameter.schema)}', + ); + return '${component.name}(${rendered.join(', ')})'; + } + + /// The positional signature of [function], e.g. + /// `capitalize(value: DynamicString) -> string`. + static String functionSignature(FunctionImplementation function) { + final List parameters = signatureOf( + function.argumentSchema, + ); + final Iterable rendered = parameters.map( + (parameter) => '${parameter.label}: ${typeHint(parameter.schema)}', + ); + return '${function.name}(${rendered.join(', ')}) -> ' + '${function.returnType.jsonValue}'; + } + + /// A compact description of the values [schema] accepts. + static String typeHint(Schema schema) { + final Object? enumValues = schema['enum']; + if (enumValues is List && enumValues.isNotEmpty) { + return enumValues.map((value) => '"$value"').join('|'); + } + + final String? ref = schemaRefName(schema); + if (ref != null) return ref; + + final Object? type = schema['type']; + if (type is String) return type; + if (type is List) return type.join('|'); + + if (schema['properties'] is Map) return 'object'; + if (schema['items'] is Map) { + final items = Schema.fromMap( + (schema['items']! as Map).cast(), + ); + return 'array<${typeHint(items)}>'; + } + return 'any'; + } + + String _renderExamples() { + final PromptExamples? examples = this.examples; + if (examples == null || examples.isEmpty) return ''; + + final decompiler = ExpressDecompiler(catalogs: catalogs); + final buffer = StringBuffer() + ..writeln('## Examples') + ..writeln(); + for (final MapEntry> entry + in examples.entries) { + buffer + ..writeln('### ${entry.key}') + ..writeln() + ..writeln(expressOpenTag) + ..writeln(decompiler.decompile(entry.value)) + ..writeln(expressCloseTag) + ..writeln(); + } + return buffer.toString(); + } +} diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/statement_splitter.dart b/packages/a2ui_agent/lib/src/inference_formats/express/statement_splitter.dart new file mode 100644 index 000000000..3937b3f45 --- /dev/null +++ b/packages/a2ui_agent/lib/src/inference_formats/express/statement_splitter.dart @@ -0,0 +1,94 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// The length of the prefix of [source] that contains only complete +/// statements. +/// +/// Express is line oriented: a statement ends at a newline or `;` that is not +/// inside brackets, a string or a comment. A statement that spans lines — +/// because a call's argument list is still open — is not complete until its +/// brackets close, so a streaming compiler must hold it back. +/// +/// Returns 0 when nothing is complete yet. +int completeStatementPrefixLength(String source) { + var index = 0; + var depth = 0; + var boundary = 0; + + while (index < source.length) { + final String char = source[index]; + + if (char == '"' || _isRawStringStart(source, index)) { + final int end = _skipString(source, index); + if (end < 0) return boundary; + index = end; + continue; + } + + if (char == '#' || source.startsWith('//', index)) { + while (index < source.length && source[index] != '\n') { + index++; + } + continue; + } + + if (source.startsWith('/*', index)) { + final int end = source.indexOf('*/', index + 2); + if (end < 0) return boundary; + index = end + 2; + continue; + } + + if (char == '(' || char == '[' || char == '{') { + depth++; + } else if (char == ')' || char == ']' || char == '}') { + if (depth > 0) depth--; + } else if (depth == 0 && (char == '\n' || char == ';')) { + boundary = index + 1; + } + index++; + } + + return boundary; +} + +bool _isRawStringStart(String source, int index) { + final String char = source[index]; + if (char != 'r' && char != 'R') return false; + if (index + 1 >= source.length || source[index + 1] != '"') return false; + if (index == 0) return true; + final String previous = source[index - 1]; + // `r"` only starts a raw string when `r` is not part of an identifier. + return !_isIdentifierPart(previous); +} + +bool _isIdentifierPart(String char) { + final int code = char.codeUnitAt(0); + return char == '_' || + code > 0x7f || + (code >= 0x30 && code <= 0x39) || + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a); +} + +/// Returns the index just past the string literal starting at [start], or -1 +/// when the literal is unterminated. +int _skipString(String source, int start) { + var index = start; + final bool raw = source[index] == 'r' || source[index] == 'R'; + if (raw) index++; + + final delimiter = source.startsWith('"""', index) ? '"""' : '"'; + index += delimiter.length; + + while (index < source.length) { + if (!raw && source[index] == r'\') { + index += 2; + continue; + } + if (source.startsWith(delimiter, index)) return index + delimiter.length; + index++; + } + return -1; +} diff --git a/packages/a2ui_agent/lib/src/processor/catalog_config.dart b/packages/a2ui_agent/lib/src/processor/catalog_config.dart new file mode 100644 index 000000000..723d95fda --- /dev/null +++ b/packages/a2ui_agent/lib/src/processor/catalog_config.dart @@ -0,0 +1,66 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +import '../catalog_transformers/base.dart'; +import '../primitives/protocol_version.dart'; +import 'catalog_providers.dart'; + +/// A catalog the agent supports, together with the rules that trim it. +/// +/// The catalog itself stays pristine; [transformedCatalog] applies the +/// configured transformers, and that result is what the model is shown and +/// what its output is validated against. +class CatalogConfig { + /// The catalog as loaded, before any transformation. + final Catalog catalog; + + /// The transformations applied, in order, by [transformedCatalog]. + final List transformers; + + Catalog? _transformed; + + CatalogConfig( + this.catalog, { + List transformers = const [], + }) : transformers = List.unmodifiable(transformers); + + /// Loads a catalog through [provider]. + factory CatalogConfig.fromProvider( + CatalogProvider provider, { + List transformers = const [], + }) => CatalogConfig(provider.load(), transformers: transformers); + + /// Loads a catalog document from [catalogPath]. + factory CatalogConfig.fromPath( + String catalogPath, { + List transformers = const [], + ProtocolVersion? protocolVersion, + String? catalogId, + }) => CatalogConfig( + FileSystemCatalogProvider( + catalogPath, + protocolVersion: protocolVersion, + catalogId: catalogId, + ).load(), + transformers: transformers, + ); + + /// The id of the underlying catalog. + String get id => catalog.id; + + /// The catalog after every transformer has been applied, in order. + /// + /// Computed once and reused: transformers are pure, and the result is read + /// on every prompt render and every validation pass. + Catalog get transformedCatalog { + if (_transformed != null) return _transformed!; + Catalog current = catalog; + for (final CatalogTransformer transformer in transformers) { + current = transformer.transform(current); + } + return _transformed = current; + } +} diff --git a/packages/a2ui_agent/lib/src/processor/catalog_providers.dart b/packages/a2ui_agent/lib/src/processor/catalog_providers.dart new file mode 100644 index 000000000..49c70d7c5 --- /dev/null +++ b/packages/a2ui_agent/lib/src/processor/catalog_providers.dart @@ -0,0 +1,142 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:a2ui_core/a2ui_core.dart'; + +import '../primitives/protocol_version.dart'; +import '../utils/catalog_document.dart'; + +/// Loads a catalog definition for the agent to negotiate and validate against. +abstract class CatalogProvider { + const CatalogProvider(); + + /// Loads the catalog. + Catalog load(); +} + +/// Loads a catalog bundled with the SDK for a protocol version. +/// +/// `package:a2ui_core` bundles the minimal catalog for `v0.9`. Other versions +/// have no bundled catalog in Dart yet, so asking for one is an error rather +/// than a silent fallback to a catalog the renderer never agreed to. +class BundledCatalogProvider extends CatalogProvider { + /// The protocol version to load the bundled catalog for. + final ProtocolVersion protocolVersion; + + const BundledCatalogProvider({ + this.protocolVersion = ProtocolVersion.current, + }); + + @override + Catalog load() { + if (protocolVersion == ProtocolVersion.v09) return MinimalCatalog(); + throw A2uiValidationError( + 'No catalog is bundled for protocol version ' + '${protocolVersion.wireValue}. package:a2ui_core bundles the minimal ' + 'catalog for ${ProtocolVersion.v09.wireValue}; load other catalogs with ' + 'FileSystemCatalogProvider or InMemoryCatalogProvider.', + ); + } +} + +/// Loads a catalog document from a JSON file. +/// +/// This provider reads from the local filesystem and therefore only runs on +/// native platforms. +class FileSystemCatalogProvider extends CatalogProvider { + /// The path to the catalog JSON file. + final String path; + + /// The protocol version the document must declare, when it declares one. + /// + /// Catalog documents only carry `protocolVersion` from `v1.0` onwards. + final ProtocolVersion? protocolVersion; + + /// The catalog id the document must declare, when it declares one. + /// + /// Catalog documents only carry `catalogId` from `v0.9` onwards; for an + /// older document this value supplies the id instead of checking it. + final String? catalogId; + + const FileSystemCatalogProvider( + this.path, { + this.protocolVersion, + this.catalogId, + }); + + @override + Catalog load() { + final file = File(path); + if (!file.existsSync()) { + throw A2uiValidationError('Catalog file not found: $path'); + } + + final Object? decoded; + try { + decoded = jsonDecode(file.readAsStringSync()); + } on FormatException catch (error) { + throw A2uiValidationError( + 'Catalog file $path is not valid JSON: ${error.message}', + ); + } + if (decoded is! Map) { + throw A2uiValidationError( + 'Catalog file $path must contain a JSON object.', + ); + } + + return catalogFromDocument( + decoded.cast(), + protocolVersion: protocolVersion, + catalogId: catalogId, + source: path, + ); + } +} + +/// Loads a catalog from an in-memory catalog document. +/// +/// This is how a renderer's inline catalogs enter the agent, and the easiest +/// way to test against a catalog without touching the filesystem. +class InMemoryCatalogProvider extends CatalogProvider { + /// The raw catalog document. + final Map catalog; + + /// The protocol version the document must declare, when it declares one. + final ProtocolVersion? protocolVersion; + + /// The catalog id the document must declare, when it declares one. + final String? catalogId; + + const InMemoryCatalogProvider( + this.catalog, { + this.protocolVersion, + this.catalogId, + }); + + @override + Catalog load() => catalogFromDocument( + catalog, + protocolVersion: protocolVersion, + catalogId: catalogId, + ); +} + +/// Wraps an already-built [Catalog] as a provider. +/// +/// Dart catalogs are usually written as code rather than loaded from JSON, so +/// this is the common case: pass `MinimalCatalog()` or your own catalog class +/// straight into a [CatalogConfig](catalog_config.dart). +class StaticCatalogProvider extends CatalogProvider { + /// The catalog to provide. + final Catalog catalog; + + const StaticCatalogProvider(this.catalog); + + @override + Catalog load() => catalog; +} diff --git a/packages/a2ui_agent/lib/src/processor/renderer_capabilities.dart b/packages/a2ui_agent/lib/src/processor/renderer_capabilities.dart new file mode 100644 index 000000000..ce8b32947 --- /dev/null +++ b/packages/a2ui_agent/lib/src/processor/renderer_capabilities.dart @@ -0,0 +1,79 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../primitives/protocol_version.dart'; + +/// What a renderer told the agent it can render. +/// +/// This is the `a2uiClientCapabilities` object a client sends with each +/// message. The agent negotiates against it to decide which catalogs are +/// active for the session. +class A2uiRendererCapabilities { + /// The ids of the pre-defined catalogs the renderer supports. + final List supportedCatalogIds; + + /// Full catalog documents the renderer supplied inline. + /// + /// A renderer may only send these when the agent advertised that it accepts + /// inline catalogs. + final List> inlineCatalogs; + + /// The protocol version the renderer declared. + final ProtocolVersion protocolVersion; + + const A2uiRendererCapabilities({ + required this.supportedCatalogIds, + this.inlineCatalogs = const [], + this.protocolVersion = ProtocolVersion.current, + }); + + /// Parses capabilities from their wire form. + /// + /// Accepts both the version-keyed envelope a client sends — + /// `{"v0.9": {"supportedCatalogIds": [...]}}` — and a bare capabilities + /// object. + factory A2uiRendererCapabilities.fromJson(Map json) { + var body = json; + ProtocolVersion version = ProtocolVersion.current; + + for (final MapEntry entry in json.entries) { + final ProtocolVersion? parsed = ProtocolVersion.tryParse(entry.key); + if (parsed != null && entry.value is Map) { + version = parsed; + body = (entry.value as Map).cast(); + break; + } + } + + final Object? ids = body['supportedCatalogIds']; + final Object? inline = body['inlineCatalogs']; + return A2uiRendererCapabilities( + supportedCatalogIds: [ + if (ids is List) + for (final Object? id in ids) + if (id is String) id, + ], + inlineCatalogs: [ + if (inline is List) + for (final Object? catalog in inline) + if (catalog is Map) catalog.cast(), + ], + protocolVersion: version, + ); + } + + /// Serializes back to the version-keyed wire form. + Map toJson() => { + protocolVersion.wireValue: { + 'supportedCatalogIds': supportedCatalogIds, + if (inlineCatalogs.isNotEmpty) 'inlineCatalogs': inlineCatalogs, + }, + }; + + @override + String toString() => + 'A2uiRendererCapabilities(${protocolVersion.wireValue}, ' + 'supported: ${supportedCatalogIds.join(', ')}, ' + 'inline: ${inlineCatalogs.length})'; +} From fffe38ee0096b46f2230cc4ba3846e41deac94be Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 11:32:42 -0700 Subject: [PATCH 14/19] - --- .../example/a2ui_agent_example.dart | 115 +++++++++++++++++- packages/a2ui_agent/lib/a2ui_agent.dart | 57 ++++++++- .../a2ui_agent/lib/src/a2ui_agent_base.dart | 10 -- .../lib/src/processor/generator.dart | 90 ++++++++++++++ .../lib/src/processor/processor.dart | 105 ++++++++++++++++ .../lib/src/utils/catalog_resolver.dart | 62 ++++++++++ .../lib/src/utils/schema_utils.dart | 55 ++++++++- packages/a2ui_agent/test/a2ui_agent_test.dart | 20 --- .../test/catalog_transformers_test.dart | 104 ++++++++++++++++ 9 files changed, 576 insertions(+), 42 deletions(-) delete mode 100644 packages/a2ui_agent/lib/src/a2ui_agent_base.dart create mode 100644 packages/a2ui_agent/lib/src/processor/generator.dart create mode 100644 packages/a2ui_agent/lib/src/processor/processor.dart create mode 100644 packages/a2ui_agent/lib/src/utils/catalog_resolver.dart delete mode 100644 packages/a2ui_agent/test/a2ui_agent_test.dart create mode 100644 packages/a2ui_agent/test/catalog_transformers_test.dart diff --git a/packages/a2ui_agent/example/a2ui_agent_example.dart b/packages/a2ui_agent/example/a2ui_agent_example.dart index a808ae23f..471ed9032 100644 --- a/packages/a2ui_agent/example/a2ui_agent_example.dart +++ b/packages/a2ui_agent/example/a2ui_agent_example.dart @@ -2,9 +2,120 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// ignore_for_file: avoid_print + import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +/// Walks through the agent workflow: register catalogs, negotiate with a +/// renderer, prompt a model, then parse and validate what it returns. void main() { - var awesome = Awesome(); - awesome.toString(); + // 1. At startup, register every catalog the agent supports. Transformers + // trim a catalog down to what this agent is willing to generate. + final generator = A2uiGenerator( + catalogs: [ + CatalogConfig( + MinimalCatalog(), + transformers: [ + ComponentPruningTransformer(const ['Column', 'Text', 'Button']), + ], + ), + ], + examples: { + 'A greeting with a dismiss button': _greetingExample(), + }, + ); + + // 2. Per request, negotiate against what the renderer says it can render. + final capabilities = A2uiRendererCapabilities.fromJson({ + 'v0.9': { + 'supportedCatalogIds': [MinimalCatalog().id], + }, + }); + final A2uiRequestProcessor processor = generator.createProcessor( + capabilities, + ); + + // 3. Prepend the prompt snippet to the agent's own system instructions. + print('--- system prompt snippet (truncated) ---'); + print(processor.promptSnippet.split('\n').take(12).join('\n')); + + // 4. Parse and validate the model's response. + final List parts = processor.parseResponse(_fakeLlmOutput); + + // 5. Deliver the payloads to the renderer. + print('\n--- parsed response ---'); + for (final part in parts) { + switch (part) { + case TextPart(text: final String text): + print('text: $text'); + case A2uiPart(a2ui: final List messages): + for (final message in messages) { + print('a2ui: ${message.toJson()}'); + } + } + } + + // The same catalogs also drive the compact Express format, where the model + // writes positional statements instead of JSON. + final expressProcessor = A2uiRequestProcessor( + catalogs: processor.activeCatalogs, + formatFactory: const ExpressFormatFactory(), + ); + print('\n--- express ---'); + for (final ResponsePart part + in expressProcessor.parseResponse(_fakeExpressOutput)) { + if (part is A2uiPart) { + for (final AgentToRendererMessage message in part.a2ui) { + print('a2ui: ${message.toJson()}'); + } + } + } } + +List _greetingExample() => [ + CreateSurfaceMessage(surfaceId: 'greeting', catalogId: MinimalCatalog().id), + UpdateComponentsMessage( + surfaceId: 'greeting', + components: [ + { + 'id': 'root', + 'component': 'Column', + 'children': ['title'], + }, + {'id': 'title', 'component': 'Text', 'text': 'Hello!', 'variant': 'h1'}, + ], + ), +]; + +const String _fakeLlmOutput = ''' +Here is the panel you asked for. + +[ + { + "version": "v0.9", + "createSurface": {"surfaceId": "s1", "catalogId": "https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json"} + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "s1", + "components": [ + {"id": "root", "component": "Column", "children": ["greeting"]}, + {"id": "greeting", "component": "Text", "text": "Good morning"} + ] + } + } +] + +Let me know if you want a different layout. +'''; + +const String _fakeExpressOutput = ''' + +surface("s2") +root = Column([greeting, dismiss]) +greeting = Text("Good morning", "h1") +dismiss = Button(Text("Dismiss"), Event("dismiss")) + +'''; diff --git a/packages/a2ui_agent/lib/a2ui_agent.dart b/packages/a2ui_agent/lib/a2ui_agent.dart index 462ed7701..cf8acc4c1 100644 --- a/packages/a2ui_agent/lib/a2ui_agent.dart +++ b/packages/a2ui_agent/lib/a2ui_agent.dart @@ -2,11 +2,58 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -/// Support for doing something awesome. +/// The A2UI agent SDK. /// -/// More dartdocs go here. +/// This package gives an agent everything it needs between "the model is about +/// to be called" and "the renderer receives A2UI": catalog management, +/// capability negotiation, prompt engineering, response parsing and payload +/// validation. +/// +/// Start with `A2uiGenerator`, which holds the catalogs an agent supports and +/// hands out an `A2uiRequestProcessor` per renderer: +/// +/// ```dart +/// final generator = A2uiGenerator( +/// catalogs: [CatalogConfig(MinimalCatalog())], +/// ); +/// final processor = generator.createProcessor(rendererCapabilities); +/// final output = await callLlm(processor.promptSnippet, request); +/// final parts = processor.parseResponse(output); +/// ``` library; -export 'src/a2ui_agent_base.dart'; - -// TODO: Export any libraries intended for clients of this package. +export 'src/catalog_transformers/base.dart'; +export 'src/catalog_transformers/pruning.dart'; +export 'src/inference_format.dart'; +export 'src/inference_formats/direct_json/constants.dart'; +export 'src/inference_formats/direct_json/format.dart'; +export 'src/inference_formats/direct_json/parser.dart'; +export 'src/inference_formats/direct_json/payload_fixer.dart'; +export 'src/inference_formats/direct_json/prompt_generator.dart'; +export 'src/inference_formats/direct_json/streaming.dart'; +export 'src/inference_formats/express/ast.dart'; +export 'src/inference_formats/express/compiler.dart'; +export 'src/inference_formats/express/constants.dart'; +export 'src/inference_formats/express/decompiler.dart'; +export 'src/inference_formats/express/format.dart'; +export 'src/inference_formats/express/lexer.dart'; +export 'src/inference_formats/express/parser.dart'; +export 'src/inference_formats/express/prompt_generator.dart'; +export 'src/inference_formats/express/statement_splitter.dart'; +export 'src/inference_formats/express/syntax_parser.dart'; +export 'src/parser/incremental_processor.dart'; +export 'src/parser/parser.dart'; +export 'src/parser/response_part.dart'; +export 'src/parser/sentinel_tokenizer.dart'; +export 'src/primitives/errors.dart'; +export 'src/primitives/protocol_version.dart'; +export 'src/processor/catalog_config.dart'; +export 'src/processor/catalog_providers.dart'; +export 'src/processor/generator.dart'; +export 'src/processor/processor.dart'; +export 'src/processor/renderer_capabilities.dart'; +export 'src/prompt/generator.dart'; +export 'src/utils/catalog_document.dart'; +export 'src/utils/catalog_resolver.dart'; +export 'src/utils/schema_utils.dart'; +export 'src/validation/payload_validator.dart'; diff --git a/packages/a2ui_agent/lib/src/a2ui_agent_base.dart b/packages/a2ui_agent/lib/src/a2ui_agent_base.dart deleted file mode 100644 index b0fbcc1e8..000000000 --- a/packages/a2ui_agent/lib/src/a2ui_agent_base.dart +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// TODO: Put public facing types in this file. - -/// Checks if you are awesome. Spoiler: you are. -class Awesome { - bool get isAwesome => true; -} diff --git a/packages/a2ui_agent/lib/src/processor/generator.dart b/packages/a2ui_agent/lib/src/processor/generator.dart new file mode 100644 index 000000000..88220c83a --- /dev/null +++ b/packages/a2ui_agent/lib/src/processor/generator.dart @@ -0,0 +1,90 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +import '../inference_format.dart'; +import '../inference_formats/direct_json/format.dart'; +import '../primitives/protocol_version.dart'; +import '../prompt/generator.dart'; +import '../utils/catalog_resolver.dart'; +import 'catalog_config.dart'; +import 'processor.dart'; +import 'renderer_capabilities.dart'; + +/// The agent-level entry point of the A2UI agent SDK. +/// +/// A generator is long lived: it holds every catalog the agent supports and +/// the examples shared across sessions, and hands out an +/// [A2uiRequestProcessor] per renderer capability signature. Build one at +/// startup and call [createProcessor] in the request handler. +class A2uiGenerator { + /// Every catalog this agent supports, in preference order. + final List catalogs; + + /// Few-shot example turns shared by every session. + final PromptExamples? examples; + + /// The format used when [createProcessor] is not given one. + final InferenceFormatFactory inferenceFormatFactory; + + /// The protocol version this agent speaks. + final ProtocolVersion protocolVersion; + + /// Whether catalogs a renderer sends inline are accepted. + final bool acceptsInlineCatalogs; + + A2uiGenerator({ + required List catalogs, + this.examples, + InferenceFormatFactory? inferenceFormatFactory, + this.protocolVersion = ProtocolVersion.current, + this.acceptsInlineCatalogs = false, + }) : catalogs = List.unmodifiable(catalogs), + inferenceFormatFactory = + inferenceFormatFactory ?? const DirectJsonFormatFactory(); + + /// Creates a processor bound to what [rendererCapabilities] can render. + /// + /// The catalogs are negotiated first, then the configured examples are + /// validated against the ones that survived: an example is part of the + /// prompt, so an example that does not conform to the active catalogs is a + /// bug that would otherwise surface as bad model output. + /// + /// Throws [A2uiCapabilityError](../primitives/errors.dart) when the agent + /// and the renderer share no catalog, and [A2uiValidationError] when an + /// example does not conform to the negotiated catalogs. + A2uiRequestProcessor createProcessor( + A2uiRendererCapabilities rendererCapabilities, { + InferenceFormatFactory? inferenceFormatFactory, + bool? acceptsInlineCatalogs, + }) { + final List> active = resolveCatalogs( + catalogs, + rendererCapabilities, + acceptsInlineCatalogs: + acceptsInlineCatalogs ?? this.acceptsInlineCatalogs, + ); + + final processor = A2uiRequestProcessor( + catalogs: active, + examples: examples, + formatFactory: inferenceFormatFactory ?? this.inferenceFormatFactory, + protocolVersion: protocolVersion, + ); + processor.validateExamples(); + return processor; + } + + /// The capabilities an agent-side renderer would report for these catalogs. + /// + /// Useful for tests and for local agents that render their own output. + A2uiRendererCapabilities get supportedCapabilities => + A2uiRendererCapabilities( + supportedCatalogIds: [ + for (final CatalogConfig config in catalogs) config.id, + ], + protocolVersion: protocolVersion, + ); +} diff --git a/packages/a2ui_agent/lib/src/processor/processor.dart b/packages/a2ui_agent/lib/src/processor/processor.dart new file mode 100644 index 000000000..74df351d2 --- /dev/null +++ b/packages/a2ui_agent/lib/src/processor/processor.dart @@ -0,0 +1,105 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +import '../inference_format.dart'; +import '../inference_formats/direct_json/format.dart'; +import '../parser/parser.dart'; +import '../parser/response_part.dart'; +import '../primitives/protocol_version.dart'; +import '../prompt/generator.dart'; +import '../validation/payload_validator.dart'; + +/// The request-scoped facade over an agent's A2UI capabilities. +/// +/// A processor is bound to the catalogs negotiated for one renderer: it +/// renders the system prompt snippet describing them, creates turn-scoped +/// parsers, and validates what comes back. Create one per renderer capability +/// signature via `A2uiGenerator.createProcessor`, and keep it for as long as +/// that renderer's capabilities hold. +class A2uiRequestProcessor { + /// The catalogs active for this session, already transformed. + final List> activeCatalogs; + + /// The few-shot example turns shown to the model. + final PromptExamples? examples; + + /// The strategy pairing this session's prompt generator and parser. + final InferenceFormat format; + + /// The protocol version this session speaks. + final ProtocolVersion protocolVersion; + + String? _promptSnippet; + + A2uiRequestProcessor({ + required List> catalogs, + this.examples, + InferenceFormatFactory? formatFactory, + this.protocolVersion = ProtocolVersion.current, + }) : activeCatalogs = List>.unmodifiable(catalogs), + format = (formatFactory ?? const DirectJsonFormatFactory()).createFormat( + List>.unmodifiable(catalogs), + examples: examples, + ); + + /// The format-specific system prompt instructions for this session. + /// + /// Rendered once and reused: it is a pure function of the active catalogs + /// and examples, and it is prepended to every request of the session. + String get promptSnippet => + _promptSnippet ??= format.promptGenerator.generate(); + + /// Creates a parser for one model turn. + /// + /// Parsers accumulate streaming state, so a turn must not share one with + /// another turn. + Parser createParser() => format.createParser(); + + /// Parses and validates a complete model response. + /// + /// Returns the conversational text and compiled A2UI payloads in the order + /// the model emitted them. Throws + /// [A2uiValidationError] when a payload does not conform to the active + /// catalogs, and + /// [A2uiFormatError](../primitives/errors.dart) when it cannot be compiled + /// at all. + List parseResponse(String content, {bool wrapped = true}) => + createParser().parseResponse(content, wrapped: wrapped); + + /// Parses a streamed model response. + /// + /// Parts are yielded as soon as they are usable, so a renderer can build the + /// UI while the model is still writing it. + Stream parseStream( + Stream chunks, { + bool wrapped = true, + }) => createParser().parseStream(chunks, wrapped: wrapped); + + /// Validates the configured [examples] against the active catalogs. + /// + /// An example that names a component the negotiated catalogs do not have + /// teaches the model to emit exactly what the renderer will reject, so this + /// runs when the processor is created rather than at inference time. + void validateExamples() { + final PromptExamples? examples = this.examples; + if (examples == null) return; + + final validator = A2uiPayloadValidator( + catalogs: activeCatalogs, + protocolVersion: protocolVersion, + ); + for (final MapEntry> entry + in examples.entries) { + final List issues = validator.validate(entry.value); + if (issues.isEmpty) continue; + throw A2uiValidationError( + "Prompt example '${entry.key}' does not conform to the active " + 'catalogs:\n${issues.map((issue) => ' - $issue').join('\n')}', + details: issues, + ); + } + } +} diff --git a/packages/a2ui_agent/lib/src/utils/catalog_resolver.dart b/packages/a2ui_agent/lib/src/utils/catalog_resolver.dart new file mode 100644 index 000000000..ada3f2580 --- /dev/null +++ b/packages/a2ui_agent/lib/src/utils/catalog_resolver.dart @@ -0,0 +1,62 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_core/a2ui_core.dart'; + +import '../primitives/errors.dart'; +import '../processor/catalog_config.dart'; +import '../processor/catalog_providers.dart'; +import '../processor/renderer_capabilities.dart'; + +/// Negotiates [catalogs] against what the renderer says it supports. +/// +/// Returns the transformed catalogs that are active for the session, in the +/// order they were registered. A catalog the renderer cannot render is left +/// out, so it never reaches the prompt and a model can never name a component +/// the client would fail on. +/// +/// When [acceptsInlineCatalogs] is true, catalog documents the renderer sent +/// inline are loaded and appended. Leave it false unless the agent has +/// advertised that it accepts them. +/// +/// Throws [A2uiCapabilityError] when nothing matches: an agent with no active +/// catalog cannot produce any UI, and failing here is far cheaper than +/// discovering it after an inference call. +List> resolveCatalogs( + List catalogs, + A2uiRendererCapabilities rendererCapabilities, { + bool acceptsInlineCatalogs = false, +}) { + final Set supported = rendererCapabilities.supportedCatalogIds + .toSet(); + final active = >[ + for (final config in catalogs) + if (supported.contains(config.id)) config.transformedCatalog, + ]; + + if (acceptsInlineCatalogs) { + for (final Map document + in rendererCapabilities.inlineCatalogs) { + final Catalog catalog = InMemoryCatalogProvider( + document, + ).load(); + if (active.any((existing) => existing.id == catalog.id)) continue; + active.add(catalog); + } + } + + if (active.isEmpty) { + throw A2uiCapabilityError( + 'No catalog is shared between the agent and the renderer. The agent ' + 'supports: ${catalogs.map((config) => config.id).join(', ')}. The ' + 'renderer supports: ${supported.join(', ')}' + '${rendererCapabilities.inlineCatalogs.isEmpty || acceptsInlineCatalogs + ? '' + : ' (it also sent inline catalogs, which this agent does not ' + 'accept)'}' + '.', + ); + } + return active; +} diff --git a/packages/a2ui_agent/lib/src/utils/schema_utils.dart b/packages/a2ui_agent/lib/src/utils/schema_utils.dart index d4d97d98e..e685a7cd6 100644 --- a/packages/a2ui_agent/lib/src/utils/schema_utils.dart +++ b/packages/a2ui_agent/lib/src/utils/schema_utils.dart @@ -151,21 +151,66 @@ bool schemaAcceptsString(Schema schema) { /// The property names across [catalogs] whose values may be plain strings. /// /// A streaming parser can safely auto-close a truncated string value for these -/// keys, because a partially received string is still a valid value for the -/// property. Keys whose values are numbers, objects or lists are excluded: -/// healing those would fabricate structure the model never emitted. +/// keys, because a prefix of the final string is still a legitimate value that +/// simply grows as more of the stream arrives. +/// +/// Three kinds of string-valued property are excluded, because a prefix of +/// their value is not a weaker version of it but a wrong one: +/// +/// - component references, where a truncated id points at nothing; +/// - enumerated values, where a prefix is not a member of the enum; +/// - pattern-constrained values, where a prefix need not match the pattern. +/// +/// Properties holding numbers, objects or lists are excluded too: healing +/// those would fabricate structure the model never emitted. Set progressiveStringKeys(Iterable> catalogs) { final keys = {}; + final excluded = {}; for (final catalog in catalogs) { for (final ComponentApi component in catalog.components.values) { final ({Map properties, Set required}) flat = flattenSchemaProperties(component.schema); for (final MapEntry entry in flat.properties.entries) { - if (schemaAcceptsString(entry.value)) keys.add(entry.key); + if (_isHealableString(entry.value)) { + keys.add(entry.key); + } else { + excluded.add(entry.key); + } } } } - return Set.unmodifiable(keys); + // A key that is unsafe in any catalog is unsafe everywhere: the streaming + // parser heals by key name, before it knows which component it belongs to. + return Set.unmodifiable(keys.difference(excluded)); +} + +bool _isHealableString(Schema schema) { + if (!schemaAcceptsString(schema)) return false; + + final String? ref = schemaRefName(schema); + if (ref == 'ComponentId' || ref == 'ChildList') return false; + + return !_hasConstrainedString(schema); +} + +/// Whether [schema] restricts strings to an enum or a pattern, in any branch. +bool _hasConstrainedString(Schema schema) { + if (schema['enum'] != null || schema['pattern'] != null) return true; + if (schema['const'] != null) return true; + + for (final key in const ['anyOf', 'oneOf', 'allOf']) { + final Object? branches = schema[key]; + if (branches is! List) continue; + for (final Object? branch in branches) { + if (branch is Map && + _hasConstrainedString( + Schema.fromMap(branch.cast()), + )) { + return true; + } + } + } + return false; } String _lastPointerSegment(String pointer) { diff --git a/packages/a2ui_agent/test/a2ui_agent_test.dart b/packages/a2ui_agent/test/a2ui_agent_test.dart deleted file mode 100644 index 94bbcf5f8..000000000 --- a/packages/a2ui_agent/test/a2ui_agent_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:a2ui_agent/a2ui_agent.dart'; -import 'package:test/test.dart'; - -void main() { - group('A group of tests', () { - final awesome = Awesome(); - - setUp(() { - // Additional setup goes here. - }); - - test('First Test', () { - expect(awesome.isAwesome, isTrue); - }); - }); -} diff --git a/packages/a2ui_agent/test/catalog_transformers_test.dart b/packages/a2ui_agent/test/catalog_transformers_test.dart new file mode 100644 index 000000000..bbf869cf7 --- /dev/null +++ b/packages/a2ui_agent/test/catalog_transformers_test.dart @@ -0,0 +1,104 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('ComponentPruningTransformer', () { + test('keeps only allowlisted components', () { + final Catalog pruned = ComponentPruningTransformer(const [ + 'Text', + 'Column', + ]).transform(MinimalCatalog()); + + expect(pruned.components.keys, unorderedEquals(['Text', 'Column'])); + }); + + test('leaves the source catalog untouched', () { + final catalog = MinimalCatalog(); + ComponentPruningTransformer(const ['Text']).transform(catalog); + + expect(catalog.components.keys, contains('Button')); + }); + + test('preserves id, functions and theme schema', () { + final catalog = MinimalCatalog(); + final Catalog pruned = ComponentPruningTransformer( + const ['Text'], + ).transform(catalog); + + expect(pruned.id, catalog.id); + expect(pruned.functions.keys, catalog.functions.keys); + expect(pruned.themeSchema, isNotNull); + }); + + test('ignores names that are not in the catalog', () { + final Catalog pruned = ComponentPruningTransformer(const [ + 'Text', + 'NotAComponent', + ]).transform(MinimalCatalog()); + + expect(pruned.components.keys, ['Text']); + }); + }); + + group('FunctionPruningTransformer', () { + test('keeps only allowlisted functions', () { + final Catalog pruned = FunctionPruningTransformer( + const [], + ).transform(MinimalCatalog()); + + expect(pruned.functions, isEmpty); + expect(pruned.components.keys, isNotEmpty); + }); + + test('keeps a named function', () { + final Catalog pruned = FunctionPruningTransformer( + const ['capitalize'], + ).transform(MinimalCatalog()); + + expect(pruned.functions.keys, ['capitalize']); + }); + }); + + group('CatalogConfig', () { + test('applies transformers in order', () { + final config = CatalogConfig( + MinimalCatalog(), + transformers: [ + ComponentPruningTransformer(const ['Text', 'Column', 'Button']), + ComponentPruningTransformer(const ['Text', 'Column']), + ], + ); + + expect( + config.transformedCatalog.components.keys, + unorderedEquals(['Text', 'Column']), + ); + }); + + test('returns the pristine catalog when there are no transformers', () { + final config = CatalogConfig(MinimalCatalog()); + + expect( + config.transformedCatalog.components.keys, + MinimalCatalog().components.keys, + ); + }); + + test('caches the transformed catalog', () { + final config = CatalogConfig( + MinimalCatalog(), + transformers: [ComponentPruningTransformer(const ['Text'])], + ); + + expect( + identical(config.transformedCatalog, config.transformedCatalog), + isTrue, + ); + }); + }); +} From dcb0995b439fb9aeaad735ec688d02bdc0565ec6 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 11:32:44 -0700 Subject: [PATCH 15/19] Create direct_json_parser_test.dart --- .../test/direct_json_parser_test.dart | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 packages/a2ui_agent/test/direct_json_parser_test.dart diff --git a/packages/a2ui_agent/test/direct_json_parser_test.dart b/packages/a2ui_agent/test/direct_json_parser_test.dart new file mode 100644 index 000000000..ea0bd30ab --- /dev/null +++ b/packages/a2ui_agent/test/direct_json_parser_test.dart @@ -0,0 +1,237 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + final catalogs = >[MinimalCatalog()]; + DirectJsonParser parser() => DirectJsonParser(catalogs: catalogs); + + group('unwrap', () { + test('splits text and payload blocks in order', () { + final List parts = parser().unwrap( + 'Before [] after', + ); + + expect(parts, hasLength(3)); + expect(parts[0].part, const TextPart('Before')); + expect(parts[1].part, const RawA2uiPart('[]')); + expect(parts[1].isFinal, isTrue); + expect(parts[2].part, const TextPart('after')); + }); + + test('handles several blocks', () { + final List parts = parser().unwrap( + '[1]mid[2]', + ); + + expect( + parts.map((part) => part.part), + [ + const RawA2uiPart('[1]'), + const TextPart('mid'), + const RawA2uiPart('[2]'), + ], + ); + }); + + test('marks an unterminated block as not final', () { + final List parts = parser().unwrap( + 'text [{"a"', + ); + + expect(parts.last.isFinal, isFalse); + expect(parts.last.part, const RawA2uiPart('[{"a"')); + }); + + test('returns only text when there is no block', () { + expect( + parser().unwrap('just talking').single.part, + const TextPart('just talking'), + ); + }); + }); + + group('wrap', () { + test('round trips through unwrap', () { + const String response = + 'Hello [{"version":"v0.9"}] bye'; + final DirectJsonParser subject = parser(); + + expect(subject.wrap(subject.unwrap(response)), response); + }); + }); + + group('compile', () { + test('compiles a list of messages', () { + final List messages = parser().compile(''' +[ + {"version": "v0.9", "createSurface": {"surfaceId": "s", "catalogId": "${MinimalCatalog().id}"}}, + {"version": "v0.9", "updateComponents": {"surfaceId": "s", "components": [ + {"id": "root", "component": "Text", "text": "Hi"} + ]}} +] +'''); + + expect(messages, hasLength(2)); + expect(messages.first, isA()); + expect( + (messages[1] as UpdateComponentsMessage).components.single['id'], + 'root', + ); + }); + + test('wraps a single message object in a list', () { + final List messages = parser().compile( + '{"version": "v0.9", "deleteSurface": {"surfaceId": "s"}}', + ); + + expect(messages.single, isA()); + }); + + test('supplies a missing version', () { + final List messages = parser().compile( + '[{"deleteSurface": {"surfaceId": "s"}}]', + ); + + expect(messages.single.version, 'v0.9'); + }); + + test('repairs trailing commas', () { + final List messages = parser().compile( + '[{"version": "v0.9", "deleteSurface": {"surfaceId": "s",},},]', + ); + + expect(messages.single, isA()); + }); + + test('leaves commas inside strings alone', () { + final List messages = parser().compile(''' +[{"version": "v0.9", "updateComponents": {"surfaceId": "s", "components": [ + {"id": "root", "component": "Text", "text": "a, b, and c"} +]}}] +'''); + + final UpdateComponentsMessage message = + messages.single as UpdateComponentsMessage; + expect(message.components.single['text'], 'a, b, and c'); + }); + + test('normalizes smart quotes', () { + final List messages = parser().compile( + '[{“version”: “v0.9”, “deleteSurface”: {“surfaceId”: “s”}}]', + ); + + expect((messages.single as DeleteSurfaceMessage).surfaceId, 's'); + }); + + test('strips a markdown fence', () { + final List messages = parser().compile( + '```json\n[{"version": "v0.9", "deleteSurface": {"surfaceId": "s"}}]\n```', + ); + + expect(messages.single, isA()); + }); + + test('rejects an empty block', () { + expect(() => parser().compile(' '), throwsA(isA())); + }); + + test('rejects unparseable JSON', () { + expect( + () => parser().compile('[{"createSurface": '), + throwsA(isA()), + ); + }); + + test('rejects a component that is not in the catalog', () { + expect( + () => parser().compile(''' +[{"version": "v0.9", "updateComponents": {"surfaceId": "s", "components": [ + {"id": "root", "component": "Carousel"} +]}}] +'''), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains("Unknown component 'Carousel'"), + ), + ), + ); + }); + + test('rejects a surface bound to an unknown catalog', () { + expect( + () => parser().compile( + '[{"version": "v0.9", "createSurface": ' + '{"surfaceId": "s", "catalogId": "other"}}]', + ), + throwsA(isA()), + ); + }); + }); + + group('parseResponse', () { + test('preserves the order of text and payloads', () { + final List parts = parser().parseResponse( + 'Intro\n[{"version":"v0.9","deleteSurface":' + '{"surfaceId":"s"}}]\nOutro', + ); + + expect(parts.map((part) => part.runtimeType.toString()), [ + 'TextPart', + 'A2uiPart', + 'TextPart', + ]); + }); + + test('compiles the whole content when it is not wrapped', () { + final List parts = parser().parseResponse( + '[{"version":"v0.9","deleteSurface":{"surfaceId":"s"}}]', + wrapped: false, + ); + + expect((parts.single as A2uiPart).a2ui, hasLength(1)); + }); + }); + + group('decompile', () { + test('renders messages as indented JSON that compiles back', () { + final DirectJsonParser subject = parser(); + final messages = [ + DeleteSurfaceMessage(surfaceId: 's'), + ]; + + final String json = subject.decompile(messages); + expect(json, contains('"deleteSurface"')); + expect(subject.compile(json).single, isA()); + }); + }); + + group('progressiveKeys', () { + test('covers free-form string properties', () { + expect(parser().progressiveKeys, containsAll(['text', 'label'])); + }); + + test('excludes component references and enums', () { + final Set keys = parser().progressiveKeys; + + expect(keys, isNot(contains('child'))); + expect(keys, isNot(contains('children'))); + expect(keys, isNot(contains('variant'))); + }); + + test('honours an override', () { + final parser = DirectJsonParser( + catalogs: catalogs, + customProgressiveKeys: const {'only'}, + ); + + expect(parser.progressiveKeys, {'only'}); + }); + }); +} From 19e72abe5823467de26609598990f9cc7ef8f8db Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 11:46:03 -0700 Subject: [PATCH 16/19] - --- packages/a2ui_agent/README.md | 181 ++++++- .../example/a2ui_agent_example.dart | 9 +- .../inference_formats/express/compiler.dart | 7 +- .../inference_formats/express/decompiler.dart | 15 +- .../src/inference_formats/express/lexer.dart | 11 +- .../src/inference_formats/express/parser.dart | 13 +- .../lib/src/parser/sentinel_tokenizer.dart | 14 +- .../lib/src/utils/catalog_document.dart | 14 +- .../lib/src/utils/catalog_resolver.dart | 12 +- .../test/catalog_document_test.dart | 314 ++++++++++++ .../test/catalog_transformers_test.dart | 16 +- .../test/direct_json_parser_test.dart | 43 +- .../test/direct_json_streaming_test.dart | 247 ++++++++++ .../test/express_compiler_test.dart | 448 ++++++++++++++++++ .../test/express_decompiler_test.dart | 242 ++++++++++ .../test/express_streaming_test.dart | 164 +++++++ packages/a2ui_agent/test/primitives_test.dart | 184 +++++++ packages/a2ui_agent/test/processor_test.dart | 358 ++++++++++++++ packages/a2ui_agent/test/validation_test.dart | 294 ++++++++++++ 19 files changed, 2505 insertions(+), 81 deletions(-) create mode 100644 packages/a2ui_agent/test/catalog_document_test.dart create mode 100644 packages/a2ui_agent/test/direct_json_streaming_test.dart create mode 100644 packages/a2ui_agent/test/express_compiler_test.dart create mode 100644 packages/a2ui_agent/test/express_decompiler_test.dart create mode 100644 packages/a2ui_agent/test/express_streaming_test.dart create mode 100644 packages/a2ui_agent/test/primitives_test.dart create mode 100644 packages/a2ui_agent/test/processor_test.dart create mode 100644 packages/a2ui_agent/test/validation_test.dart diff --git a/packages/a2ui_agent/README.md b/packages/a2ui_agent/README.md index 0bbb0e5d8..33c2896e5 100644 --- a/packages/a2ui_agent/README.md +++ b/packages/a2ui_agent/README.md @@ -1,3 +1,182 @@ # A2UI Agent SDK -TODO: add readme +The agent side of [A2UI](https://a2ui.org) for Dart: everything between "the +model is about to be called" and "the renderer receives A2UI". + +It covers catalog management, capability negotiation, prompt engineering, +response parsing, payload validation and transport packaging, so an agent can +generate rich UI that provably conforms to what its client can render. + +Built on [`package:a2ui_core`](../a2ui_core), which supplies the protocol +models this SDK negotiates, prompts with and validates against. + +## Getting started + +```dart +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; + +// 1. At startup: register the catalogs this agent supports. +final generator = A2uiGenerator( + catalogs: [ + CatalogConfig( + MinimalCatalog(), + transformers: [ + ComponentPruningTransformer(['Column', 'Text', 'Button']), + ], + ), + ], + examples: {'A greeting card': greetingMessages}, +); + +// 2. Per request: negotiate against what the renderer can render. +final processor = generator.createProcessor( + A2uiRendererCapabilities.fromJson(request.a2uiClientCapabilities), +); + +// 3. Prompt the model, prepending your own role and workflow instructions. +final output = await callLlm('$roleInstructions\n${processor.promptSnippet}'); + +// 4. Parse and validate. +for (final part in processor.parseResponse(output)) { + switch (part) { + case TextPart(:final text): + sendText(text); + case A2uiPart(:final a2ui): + sendToRenderer(a2ui); // 5. Deliver. + } +} +``` + +Run the full walkthrough with +`dart run example/a2ui_agent_example.dart`. + +## Architecture + +The SDK separates single-responsibility primitives from a high-level facade, so +that an agent can adopt the whole pipeline or reach past it for one piece. + +| Layer | Types | +| --- | --- | +| Application facade | `A2uiGenerator`, `A2uiRequestProcessor`, `CatalogConfig`, catalog providers | +| Catalog transformers | `CatalogTransformer`, `ComponentPruningTransformer`, `FunctionPruningTransformer` | +| Inference formats | `InferenceFormat`, `InferenceFormatFactory`, `DirectJsonFormat`, `ExpressFormat` | +| Prompt generation | `PromptGenerator` and the per-format generators | +| Parsing | `Parser`, `RawResponsePart`, `TextPart`, `RawA2uiPart`, `A2uiPart` | +| Validation | `A2uiPayloadValidator` | +| Negotiation | `resolveCatalogs`, `A2uiRendererCapabilities` | + +### Catalogs + +A catalog is a `a2ui_core` `Catalog`: either written in Dart, or loaded from a +catalog document with `FileSystemCatalogProvider`, `InMemoryCatalogProvider` or +`BundledCatalogProvider`. `CatalogConfig` pairs one with the transformers that +trim it; the pristine catalog is never mutated. + +`resolveCatalogs` matches the registered catalogs against the renderer's +`a2uiClientCapabilities` and returns the transformed catalogs active for the +session. A catalog the renderer cannot render never reaches the prompt, so the +model cannot name a component the client would reject. If nothing matches, the +call throws rather than letting a doomed inference run. + +### Inference formats + +A format pairs a prompt generator with a parser. + +**Direct JSON** (the default) has the model emit A2UI wire JSON inside +`` tags: + +``` + +[{"version": "v0.9", "updateComponents": {"surfaceId": "s", "components": [ + {"id": "root", "component": "Text", "text": "Hello"} +]}}] + +``` + +**Express** trades that verbosity for a positional DSL inside +`` tags, which cuts output tokens substantially — the reason the +format exists: + +``` + +surface("s") +root = Column([title, cta]) +title = Text($/heading, "h1") +cta = Button(Text("Continue"), Event("continue")) + +``` + +Express is catalog-agnostic: positional arguments are mapped onto property +names through the catalog signature, and the same function renders the +signature shown to the model, so the syntax it is taught is exactly the syntax +its output is parsed against. `ExpressDecompiler` converts payloads back to +Express, which is how few-shot examples authored as A2UI JSON are shown to the +model in the compact syntax. + +Select a format per agent or per request: + +```dart +generator.createProcessor( + capabilities, + inferenceFormatFactory: const ExpressFormatFactory(), +); +``` + +### Streaming + +Both formats parse incrementally. `parseChunk` returns only what became usable +since the previous call, and `parseStream` wraps that as a `Stream`: + +```dart +await for (final part in processor.parseStream(modelChunks)) { + // TextPart and A2uiPart, in the order the model produced them. +} +``` + +Direct JSON emits a component as soon as it is renderable and re-emits it only +when its content actually changed, so a string grows on screen as it arrives. +Truncated values are healed only for property keys where a prefix is a +legitimate value — never for component references, enums or pattern-constrained +strings, where a prefix would be wrong rather than merely incomplete. Express +compiles statement by statement as each line completes. + +A payload the model never finished is salvaged as far as it parses, rather than +discarded. + +### Validation + +`A2uiPayloadValidator` checks compiled payloads against the negotiated +catalogs: envelope version, catalog identity, component existence, required and +unknown properties, duplicate ids, JSON Pointer syntax, and — opt in — +dangling child references and reference cycles. Parsing runs it automatically, +so `parseResponse` either returns a conforming payload or throws. + +Prompt examples are validated too, when the processor is created: an example +that names a component the negotiated catalogs lack would teach the model to +emit exactly what the renderer will reject. + +## Relationship to the specification + +This package implements the +[A2UI agent SDK blueprint](https://github.com/a2ui-project/a2ui/blob/main/blueprints/modules/a2ui_agent.blueprint.md). +Where Dart differs from the reference Python SDK: + +- The blueprint's `AgentToRendererMessage` is `a2ui_core`'s `A2uiMessage`; the + name is available as a typedef. +- `package:a2ui_core` models the `v0.9` envelopes, so payloads compile to + `createSurface`, `updateComponents`, `updateDataModel` and `deleteSurface`. + `ProtocolVersion` names the other versions so that a catalog declaring one is + reported rather than silently mis-parsed. +- Validation lives here rather than in the core package, which does not yet + ship an `A2uiValidator`. +- Express targets `v0.9`, so a standalone function call — a `callFunction` RPC + in `v1.0` — is reported as unsupported instead of being dropped. +- `BundledCatalogProvider` serves the minimal catalog bundled with + `a2ui_core` for `v0.9`. +- Catalog transformers are pure functions over `Catalog`, and generic over the + component type, so pruning a typed catalog returns a catalog of the same + type. + +`FileSystemCatalogProvider` and `CatalogConfig.fromPath` read from disk and so +require a native platform. diff --git a/packages/a2ui_agent/example/a2ui_agent_example.dart b/packages/a2ui_agent/example/a2ui_agent_example.dart index 471ed9032..10431e085 100644 --- a/packages/a2ui_agent/example/a2ui_agent_example.dart +++ b/packages/a2ui_agent/example/a2ui_agent_example.dart @@ -21,9 +21,7 @@ void main() { ], ), ], - examples: { - 'A greeting with a dismiss button': _greetingExample(), - }, + examples: {'A greeting with a dismiss button': _greetingExample()}, ); // 2. Per request, negotiate against what the renderer says it can render. @@ -63,8 +61,9 @@ void main() { formatFactory: const ExpressFormatFactory(), ); print('\n--- express ---'); - for (final ResponsePart part - in expressProcessor.parseResponse(_fakeExpressOutput)) { + for (final ResponsePart part in expressProcessor.parseResponse( + _fakeExpressOutput, + )) { if (part is A2uiPart) { for (final AgentToRendererMessage message in part.a2ui) { print('a2ui: ${message.toJson()}'); diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/compiler.dart b/packages/a2ui_agent/lib/src/inference_formats/express/compiler.dart index 9d9812937..7ccba5945 100644 --- a/packages/a2ui_agent/lib/src/inference_formats/express/compiler.dart +++ b/packages/a2ui_agent/lib/src/inference_formats/express/compiler.dart @@ -186,11 +186,7 @@ class ExpressCompiler { } _usedIds.add(name); final sink = >[]; - final Map component = _compileComponent( - name, - value, - sink, - ); + final Map component = _compileComponent(name, value, sink); return [component, ...sink]; } @@ -591,7 +587,6 @@ class ExpressCompiler { ..addAll(descendants); } - String _generateId(String parentId, String property, {int? index}) { final base = index == null ? '${parentId}_$property' diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/decompiler.dart b/packages/a2ui_agent/lib/src/inference_formats/express/decompiler.dart index 4f00b7697..3726312d9 100644 --- a/packages/a2ui_agent/lib/src/inference_formats/express/decompiler.dart +++ b/packages/a2ui_agent/lib/src/inference_formats/express/decompiler.dart @@ -35,9 +35,7 @@ class ExpressDecompiler { '${_string(message.catalogId)})', ); case DeleteSurfaceMessage(): - lines.add( - '$expressDeleteSurfaceCall(${_string(message.surfaceId)})', - ); + lines.add('$expressDeleteSurfaceCall(${_string(message.surfaceId)})'); case UpdateDataModelMessage(): lines.add( r'$' @@ -83,12 +81,9 @@ class ExpressDecompiler { positional.add(expressSkipPlaceholder); continue; } - positional.add( - _value(component[parameter.name], parameter.schema), - ); + positional.add(_value(component[parameter.name], parameter.schema)); } - while (positional.isNotEmpty && - positional.last == expressSkipPlaceholder) { + while (positional.isNotEmpty && positional.last == expressSkipPlaceholder) { positional.removeLast(); } @@ -233,9 +228,7 @@ class ExpressDecompiler { /// Quotes [value], preferring a raw string when escaping would obscure it. String _string(String value) { - if (value.contains(r'\') && - !value.contains('"') && - !value.contains('\n')) { + if (value.contains(r'\') && !value.contains('"') && !value.contains('\n')) { return 'r"$value"'; } final String escaped = value diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/lexer.dart b/packages/a2ui_agent/lib/src/inference_formats/express/lexer.dart index f0f27d6b3..0e2b5121f 100644 --- a/packages/a2ui_agent/lib/src/inference_formats/express/lexer.dart +++ b/packages/a2ui_agent/lib/src/inference_formats/express/lexer.dart @@ -87,11 +87,7 @@ class ExpressLexer { ExpressToken _next() { _skipIgnored(); if (_offset >= source.length) { - return ExpressToken( - type: ExpressTokenType.eof, - lexeme: '', - line: _line, - ); + return ExpressToken(type: ExpressTokenType.eof, lexeme: '', line: _line); } final int startLine = _line; @@ -239,10 +235,7 @@ class ExpressLexer { _offset++; // consume '?' final int start = _offset; if (_offset >= source.length || !_isIdentifierStart(source[_offset])) { - throw A2uiFormatError( - "A check must be written '?name'.", - line: line, - ); + throw A2uiFormatError("A check must be written '?name'.", line: line); } while (_offset < source.length && _isIdentifierPart(source[_offset])) { _offset++; diff --git a/packages/a2ui_agent/lib/src/inference_formats/express/parser.dart b/packages/a2ui_agent/lib/src/inference_formats/express/parser.dart index 404846c84..26dc3de5c 100644 --- a/packages/a2ui_agent/lib/src/inference_formats/express/parser.dart +++ b/packages/a2ui_agent/lib/src/inference_formats/express/parser.dart @@ -82,13 +82,12 @@ class ExpressParser extends Parser { protocolVersion: protocolVersion, ); - ExpressStreamProcessor get _processor => - _stream ??= ExpressStreamProcessor( - createCompiler: _newCompiler, - validator: _validator, - openTag: openTag, - closeTag: closeTag, - ); + ExpressStreamProcessor get _processor => _stream ??= ExpressStreamProcessor( + createCompiler: _newCompiler, + validator: _validator, + openTag: openTag, + closeTag: closeTag, + ); } /// Compiles Express statements as they stream in. diff --git a/packages/a2ui_agent/lib/src/parser/sentinel_tokenizer.dart b/packages/a2ui_agent/lib/src/parser/sentinel_tokenizer.dart index c3c280f34..6089baa64 100644 --- a/packages/a2ui_agent/lib/src/parser/sentinel_tokenizer.dart +++ b/packages/a2ui_agent/lib/src/parser/sentinel_tokenizer.dart @@ -82,9 +82,7 @@ class SentinelTokenizer { final int keep = _partialTagSuffixLength(rest, tag); final String emit = rest.substring(0, rest.length - keep); if (emit.isNotEmpty) { - tokens.add( - _inBlock ? BlockContentToken(emit) : TextToken(emit), - ); + tokens.add(_inBlock ? BlockContentToken(emit) : TextToken(emit)); } _buffer.write(rest.substring(rest.length - keep)); return tokens; @@ -95,8 +93,9 @@ class SentinelTokenizer { tokens.add(_inBlock ? BlockContentToken(emit) : TextToken(emit)); } tokens.add( - _inBlock ? const BlockEndToken(terminated: true) : const - BlockStartToken(), + _inBlock + ? const BlockEndToken(terminated: true) + : const BlockStartToken(), ); _inBlock = !_inBlock; rest = rest.substring(index + tag.length); @@ -158,10 +157,7 @@ class SentinelTokenizer { raw.write(content); case BlockEndToken(terminated: final bool terminated): parts.add( - RawResponsePart( - RawA2uiPart(raw.toString()), - isFinal: terminated, - ), + RawResponsePart(RawA2uiPart(raw.toString()), isFinal: terminated), ); raw.clear(); inBlock = false; diff --git a/packages/a2ui_agent/lib/src/utils/catalog_document.dart b/packages/a2ui_agent/lib/src/utils/catalog_document.dart index d1898d74a..0f38bb86a 100644 --- a/packages/a2ui_agent/lib/src/utils/catalog_document.dart +++ b/packages/a2ui_agent/lib/src/utils/catalog_document.dart @@ -66,9 +66,14 @@ Map catalogToDocument(Catalog catalog) { final components = {}; for (final MapEntry entry in catalog.components.entries) { - final Map schema = entry.value.schema.toJsonMap(); - expandSchemaRefs(schema); - final Map flattened = _mergeAllOf(schema); + // Flatten before expanding references: a shared fragment such as + // `Checkable` carries both a `REF:` marker and the properties it + // contributes, so expanding first would replace it with a bare `$ref` and + // lose those properties from the component's own property list. + final Map flattened = _mergeAllOf( + entry.value.schema.toJsonMap(), + ); + expandSchemaRefs(flattened); components[entry.key] = { 'allOf': [ {r'$ref': r'common_types.json#/$defs/ComponentCommon'}, @@ -289,8 +294,7 @@ Map _stripComponentEnvelope(Map schema) { if (branch is! Map) continue; final Object? branchProperties = branch['properties']; if (branchProperties is Map) { - for (final MapEntry entry - in branchProperties.entries) { + for (final MapEntry entry in branchProperties.entries) { final Object? key = entry.key; if (key is String && !envelopeKeys.contains(key)) { properties[key] = entry.value; diff --git a/packages/a2ui_agent/lib/src/utils/catalog_resolver.dart b/packages/a2ui_agent/lib/src/utils/catalog_resolver.dart index ada3f2580..fe4d5cef9 100644 --- a/packages/a2ui_agent/lib/src/utils/catalog_resolver.dart +++ b/packages/a2ui_agent/lib/src/utils/catalog_resolver.dart @@ -47,15 +47,15 @@ List> resolveCatalogs( } if (active.isEmpty) { + final bool ignoredInline = + rendererCapabilities.inlineCatalogs.isNotEmpty && + !acceptsInlineCatalogs; throw A2uiCapabilityError( 'No catalog is shared between the agent and the renderer. The agent ' 'supports: ${catalogs.map((config) => config.id).join(', ')}. The ' - 'renderer supports: ${supported.join(', ')}' - '${rendererCapabilities.inlineCatalogs.isEmpty || acceptsInlineCatalogs - ? '' - : ' (it also sent inline catalogs, which this agent does not ' - 'accept)'}' - '.', + 'renderer supports: ${supported.join(', ')}.' + '${ignoredInline ? ' The renderer also sent inline catalogs, which this ' + 'agent does not accept.' : ''}', ); } return active; diff --git a/packages/a2ui_agent/test/catalog_document_test.dart b/packages/a2ui_agent/test/catalog_document_test.dart new file mode 100644 index 000000000..8df069f1e --- /dev/null +++ b/packages/a2ui_agent/test/catalog_document_test.dart @@ -0,0 +1,314 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + final String minimalId = MinimalCatalog().id; + + group('catalogToDocument', () { + test('wraps components in the standard envelope', () { + final Map document = catalogToDocument(MinimalCatalog()); + + expect(document['catalogId'], minimalId); + final components = document['components'] as Map; + final text = components['Text'] as Map; + final allOf = text['allOf'] as List; + + expect(allOf.first, { + r'$ref': r'common_types.json#/$defs/ComponentCommon', + }); + final body = allOf[1] as Map; + expect((body['properties'] as Map)['component'], { + 'const': 'Text', + }); + expect(body['required'], containsAll(['component', 'text'])); + }); + + test('expands REF markers into JSON Schema references', () { + final Map document = catalogToDocument(MinimalCatalog()); + final String encoded = jsonEncode(document); + + expect(encoded, contains(r'common_types.json#/$defs/DynamicString')); + expect(encoded, isNot(contains('REF:'))); + }); + + test('lists functions with their return type', () { + final Map document = catalogToDocument(MinimalCatalog()); + + expect((document['functions'] as List).single, { + 'name': 'capitalize', + 'returnType': 'string', + 'parameters': isA>(), + }); + }); + + test('includes the theme properties', () { + final Map document = catalogToDocument(MinimalCatalog()); + + expect( + (document['theme'] as Map).keys, + contains('primaryColor'), + ); + }); + + test('flattens allOf composition into one property list', () { + final Map document = catalogToDocument(MinimalCatalog()); + final button = + (document['components'] as Map)['Button'] + as Map; + final body = + (button['allOf'] as List)[1] as Map; + + expect( + (body['properties'] as Map).keys, + containsAll(['component', 'checks', 'child', 'variant', 'action']), + ); + }); + }); + + group('catalogFromDocument', () { + test('round trips a catalog through its document form', () { + final Catalog restored = catalogFromDocument( + catalogToDocument(MinimalCatalog()), + ); + + expect(restored.id, minimalId); + expect( + restored.components.keys, + unorderedEquals(MinimalCatalog().components.keys), + ); + expect(restored.functions.keys, ['capitalize']); + }); + + test('preserves signatures across the round trip', () { + final Catalog restored = catalogFromDocument( + catalogToDocument(MinimalCatalog()), + ); + + expect( + signatureOf( + restored.components['Text']!.schema, + ).map((parameter) => parameter.label).toList(), + signatureOf( + MinimalCatalog().components['Text']!.schema, + ).map((parameter) => parameter.label).toList(), + ); + }); + + test('preserves properties contributed by a shared fragment', () { + final Catalog restored = catalogFromDocument( + catalogToDocument(MinimalCatalog()), + ); + + // Button composes the shared `Checkable` fragment via allOf; `checks` + // must survive the trip through the document. + expect( + signatureOf( + restored.components['Button']!.schema, + ).map((parameter) => parameter.label).toList(), + signatureOf( + MinimalCatalog().components['Button']!.schema, + ).map((parameter) => parameter.label).toList(), + ); + }); + + test('compiles Express against a restored catalog', () { + final Catalog restored = catalogFromDocument( + catalogToDocument(MinimalCatalog()), + ); + + final List messages = ExpressCompiler( + catalogs: [restored], + ).compile('root = Text("Hi", "h1")'); + + expect( + messages.whereType().single.components.single, + {'id': 'root', 'component': 'Text', 'text': 'Hi', 'variant': 'h1'}, + ); + }); + + test('rejects a catalog id that contradicts the expected one', () { + expect( + () => catalogFromDocument({ + 'catalogId': 'actual', + 'components': {}, + }, catalogId: 'expected'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Catalog id mismatch'), + ), + ), + ); + }); + + test('rejects a protocol version that contradicts the expected one', () { + expect( + () => catalogFromDocument({ + 'catalogId': 'c', + 'protocolVersion': 'v1.0', + 'components': {}, + }, protocolVersion: ProtocolVersion.v09), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Protocol version mismatch'), + ), + ), + ); + }); + + test('supplies the id for a document that predates catalogId', () { + final Catalog catalog = catalogFromDocument({ + 'components': {}, + }, catalogId: 'supplied'); + + expect(catalog.id, 'supplied'); + }); + + test('rejects a document with no id at all', () { + expect( + () => catalogFromDocument({'components': {}}), + throwsA(isA()), + ); + }); + + test('rejects a document with no components object', () { + expect( + () => catalogFromDocument({'catalogId': 'c'}), + throwsA(isA()), + ); + }); + + test('declaration-only functions refuse to execute', () { + final Catalog restored = catalogFromDocument( + catalogToDocument(MinimalCatalog()), + ); + + expect( + () => restored.functions['capitalize']!.execute( + const {}, + DataContext(DataModel(), (name, args, context) => null, '/'), + ), + throwsUnsupportedError, + ); + }); + }); + + group('providers', () { + test('BundledCatalogProvider loads the minimal catalog for v0.9', () { + expect(const BundledCatalogProvider().load().id, minimalId); + }); + + test('BundledCatalogProvider rejects a version with no bundle', () { + expect( + () => const BundledCatalogProvider( + protocolVersion: ProtocolVersion.v10, + ).load(), + throwsA(isA()), + ); + }); + + test('InMemoryCatalogProvider builds a catalog from a document', () { + final Catalog catalog = const InMemoryCatalogProvider({ + 'catalogId': 'inline', + 'components': { + 'Badge': { + 'properties': { + 'label': {'type': 'string'}, + }, + 'required': ['label'], + }, + }, + }).load(); + + expect(catalog.id, 'inline'); + expect( + signatureOf(catalog.components['Badge']!.schema).single.name, + 'label', + ); + }); + + test('StaticCatalogProvider passes a catalog through', () { + final catalog = MinimalCatalog(); + + expect(StaticCatalogProvider(catalog).load(), same(catalog)); + }); + + group('FileSystemCatalogProvider', () { + late Directory directory; + + setUp(() { + directory = Directory.systemTemp.createTempSync('a2ui_agent_test'); + }); + + tearDown(() => directory.deleteSync(recursive: true)); + + File write(String name, String contents) => + File('${directory.path}/$name')..writeAsStringSync(contents); + + test('loads a catalog document from disk', () { + final File file = write( + 'catalog.json', + jsonEncode(catalogToDocument(MinimalCatalog())), + ); + + expect(FileSystemCatalogProvider(file.path).load().id, minimalId); + }); + + test('reports a missing file', () { + expect( + () => + FileSystemCatalogProvider('${directory.path}/absent.json').load(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('not found'), + ), + ), + ); + }); + + test('reports malformed JSON', () { + final File file = write('bad.json', '{not json'); + + expect( + () => FileSystemCatalogProvider(file.path).load(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('not valid JSON'), + ), + ), + ); + }); + + test('CatalogConfig.fromPath loads and transforms', () { + final File file = write( + 'catalog.json', + jsonEncode(catalogToDocument(MinimalCatalog())), + ); + + final config = CatalogConfig.fromPath( + file.path, + transformers: [ + ComponentPruningTransformer(const ['Text']), + ], + ); + + expect(config.transformedCatalog.components.keys, ['Text']); + }); + }); + }); +} diff --git a/packages/a2ui_agent/test/catalog_transformers_test.dart b/packages/a2ui_agent/test/catalog_transformers_test.dart index bbf869cf7..d614f6cb4 100644 --- a/packages/a2ui_agent/test/catalog_transformers_test.dart +++ b/packages/a2ui_agent/test/catalog_transformers_test.dart @@ -26,9 +26,9 @@ void main() { test('preserves id, functions and theme schema', () { final catalog = MinimalCatalog(); - final Catalog pruned = ComponentPruningTransformer( - const ['Text'], - ).transform(catalog); + final Catalog pruned = ComponentPruningTransformer(const [ + 'Text', + ]).transform(catalog); expect(pruned.id, catalog.id); expect(pruned.functions.keys, catalog.functions.keys); @@ -56,9 +56,9 @@ void main() { }); test('keeps a named function', () { - final Catalog pruned = FunctionPruningTransformer( - const ['capitalize'], - ).transform(MinimalCatalog()); + final Catalog pruned = FunctionPruningTransformer(const [ + 'capitalize', + ]).transform(MinimalCatalog()); expect(pruned.functions.keys, ['capitalize']); }); @@ -92,7 +92,9 @@ void main() { test('caches the transformed catalog', () { final config = CatalogConfig( MinimalCatalog(), - transformers: [ComponentPruningTransformer(const ['Text'])], + transformers: [ + ComponentPruningTransformer(const ['Text']), + ], ); expect( diff --git a/packages/a2ui_agent/test/direct_json_parser_test.dart b/packages/a2ui_agent/test/direct_json_parser_test.dart index ea0bd30ab..ae9eafd3f 100644 --- a/packages/a2ui_agent/test/direct_json_parser_test.dart +++ b/packages/a2ui_agent/test/direct_json_parser_test.dart @@ -28,14 +28,11 @@ void main() { '[1]mid[2]', ); - expect( - parts.map((part) => part.part), - [ - const RawA2uiPart('[1]'), - const TextPart('mid'), - const RawA2uiPart('[2]'), - ], - ); + expect(parts.map((part) => part.part), [ + const RawA2uiPart('[1]'), + const TextPart('mid'), + const RawA2uiPart('[2]'), + ]); }); test('marks an unterminated block as not final', () { @@ -56,12 +53,27 @@ void main() { }); group('wrap', () { - test('round trips through unwrap', () { - const String response = - 'Hello [{"version":"v0.9"}] bye'; + test('re-adds the sentinel tags around raw blocks', () { + final DirectJsonParser subject = parser(); + + expect( + subject.wrap(const [ + RawResponsePart(TextPart('Hello')), + RawResponsePart(RawA2uiPart('[{"version":"v0.9"}]')), + ]), + 'Hello[{"version":"v0.9"}]', + ); + }); + + test('is a fixed point of unwrap', () { final DirectJsonParser subject = parser(); + const response = 'Hello [{"version":"v0.9"}] bye'; - expect(subject.wrap(subject.unwrap(response)), response); + // unwrap trims conversational text, so wrapping is idempotent from the + // second pass on rather than byte-identical to the model's output. + final String wrapped = subject.wrap(subject.unwrap(response)); + expect(subject.wrap(subject.unwrap(wrapped)), wrapped); + expect(wrapped, contains('[{"version":"v0.9"}]')); }); }); @@ -115,8 +127,7 @@ void main() { ]}}] '''); - final UpdateComponentsMessage message = - messages.single as UpdateComponentsMessage; + final message = messages.single as UpdateComponentsMessage; expect(message.components.single['text'], 'a, b, and c'); }); @@ -130,7 +141,9 @@ void main() { test('strips a markdown fence', () { final List messages = parser().compile( - '```json\n[{"version": "v0.9", "deleteSurface": {"surfaceId": "s"}}]\n```', + '```json\n' + '[{"version": "v0.9", "deleteSurface": {"surfaceId": "s"}}]\n' + '```', ); expect(messages.single, isA()); diff --git a/packages/a2ui_agent/test/direct_json_streaming_test.dart b/packages/a2ui_agent/test/direct_json_streaming_test.dart new file mode 100644 index 000000000..1666a02b7 --- /dev/null +++ b/packages/a2ui_agent/test/direct_json_streaming_test.dart @@ -0,0 +1,247 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + final catalogs = >[MinimalCatalog()]; + + /// Feeds [response] to a parser one [size]-character chunk at a time. + List stream(String response, {int size = 1}) { + final parser = DirectJsonParser(catalogs: catalogs); + final parts = []; + for (var index = 0; index < response.length; index += size) { + final int end = index + size < response.length + ? index + size + : response.length; + parts.addAll(parser.parseChunk(response.substring(index, end))); + } + parts.addAll(parser.flush()); + return parts; + } + + List messagesOf(List parts) => [ + for (final part in parts) + if (part is A2uiPart) ...part.a2ui, + ]; + + String textOf(List parts) => [ + for (final part in parts) + if (part is TextPart) part.text, + ].join(); + + const response = ''' +Building it now. +[ +{"version":"v0.9","createSurface":{"surfaceId":"s","catalogId":"https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json"}}, +{"version":"v0.9","updateComponents":{"surfaceId":"s","components":[ +{"id":"root","component":"Column","children":["a","b"]}, +{"id":"a","component":"Text","text":"First"}, +{"id":"b","component":"Text","text":"Second"} +]}} +] +Done.'''; + + group('streaming', () { + test('settles on the same UI as a non-streamed parse', () { + final List streamed = messagesOf( + stream(response), + ); + final List whole = messagesOf( + DirectJsonParser(catalogs: catalogs).parseResponse(response), + ); + + // Streaming emits a component repeatedly as its content grows, so the + // comparable value is the state each id settles on, not the emission + // count. + expect(_finalState(streamed), _finalState(whole)); + expect(streamed.whereType(), hasLength(1)); + expect(streamed.whereType().single.surfaceId, 's'); + }); + + test('emits conversational text around the block', () { + expect(textOf(stream(response)).trim(), startsWith('Building it now.')); + expect(textOf(stream(response)).trim(), endsWith('Done.')); + }); + + test('never re-emits a component that has not changed', () { + final emissions = >{}; + for (final AgentToRendererMessage message in messagesOf( + stream(response), + )) { + if (message is! UpdateComponentsMessage) continue; + for (final Map component in message.components) { + emissions + .putIfAbsent('${component['id']}', () => []) + .add('$component'); + } + } + + expect(emissions.keys, unorderedEquals(['root', 'a', 'b'])); + for (final MapEntry> entry in emissions.entries) { + expect( + entry.value.toSet(), + hasLength(entry.value.length), + reason: 'component ${entry.key} was emitted unchanged twice', + ); + } + }); + + test('emits components before the payload is complete', () { + final parser = DirectJsonParser(catalogs: catalogs); + const prefix = + '[{"version":"v0.9","updateComponents":{"surfaceId":"s",' + '"components":[{"id":"root","component":"Column","children":["a"]},'; + + final List parts = parser.parseChunk(prefix); + final List messages = messagesOf(parts); + + expect(messages, hasLength(1)); + expect( + (messages.single as UpdateComponentsMessage).components.single['id'], + 'root', + ); + }); + + test('grows a progressive string as it arrives', () { + final parser = DirectJsonParser(catalogs: catalogs); + final texts = []; + + void feed(String chunk) { + for (final ResponsePart part in parser.parseChunk(chunk)) { + if (part is! A2uiPart) continue; + for (final AgentToRendererMessage message in part.a2ui) { + if (message is! UpdateComponentsMessage) continue; + for (final Map component in message.components) { + texts.add(component['text']); + } + } + } + } + + feed( + '[{"version":"v0.9","updateComponents":{"surfaceId":"s",' + '"components":[{"id":"a","component":"Text","text":"Hel', + ); + feed('lo wor'); + feed('ld"}]}}]'); + + expect(texts, ['Hel', 'Hello wor', 'Hello world']); + }); + + test('holds back a component whose type is still arriving', () { + final parser = DirectJsonParser(catalogs: catalogs); + + final List parts = parser.parseChunk( + '[{"version":"v0.9","updateComponents":{"surfaceId":"s",' + '"components":[{"id":"a","component":"Te', + ); + + expect(messagesOf(parts), isEmpty); + }); + + test('does not leak a partially received sentinel tag as text', () { + final parser = DirectJsonParser(catalogs: catalogs); + + expect(parser.parseChunk('hi []'), isEmpty); + }); + + test('salvages a payload the model never closed', () { + final List messages = messagesOf( + stream( + '[{"version":"v0.9","updateComponents":{"surfaceId":"s",' + '"components":[{"id":"a","component":"Text","text":"Trunca', + ), + ); + + // The truncated string is healed to the prefix that did arrive rather + // than dropping the component entirely. + expect(_finalState(messages), {'a': 'Trunca'}); + }); + + test('handles an unwrapped stream', () { + final parser = DirectJsonParser(catalogs: catalogs); + final parts = [ + ...parser.parseChunk( + '[{"version":"v0.9","deleteSurface":{"surfaceId":"s"}}]', + wrapped: false, + ), + ...parser.flush(), + ]; + + expect(messagesOf(parts).single, isA()); + }); + + test('parses a stream of chunks', () async { + final parser = DirectJsonParser(catalogs: catalogs); + final List parts = await parser + .parseStream(Stream.fromIterable(_chunks(response, 7))) + .toList(); + + expect(messagesOf(parts).whereType(), hasLength(1)); + }); + + test('is chunk-size independent', () { + final List byOne = _describe(messagesOf(stream(response))); + final List byThirteen = _describe( + messagesOf(stream(response, size: 13)), + ); + final List byHuge = _describe( + messagesOf(stream(response, size: 1000)), + ); + + expect(byThirteen, byOne); + expect(byHuge, byOne); + }); + + test('rejects a component that is not in the catalog', () { + expect( + () => stream( + '[{"version":"v0.9","updateComponents":{"surfaceId":"s",' + '"components":[{"id":"a","component":"Carousel"}]}}]', + ), + throwsA(isA()), + ); + }); + }); +} + +/// The text each component id settled on. +Map _finalState(List messages) { + final state = {}; + for (final message in messages) { + if (message is! UpdateComponentsMessage) continue; + for (final Map component in message.components) { + state['${component['id']}'] = component['text']; + } + } + return state; +} + +/// The final content of each component, in emission order. +List _describe(List messages) { + final rendered = {}; + for (final message in messages) { + if (message is! UpdateComponentsMessage) continue; + for (final Map component in message.components) { + rendered['${component['id']}'] = '$component'; + } + } + return [ + for (final MapEntry entry in rendered.entries) + '${entry.key}=${entry.value}', + ]; +} + +Iterable _chunks(String value, int size) sync* { + for (var index = 0; index < value.length; index += size) { + yield value.substring( + index, + index + size < value.length ? index + size : value.length, + ); + } +} diff --git a/packages/a2ui_agent/test/express_compiler_test.dart b/packages/a2ui_agent/test/express_compiler_test.dart new file mode 100644 index 000000000..32df8b712 --- /dev/null +++ b/packages/a2ui_agent/test/express_compiler_test.dart @@ -0,0 +1,448 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + final catalogs = >[MinimalCatalog()]; + final String catalogId = MinimalCatalog().id; + + ExpressCompiler compiler() => ExpressCompiler(catalogs: catalogs); + + List> componentsOf( + List messages, + ) => [ + for (final message in messages) + if (message is UpdateComponentsMessage) ...message.components, + ]; + + group('lexer', () { + test('reads strings, numbers, booleans, null and paths', () { + final List tokens = ExpressLexer( + r'x = f("a", 1, -2.5, true, null, $/p/q, ?req, _)', + ).tokenize(); + + expect(tokens.map((token) => token.type).toList(), [ + ExpressTokenType.identifier, + ExpressTokenType.assign, + ExpressTokenType.identifier, + ExpressTokenType.leftParen, + ExpressTokenType.string, + ExpressTokenType.comma, + ExpressTokenType.number, + ExpressTokenType.comma, + ExpressTokenType.number, + ExpressTokenType.comma, + ExpressTokenType.boolean, + ExpressTokenType.comma, + ExpressTokenType.nullLiteral, + ExpressTokenType.comma, + ExpressTokenType.path, + ExpressTokenType.comma, + ExpressTokenType.check, + ExpressTokenType.comma, + ExpressTokenType.underscore, + ExpressTokenType.rightParen, + ExpressTokenType.eof, + ]); + }); + + test('applies escapes in standard strings but not raw strings', () { + expect(ExpressLexer(r'"a\nb"').tokenize().first.value, 'a\nb'); + expect(ExpressLexer(r'r"a\nb"').tokenize().first.value, r'a\nb'); + }); + + test('reads triple-quoted strings across lines', () { + expect(ExpressLexer('"""a\nb"""').tokenize().first.value, 'a\nb'); + }); + + test('skips comments and semicolons', () { + final List tokens = ExpressLexer(''' +# a comment +a // trailing +/* block */ ;b +''').tokenize(); + + expect(tokens.map((token) => token.lexeme).toList(), ['a', 'b', '']); + }); + + test('tracks line numbers', () { + final List tokens = ExpressLexer('a\n\nb').tokenize(); + + expect(tokens[0].line, 1); + expect(tokens[1].line, 3); + }); + + test('rejects an unterminated string', () { + expect( + () => ExpressLexer('"open').tokenize(), + throwsA(isA()), + ); + }); + }); + + group('compile', () { + test('emits createSurface, then components in source order', () { + final List messages = compiler().compile(''' +surface("s1") +root = Column([title, cta]) +title = Text("Hello", "h1") +cta = Button(label, Event("go")) +label = Text("Go") +'''); + + expect(messages, hasLength(2)); + final created = messages.first as CreateSurfaceMessage; + expect(created.surfaceId, 's1'); + expect(created.catalogId, catalogId); + + expect( + componentsOf(messages).map((component) => component['id']).toList(), + ['root', 'title', 'cta', 'label'], + ); + }); + + test('maps positional arguments through the catalog signature', () { + final List> components = componentsOf( + compiler().compile('root = Text("Hi", "h2")'), + ); + + expect(components.single, { + 'id': 'root', + 'component': 'Text', + 'text': 'Hi', + 'variant': 'h2', + }); + }); + + test('skips an optional argument written as an underscore', () { + final List> components = componentsOf( + compiler().compile('root = Column([a], _, "center")\na = Text("x")'), + ); + + expect(components.first.containsKey('justify'), isFalse); + expect(components.first['align'], 'center'); + }); + + test('accepts named arguments mixed with positional ones', () { + final List> components = componentsOf( + compiler().compile('root = Text("Hi", variant="h3")'), + ); + + expect(components.single['variant'], 'h3'); + }); + + test('compiles data paths into bindings', () { + final List> components = componentsOf( + compiler().compile(r'root = Text($/user/name)'), + ); + + expect(components.single['text'], {'path': '/user/name'}); + }); + + test('keeps a relative path relative', () { + final List> components = componentsOf( + compiler().compile(r'root = Text($firstName)'), + ); + + expect(components.single['text'], {'path': 'firstName'}); + }); + + test('flattens inline children ahead of their descendants', () { + final List> components = componentsOf( + compiler().compile('root = Column([Text("a"), Row([Text("b")])])'), + ); + + expect(components.map((component) => component['id']).toList(), [ + 'root', + 'root_children0', + 'root_children1', + 'root_children1_children0', + ]); + expect(components.first['children'], [ + 'root_children0', + 'root_children1', + ]); + expect(components[2]['children'], ['root_children1_children0']); + }); + + test('compiles an inline component in a single-child slot', () { + final List> components = componentsOf( + compiler().compile('root = Button(Text("Go"), Event("go"))'), + ); + + expect(components.first['child'], 'root_child'); + expect(components[1]['text'], 'Go'); + }); + + test('compiles a list template', () { + final List> components = componentsOf( + compiler().compile(r''' +root = Column(_template($/items, item)) +item = Text($label) +'''), + ); + + expect(components.first['children'], { + 'componentId': 'item', + 'path': '/items', + }); + }); + + test('compiles events with a context map', () { + final List> components = componentsOf( + compiler().compile(r''' +root = Button(label, Event("save", {rep: $/form/rep, force: true})) +label = Text("Save") +'''), + ); + + expect(components.first['action'], { + 'event': { + 'name': 'save', + 'context': { + 'rep': {'path': '/form/rep'}, + 'force': true, + }, + }, + }); + }); + + test('compiles a catalog function call', () { + final List> components = componentsOf( + compiler().compile(r'root = Text(capitalize($/name))'), + ); + + expect(components.single['text'], { + 'call': 'capitalize', + 'args': { + 'value': {'path': '/name'}, + }, + 'returnType': 'string', + }); + }); + + test('compiles checks, taking the last argument as the message', () { + final List> components = componentsOf( + compiler().compile(r''' +root = TextField("Name", [?capitalize($/name, "Must be capitalized")]) +'''), + ); + + expect(components.single['checks'], [ + { + 'condition': { + 'call': 'capitalize', + 'args': { + 'value': {'path': '/name'}, + }, + 'returnType': 'boolean', + }, + 'message': 'Must be capitalized', + }, + ]); + }); + + test('supplies a default message for a bare check', () { + final List> components = componentsOf( + compiler().compile(r'root = TextField("Name", [?capitalize($/name)])'), + ); + + expect( + (components.single['checks'] as List).single, + containsPair('message', 'Failed check: capitalize'), + ); + }); + + test('emits an updateDataModel per data assignment', () { + final List messages = compiler().compile(r''' +$/title = "Enable notifications" +$/user = {firstName: "Alice", age: 30} +'''); + + expect(messages.first, isA()); + final List updates = messages + .whereType() + .toList(); + expect(updates, hasLength(2)); + expect(updates.first.path, '/title'); + expect(updates.first.value, 'Enable notifications'); + expect(updates[1].value, {'firstName': 'Alice', 'age': 30}); + }); + + test('emits no components when a block only assigns data', () { + final List messages = compiler().compile( + r'$/title = "Only data"', + ); + + expect(messages.whereType(), isEmpty); + }); + + test('uses the default surface when none is named', () { + final List messages = compiler().compile( + 'root = Text("Hi")', + ); + + expect( + (messages.first as CreateSurfaceMessage).surfaceId, + expressDefaultSurfaceId, + ); + }); + + test('takes the catalog id from surface()', () { + final List messages = compiler().compile( + 'surface("s", "custom-catalog")\nroot = Text("Hi")', + ); + + expect( + (messages.first as CreateSurfaceMessage).catalogId, + 'custom-catalog', + ); + }); + + test('skips createSurface for a surface the renderer already has', () { + final compiler = ExpressCompiler( + catalogs: catalogs, + existingSurfaceIds: const {'s1'}, + ); + + final List messages = compiler.compile( + 'surface("s1")\nroot = Text("Hi")', + ); + + expect(messages.whereType(), isEmpty); + expect(messages.single, isA()); + }); + + test('compiles deleteSurface', () { + final List messages = compiler().compile( + 'deleteSurface("gone")', + ); + + expect((messages.single as DeleteSurfaceMessage).surfaceId, 'gone'); + }); + + test('creates one surface across several statement batches', () { + final ExpressCompiler session = compiler(); + + final List first = session.compile( + 'root = Text("a")', + ); + final List second = session.compile( + 'b = Text("b")', + ); + + expect(first.whereType(), hasLength(1)); + expect(second.whereType(), isEmpty); + }); + + test('gives generated ids a fresh name when one is taken', () { + final List> components = componentsOf( + compiler().compile(''' +root_children0 = Text("taken") +root = Column([Text("inline")]) +'''), + ); + + expect(components.map((component) => component['id']).toList(), [ + 'root_children0', + 'root', + 'root_children02', + ]); + }); + }); + + group('errors', () { + test('rejects an unknown component', () { + expect( + () => compiler().compile('root = Carousel()'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains("Unknown component 'Carousel'"), + ), + ), + ); + }); + + test('rejects a missing required argument', () { + expect( + () => compiler().compile('root = Text()'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains("missing required argument 'text'"), + ), + ), + ); + }); + + test('rejects too many positional arguments', () { + expect( + () => compiler().compile('root = Text("a", "h1", "extra")'), + throwsA(isA()), + ); + }); + + test('rejects an unknown parameter name', () { + expect( + () => compiler().compile('root = Text("a", nope="x")'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains("no parameter 'nope'"), + ), + ), + ); + }); + + test('reports the line a failure happened on', () { + expect( + () => compiler().compile('root = Text("a")\nbad = Nope()'), + throwsA( + isA().having((error) => error.line, 'line', 2), + ), + ); + }); + + test('rejects a standalone call that needs the v1.0 RPC envelope', () { + expect( + () => compiler().compile('openUrl("https://example.com")'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('callFunction'), + ), + ), + ); + }); + + test('rejects assigning a non-component to a variable', () { + expect( + () => compiler().compile('root = "just text"'), + throwsA(isA()), + ); + }); + + test('rejects compiling without a catalog', () { + expect( + () => ExpressCompiler(catalogs: const []).compile('root = Text("a")'), + throwsA(isA()), + ); + }); + + test('rejects unbalanced syntax', () { + expect( + () => compiler().compile('root = Text("a"'), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/a2ui_agent/test/express_decompiler_test.dart b/packages/a2ui_agent/test/express_decompiler_test.dart new file mode 100644 index 000000000..df26240c1 --- /dev/null +++ b/packages/a2ui_agent/test/express_decompiler_test.dart @@ -0,0 +1,242 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + final catalogs = >[MinimalCatalog()]; + final decompiler = ExpressDecompiler(catalogs: catalogs); + + ExpressCompiler compiler() => ExpressCompiler(catalogs: catalogs); + + /// Compiles [source], decompiles the result, and compiles it again. + /// + /// A format that cannot survive this loses information every time an agent + /// turns an example payload into a prompt. + void expectRoundTrip(String source) { + final List first = compiler().compile(source); + final String express = decompiler.decompile(first); + final List second = compiler().compile(express); + + expect( + second.map((message) => message.toJson()).toList(), + first.map((message) => message.toJson()).toList(), + reason: 'decompiled to:\n$express', + ); + } + + group('decompile', () { + test('renders a surface directive', () { + expect( + decompiler.decompile([ + CreateSurfaceMessage(surfaceId: 's', catalogId: 'cat'), + ]), + 'surface("s", "cat")', + ); + }); + + test('renders deleteSurface', () { + expect( + decompiler.decompile([DeleteSurfaceMessage(surfaceId: 's')]), + 'deleteSurface("s")', + ); + }); + + test('renders a data assignment', () { + expect( + decompiler.decompile([ + UpdateDataModelMessage( + surfaceId: 's', + path: '/title', + value: 'Hello', + ), + ]), + r'$/title = "Hello"', + ); + }); + + test('renders positional arguments and drops trailing gaps', () { + expect( + decompiler.decompile([ + UpdateComponentsMessage( + surfaceId: 's', + components: [ + {'id': 'a', 'component': 'Text', 'text': 'Hi'}, + ], + ), + ]), + 'a = Text("Hi")', + ); + }); + + test('uses an underscore for a skipped middle argument', () { + expect( + decompiler.decompile([ + UpdateComponentsMessage( + surfaceId: 's', + components: [ + { + 'id': 'a', + 'component': 'Column', + 'children': ['b'], + 'align': 'center', + }, + ], + ), + ]), + 'a = Column([b], _, "center")', + ); + }); + + test('renders a data binding', () { + expect( + decompiler.decompile([ + UpdateComponentsMessage( + surfaceId: 's', + components: [ + { + 'id': 'a', + 'component': 'Text', + 'text': {'path': '/name'}, + }, + ], + ), + ]), + r'a = Text($/name)', + ); + }); + + test('renders a list template', () { + expect( + decompiler.decompile([ + UpdateComponentsMessage( + surfaceId: 's', + components: [ + { + 'id': 'a', + 'component': 'Column', + 'children': {'componentId': 'item', 'path': '/items'}, + }, + ], + ), + ]), + r'a = Column(_template($/items, item))', + ); + }); + + test('prefers a raw string when the value contains backslashes', () { + expect( + decompiler.decompile([ + UpdateComponentsMessage( + surfaceId: 's', + components: [ + { + 'id': 'a', + 'component': 'TextField', + 'label': 'Zip', + 'validationRegexp': r'^\d{5}$', + }, + ], + ), + ]), + r'a = TextField("Zip", _, _, _, r"^\d{5}$")', + ); + }); + + test('rejects an id that is not an Express identifier', () { + expect( + () => decompiler.decompile([ + UpdateComponentsMessage( + surfaceId: 's', + components: [ + {'id': 'not-an-identifier', 'component': 'Text', 'text': 'x'}, + ], + ), + ]), + throwsA(isA()), + ); + }); + + test('rejects a component that is not in the catalog', () { + expect( + () => decompiler.decompile([ + UpdateComponentsMessage( + surfaceId: 's', + components: [ + {'id': 'a', 'component': 'Carousel'}, + ], + ), + ]), + throwsA(isA()), + ); + }); + }); + + group('round trip', () { + test('a layout with named children', () { + expectRoundTrip(''' +surface("s1") +root = Column([title, cta], "spaceBetween", "center") +title = Text("Hello", "h1") +cta = Button(label, Event("go")) +label = Text("Go") +'''); + }); + + test('inline children', () { + expectRoundTrip('root = Column([Text("a"), Row([Text("b")])])'); + }); + + test('data bindings and function calls', () { + expectRoundTrip(r''' +root = Column([bound, formatted]) +bound = Text($/user/name) +formatted = Text(capitalize($/user/name)) +'''); + }); + + test('a list template', () { + expectRoundTrip(r''' +root = Column(_template($/items, item)) +item = Text($label) +'''); + }); + + test('events with context', () { + expectRoundTrip(r''' +root = Button(label, Event("save", {rep: $/form/rep, force: true})) +label = Text("Save") +'''); + }); + + test('validation checks', () { + expectRoundTrip(r''' +root = TextField("Name", [?capitalize($/name, "Must be capitalized")]) +'''); + }); + + test('data assignments', () { + expectRoundTrip(r''' +$/title = "Enable notifications" +$/count = 3 +$/enabled = true +root = Text($/title) +'''); + }); + + test('escaped and raw strings', () { + expectRoundTrip(r''' +root = Column([multiline, pattern]) +multiline = Text("line 1\nline 2") +pattern = TextField("Zip", _, _, _, r"^\d{5}$") +'''); + }); + + test('surface deletion', () { + expectRoundTrip('deleteSurface("gone")'); + }); + }); +} diff --git a/packages/a2ui_agent/test/express_streaming_test.dart b/packages/a2ui_agent/test/express_streaming_test.dart new file mode 100644 index 000000000..33ab16ca8 --- /dev/null +++ b/packages/a2ui_agent/test/express_streaming_test.dart @@ -0,0 +1,164 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + final catalogs = >[MinimalCatalog()]; + + List stream(String response, {int size = 1}) { + final parser = ExpressParser(catalogs: catalogs); + final parts = []; + for (var index = 0; index < response.length; index += size) { + final int end = index + size < response.length + ? index + size + : response.length; + parts.addAll(parser.parseChunk(response.substring(index, end))); + } + parts.addAll(parser.flush()); + return parts; + } + + List messagesOf(List parts) => [ + for (final part in parts) + if (part is A2uiPart) ...part.a2ui, + ]; + + List componentIdsOf(List parts) => [ + for (final message in messagesOf(parts)) + if (message is UpdateComponentsMessage) + for (final Map component in message.components) + component['id'], + ]; + + const response = ''' +Here you go. + +surface("s1") +root = Column([title, cta]) +title = Text("Hello", "h1") +cta = Button(label, Event("go")) +label = Text("Go") + +Anything else?'''; + + group('statement splitting', () { + test('stops at the last complete statement', () { + expect(completeStatementPrefixLength('a = Text("x")\nb = Te'), 14); + }); + + test('does not split inside an open call', () { + expect(completeStatementPrefixLength('a = Column([\n b,\n'), 0); + }); + + test('does not split inside a string', () { + expect(completeStatementPrefixLength('a = Text("line\nbreak'), 0); + }); + + test('treats a semicolon as a boundary', () { + expect(completeStatementPrefixLength('a = Text("x"); b'), 14); + }); + + test('ignores brackets inside comments', () { + expect( + completeStatementPrefixLength('# a ( comment\na = Text("x")\n'), + 28, + ); + }); + }); + + group('streaming', () { + test('produces the same messages as a non-streamed parse', () { + final List streamed = messagesOf( + stream(response), + ); + final List whole = messagesOf( + ExpressParser(catalogs: catalogs).parseResponse(response), + ); + + expect(_components(streamed), _components(whole)); + expect(streamed.whereType(), hasLength(1)); + }); + + test('emits each component exactly once', () { + final List ids = componentIdsOf(stream(response)); + + expect(ids, ['root', 'title', 'cta', 'label']); + }); + + test('emits a statement as soon as its line completes', () { + final parser = ExpressParser(catalogs: catalogs); + + expect(parser.parseChunk('\nroot = Text("a")'), isEmpty); + + final List parts = parser.parseChunk('\n'); + final List messages = [ + for (final part in parts) + if (part is A2uiPart) ...part.a2ui, + ]; + expect(messages.whereType(), hasLength(1)); + expect( + messages.whereType().single.components.single, + containsPair('id', 'root'), + ); + }); + + test('holds back a statement that spans lines until it closes', () { + final parser = ExpressParser(catalogs: catalogs); + + expect( + parser.parseChunk('\nroot = Column([\n Text("a"),\n'), + isEmpty, + ); + expect(parser.parseChunk('])\n'), isNotEmpty); + }); + + test('emits surrounding conversational text', () { + final String text = [ + for (final part in stream(response)) + if (part is TextPart) part.text, + ].join(); + + expect(text.trim(), startsWith('Here you go.')); + expect(text.trim(), endsWith('Anything else?')); + }); + + test('is chunk-size independent', () { + expect(componentIdsOf(stream(response, size: 11)), [ + 'root', + 'title', + 'cta', + 'label', + ]); + expect(componentIdsOf(stream(response, size: 5000)), [ + 'root', + 'title', + 'cta', + 'label', + ]); + }); + + test('drops a statement the model never finished', () { + final List ids = componentIdsOf( + stream('\nroot = Text("ok")\ntitle = Text("trunc'), + ); + + expect(ids, ['root']); + }); + + test('rejects an unknown component mid-stream', () { + expect( + () => stream('\nroot = Carousel()\n'), + throwsA(isA()), + ); + }); + }); +} + +List _components(List messages) => [ + for (final message in messages) + if (message is UpdateComponentsMessage) ...message.components, +]; diff --git a/packages/a2ui_agent/test/primitives_test.dart b/packages/a2ui_agent/test/primitives_test.dart new file mode 100644 index 000000000..b66399afe --- /dev/null +++ b/packages/a2ui_agent/test/primitives_test.dart @@ -0,0 +1,184 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('ProtocolVersion', () { + test('parses both the prefixed and bare forms', () { + expect(ProtocolVersion.tryParse('v0.9'), ProtocolVersion.v09); + expect(ProtocolVersion.tryParse('0.9'), ProtocolVersion.v09); + expect(ProtocolVersion.tryParse('v1.0'), ProtocolVersion.v10); + expect(ProtocolVersion.tryParse('v0.9.1'), ProtocolVersion.v091); + expect(ProtocolVersion.tryParse('v0.8'), ProtocolVersion.v08); + }); + + test('returns null for an unknown version', () { + expect(ProtocolVersion.tryParse('v2.0'), isNull); + expect(ProtocolVersion.tryParse('nonsense'), isNull); + }); + + test('renders its wire value', () { + expect('${ProtocolVersion.v091}', 'v0.9.1'); + expect(ProtocolVersion.current, ProtocolVersion.v09); + }); + }); + + group('response parts', () { + test('text parts compare by content', () { + expect(const TextPart('a'), const TextPart('a')); + expect(const TextPart('a').hashCode, const TextPart('a').hashCode); + expect(const TextPart('a'), isNot(const TextPart('b'))); + }); + + test('raw parts compare by content', () { + expect(const RawA2uiPart('[]'), const RawA2uiPart('[]')); + expect( + const RawA2uiPart('[]').hashCode, + const RawA2uiPart('[]').hashCode, + ); + expect(const RawA2uiPart('[]'), isNot(const RawA2uiPart('[1]'))); + }); + + test('describe themselves without dumping their content', () { + expect(const TextPart('hello').toString(), "TextPart('hello')"); + expect( + TextPart('x' * 60).toString(), + allOf(startsWith('TextPart('), contains('…')), + ); + expect( + const TextPart('two\nlines').toString(), + r"TextPart('two\nlines')", + ); + expect(const RawA2uiPart('[]').toString(), "RawA2uiPart('[]')"); + expect( + const RawResponsePart(TextPart('a'), isFinal: false).toString(), + contains('isFinal: false'), + ); + }); + + test('A2uiPart reports its message count and serializes', () { + final part = A2uiPart([DeleteSurfaceMessage(surfaceId: 's')]); + + expect(part.toString(), 'A2uiPart(1 message(s))'); + expect(part.toJson().single, containsPair('version', 'v0.9')); + }); + }); + + group('errors', () { + test('A2uiFormatError reports the line and source', () { + final error = A2uiFormatError( + 'bad thing', + line: 3, + source: 'root = Nope()', + ); + + expect( + error.toString(), + allOf( + contains('bad thing'), + contains('line 3'), + contains('root = Nope()'), + ), + ); + expect(error.code, 'FORMAT_ERROR'); + }); + + test('A2uiFormatError omits absent details', () { + expect(A2uiFormatError('bare').toString(), isNot(contains('line'))); + }); + + test('A2uiCapabilityError carries its code', () { + expect(A2uiCapabilityError('no overlap').code, 'CAPABILITY_ERROR'); + }); + + test('A2uiValidationIssue names where the problem is', () { + expect( + const A2uiValidationIssue( + 'broken', + surfaceId: 's', + componentId: 'root', + ).toString(), + "broken (in surface 's', component 'root')", + ); + expect(const A2uiValidationIssue('broken').toString(), 'broken'); + }); + }); + + group('statement splitting', () { + test('handles raw and triple-quoted strings', () { + expect(completeStatementPrefixLength(r'a = TextField(r"^\d+$")'), 0); + expect(completeStatementPrefixLength('a = Text("""x\ny""")\n'), 20); + }); + + test('does not treat an identifier ending in r as a raw string', () { + expect(completeStatementPrefixLength('myvar = Text("x")\n'), 18); + }); + + test('waits for an unterminated block comment', () { + expect(completeStatementPrefixLength('/* open\na = Text("x")\n'), 0); + }); + + test('recovers after a closed block comment', () { + expect(completeStatementPrefixLength('/* shut */ a = Text("x")\n'), 25); + }); + + test('ignores an unmatched closing bracket', () { + expect(completeStatementPrefixLength(') a = Text("x")\n'), 16); + }); + }); + + group('Express type hints', () { + test('renders enums as alternatives', () { + expect( + ExpressPromptGenerator.componentSignature(MinimalTextApi()), + 'Text(text: DynamicString, variant?: "h1"|"h2"|"h3"|"h4"|"h5"|' + '"caption"|"body")', + ); + }); + + test('renders common type references by name', () { + expect( + ExpressPromptGenerator.componentSignature(MinimalRowApi()), + startsWith('Row(children: ChildList'), + ); + }); + + test('renders a function signature with its return type', () { + expect( + ExpressPromptGenerator.functionSignature(CapitalizeFunction()), + 'capitalize(value: DynamicString) -> string', + ); + }); + }); + + group('sentinel tokenizer', () { + test('reports whether it is inside a block', () { + final tokenizer = SentinelTokenizer(openTag: '', closeTag: ''); + + expect(tokenizer.inBlock, isFalse); + tokenizer.add('text '); + expect(tokenizer.inBlock, isTrue); + tokenizer.add('body '); + expect(tokenizer.inBlock, isFalse); + }); + + test('flushes an unterminated block', () { + final tokenizer = SentinelTokenizer(openTag: '', closeTag: ''); + + // ' added = tokenizer.add('body().single.content, 'body'); + expect(added.whereType(), isEmpty); + + final List flushed = tokenizer.flush(); + expect(flushed.whereType().single.content, '().single.terminated, isFalse); + expect(tokenizer.inBlock, isFalse); + }); + }); +} diff --git a/packages/a2ui_agent/test/processor_test.dart b/packages/a2ui_agent/test/processor_test.dart new file mode 100644 index 000000000..0150c35c4 --- /dev/null +++ b/packages/a2ui_agent/test/processor_test.dart @@ -0,0 +1,358 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + final String minimalId = MinimalCatalog().id; + + A2uiRendererCapabilities capabilities(List ids) => + A2uiRendererCapabilities(supportedCatalogIds: ids); + + group('A2uiRendererCapabilities', () { + test('parses the version-keyed wire form', () { + final capabilities = A2uiRendererCapabilities.fromJson({ + 'v0.9': { + 'supportedCatalogIds': ['a', 'b'], + }, + }); + + expect(capabilities.supportedCatalogIds, ['a', 'b']); + expect(capabilities.protocolVersion, ProtocolVersion.v09); + }); + + test('parses a bare capabilities object', () { + final capabilities = A2uiRendererCapabilities.fromJson({ + 'supportedCatalogIds': ['a'], + }); + + expect(capabilities.supportedCatalogIds, ['a']); + }); + + test('parses inline catalogs', () { + final capabilities = A2uiRendererCapabilities.fromJson({ + 'v0.9': { + 'supportedCatalogIds': [], + 'inlineCatalogs': [ + {'catalogId': 'inline', 'components': {}}, + ], + }, + }); + + expect(capabilities.inlineCatalogs.single['catalogId'], 'inline'); + }); + + test('round trips through JSON', () { + const capabilities = A2uiRendererCapabilities(supportedCatalogIds: ['a']); + + expect( + A2uiRendererCapabilities.fromJson( + capabilities.toJson(), + ).supportedCatalogIds, + ['a'], + ); + }); + + test('tolerates a malformed payload', () { + final capabilities = A2uiRendererCapabilities.fromJson({ + 'v0.9': {'supportedCatalogIds': 'not a list'}, + }); + + expect(capabilities.supportedCatalogIds, isEmpty); + }); + }); + + group('resolveCatalogs', () { + test('returns the transformed catalogs the renderer supports', () { + final List> active = resolveCatalogs([ + CatalogConfig( + MinimalCatalog(), + transformers: [ + ComponentPruningTransformer(const ['Text']), + ], + ), + ], capabilities([minimalId])); + + expect(active.single.components.keys, ['Text']); + }); + + test('leaves out catalogs the renderer cannot render', () { + final List> active = resolveCatalogs([ + CatalogConfig(MinimalCatalog()), + CatalogConfig(Catalog(id: 'other', components: const [])), + ], capabilities([minimalId])); + + expect(active.map((catalog) => catalog.id), [minimalId]); + }); + + test('throws when nothing is shared', () { + expect( + () => resolveCatalogs([ + CatalogConfig(MinimalCatalog()), + ], capabilities(['something-else'])), + throwsA(isA()), + ); + }); + + test('loads inline catalogs when the agent accepts them', () { + final List> active = resolveCatalogs( + [CatalogConfig(MinimalCatalog())], + A2uiRendererCapabilities( + supportedCatalogIds: [minimalId], + inlineCatalogs: const [ + { + 'catalogId': 'inline', + 'components': { + 'Badge': { + 'allOf': [ + { + 'properties': { + 'label': {'type': 'string'}, + }, + 'required': ['label'], + }, + ], + }, + }, + }, + ], + ), + acceptsInlineCatalogs: true, + ); + + expect(active.map((catalog) => catalog.id), [minimalId, 'inline']); + expect(active[1].components.keys, ['Badge']); + }); + + test('ignores inline catalogs when the agent does not accept them', () { + final List> active = resolveCatalogs( + [CatalogConfig(MinimalCatalog())], + A2uiRendererCapabilities( + supportedCatalogIds: [minimalId], + inlineCatalogs: const [ + {'catalogId': 'inline', 'components': {}}, + ], + ), + ); + + expect(active, hasLength(1)); + }); + }); + + group('A2uiGenerator', () { + test('creates a processor bound to the negotiated catalogs', () { + final generator = A2uiGenerator( + catalogs: [ + CatalogConfig( + MinimalCatalog(), + transformers: [ + ComponentPruningTransformer(const ['Text', 'Column']), + ], + ), + ], + ); + + final A2uiRequestProcessor processor = generator.createProcessor( + capabilities([minimalId]), + ); + + expect(processor.activeCatalogs.single.components.keys, [ + 'Text', + 'Column', + ]); + }); + + test('defaults to the Direct JSON format', () { + final generator = A2uiGenerator( + catalogs: [CatalogConfig(MinimalCatalog())], + ); + + expect( + generator.createProcessor(capabilities([minimalId])).format, + isA(), + ); + }); + + test('accepts a format override per request', () { + final generator = A2uiGenerator( + catalogs: [CatalogConfig(MinimalCatalog())], + ); + + final A2uiRequestProcessor processor = generator.createProcessor( + capabilities([minimalId]), + inferenceFormatFactory: const ExpressFormatFactory(), + ); + + expect(processor.format, isA()); + expect(processor.createParser(), isA()); + }); + + test('rejects an example that uses a pruned component', () { + final generator = A2uiGenerator( + catalogs: [ + CatalogConfig( + MinimalCatalog(), + transformers: [ + ComponentPruningTransformer(const ['Text']), + ], + ), + ], + examples: { + 'uses a button': [ + UpdateComponentsMessage( + surfaceId: 's', + components: [ + {'id': 'root', 'component': 'Button'}, + ], + ), + ], + }, + ); + + expect( + () => generator.createProcessor(capabilities([minimalId])), + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf(contains('uses a button'), contains('Button')), + ), + ), + ); + }); + + test('accepts an example that conforms', () { + final generator = A2uiGenerator( + catalogs: [CatalogConfig(MinimalCatalog())], + examples: { + 'a greeting': [ + CreateSurfaceMessage(surfaceId: 's', catalogId: minimalId), + UpdateComponentsMessage( + surfaceId: 's', + components: [ + {'id': 'root', 'component': 'Text', 'text': 'Hi'}, + ], + ), + ], + }, + ); + + expect( + generator.createProcessor(capabilities([minimalId])).examples, + isNotNull, + ); + }); + + test('reports the catalogs it supports', () { + final generator = A2uiGenerator( + catalogs: [CatalogConfig(MinimalCatalog())], + ); + + expect(generator.supportedCapabilities.supportedCatalogIds, [minimalId]); + }); + }); + + group('A2uiRequestProcessor', () { + A2uiRequestProcessor processor({InferenceFormatFactory? factory}) => + A2uiRequestProcessor( + catalogs: [MinimalCatalog()], + formatFactory: factory, + ); + + test('renders a prompt snippet describing the catalogs', () { + final String snippet = processor().promptSnippet; + + expect(snippet, contains('')); + expect(snippet, contains('')); + expect(snippet, contains('"Text"')); + expect(snippet, contains(MinimalCatalog().id)); + }); + + test('caches the prompt snippet', () { + final A2uiRequestProcessor subject = processor(); + + expect(identical(subject.promptSnippet, subject.promptSnippet), isTrue); + }); + + test('renders Express signatures when that format is used', () { + final String snippet = processor( + factory: const ExpressFormatFactory(), + ).promptSnippet; + + expect(snippet, contains('')); + expect(snippet, contains('Text(text: DynamicString')); + expect(snippet, contains('capitalize(value: DynamicString) -> string')); + }); + + test('renders examples in the prompt', () { + final subject = A2uiRequestProcessor( + catalogs: [MinimalCatalog()], + examples: { + 'a greeting': [ + UpdateComponentsMessage( + surfaceId: 's', + components: [ + {'id': 'root', 'component': 'Text', 'text': 'Hi'}, + ], + ), + ], + }, + ); + + expect(subject.promptSnippet, contains('a greeting')); + expect(subject.promptSnippet, contains('"Hi"')); + }); + + test('gives each turn its own parser', () { + final A2uiRequestProcessor subject = processor(); + + expect( + identical(subject.createParser(), subject.createParser()), + isFalse, + ); + }); + + test('parses and validates a response', () { + final List parts = processor().parseResponse( + 'Sure.[{"version":"v0.9","updateComponents":' + '{"surfaceId":"s","components":[{"id":"root","component":"Text",' + '"text":"Hi"}]}}]', + ); + + expect(parts.first, const TextPart('Sure.')); + expect( + (parts[1] as A2uiPart).a2ui.single, + isA(), + ); + }); + + test('parses a streamed response', () async { + final List parts = await processor() + .parseStream( + Stream.fromIterable([ + '[{"version":"v0.9","deleteSurface":', + '{"surfaceId":"s"}}]', + ]), + ) + .toList(); + + expect( + [ + for (final part in parts) + if (part is A2uiPart) ...part.a2ui, + ].single, + isA(), + ); + }); + + test('exposes the active catalogs as an unmodifiable list', () { + expect( + () => processor().activeCatalogs.add(MinimalCatalog()), + throwsUnsupportedError, + ); + }); + }); +} diff --git a/packages/a2ui_agent/test/validation_test.dart b/packages/a2ui_agent/test/validation_test.dart new file mode 100644 index 000000000..deba2678d --- /dev/null +++ b/packages/a2ui_agent/test/validation_test.dart @@ -0,0 +1,294 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + final validator = A2uiPayloadValidator(catalogs: [MinimalCatalog()]); + final String minimalId = MinimalCatalog().id; + + UpdateComponentsMessage components(List> components) => + UpdateComponentsMessage(surfaceId: 's', components: components); + + List issues( + List messages, { + bool partial = false, + bool checkReferences = false, + }) => validator + .validate(messages, partial: partial, checkReferences: checkReferences) + .map((issue) => issue.toString()) + .toList(); + + group('catalog conformance', () { + test('accepts a conforming payload', () { + expect( + issues([ + CreateSurfaceMessage(surfaceId: 's', catalogId: minimalId), + components([ + { + 'id': 'root', + 'component': 'Column', + 'children': ['a'], + }, + {'id': 'a', 'component': 'Text', 'text': 'Hi', 'variant': 'h1'}, + ]), + ]), + isEmpty, + ); + }); + + test('flags an unknown component', () { + expect( + issues([ + components([ + {'id': 'a', 'component': 'Carousel'}, + ]), + ]), + [contains("Unknown component 'Carousel'")], + ); + }); + + test('flags a missing required property', () { + expect( + issues([ + components([ + {'id': 'a', 'component': 'Text'}, + ]), + ]), + [contains("missing required property 'text'")], + ); + }); + + test('flags a property the component does not declare', () { + expect( + issues([ + components([ + {'id': 'a', 'component': 'Text', 'text': 'Hi', 'colour': 'red'}, + ]), + ]), + [contains("has no property 'colour'")], + ); + }); + + test('flags a surface bound to an inactive catalog', () { + expect( + issues([CreateSurfaceMessage(surfaceId: 's', catalogId: 'other')]), + [contains('not active for this session')], + ); + }); + + test('flags a duplicate component id in one message', () { + expect( + issues([ + components([ + {'id': 'a', 'component': 'Text', 'text': '1'}, + {'id': 'a', 'component': 'Text', 'text': '2'}, + ]), + ]), + [contains('Duplicate component id')], + ); + }); + + test('flags a component with no id', () { + expect( + issues([ + components([ + {'component': 'Text', 'text': 'Hi'}, + ]), + ]), + [contains("missing a string 'id'")], + ); + }); + + test('flags a version the session did not negotiate', () { + expect(issues([DeleteSurfaceMessage(version: 'v0.8', surfaceId: 's')]), [ + contains('negotiated v0.9'), + ]); + }); + }); + + group('partial payloads', () { + test('skips required-property checks while streaming', () { + expect( + issues([ + components([ + {'id': 'a', 'component': 'Text'}, + ]), + ], partial: true), + isEmpty, + ); + }); + + test('still flags an unknown component while streaming', () { + expect( + issues([ + components([ + {'id': 'a', 'component': 'Carousel'}, + ]), + ], partial: true), + [contains('Unknown component')], + ); + }); + }); + + group('references', () { + test('flags a child that is never defined', () { + expect( + issues([ + components([ + { + 'id': 'root', + 'component': 'Column', + 'children': ['missing'], + }, + ]), + ], checkReferences: true), + [contains("references undefined component 'missing'")], + ); + }); + + test('flags a reference cycle', () { + expect( + issues([ + components([ + { + 'id': 'root', + 'component': 'Column', + 'children': ['child'], + }, + { + 'id': 'child', + 'component': 'Column', + 'children': ['root'], + }, + ]), + ], checkReferences: true), + [contains('reference cycle')], + ); + }); + + test('follows a list template reference', () { + expect( + issues([ + components([ + { + 'id': 'root', + 'component': 'Column', + 'children': {'componentId': 'missing', 'path': '/items'}, + }, + ]), + ], checkReferences: true), + [contains("references undefined component 'missing'")], + ); + }); + + test('does not check references by default', () { + expect( + issues([ + components([ + { + 'id': 'root', + 'component': 'Column', + 'children': ['defined-elsewhere'], + }, + ]), + ]), + isEmpty, + ); + }); + }); + + group('JSON pointers', () { + test('accepts well-formed pointers', () { + expect(isValidJsonPointer('/a/b'), isTrue); + expect(isValidJsonPointer(''), isTrue); + expect(isValidJsonPointer('/a~0b/c~1d'), isTrue); + expect(isValidJsonPointer('relative'), isTrue); + }); + + test('rejects a malformed escape', () { + expect(isValidJsonPointer('/a~2b'), isFalse); + expect(isValidJsonPointer('/a~'), isFalse); + }); + + test('flags a malformed data model path', () { + expect( + issues([ + UpdateDataModelMessage(surfaceId: 's', path: '/bad~2path', value: 1), + ]), + [contains('not a valid JSON Pointer')], + ); + }); + + test('flags a malformed data binding', () { + expect( + issues([ + components([ + { + 'id': 'a', + 'component': 'Text', + 'text': {'path': '/bad~9'}, + }, + ]), + ]), + [contains('invalid JSON Pointer')], + ); + }); + }); + + group('validateOrThrow', () { + test('throws with every issue listed', () { + expect( + () => validator.validateOrThrow([ + components([ + {'id': 'a', 'component': 'Carousel'}, + {'id': 'b', 'component': 'Text'}, + ]), + ]), + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf(contains('Carousel'), contains("required property 'text'")), + ), + ), + ); + }); + + test('does not throw for a conforming payload', () { + expect( + () => validator.validateOrThrow([ + components([ + {'id': 'a', 'component': 'Text', 'text': 'Hi'}, + ]), + ]), + returnsNormally, + ); + }); + }); + + group('without catalogs', () { + test('still runs structural checks', () { + const bare = A2uiPayloadValidator(catalogs: []); + + expect( + bare.validate([ + UpdateComponentsMessage( + surfaceId: 's', + components: [ + {'id': 'a', 'component': 'AnythingGoes'}, + ], + ), + ]), + isEmpty, + ); + expect( + bare.validate([DeleteSurfaceMessage(surfaceId: '')]), + hasLength(1), + ); + }); + }); +} From e9bd3bfb0846b88861e4e4b38f1bbd613cd1918e Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 11:53:26 -0700 Subject: [PATCH 17/19] - --- packages/a2ui_agent/README.md | 21 ++++++++++--------- packages/a2ui_agent/test/a2ui_agent_test.dart | 20 ------------------ 2 files changed, 11 insertions(+), 30 deletions(-) delete mode 100644 packages/a2ui_agent/test/a2ui_agent_test.dart diff --git a/packages/a2ui_agent/README.md b/packages/a2ui_agent/README.md index 33c2896e5..b9226340f 100644 --- a/packages/a2ui_agent/README.md +++ b/packages/a2ui_agent/README.md @@ -1,7 +1,8 @@ # A2UI Agent SDK -The agent side of [A2UI](https://a2ui.org) for Dart: everything between "the -model is about to be called" and "the renderer receives A2UI". +The agent side of [A2UI](https://a2ui.org) for Dart: +when agent and renderer are in different processes, this SDK is +what is needed on agent side. It covers catalog management, capability negotiation, prompt engineering, response parsing, payload validation and transport packaging, so an agent can @@ -56,15 +57,15 @@ Run the full walkthrough with The SDK separates single-responsibility primitives from a high-level facade, so that an agent can adopt the whole pipeline or reach past it for one piece. -| Layer | Types | -| --- | --- | -| Application facade | `A2uiGenerator`, `A2uiRequestProcessor`, `CatalogConfig`, catalog providers | +| Layer | Types | +| -------------------- | --------------------------------------------------------------------------------- | +| Application facade | `A2uiGenerator`, `A2uiRequestProcessor`, `CatalogConfig`, catalog providers | | Catalog transformers | `CatalogTransformer`, `ComponentPruningTransformer`, `FunctionPruningTransformer` | -| Inference formats | `InferenceFormat`, `InferenceFormatFactory`, `DirectJsonFormat`, `ExpressFormat` | -| Prompt generation | `PromptGenerator` and the per-format generators | -| Parsing | `Parser`, `RawResponsePart`, `TextPart`, `RawA2uiPart`, `A2uiPart` | -| Validation | `A2uiPayloadValidator` | -| Negotiation | `resolveCatalogs`, `A2uiRendererCapabilities` | +| Inference formats | `InferenceFormat`, `InferenceFormatFactory`, `DirectJsonFormat`, `ExpressFormat` | +| Prompt generation | `PromptGenerator` and the per-format generators | +| Parsing | `Parser`, `RawResponsePart`, `TextPart`, `RawA2uiPart`, `A2uiPart` | +| Validation | `A2uiPayloadValidator` | +| Negotiation | `resolveCatalogs`, `A2uiRendererCapabilities` | ### Catalogs diff --git a/packages/a2ui_agent/test/a2ui_agent_test.dart b/packages/a2ui_agent/test/a2ui_agent_test.dart deleted file mode 100644 index 94bbcf5f8..000000000 --- a/packages/a2ui_agent/test/a2ui_agent_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:a2ui_agent/a2ui_agent.dart'; -import 'package:test/test.dart'; - -void main() { - group('A group of tests', () { - final awesome = Awesome(); - - setUp(() { - // Additional setup goes here. - }); - - test('First Test', () { - expect(awesome.isAwesome, isTrue); - }); - }); -} From 04f21875d35201229cdf3d5cc0706e8068a29fc1 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 13:26:27 -0700 Subject: [PATCH 18/19] Update CHANGELOG.md --- packages/a2ui_agent/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/a2ui_agent/CHANGELOG.md b/packages/a2ui_agent/CHANGELOG.md index 0b024e263..dea40e48d 100644 --- a/packages/a2ui_agent/CHANGELOG.md +++ b/packages/a2ui_agent/CHANGELOG.md @@ -1,3 +1,3 @@ -## 0.0.1-wip +## 0.0.1-wip001 - Initial version. From 442d1a6d396ba33ab7b5a45312c7ebdcd10f9393 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Tue, 11 Aug 2026 13:27:57 -0700 Subject: [PATCH 19/19] Update pubspec.yaml --- packages/a2ui_agent/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/a2ui_agent/pubspec.yaml b/packages/a2ui_agent/pubspec.yaml index 41c816fe5..8a097ae67 100644 --- a/packages/a2ui_agent/pubspec.yaml +++ b/packages/a2ui_agent/pubspec.yaml @@ -4,7 +4,7 @@ name: a2ui_agent description: The A2UI agent SDK. -version: 0.0.1-wip +version: 0.0.1-wip001 repository: https://github.com/flutter/genui/tree/main/packages/a2ui_agent resolution: workspace