Skip to content

fix(swift-core): route unimplemented v1.0 functions to the agent - #3741

Open
nan-yu wants to merge 1 commit into
mainfrom
fix/swift-agent-function-routing
Open

nan-yu wants to merge 1 commit into
mainfrom
fix/swift-agent-function-routing

Conversation

@nan-yu

@nan-yu nan-yu commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on the Python PR (#3740). Review only the top commit (fix(swift-core): ...).
Fixes #3689. Part of #3536.

Description

The Swift core did not route v1.0 function calls it cannot run to the agent. A binding to an unimplemented function reported "Function not found" even with an outboundListener; only app code calling MessageProcessor.callAgentFunction reached the agent. This PR ports the routing from #3735.

Changes

  1. Agent function caller. AgentFunctionCall (name, resolved catalogID, args) and AgentFunctionCaller are new. SurfaceViewModel.agentFunctionCaller is the hook, and SurfaceViewModel.callAgentFunction(_:) shares one request between identical calls still awaiting a response.
  2. MessageProcessor installs the caller on v1.0 surfaces while outboundListener is set. Since outboundListener is mutable, setting or clearing it updates existing surfaces. agentFunctionTimeoutSeconds defaults to 30.
  3. Schema-only functions. Swift had no way to declare a function without an implementation. SchemaOnlyFunction is new, and FunctionImplementation.hasLocalImplementation (default true) tells them apart.
  4. DataContext routes a call whose catalog resolved but has no local implementation, with the resolved catalog id. For a declared function it enforces requiresUserActivation, applies top-level schema defaults and validates the arguments before sending. A call with a pending nested argument is not sent until that argument settles. An unresolvable catalog stays a catalog error; pre-1.0 surfaces and @ system functions are not routed.
  5. NodeResolver keeps evaluation synchronous: a routed call is .null while pending. Agent calls are tracked per node, and when a response arrives only the nodes that made the call are re-resolved. Results are cached per call, so re-resolving a node for an unrelated change does not re-send a request. Errors and timeouts go through the existing function-error path as EXPRESSION_ERROR. Action function calls run with user activation and are always sent.
  6. callRendererFunction for a schema-only function still returns INVALID_FUNCTION_CALL.
  7. Docs: the ValidationConfig doc comment and a new "Agent-side functions" section in swift/core/README.md.

Testing

Swift is not available on the Linux host used for this change, so it was built and tested in Docker swift:6.1 with a scratch copy of the package that maps Combine to OpenCombine and drops the SwiftUI targets. The repository is unchanged by that setup.

  1. New AgentFunctionRoutingTests (19 tests) mirror the web_core suite, plus check rules, user activation, and installing or removing the caller when outboundListener changes.
  2. A2UICoreTests: 353/353 pass (335 on the base, plus the new tests). A2UIJSONTests: 38/38.
  3. BasicCatalogTests and A2UIConformanceTests have the same Linux-only crashes and failures on the base commit and with this change.
  4. swift-format reports no changes.

Not verified locally: the Apple Combine build, macOS and iOS, and the SwiftUI targets. The new parameters and protocol requirements all have defaults, so those targets should compile unchanged; CI's ios-build covers them.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements agent-side function routing (fallback routing of callAgentFunction) for v1.0 surfaces in the Swift core SDK. It introduces SchemaOnlyFunction to declare functions implemented on the agent, and updates DataContext, NodeResolver, SurfaceViewModel, and MessageProcessor to route calls without local implementations to the agent while managing in-flight requests, argument validation, and timeouts. Comprehensive unit tests have been added to verify this routing behavior. I have no additional feedback to provide as no review comments were submitted.

Comment on lines +1554 to +1559
_ = self.evaluateDynamicValue(
funcCallVal,
basePath: basePath,
index: index,
isUserActivated: true
)

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.

Action functionCall handlers run outside a tree-resolution pass (currentNodeAgentCalls == nil, so tracksNode == false). If an action's functionCall passes a nested agent-routed call as an argument (for example openUrl(url: createCheckoutUrl()) or submitOrder(token: mintToken())), resolveArguments dispatches the inner agent call, sees pendingAgentCallCount increase, and returns nil, so the outer call never executes. When the inner call later settles, settleAgentCall returns early on guard tracksNode ... and discards the value without ever re-evaluating funcCallVal (unlike web_core and Python, which await nested arguments before invoking the outer action function).

Consider driving action functionCall evaluation through a transient per-action settlement loop (or async evaluation helper) so nested agent arguments settle and feed into the outer call.

Details and suggested fix

One lightweight way to keep DataContext synchronous while supporting nested agent arguments in actions is to evaluate funcCallVal against a transient per-invocation FunctionHandler wrapper (or per-action cache on NodeResolver) that records in-flight AgentFunctionCalls for that action click, awaits their settlement via agentFunctionCaller, caches their results for that invocation, and re-runs evaluateDynamicValue until no new pending calls are added (stopping if any inner call fails).

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.

Done in 56b2574. NodeResolver.runActionFunctionCall evaluates the action with user activation and records the agent calls it makes. When the evaluation was deferred because an argument was still pending, it waits for those calls to settle on the surface cache (the same request, never resent) and runs the action again. It stops if any argument failed, and that failure is reported once. Actions with only local functions still run synchronously in one pass. Tests: sendsActionFunctionCallOnceNestedAgentArgumentSettles, stopsActionFunctionCallWhenNestedAgentArgumentFails (a new click retries), and stopsActionFunctionCallWhenNestedArgumentFailsSynchronously.

🤖 Agent-generated

@pinieb pinieb added the lang: swift Affects SDKs or apps in the Swift programming language label Oct 30, 2026
nan-yu added a commit that referenced this pull request Nov 2, 2026
- Event actions resolve context entries one at a time, then userMessage,
  and stop at the first failure without sending later calls. Actions report
  only their first failure; late sibling failures are silent.
- A check waiting on an agent call is pending (ResolvedCheck.isPending,
  Node.isValidationPending) and fails once the call fails.
- Replacing or clearing agentFunctionCaller drops in-flight calls and kept
  answers; answers from the previous caller are ignored.
- CANCELLED calls report nothing. deleteSurface cancels with
  FunctionError.surfaceCallsCancelled ("Pending calls for surface '<id>'
  were cancelled."), and a deleted surface sends nothing.
- Calls whose arguments hold NaN or infinite numbers fail locally with
  EXPRESSION_ERROR instead of being sent.

Refs #3741

Signed-off-by: Nan Yu <nanyu@google.com>
@nan-yu

nan-yu commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Aligned the Swift core with the cross-SDK fix list from the TS/Dart/Python reviews in 6559fa1. Each change has a test in AgentFunctionRoutingTests.swift.

  1. U2: event actions resolve context entries one at a time, then userMessage, and stop at the first failure without sending later calls or dispatching. An action reports only its first failure, and later sibling failures are silent.
  2. U11: a check waiting on an agent call is pending (ResolvedCheck.isPending, Node.isValidationPending) instead of invalid. It becomes invalid, and the node re-resolves, when the call fails.
  3. U5: replacing or clearing agentFunctionCaller drops in-flight calls and kept answers, and answers from the previous caller aren't cached.
  4. U7/U8: CANCELLED errors report nothing. deleteSurface cancels with FunctionError.surfaceCallsCancelled ("Pending calls for surface '' were cancelled."). A disposed surface's callAgentFunction, and the processor's caller after the surface is deleted, throw that error without sending.
  5. U4: object key order doesn't affect call sharing (already true through JSONValue equality). Arguments holding NaN or ±Infinity fail locally with EXPRESSION_ERROR and aren't sent.
  6. S2: the swift/core/README.md agent-side functions section lists these behaviors.

U1, U3, U6, U9, U10, U12–U16 were already correct, and I added tests where none existed. One Swift-specific limit: Foundation's JSONDecoder may not keep the key order of context, so entries resolve in the order of the decoded object.

Full Swift core suite under Linux (Docker swift:6.1): 602 tests, with the same 5 known harness issues as the baseline (583 tests). swift format lint is clean.

🤖 Agent-generated

@nan-yu

nan-yu commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up for U15 in f828ee6: when an event action's userMessage resolves to a non-string, at once or after an agent call, the action now stops with a generic INVALID_ACTION error ("Action event 'userMessage' must resolve to a string."), and nothing is dispatched. This matches TS, Python and Dart. Swift's lower-level dispatch (ResolvedAction.Identity.event) takes a typed String?, so it has no literal non-string case to ignore.

Tests: eventUserMessageThatResolvesToNonStringIsInvalidAction and eventUserMessageThatResolvesToNonStringAtOnceIsInvalidAction. The README note is updated. The full Swift core suite in Docker ran 603 tests with the same 5 known harness issues as the baseline, and swift format lint is clean.

🤖 Agent-generated

nan-yu added a commit that referenced this pull request Nov 2, 2026
…to a non-string

Matches web_core, Python and Dart: a userMessage expression that resolves
to a non-string, at once or after an agent call, stops the action with
INVALID_ACTION ("Action event 'userMessage' must resolve to a string.")
and nothing is dispatched.

Refs #3741

Signed-off-by: Nan Yu <nanyu@google.com>
@nan-yu

nan-yu commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up for U15 in 187e04d: a userMessage that resolves to null, such as a binding to a missing path, now counts as absent. The event is dispatched without a user message and nothing is reported, whether the null comes at once or after an agent call. Only a non-null non-string reports INVALID_ACTION. Tests: eventUserMessageThatResolvesToNullIsAbsent and eventUserMessageThatResolvesToNullAfterAgentCallIsAbsent. The full Swift core suite ran 605 tests with the same 5 known harness issues as the baseline, and lint is clean.

🤖 Agent-generated

nan-yu added a commit that referenced this pull request Nov 2, 2026
A userMessage that resolves to null, at once or after an agent call, is
dispatched without a user message and reports nothing. Only a non-null
non-string reports INVALID_ACTION.

Refs #3741

Signed-off-by: Nan Yu <nanyu@google.com>
@nan-yu

nan-yu commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Round 2 alignment with the cross-SDK list is in 6e0b134. Each behavior change has a test in AgentFunctionRoutingTests.swift, and the formatString change also has one in FormatStringAgentRoutingTests.swift.

Fixed:

  1. V1: a CANCELLED call is no longer remembered as failed, so the next evaluation with the same arguments sends it again.
  2. V3: Swift waits. formatString resolves its ${...} parts through the same DataContext, so a function that resolves an agent call while it runs now makes its whole value pending until the call settles, in bindings and in actions. A one-shot read through a DataContext built on the surface no longer sends: it reads only a kept answer, and otherwise fails with EXPRESSION_ERROR. Arguments of an agent-initiated callRendererFunction already never routed; there is now a test for it.
  3. V8: answers of calls that resolved nodes make are never evicted from the 256-entry cache. A test with 300 distinct calls checks that none is sent twice. A settle already re-resolved only the nodes that make the call.
  4. V9 (breaking): a local function that throws while an action runs reports EXECUTION_ERROR instead of EXPRESSION_ERROR. SurfaceViewModelTests is updated.
  5. V11: schema-only arguments get nested object defaults.

Already correct, now with tests: V4 (processor dispose()), V5 (deleteSurface cancels in one place, the explicit cancelPendingCalls(forSurfaceID:)), V6, V7 (sibling arguments are sent together, and event entries stay sequential), V12, V13 and V14. V2 and V10 needed no change.

The swift/core/README.md agent-side functions notes are updated. Full Swift core suite under Linux (Docker swift:6.1): 650 tests, with the same 5 known harness issues as the baseline (637 tests). swift format lint is clean.

🤖 Agent-generated

nan-yu added a commit that referenced this pull request Nov 2, 2026
- A CANCELLED call isn't remembered as failed; the next evaluation sends
  it again (V1).
- Evaluations that can't wait never route: a one-shot DataContext read on
  the surface only reads kept answers, and a function that resolves parts
  itself (formatString ${...}) is pending until its agent calls settle (V3).
- Answers of calls that resolved nodes make are never evicted from the
  256-entry cache, so they aren't sent again (V8).
- BREAKING: a local function that throws while an action runs is reported
  as EXECUTION_ERROR instead of EXPRESSION_ERROR (V9).
- Schema-only arguments get nested object defaults (V11).
- Tests for processor dispose(), deleteSurface cancellation, concurrent
  sibling arguments, event entry errors, RPC argument refusal and
  __proto__ keys (V4, V5, V7, V12, V13, V14).

Refs #3741

Signed-off-by: Nan Yu <nanyu@google.com>
@nan-yu

nan-yu commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up in 90cf170, applying the round 2 reconciliation:

  1. formatString ${...} parts, and any call a running local function resolves, are refused when they call an agent function: EXPRESSION_ERROR naming the function, nothing sent, and the part renders empty. This replaces the waiting behavior from the previous commit.
  2. A malformed event context entry or userMessage (for example a @call whose name isn't a string, or args that isn't an object) stops the action with EXPRESSION_ERROR and surfaceId: "Action event context entry '' is invalid: " or "Action event userMessage is invalid: ". The result is the same whether the entry comes first or after a pending one.
  3. When a nested argument of an agent-initiated call fails with an error that has no message, the response says "An argument of the function call could not be resolved."
  4. A new test confirms that refusals inside actions (agentOnly, argument validation) stay EXPRESSION_ERROR.

Full Swift core suite in Docker: 654 tests, with the same 5 known harness issues as the baseline. swift format lint is clean, and the README notes are updated.

🤖 Agent-generated

nan-yu added a commit that referenced this pull request Nov 2, 2026
- formatString ${...} parts (any call a running local function resolves)
  that call an agent function are refused with EXPRESSION_ERROR; nothing
  is sent and the part is empty. This replaces the waiting behavior.
- A malformed event context entry or userMessage stops the action with
  EXPRESSION_ERROR "Action event context entry '<key>' is invalid: ..."
  or "Action event userMessage is invalid: ...".
- An agent-initiated call whose nested argument fails without a message
  responds "An argument of the function call could not be resolved."
- Tests that refusals in actions stay EXPRESSION_ERROR.

Refs #3741

Signed-off-by: Nan Yu <nanyu@google.com>
nan-yu added a commit that referenced this pull request Nov 2, 2026
… agent answer

Refs #3741

Signed-off-by: Nan Yu <nanyu@google.com>
nan-yu added a commit that referenced this pull request Nov 2, 2026
…eys in event values

- From v1.0, a local function call whose arguments don't match the
  function's schema is refused with EXPRESSION_ERROR and doesn't run, in
  bindings and actions; an event action with such an entry isn't
  dispatched.
- An event context entry or userMessage holding an object with an unknown
  @-prefixed key (other than @path, @call, or an @@ escape) is malformed:
  EXPRESSION_ERROR "Action event context entry '<key>' is invalid: ...",
  nothing dispatched.

Refs #3741

Signed-off-by: Nan Yu <nanyu@google.com>
@nan-yu

nan-yu commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up in d9e9fec for two gaps found by the shared conformance cases:

  1. V9: from v1.0, a local function call whose arguments don't match the function's schema (for example regex without its required pattern) is refused with EXPRESSION_ERROR and doesn't run, in bindings and in actions. An event action with such a context entry isn't dispatched. Before this, Swift ran the function with the invalid arguments.
  2. V12: an object in an event context entry or userMessage with an unknown @ key, such as {"@unknown": 1}, is malformed: EXPRESSION_ERROR with surfaceId and "Action event context entry '' is invalid: ...", with nothing dispatched, whether it comes first or after a pending entry. @@ keys stay literal.

Tests are in AgentFunctionRoutingTests and LocalArgumentValidationTests. The full Swift core suite in Docker ran 662 tests, with the same 5 known harness issues as the baseline (656 tests). swift format lint is clean, and the README notes are updated.

🤖 Agent-generated

@nan-yu
nan-yu removed this pull request from stack #3751 November 2, 2026 22:34
@nan-yu
nan-yu added this pull request to stack #3849 November 2, 2026 22:35
Base automatically changed from fix/python-agent-function-routing to main November 3, 2026 01:08
@nan-yu
nan-yu force-pushed the fix/swift-agent-function-routing branch from a4ec5be to 622f88a Compare November 3, 2026 01:22

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lang: swift Affects SDKs or apps in the Swift programming language

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants