Skip to content

Feat: port TaskResultAggregator from adk-python to the A2A agent executor - #582

Open
AmaadMartin wants to merge 5 commits into
mainfrom
feat/a2a-task-result-aggregator
Open

Feat: port TaskResultAggregator from adk-python to the A2A agent executor#582
AmaadMartin wants to merge 5 commits into
mainfrom
feat/a2a-task-result-aggregator

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    N/A — no existing issue.
  2. Or, if no issue exists, describe the change:

Problem: When an ADK agent runs behind A2A it can emit many TaskStatusUpdateEvents during a single request. adk-python folds that stream into one final task state using a documented precedence, and rewrites the intermediate events so a mid-stream terminal state cannot end event aggregation early in the A2A request handler. adk-js has no equivalent — TaskResultAggregator returns zero hits across core/src and dev/src, and core/src/a2a/agent_executor.ts contains no task-state precedence logic at all.

Solution: Port TaskResultAggregator (src/google/adk/a2a/executor/task_result_aggregator.py) to core/src/a2a/task_result_aggregator.ts and wire it into A2AAgentExecutor.execute the way a2a_agent_executor.py::_handle_request wires the Python one.

  • Precedence, highest first: failed > auth-required > input-required > working. A lower-priority update never overwrites a higher-priority one already recorded.
  • Every observed TaskStatusUpdateEvent has status.state forced to working in place before it is forwarded; the aggregated state is tracked separately. event.final and every other field are left untouched, matching Python.
  • The status message is captured alongside the state, and refreshed while the aggregated state is still working.
  • taskState / taskStatusMessage getters expose the result.
  • Exactly one aggregator per execute() call — a local, never a field on A2AAgentExecutor. The executor instance is shared across A2A requests (see the pre-existing agentPartialArtifactIdsMap field), so a field would leak one request's task state into another's.

resolveFinalStatus(fallback) returns the fallback object unchanged while the aggregated state is working, so the executor's default event stream is byte-identical to before this change.

core/src/a2a/a2a_event.ts gains TaskState.AUTH_REQUIRED = 'auth-required', which the local mirror enum was missing. It is a member of @a2a-js/sdk's TaskState union, so the compiler enforces that the value is backed by the package. Purely additive: no switch over TaskState exists in the repo, and isTerminalTaskStatusUpdateEvent is deliberately not changed — auth-required is an interrupt state, not a terminal one.

Scope — the wiring is not yet load-bearing. convertAdkEventToA2AEvent currently emits TaskArtifactUpdateEvent exclusively, so today processEvent in the run loop observes only artifact-update events and the aggregated state always stays working. The resolveFinalStatus call is therefore a no-op on every path adk-js can produce right now; it becomes load-bearing once the converter is ported to emit intermediate auth-required / input-required status updates the way event_converter.py does for EUC and long-running function calls. That converter port is a distinct, larger change and is deliberately out of scope here — convertAdkEventToA2AEvent is untouched.

Cross-language parity decisions (per "know which side wins a conflict"):

  • Parity wins (observable across the A2A boundary): the state string values, the precedence order, the force-to-working rewrite, and the untouched final flag.
  • Local convention wins (never leaves the process): processEvent / taskState naming, private members with no _ prefix, getters instead of @property, and module layout.
  • Dropped deliberately, no adk-js counterpart: Python's _compat indirection exists only to bridge a2a-sdk 0.3.x vs 1.x enum shapes; adk-js pins a single SDK line (@a2a-js/sdk ^0.3.10 in core/package.json, lockfile resolves 0.3.13), so there is nothing to bridge. Python's @a2a_experimental decorator is dropped because no module under core/src/a2a/ uses the repo's @experimental decorator.
  • One deliberate behavioural divergence: Python's close-out has an extra branch — when the aggregated state is working and a status message with parts was captured, it republishes those parts as a TaskArtifactUpdateEvent and then emits completed. adk-js does not need it: its converter already publishes agent content as artifact updates directly, so republishing would duplicate content on the wire. resolveFinalStatus instead defers to getFinalTaskStatusUpdate(adkEvents, context), which already derives completed / input-required / failed from the ADK event stream.

Collision check (required before implementation): gh pr list --repo AmaadMartin/adk-js --state open --limit 1000 returned 480 open PRs. Filtering titles and branch names for aggregat|task ?result|a2a|task state|auth-required|input-required surfaced 5 candidates (#384, #314, #300, #277, #190); gh pr diff --name-only on each confirmed no overlap with the files touched here. #384 is the nearest neighbour — it widens getTaskInputRequiredEvent's parameter in core/src/a2a/event_processor_utils.ts, a file this PR does not modify. Nothing on the fork implements a task-result aggregator, so this branches from main rather than stacking.

Complexity review rounds 1–2

Round 2 confirmed both actionable findings fixed, and recorded that the remaining one is a scoping call further review rounds cannot settle. Its one concrete residual cost — "live-but-inert code in agent_executor.ts in the interim" — is now mitigated by a simplicity: note at the processEvent call site stating that convertAdkEventToA2AEvent emits artifact updates exclusively, so a reader is not misled into thinking status updates flow today. No behaviour change.

A maintainer decision is required to merge this. See the third item below.

Round 1 findings

Fixed — resolveFinalStatus is now a method, not a free function. The reviewer was right and my original rationale ("module-level so it's directly unit-testable") was weak: the function read the aggregator's state and message, had exactly one caller, and leaked the WORKING sentinel outside the class. A public method is equally testable, and the repo's own rule ("extract a method that does not use this") points the other way here because it does use this. Net -12 lines and one fewer exported symbol. I named it resolveFinalStatus rather than the suggested applyTo because it returns a new event and mutates nothing, and the guidelines require a name that matches what the function does.

Fixed — the as unknown as () => Runner cast is confined to one stubRunner() helper. It now backs all three call sites, replacing the two pre-existing occurrences of the identical shape as well as mine, so the file has one such cast where it previously had three. Only mock wiring moved; no assertion was touched.

Declined, with reasoning — "drop the module and wiring; land them with the first real caller." The reviewer's factual analysis is correct and I do not dispute it: convertAdkEventToA2AEvent returns only artifact-update events, so processEvent's guard never passes and resolveFinalStatus always takes its fallback branch. I verified this independently before the reviewer did and reported it above as mutation 7. I am not taking the remedy, for three reasons:

  1. It is not a simplification of this change, it is the deletion of it. The approved design for this task is the aggregator port; removing the module leaves an empty PR, not a smaller one.
  2. The split was deliberate and the other half is already queued. Porting event_converter.py to emit intermediate auth-required / input-required status updates is a distinct, larger change that this task is explicitly scoped to exclude, with a separate task tracking it. Landing the aggregator now means the converter port is a small diff against a tested, reviewed precedence table instead of one large change carrying both.
  3. Re-deriving it later is a known failure mode. If this is dropped, the queued converter task must re-implement the same 90 lines and the same 20-case port of the Python suite, competing with this branch rather than building on it.

That said, the reviewer is applying the rubric correctly and the disagreement is a genuine scoping judgement, not a defect — so it is flagged here and in .foundry_result.json rather than silently resolved. If the maintainer prefers the strict reading, the right action is to close this PR and fold the module into the converter port; nothing here is a prerequisite for that work, and the module plus its test file move as a unit.

One thing the review did not flag that follows from its own logic: after the refactor the taskState / taskStatusMessage getters have no production reader either, since resolveFinalStatus reads the private fields directly. They are kept deliberately — they are required by the spec, they mirror Python's task_state / task_status_message properties, and they are the only way a test can observe the "refresh the message while still working" rule, which resolveFinalStatus cannot expose because it returns the fallback unchanged in exactly that state.

No suppressions in src/. One as unknown as () => Runner appears in the added executor test, at the vi.mocked(Runner).mockImplementation boundary: agent_executor_test.ts mocks the whole runner module, so the double is structurally partial (Runner also requires agent, pluginManager, runEphemeral, saveArtifacts and the RUNNER_SIGNATURE_SYMBOL brand). This is the file's pre-existing idiom — it already occurs 3 times — and the added test is the only new occurrence. The other three casts the first draft copied were removed: the session now uses the createSession factory, and the RunnerConfig literal needs no cast because every field is optional.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.

New file core/test/a2a/task_result_aggregator_test.ts — 20 cases. Cases 1–13 are direct ports of every case in tests/unittests/a2a/executor/test_task_result_aggregator.py; 14–17 close branches the Python suite leaves uncovered; 18–20 cover resolveFinalStatus. Case 8 passes both a TaskArtifactUpdateEvent and a Task whose status.state is failed, and asserts the Task is left untouched — the regression guard against duck-typing on status instead of on kind (an A2A Task also has status.state, and Python's isinstance check excludes it).

core/test/a2a/agent_executor_test.ts — exactly one new it added; no existing test modified or deleted.

Coverage of the new module is 100% statements / branches / functions / lines:

npx vitest run --project unit:core --coverage.enabled --coverage.reporter=text \
  --coverage.include='core/src/a2a/task_result_aggregator.ts' \
  core/test/a2a/task_result_aggregator_test.ts

File               | % Stmts | % Branch | % Funcs | % Lines
 ..._aggregator.ts |     100 |      100 |     100 |     100

Mutation proof. Each mutation was applied to the source, the suite re-run, and the source restored:

# Mutation Test that FAILED Failure message
1 Drop && this.state !== TaskState.FAILED from the auth branch keeps failed when auth-required arrives afterwards expected 'auth-required' to be 'failed'
2 Drop && this.state !== TaskState.AUTH_REQUIRED from the input branch keeps auth-required when input-required arrives afterwards expected 'input-required' to be 'auth-required'
3 Delete the trailing event.status.state = TaskState.WORKING; 5 tests, incl. records a failed event and rewrites it to working expected 'failed' to be 'working'
4 Replace the isTaskStatusUpdateEvent guard with a duck-typed 'artifact' in event ignores events that are not status updates, including a failed Task expected 'failed' to be 'working'
5 Make resolveFinalStatus always return fallback overrides the fallback state and message once a signal is aggregated (+1) expected 'completed' to be 'failed'
6 Make resolveFinalStatus always override (drop the working early return) publishes a completed final status when the run only produces artifact updates expected 'working' to be 'completed'

Disclosed negative result. A seventh mutation — removing the resolveFinalStatus(...) call from the executor's close-out entirely — leaves all 6 executor tests passing. That is not a gap in the test, it is the scope note above made measurable: because the converter emits only artifact updates today, the aggregated state never leaves working and the call is genuinely a no-op on every reachable path. The executor test pins the postcondition it can pin (the aggregator must not corrupt the completed close-out, mutation 6); resolveFinalStatus's own behaviour is pinned by mutation 5 against the direct unit tests. There is no way to drive a status-update event through execute() without changing convertAdkEventToA2AEvent, which is out of scope.

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

No live A2A server run is needed: the change has no wire, network, or filesystem surface, adds no dependency, and produces no observable difference in the executor's default event stream. Verification is the targeted suite below plus a side-by-side reread of task_result_aggregator.py against task_result_aggregator.ts, confirming every branch, every guard and the untouched final flag correspond one to one.

npm run build                                                                    # OK
npx vitest run --project unit:core core/test/a2a/                                # 16 files, 221 tests passed
npx vitest run --project unit:core core/test/a2a/task_result_aggregator_test.ts  # 20 passed
npx vitest run --project unit:core core/test/a2a/agent_executor_test.ts          # 6 passed
npx vitest run --project unit:core core/test/a2a/a2a_event_test.ts               # 30 passed
npx vitest run --project unit:core core/test/a2a/event_processor_utils_test.ts   # 13 passed
npm run lint                                                                     # clean
npm run format:check                                                             # clean
npm run ts:check                                                                 # no errors in any touched file

CI note. All checks green on 106383d6: run-tests, run-tests (ubuntu-latest), run-tests (macos-latest), run-tests (windows-latest) and check-license (run 30839065584).

Earlier revisions of this branch saw red macOS and Windows legs. Those were pre-existing runner flakes in files this change does not touch, and the green run on effectively the same code confirms it: macOS failed on tests/integration/app_loader/app_loader_test.ts > should discover apps vs agents across directories and standalone files (also failing on feat/ui-widget-event-actions run 30834213956 and feat/agent-to-mcp-server run 30832794899), and Windows on core/test/code_executors/unsafe_local_code_executor_test.ts > should execute shell code and return stdout (also failing on feat/application-integration-toolset-part1 run 30831832751 and feat/ui-widget-event-actions run 30837081190). One Windows leg was cancelled by matrix fail-fast rather than failing on its own.

  • macOS — tests/integration/app_loader/app_loader_test.ts > should discover apps vs agents across directories and standalone files. Also fails on feat/ui-widget-event-actions (run 30834213956) and feat/agent-to-mcp-server (run 30832794899).
  • Windows — core/test/code_executors/unsafe_local_code_executor_test.ts > should execute shell code and return stdout, a process-spawn timeout. Also fails on feat/application-integration-toolset-part1 (run 30831832751) and feat/ui-widget-event-actions (run 30837081190). On the latest run the Windows leg was cancelled by matrix fail-fast rather than failing on its own.

Nothing here touches core/src/code_executors or the app loader. The failed jobs were re-run and failed identically on the same unrelated tests.

a2a_event_test.ts and event_processor_utils_test.ts are included because this change edits the TaskState enum they exercise. npm run ts:check reports pre-existing errors elsewhere in the repo (a stale core/dist vs core/src declaration-identity artifact, e.g. BASE_AGENT_SIGNATURE_SYMBOL in core/test/a2a/agent_card_test.ts); the count did not increase and none of them are in a2a_event.ts, agent_executor.ts or task_result_aggregator.ts.

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.

Amaad Martin added 5 commits August 3, 2026 09:47
The A2A protocol's TaskState union includes 'auth-required' but the local
mirror enum in a2a_event.ts omitted it. Additive; no switch over TaskState
exists, and isTerminalTaskStatusUpdateEvent is deliberately unchanged since
auth-required is an interrupt state, not a terminal one.
Folds the TaskStatusUpdateEvents emitted during one agent run into a single
final task state using adk-python's precedence table (failed > auth-required
> input-required > working), and rewrites each observed status update to
'working' in place so a terminal intermediate state cannot end event
aggregation in the A2A request handler before the run is over.

applyAggregatedTaskState is a module-level function rather than a private
method so the resolution logic is directly unit-testable.
… task

One aggregator per execute() call (a field would leak one request's task
state into another's, since the executor instance is shared across requests).
Matching adk-python, the pre-loop working event and the catch-branch failure
event are not routed through the aggregator.
Review feedback. applyAggregatedTaskState read the aggregator's state and
message and had exactly one caller, so a free function bought nothing over a
method and leaked the WORKING sentinel outside the class; it is now
resolveFinalStatus(fallback). Named for what it returns rather than applyTo,
which would imply mutation.

The executor test's Runner double moves into a stubRunner() helper, confining
the partial-double cast to one site and replacing the two pre-existing
occurrences of the same shape. Assertions are untouched.
…t yet

Review round 2 flagged the interim cost of live-but-inert code in the
executor. A reader of the processEvent call cannot otherwise tell that
convertAdkEventToA2AEvent emits artifact updates exclusively, so the
aggregator records nothing until the converter port lands.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant