Repository navigation
Conversation
…the resolved catalogId A function call whose catalog cannot be resolved is now a CATALOG_ERROR and is not sent as callAgentFunction. The outbound call always names the catalog the call resolved to, and calls that differ only in naming the default catalog share one request. MessageProcessor installs the agent caller only on v1.0 surfaces and only when it has an outbound listener. Signed-off-by: Nan Yu <nanyu@google.com>
There was a problem hiding this comment.
Code Review
This pull request updates the agent function fallback routing to follow the v1.0 specification. Key changes include reporting unresolved catalogs as CATALOG_ERROR instead of sending them to the agent, ensuring outbound calls name the resolved catalog, and sharing requests for pending calls that only differ by the default catalog name. Additionally, MessageProcessor now only provides callAgentFunction for v1.0 surfaces with an outbound listener. Feedback on these changes suggests improving the cache eviction strategy in _AgentCallCache to evict only the oldest alias rather than clearing the entire map, which avoids prematurely discarding active pending aliases.
…ble is full Evict the oldest alias of a settled call instead of clearing the whole table, so isPendingAgentCall keeps finding calls still in flight. Signed-off-by: Nan Yu <nanyu@google.com>
A function call whose argument reads an agent call that is still pending now waits for it instead of running with a null argument, in bindings and when a value is resolved once. An outer agent call is sent only after its agent arguments settle, and a failed argument stops the enclosing call without reporting the failure twice. Signed-off-by: Nan Yu <nanyu@google.com>
…t-function-routing
…cel surface RPCs on delete
… fallback edge cases Signed-off-by: Nan Yu <nanyu@google.com>
…ing' into HEAD Signed-off-by: Nan Yu <nanyu@google.com>
…avoid duplicate INVALID_ACTION Signed-off-by: Nan Yu <nanyu@google.com>
Signed-off-by: Nan Yu <nanyu@google.com>
…ing' into fix/dart-agent-function-routing Signed-off-by: Nan Yu <nanyu@google.com> # Conflicts: # dart/a2ui_core/lib/src/resolution/generic_binder.dart
gspencergoog
left a comment
There was a problem hiding this comment.
This change aligns v1.0 agent function fallback routing in dart/a2ui_core (data_context.dart, generic_binder.dart, processor.dart, and rpc_handler.dart) with the v1.0 specification. Unresolvable catalogs now report CATALOG_ERROR locally, outbound calls carry the resolved catalogId, nested expressions await pending agent arguments, and surface deletion cancels pending RPCs.
The v1.0 catalog routing and signal dependency wiring are solid, but the new imperative action path in resolveActionAsync introduces two high-severity defects: pre-v1.0 failed action calls leak the private _failedArg sentinel into resolved maps and arguments, and _resolveActionFieldsAsync starts all field Futures eagerly before awaiting (leaking unhandled rejections on early returns). Several medium-severity issues also remain around unreachable _AgentCallCache._aliases code, synchronous return contracts, post-await surface deletion guards, RpcErrorCode.cancelled error reporting, and missing changelog notes.
🤖 Agent-generated
| return _evaluateActionCall( | ||
| _asStringKeyedMap(value as Map), | ||
| depth, | ||
| reportTopLevelAgentError: true, | ||
| ); |
There was a problem hiding this comment.
On pre-v1.0 surfaces, _resolveActionValue calls _evaluateActionCall with reportTopLevelAgentError: true, returning _failedArg on failure. Because _containsFailedArg checks require atLeastV10, that private Object() sentinel leaks into resolved context fields and outer arguments. Failed v0.9.1 action calls crash jsonEncode on _failedArg, pass Object() to outer functions, or trigger spurious INVALID_ACTION errors.
| return _evaluateActionCall( | |
| _asStringKeyedMap(value as Map), | |
| depth, | |
| reportTopLevelAgentError: true, | |
| ); | |
| return _evaluateActionCall( | |
| _asStringKeyedMap(value as Map), | |
| depth, | |
| reportTopLevelAgentError: true, | |
| ).then((res) => identical(res, _failedArg) ? null : res); |
🤖 Agent-generated
| FutureOr<Map<String, dynamic>?> _resolveActionFieldsAsync( | ||
| Map<String, dynamic> map, { | ||
| void Function()? onExpressionFailed, | ||
| }) { | ||
| final result = Map<String, dynamic>.from(map); | ||
| final Object? ctx = result['context']; | ||
| final rawContext = <String, Object?>{}; | ||
| var hasFuture = false; | ||
| if (ctx is Map) { | ||
| for (final MapEntry<Object?, Object?> e in ctx.entries) { | ||
| final Object? rawVal = _resolveActionValue( | ||
| e.value, | ||
| 0, | ||
| keepFailed: atLeastV10, | ||
| ); | ||
| if (rawVal is Future) { | ||
| hasFuture = true; | ||
| } else if (atLeastV10 && _containsFailedArg(rawVal)) { | ||
| onExpressionFailed?.call(); | ||
| return null; | ||
| } | ||
| rawContext[e.key.toString()] = rawVal; | ||
| } | ||
| } | ||
| final bool hasUserMessage = result.containsKey('userMessage'); | ||
| Object? rawMsg; | ||
| if (hasUserMessage) { | ||
| rawMsg = _resolveActionValue( | ||
| result['userMessage'], | ||
| 0, | ||
| keepFailed: atLeastV10, | ||
| ); | ||
| if (rawMsg is Future) { | ||
| hasFuture = true; | ||
| } else { | ||
| if (atLeastV10 && _containsFailedArg(rawMsg)) { | ||
| onExpressionFailed?.call(); | ||
| return null; | ||
| } | ||
| if (rawMsg != null && rawMsg is! String) return null; | ||
| } | ||
| } | ||
| if (!hasFuture) { | ||
| result['context'] = rawContext; | ||
| if (hasUserMessage) result['userMessage'] = rawMsg; | ||
| return result; | ||
| } | ||
| return () async { | ||
| final resolvedContext = <String, Object?>{}; | ||
| for (final MapEntry<String, Object?> e in rawContext.entries) { | ||
| final Object? rawVal = e.value; | ||
| final Object? val = rawVal is Future ? await rawVal : rawVal; | ||
| if (atLeastV10 && _containsFailedArg(val)) { | ||
| onExpressionFailed?.call(); | ||
| return null; | ||
| } | ||
| resolvedContext[e.key] = val; | ||
| } | ||
| result['context'] = resolvedContext; | ||
| if (hasUserMessage) { | ||
| final Object? msg = rawMsg is Future ? await rawMsg : rawMsg; | ||
| if (atLeastV10 && _containsFailedArg(msg)) { | ||
| onExpressionFailed?.call(); | ||
| return null; | ||
| } | ||
| if (msg != null && msg is! String) return null; | ||
| result['userMessage'] = msg; | ||
| } | ||
| return result; | ||
| }(); | ||
| } |
There was a problem hiding this comment.
_resolveActionFieldsAsync starts all context and userMessage Futures synchronously before awaiting any result (raised by both reviewers). Early returns on a failed entry, non-string userMessage, or exception abandon sibling Futures without error handlers. Callers without onError hit unhandled async Future exceptions, and invalid actions still dispatch later side-effecting agent calls.
| FutureOr<Map<String, dynamic>?> _resolveActionFieldsAsync( | |
| Map<String, dynamic> map, { | |
| void Function()? onExpressionFailed, | |
| }) { | |
| final result = Map<String, dynamic>.from(map); | |
| final Object? ctx = result['context']; | |
| final rawContext = <String, Object?>{}; | |
| var hasFuture = false; | |
| if (ctx is Map) { | |
| for (final MapEntry<Object?, Object?> e in ctx.entries) { | |
| final Object? rawVal = _resolveActionValue( | |
| e.value, | |
| 0, | |
| keepFailed: atLeastV10, | |
| ); | |
| if (rawVal is Future) { | |
| hasFuture = true; | |
| } else if (atLeastV10 && _containsFailedArg(rawVal)) { | |
| onExpressionFailed?.call(); | |
| return null; | |
| } | |
| rawContext[e.key.toString()] = rawVal; | |
| } | |
| } | |
| final bool hasUserMessage = result.containsKey('userMessage'); | |
| Object? rawMsg; | |
| if (hasUserMessage) { | |
| rawMsg = _resolveActionValue( | |
| result['userMessage'], | |
| 0, | |
| keepFailed: atLeastV10, | |
| ); | |
| if (rawMsg is Future) { | |
| hasFuture = true; | |
| } else { | |
| if (atLeastV10 && _containsFailedArg(rawMsg)) { | |
| onExpressionFailed?.call(); | |
| return null; | |
| } | |
| if (rawMsg != null && rawMsg is! String) return null; | |
| } | |
| } | |
| if (!hasFuture) { | |
| result['context'] = rawContext; | |
| if (hasUserMessage) result['userMessage'] = rawMsg; | |
| return result; | |
| } | |
| return () async { | |
| final resolvedContext = <String, Object?>{}; | |
| for (final MapEntry<String, Object?> e in rawContext.entries) { | |
| final Object? rawVal = e.value; | |
| final Object? val = rawVal is Future ? await rawVal : rawVal; | |
| if (atLeastV10 && _containsFailedArg(val)) { | |
| onExpressionFailed?.call(); | |
| return null; | |
| } | |
| resolvedContext[e.key] = val; | |
| } | |
| result['context'] = resolvedContext; | |
| if (hasUserMessage) { | |
| final Object? msg = rawMsg is Future ? await rawMsg : rawMsg; | |
| if (atLeastV10 && _containsFailedArg(msg)) { | |
| onExpressionFailed?.call(); | |
| return null; | |
| } | |
| if (msg != null && msg is! String) return null; | |
| result['userMessage'] = msg; | |
| } | |
| return result; | |
| }(); | |
| } | |
| FutureOr<Map<String, dynamic>?> _resolveActionFieldsAsync( | |
| Map<String, dynamic> map, { | |
| void Function()? onExpressionFailed, | |
| }) { | |
| final result = Map<String, dynamic>.from(map); | |
| final Object? ctx = result['context']; | |
| final List<MapEntry<Object?, Object?>> entries = | |
| ctx is Map ? ctx.entries.toList() : const []; | |
| final resolved = <String, Object?>{}; | |
| final bool hasMsg = result.containsKey('userMessage'); | |
| bool checkFailed(Object? v) { | |
| if (!atLeastV10 || !_containsFailedArg(v)) return false; | |
| onExpressionFailed?.call(); | |
| return true; | |
| } | |
| Future<Map<String, dynamic>?> finishMsg(Future<dynamic> pending) async { | |
| final Object? msg = await pending; | |
| if (checkFailed(msg) || (msg != null && msg is! String)) return null; | |
| result['context'] = resolved; | |
| result['userMessage'] = msg; | |
| return result; | |
| } | |
| Future<Map<String, dynamic>?> finishCtx(int i, Future<dynamic> cur) async { | |
| for (var j = i; j < entries.length; j++) { | |
| final Object? raw = j == i | |
| ? cur | |
| : _resolveActionValue(entries[j].value, 0, keepFailed: atLeastV10); | |
| final Object? val = raw is Future ? await raw : raw; | |
| if (checkFailed(val)) return null; | |
| resolved[entries[j].key.toString()] = val; | |
| } | |
| if (hasMsg) { | |
| final Object? rawMsg = _resolveActionValue( | |
| result['userMessage'], | |
| 0, | |
| keepFailed: atLeastV10, | |
| ); | |
| if (rawMsg is Future) return finishMsg(rawMsg); | |
| if (checkFailed(rawMsg) || (rawMsg != null && rawMsg is! String)) { | |
| return null; | |
| } | |
| result['userMessage'] = rawMsg; | |
| } | |
| result['context'] = resolved; | |
| return result; | |
| } | |
| for (var i = 0; i < entries.length; i++) { | |
| final Object? raw = | |
| _resolveActionValue(entries[i].value, 0, keepFailed: atLeastV10); | |
| if (raw is Future) return finishCtx(i, raw); | |
| if (checkFailed(raw)) return null; | |
| resolved[entries[i].key.toString()] = raw; | |
| } | |
| if (hasMsg) { | |
| final Object? rawMsg = _resolveActionValue( | |
| result['userMessage'], | |
| 0, | |
| keepFailed: atLeastV10, | |
| ); | |
| if (rawMsg is Future) return finishMsg(rawMsg); | |
| if (checkFailed(rawMsg) || (rawMsg != null && rawMsg is! String)) { | |
| return null; | |
| } | |
| result['userMessage'] = rawMsg; | |
| } | |
| result['context'] = resolved; | |
| return result; | |
| } |
🤖 Agent-generated
| _AgentCall? lookup( | ||
| FunctionCall call, | ||
| String? resolvedCatalogId, | ||
| Map<String, dynamic> args, | ||
| ) { | ||
| if (call.catalogId == null && resolvedCatalogId != null) { | ||
| defaultCatalogId ??= resolvedCatalogId; | ||
| } | ||
| final String writtenKey = DataContext._agentCallKey( | ||
| call.call, | ||
| call.catalogId ?? defaultCatalogId, | ||
| args, | ||
| ); | ||
| if (resolvedCatalogId == null) { | ||
| return entries[_aliases[writtenKey] ?? writtenKey]; | ||
| } | ||
| final String key = | ||
| DataContext._agentCallKey(call.call, resolvedCatalogId, args); | ||
| if (writtenKey != key) { | ||
| if (!_aliases.containsKey(writtenKey) && _aliases.length >= _maxEntries) { | ||
| _evictAlias(); | ||
| } | ||
| _aliases[writtenKey] = key; | ||
| } | ||
| return entries[key]; | ||
| } | ||
|
|
||
| /// Removes one alias to make room for another. | ||
| /// | ||
| /// Prefers the oldest alias whose call is no longer pending, so that | ||
| /// [DataContext.isPendingAgentCall] keeps finding calls still in flight. | ||
| /// Only when every alias points at a pending call is the oldest one | ||
| /// removed. | ||
| void _evictAlias() { | ||
| for (final MapEntry<String, String> alias in _aliases.entries) { | ||
| if (entries[alias.value]?.pending != true) { | ||
| _aliases.remove(alias.key); | ||
| return; | ||
| } | ||
| } | ||
| final String? oldest = _aliases.keys.firstOrNull; | ||
| if (oldest != null) _aliases.remove(oldest); | ||
| } |
There was a problem hiding this comment.
Although _evictAlias was added for prior thread r4408126301, lookup assigns defaultCatalogId before computing writtenKey (call.catalogId ?? defaultCatalogId). On every SurfaceModel and single-catalog DataContext, writtenKey equals key, leaving _aliases empty across all 301 calls in agent_function_fallback_test.dart:660. Unreachable alias-eviction logic leaves dead code in _AgentCallCache and makes the 300-call alias test pass solely through evictIfFull().
- final Map<String, String> _aliases = {};
...
bool hasFailed(FunctionCall call, Map<String, dynamic> args) {
final String writtenKey = DataContext._agentCallKey(
call.call,
call.catalogId ?? defaultCatalogId,
args,
);
- return failed.contains(_aliases[writtenKey] ?? writtenKey);
+ return failed.contains(writtenKey);
}
...
_AgentCall? lookup(
FunctionCall call,
String? resolvedCatalogId,
Map<String, dynamic> args,
) {
if (call.catalogId == null && resolvedCatalogId != null) {
defaultCatalogId ??= resolvedCatalogId;
}
- final String writtenKey = DataContext._agentCallKey(
+ final String key = DataContext._agentCallKey(
call.call,
- call.catalogId ?? defaultCatalogId,
+ resolvedCatalogId ?? call.catalogId ?? defaultCatalogId,
args,
);
- if (resolvedCatalogId == null) {
- return entries[_aliases[writtenKey] ?? writtenKey];
- }
- final String key =
- DataContext._agentCallKey(call.call, resolvedCatalogId, args);
- if (writtenKey != key) {
- if (!_aliases.containsKey(writtenKey) && _aliases.length >= _maxEntries) {
- _evictAlias();
- }
- _aliases[writtenKey] = key;
- }
return entries[key];
}
-
- void _evictAlias() {
- ...
- }🤖 Agent-generated
| return () async { | ||
| final result = <String, dynamic>{}; | ||
| for (final MapEntry<String, dynamic> entry in stringMap.entries) { | ||
| final String keyStr = entry.key; | ||
| final String unescapedKey = | ||
| keyStr.startsWith('@@') ? keyStr.substring(1) : keyStr; | ||
| final Object? val = _resolveActionValue( | ||
| entry.value, | ||
| depth + 1, | ||
| keepFailed: keepFailed, | ||
| ); | ||
| result[unescapedKey] = val is Future ? await val : val; | ||
| } | ||
| return result; | ||
| }(); |
There was a problem hiding this comment.
resolveActionAsync promises synchronous returns without nested Futures, yet _resolveActionValue wraps every nested Map and List containing bindings in async closures (raised by both reviewers). It also leaves [onExpressionFailed] undocumented on the public DataContext API. Synchronous @path bindings inside nested action maps or lists still return a Future, deferring GenericBinder dispatch across a microtask.
| return () async { | |
| final result = <String, dynamic>{}; | |
| for (final MapEntry<String, dynamic> entry in stringMap.entries) { | |
| final String keyStr = entry.key; | |
| final String unescapedKey = | |
| keyStr.startsWith('@@') ? keyStr.substring(1) : keyStr; | |
| final Object? val = _resolveActionValue( | |
| entry.value, | |
| depth + 1, | |
| keepFailed: keepFailed, | |
| ); | |
| result[unescapedKey] = val is Future ? await val : val; | |
| } | |
| return result; | |
| }(); | |
| final List<MapEntry<String, dynamic>> entries = | |
| stringMap.entries.toList(); | |
| final result = <String, dynamic>{}; | |
| for (var i = 0; i < entries.length; i++) { | |
| final String keyStr = entries[i].key; | |
| final String outKey = | |
| keyStr.startsWith('@@') ? keyStr.substring(1) : keyStr; | |
| final Object? val = _resolveActionValue( | |
| entries[i].value, | |
| depth + 1, | |
| keepFailed: keepFailed, | |
| ); | |
| if (val is Future) { | |
| return () async { | |
| result[outKey] = await val; | |
| for (var j = i + 1; j < entries.length; j++) { | |
| final String k = entries[j].key; | |
| final Object? next = _resolveActionValue( | |
| entries[j].value, | |
| depth + 1, | |
| keepFailed: keepFailed, | |
| ); | |
| result[k.startsWith('@@') ? k.substring(1) : k] = | |
| next is Future ? await next : next; | |
| } | |
| return result; | |
| }(); | |
| } | |
| result[outKey] = val; | |
| } | |
| return result; |
🤖 Agent-generated
| callAgentFunction: _outboundListener != null && | ||
| operation.version.isAtLeast(A2uiProtocolVersion.v1_0) | ||
| ? (call) => rpc.callAgentFunction(operation.surfaceId, call) | ||
| : null, |
There was a problem hiding this comment.
_createSurface forwards callAgentFunction straight to rpc.callAgentFunction without checking whether the surface still exists in model. An action suspended awaiting a local Future argument during DeleteSurfaceOp resumes after cancelSurfaceCalls and sends a new RPC. Resumed calls still send callAgentFunction messages for deleted surfaces and leave active timeout timers in RpcHandler.
| callAgentFunction: _outboundListener != null && | |
| operation.version.isAtLeast(A2uiProtocolVersion.v1_0) | |
| ? (call) => rpc.callAgentFunction(operation.surfaceId, call) | |
| : null, | |
| callAgentFunction: _outboundListener != null && | |
| operation.version.isAtLeast(A2uiProtocolVersion.v1_0) | |
| ? (call) { | |
| if (model.getSurface(operation.surfaceId) == null) { | |
| return Future<Object?>.error( | |
| A2uiRpcError( | |
| "Surface '${operation.surfaceId}' was deleted.", | |
| RpcErrorCode.cancelled, | |
| ), | |
| ); | |
| } | |
| return rpc.callAgentFunction(operation.surfaceId, call); | |
| } | |
| : null, |
🤖 Agent-generated
| if (!reportTopLevelAgentError) rethrow; | ||
| if (_onError == null) rethrow; | ||
| _report(error, call.call); | ||
| return _failedArg; |
There was a problem hiding this comment.
_evaluateActionCall, _agentCall, and GenericBinder._runLocalFunction report RpcErrorCode.cancelled rejections as EXPRESSION_ERROR. Default deleteSurface hides this only because model.deleteSurface disposes the surface right before rpc.cancelSurfaceCalls runs. Custom ComponentContext.onError callbacks receive spurious EXPRESSION_ERROR reports during teardown, and calling cancelSurfaceCalls on a live surface emits client errors.
| if (!reportTopLevelAgentError) rethrow; | |
| if (_onError == null) rethrow; | |
| _report(error, call.call); | |
| return _failedArg; | |
| if (!reportTopLevelAgentError) rethrow; | |
| if (error is A2uiRpcError && error.rpcCode == RpcErrorCode.cancelled) { | |
| return _failedArg; | |
| } | |
| if (_onError == null) rethrow; | |
| _report(error, call.call); | |
| return _failedArg; |
🤖 Agent-generated
| _agentCalls.markFailed(key); | ||
| _agentCalls.version.value++; | ||
| }); | ||
| _agentCalls.entries.remove(key); |
There was a problem hiding this comment.
In onError, _agentCalls.entries.remove(key) runs after batch(...) finishes flushing subscribed effects. Any effect listener that calls resolveDynamicValue during that synchronous flush hits _agentCalls.lookup and gets the failed entry back. Synchronous retries triggered inside reactive listeners return null instead of re-dispatching the evicted agent call.
onError: (Object error) {
batch(() {
+ _agentCalls.entries.remove(key);
entry.pending = false;
entry.failed = true;
entry.version.value++;
_agentCalls.markFailed(key);
_agentCalls.version.value++;
});
- _agentCalls.entries.remove(key);
_report(error, call.call);
},🤖 Agent-generated
| - A function call naming a `catalogId` on a `DataContext` created without | ||
| `invokerForCatalog` is now reported as a `CATALOG_ERROR` rather than an | ||
| `EXPRESSION_ERROR`. |
There was a problem hiding this comment.
The ## Unreleased notes omit the newly exported public methods DataContext.resolveActionAsync and RpcHandler.cancelSurfaceCalls. They also leave out async event action resolution, surface disposal in MessageProcessor.dispose(), and call cancellation on deleteSurface. Package consumers reading the changelog miss new public APIs and surface teardown behavior introduced in this change.
| - A function call naming a `catalogId` on a `DataContext` created without | |
| `invokerForCatalog` is now reported as a `CATALOG_ERROR` rather than an | |
| `EXPRESSION_ERROR`. | |
| - A function call naming a `catalogId` on a `DataContext` created without | |
| `invokerForCatalog` is now reported as a `CATALOG_ERROR` rather than an | |
| `EXPRESSION_ERROR`. | |
| - Added `RpcHandler.cancelSurfaceCalls` and `DataContext.resolveActionAsync`, | |
| resolved `action.event` fields asynchronously when arguments call the agent, | |
| cancelled pending surface RPCs on `deleteSurface`, and disposed surfaces in | |
| `MessageProcessor.dispose()`. |
🤖 Agent-generated
| expect(agentCalls, isEmpty); | ||
| await _flush(); | ||
| expect(clientErrors.single.code, 'CATALOG_ERROR'); | ||
| }); |
There was a problem hiding this comment.
No unit test exercises a non-system function call with catalogId on a DataContext created without invokerForCatalog. Existing tests in catalog_resolution_test.dart only cover @index, which exits before _invokeCall. Re-adding functionName would emit EXPRESSION_ERROR and forward unresolvable catalog calls to callAgentFunction without failing tests.
| }); | |
| }); | |
| test( | |
| 'a catalogId on a DataContext without invokerForCatalog is a ' | |
| 'CATALOG_ERROR', () { | |
| final errors = <A2uiExpressionError>[]; | |
| final context = DataContext( | |
| DataModel(), | |
| _catalog().invoke, | |
| '/', | |
| onError: errors.add, | |
| protocolVersion: A2uiProtocolVersion.v1_0, | |
| callAgentFunction: callAgent, | |
| ); | |
| expect( | |
| context.resolveDynamicValue({ | |
| '@call': 'remote', | |
| 'catalogId': 'elsewhere', | |
| 'args': <String, Object?>{}, | |
| }), | |
| isNull, | |
| ); | |
| expect(agentCalls, isEmpty); | |
| expect(errors.single.code, 'CATALOG_ERROR'); | |
| }); |
🤖 Agent-generated
Stacked on #3735. Review only the top commit (
fix(dart-core): ...).Part of #3536.
Description
Dart already routes a v1.0 function call it cannot run to the agent with
callAgentFunction. This PR aligns it with the routing rules in #3735 and the clarified protocol doc:Only a function miss is routed. Before, any
A2uiCatalogResolutionErrorwent to the agent, including an unknowncatalogIdand a surface with no default catalog. Those are now reported asCATALOG_ERRORand are not sent. A call is routed only when its catalog resolved and the function is missing from it.The outbound call names the resolved catalog. Before, it carried the payload's
catalogId, which is empty when the binding relies on the surface default. It now always carries the catalog the call resolved to, as agreed on [v1.0][spec] No way to declare agent-hosted functions #3536 (comment). In-flight sharing is keyed on the resolved catalog, so a call with and without the defaultcatalogIdshares one request and one pending state.The caller is installed only when the agent is reachable.
SurfaceModel.callAgentFunctionis now set only on v1.0 surfaces of a processor with anoutboundListener. Before, it was always set, so a processor without a listener routed calls and failed withNO_LISTENER.A call waits for a pending agent argument. Before, a function whose argument read an agent call still in flight ran with that argument null (a local function), or was sent with it null (an agent call). It now waits for the argument to settle, in bindings and when a value is resolved once. A failed argument stops the enclosing call, and the failure is reported once.
Pending aliases survive a full table. When the alias table is full, the oldest alias of a settled call is evicted instead of clearing the table.
Schema-only functions do not apply to Dart: surface catalogs are typed
Catalog<T, FunctionImplementation>, so every function on a surface has an implementation.Testing
agent_function_fallback_test.dart: an unknowncatalogIdand a missing default catalog areCATALOG_ERRORand nothing is sent (binding andevaluateFunctionCall); an explicitcatalogIdis carried; the default catalog id is filled in (binding and action); calls with and without the defaultcatalogIdshare one request; a@system function is never sent.processor_rpc_test.dart: an end-to-end binding sends{"@call", "catalogId", "args"}and takes the agent's value; no listener means no caller; a v0.9 surface has no caller.dart analyzeis clean,dart testfor a2ui_core passes (1897 passed, 7 skipped), andflutter testfor a2ui_flutter passes (382).