Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions dart/a2ui_agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@
round-trip without merging into sibling keys, accepts numeric path segments in
`updateDataModel` (such as `/items/0`), and sorts map keys when flattening
surface-creation data models into leaf assignments.
- Breaking: `Event(...)` in Express no longer accepts the event name as a
`name=` keyword argument; pass it positionally, as the TypeScript and Python
compilers require. A keyword `Event` does not define now throws
`A2uiValidationError`.
- `ExpressCompiler` accepts `userMessage=` and `context=` keyword arguments on
`Event(...)`, compiling `userMessage` to the v1.0 `event.userMessage` field,
and throws `A2uiValidationError` for `userMessage` before v1.0 and for any
keyword `Event` does not define, where it used to drop them. A function call
Comment thread
ditman marked this conversation as resolved.
in an action slot nested inside a list item or object property (such as
`items: [{label, action: openUrl(...)}]`) is now compiled as a
`functionCall` action. `ExpressDecompiler` writes `userMessage=` back
([#3598](https://github.com/a2ui-project/a2ui/issues/3598)).
- `DirectJsonParser.unwrap`, `DirectJsonResponseStream`, and
`ExpressParser.unwrap` remove the backticks left at the edges of a block when
a model writes the tags as inline code (`` `<a2ui>` ... `</a2ui>` `` or
Expand Down
160 changes: 138 additions & 22 deletions dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import 'dart:convert';
import 'package:a2ui_core/a2ui_core.dart';

import '../../utils/catalogs.dart';
Expand All @@ -30,6 +31,9 @@ String defaultCheckMessage(String check) =>
/// The helpers that belong to no catalog.
const Set<String> _reservedHelpers = {'Event', '_template'};

/// The keyword arguments the reserved `Event(...)` signature accepts.
const List<String> _eventKeywords = ['userMessage', 'context'];
Comment thread
ditman marked this conversation as resolved.

/// Compiles Express blocks into A2UI messages of the protocol version the
/// catalogs target.
///
Expand Down Expand Up @@ -311,13 +315,37 @@ class _StandaloneCall {
final _Surface? surface;
}

/// The catalog schema declared at a value's position, with the helper whose
/// catalog declares it.
class _Slot {
const _Slot(this.schema, this.helper);

final Map<String, Object?> schema;
final CatalogSchemaHelper helper;

/// Whether the slot takes an action.
bool get holdsAction => isAction(schema);

/// The slot of the items of an array, or null when the schema declares
/// none.
_Slot? get item => _of(helper.resolveItemSchema(schema));

/// The slot of the property [name] of an object, whatever the property is
/// named, or null when the schema declares none.
_Slot? property(String name) =>
_of(helper.resolvePropertySchema(schema, name));

_Slot? _of(Map<String, Object?>? found) =>
found == null ? null : _Slot(found, helper);
}

/// Where a value is being compiled.
class _Context {
_Context({
required this.idPrefix,
required this.hoisted,
this.isAction = false,
this.valueBinding,
this.slot,
});

/// The id a component written inline here takes.
Expand All @@ -326,17 +354,32 @@ class _Context {
/// Components written inline, to emit after their parent.
final List<Map<String, Object?>> hoisted;

/// Whether the value fills an action slot.
final bool isAction;
/// Whether the value fills an action slot, which its schema says.
bool get isAction => slot?.holdsAction ?? false;

/// The binding of the enclosing component's `value`, which a check tests.
final Map<String, Object?>? valueBinding;

/// The schema declared at this position, when known. Array items and map
/// entries resolve their own sub-schema from it, so an action slot nested
/// inside a list item (such as `items: [{label, action}]`) is compiled as
/// an action too.
final _Slot? slot;

_Context child(Object suffix) => _Context(
idPrefix: '${idPrefix}_$suffix',
hoisted: hoisted,
valueBinding: valueBinding,
);

/// The context of the value at [suffix] inside this one, in [slot]: an
/// array item's or a map entry's.
_Context at(Object suffix, _Slot? slot) => _Context(
idPrefix: '${idPrefix}_$suffix',
hoisted: hoisted,
valueBinding: valueBinding,
slot: slot,
);
}

class _SurfaceCompiler {
Expand Down Expand Up @@ -748,7 +791,7 @@ class _SurfaceCompiler {
_Context(
idPrefix: '${id}_$property',
hoisted: hoisted,
isAction: isAction(schema),
slot: schema == null ? null : _Slot(schema, helper),
),
);
if (value is List && expectsOptionObjects(schema)) {
Expand Down Expand Up @@ -897,13 +940,13 @@ class _SurfaceCompiler {
SkippedNode() => throw A2uiValidationError(
"'_' can only stand in for an argument.",
),
ArrayNode(:final List<ExpressNode> items) => [
for (final (int index, ExpressNode item) in items.indexed)
_value(item, context.child(index)),
],
ArrayNode(:final List<ExpressNode> items) => _list(items, context),
MapNode(:final Map<String, ExpressNode> entries) => {
for (final MapEntry<String, ExpressNode> entry in entries.entries)
entry.key: _value(entry.value, context.child(entry.key)),
entry.key: _value(
entry.value,
context.at(entry.key, context.slot?.property(entry.key)),
),
},
VariableNode(:final String name) => _variable(name, context),
CheckNode() => _check(node, context).$1,
Expand Down Expand Up @@ -1052,20 +1095,77 @@ class _SurfaceCompiler {
};
}

/// `Event(name, context?)`: an action sent to the agent.
/// The values of an array, each in the slot of the array's items, which is
/// resolved once since every item shares it.
List<Object?> _list(List<ExpressNode> items, _Context context) {
final _Slot? itemSlot = context.slot?.item;
return [
for (final (int index, ExpressNode item) in items.indexed)
_value(item, context.at(index, itemSlot)),
];
}

/// `Event(name, context?, userMessage=?, context=?)`: an action sent to
/// the agent.
///
/// `userMessage` is a v1.0 field of the Action, so before v1.0 it is
/// rejected, as is any keyword the signature does not define, rather than
/// dropped from the compiled action.
Map<String, Object?> _event(CallNode call, _Context context) {
ExpressNode? argument(int index, String name) =>
call.kwargs[name] ??
(index < call.args.length ? call.args[index] : null);
if (call.args.length > 2) {
throw A2uiValidationError(
'Event takes at most 2 positional arguments (name, context), but '
'${call.args.length} are given.',
);
}
for (final String key in call.kwargs.keys) {
if (!_eventKeywords.contains(key)) {
throw A2uiValidationError(
"Event has no keyword parameter '$key'. Its keyword parameters "
'are: ${_eventKeywords.join(', ')}.',
);
}
}
Comment thread
ditman marked this conversation as resolved.
if (call.args.length == 2 && call.kwargs.containsKey('context')) {
throw A2uiValidationError("Parameter 'context' of Event is given twice.");
}

final ExpressNode? nameNode = argument(0, 'name');
final ExpressNode? nameNode = call.args.firstOrNull;
final Object? eventName = nameNode == null
? null
: _value(nameNode, context.child('name'));
if (eventName is! String) {
throw A2uiValidationError('Event needs a name, such as Event("submit").');
}
final ExpressNode? contextNode = argument(1, 'context');
final event = <String, Object?>{'name': eventName};

if (call.kwargs['userMessage'] case final ExpressNode userMessageNode) {
if (!_v1) {
throw A2uiValidationError(
'Event userMessage requires protocol v1.0 or later; the target '
'version ${compiler._version.jsonValue} has no userMessage field.',
);
}
final Object? userMessage = _value(
userMessageNode,
context.child('userMessage'),
);
// `userMessage` is a DynamicString: a string, a data binding or a
// function call.
if (userMessage is! String &&
!_isBinding(userMessage) &&
!_isCall(userMessage)) {
throw A2uiValidationError(
'Event userMessage must be a string, a \$path data binding or a '
'function call, got ${_describe(userMessageNode)}.',
);
}
event['userMessage'] = userMessage;
}

final ExpressNode? contextNode = call.args.length == 2
? call.args[1]
: call.kwargs['context'];
final Object? eventContext = contextNode == null
? null
: _value(contextNode, context.child('context'));
Expand All @@ -1075,13 +1175,10 @@ class _SurfaceCompiler {
'Event("submit", {id: \$/item/id}).',
);
}
return {
'event': {
'name': eventName,
if (eventContext is Map && eventContext.isNotEmpty)
'context': eventContext,
},
};
if (eventContext is Map && eventContext.isNotEmpty) {
event['context'] = eventContext;
}
return {'event': event};
}

/// [id], the id an inline component takes from where it is written, such
Expand All @@ -1102,6 +1199,25 @@ class _SurfaceCompiler {

bool _isBinding(Object? value) =>
value is Map && value.length == 1 && value[_pathKey] is String;

bool _isCall(Object? value) => value is Map && value[_callKey] is String;

/// Names a parsed value for an error message in the terms the model wrote
/// it in, rather than as the node's type.
String _describe(ExpressNode node) => switch (node) {
CallNode(:final String name) => 'a call to $name(...)',
CheckNode(:final String name) => 'a check (?$name)',
VariableNode(:final String name) => 'the variable $name',
PathNode(:final String path) => 'the data binding \$$path',
SkippedNode() => "'_'",
MapNode() => 'a map literal',
ArrayNode() => 'a list',
LiteralNode(value: null) => 'null',
LiteralNode(value: final bool value) => 'the boolean $value',
LiteralNode(value: final num value) => 'the number ${jsonEncode(value)}',
LiteralNode(value: final String value) => 'the string ${jsonEncode(value)}',
LiteralNode(:final Object? value) => 'a ${value.runtimeType}',
};
}

/// Marks a context in which a component cannot be written.
Expand Down
65 changes: 31 additions & 34 deletions dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,10 @@ class _Component {

String get _id => '${json['id']}';

/// The helper of the component's catalog, set once [write] resolves it,
/// through which nested schemas are reached.
CatalogSchemaHelper? _helper;

String write() {
final id = json['id']! as String;
final String varName = variables[id]!;
Expand All @@ -486,6 +490,7 @@ class _Component {
component: true,
subject: "Component '$id'",
);
_helper = helper;
final List<String> properties = helper.properties(type);
for (final String key in json.keys) {
if (key == 'id' || key == 'component' || key == 'checks') continue;
Expand Down Expand Up @@ -515,7 +520,7 @@ class _Component {
}
arguments
..addAll(List.filled(skipped, '_'))
..add(_property(value, helper.propertySchema(type, property)));
..add(_value(value, helper.propertySchema(type, property)));
skipped = 0;
}
if (json['checks'] case final Object checks) arguments.add(_checks(checks));
Expand All @@ -524,12 +529,8 @@ class _Component {
return '$varName = $type(${arguments.join(', ')})';
}

String _property(Object value, Map<String, Object?>? schema) {
if (isAction(schema)) return _action(value);
return _value(value, schema);
}

String _value(Object? value, Map<String, Object?>? schema) {
if (isAction(schema) && value != null) return _action(value);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The compiler now finds nested slots with resolveSubschema, which follows $ref, allOf, oneOf and anyOf. The decompiler still reaches nested schemas through _propertySchema and _itemSchema, which read only direct properties and items.

So if a catalog writes the item schema as items: {$ref: '#/$defs/LinkItem'}, the compiler wraps openUrl(...) in functionCall, but decompiling that JSON writes a {functionCall: ...} map literal instead of openUrl(...). Could the decompiler use helper.resolveSubschema as well?

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

_Component now holds CatalogSchemaHelper? _helper and delegates property(name) and item() to _helper?.resolvePropertySchema(_schema, name) and _helper?.resolveItemSchema(_schema). Removed the standalone _propertySchema and _itemSchema helpers, and added a decompiler test where items is a $ref to #/$defs/NavItem with a functionCall action.

if (isComponentId(schema) && value is String) return _reference(value);
if (isChildList(schema)) {
if (value is List && value.every((Object? v) => v is String)) {
Expand All @@ -549,8 +550,11 @@ class _Component {
Map() =>
_binding(value) ??
_call(value) ??
_mapLiteral(value, (key) => _propertySchema(schema, key)),
List() => _list(value, _itemSchema(schema)),
_mapLiteral(
value,
(key) => _helper?.resolvePropertySchema(schema, key),
),
List() => _list(value, _helper?.resolveItemSchema(schema)),
_ => _literal(value),
};
}
Expand All @@ -560,21 +564,33 @@ class _Component {
/// as written.
String _reference(String id) => variables[id] ?? _string(id);

/// `Event(name, context)` for an event, the call itself for a local
/// function, and the map literal otherwise.
/// `Event(name, context?, userMessage=?)` for an event, the call itself
/// for a local function, and the map literal otherwise.
String _action(Object value) {
switch (value) {
case {'event': final Map<Object?, Object?> event} when value.length == 1:
final Object? name = event['name'];
final Object? context = event['context'];
final Object? userMessage = event['userMessage'];
const keys = {'name', 'context', 'userMessage'};
final bool isEvent =
name is String &&
event.keys.every((k) => k == 'name' || k == 'context') &&
(context == null || (context is Map && context.isNotEmpty));
event.keys.every(keys.contains) &&
(context == null || (context is Map && context.isNotEmpty)) &&
// Before v1.0 an event has no userMessage, and the compiler
// rejects `userMessage=`, so the object stays a map literal.
(userMessage == null ||
(_v1 &&
(userMessage is String ||
_binding(_asMap(userMessage)) != null ||
_call(_asMap(userMessage)) != null)));
if (isEvent) {
return context == null
? 'Event(${_string(name)})'
: 'Event(${_string(name)}, ${_value(context, null)})';
final arguments = <String>[
_string(name),
if (context != null) _value(context, null),
if (userMessage != null) 'userMessage=${_value(userMessage, null)}',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This writes userMessage= at any version, but the compiler rejects userMessage before v1.0 (compiler.dart:1110-1115). A v0.9 payload with {event: {name: "x", userMessage: "hi"}} used to decompile to a map literal that recompiled. It now becomes Event("x", userMessage="hi"), which fails to compile at v0.9.

Should isEvent also require _v1 when userMessage is set, so v0.9 falls back to the map literal? That might also let the lowered v0.9 conformance case run instead of being skipped.

🤖 Agent-generated

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

isEvent now requires _v1 when userMessage != null, so in v0.9 an event object carrying userMessage falls back to a map literal instead of emitting Event(..., userMessage=...) that the v0.9 compiler would reject. Added a v0.9 unit test in express_decompiler_test.dart covering this.

(Note: the lowered v0.9 conformance cases in express_conformance_test.dart stay in _v1OnlyCases because their shared expect_contains assertions look for the Event(..., userMessage=...) string, which is only emitted in v1.0.)

];
return 'Event(${arguments.join(', ')})';
}
case {'functionCall': final Map<Object?, Object?> call}
when value.length == 1:
Expand Down Expand Up @@ -856,24 +872,5 @@ bool _equal(Object? a, Object? b) => switch ((a, b)) {
_ => a == b,
};

Map<String, Object?>? _propertySchema(
Map<String, Object?>? schema,
String key,
) {
if (schema == null) return null;
if (schema['properties'] case final Map<Object?, Object?> properties) {
if (properties[key] case final Map<Object?, Object?> property) {
return property.cast<String, Object?>();
}
}
return null;
}

Map<String, Object?>? _itemSchema(Map<String, Object?>? schema) =>
switch (schema?['items']) {
final Map<Object?, Object?> items => items.cast<String, Object?>(),
_ => null,
};

A2uiValidationError _noNotation(String what) =>
A2uiValidationError('$what has no Express notation.');
Loading
Loading