Skip to content

remoteagent/v2: AfterA2ARequestCallbacks are not invoked on the aggregated event synthesized from partial artifact chunks #948

@will-arxed

Description

@will-arxed

🔴 Required Information

Describe the Bug:

AfterA2ARequestCallbacks registered on a remoteagent.NewA2A agent (agent/remoteagent/v2) are invoked on each incoming A2A event, but not on the non-partial event the run processor synthesizes when it reassembles a partial-chunked artifact. As a result, a callback that inspects or mutates response content cannot observe or transform a chunked/reassembled artifact — only an artifact delivered as a single non-partial event passes through the callback.

In agent/remoteagent/v2/a2a_agent.go, processEvent runs runAfterA2ARequestCallbacks on the converted incoming event, then calls aggregatePartial and yields its output directly:

// agent/remoteagent/v2/a2a_agent.go (v1.4.0), processEvent
event, err = processor.convertToSessionEvent(ctx, a2aEvent, a2aErr)        // L267
if cbResp, cbErr := processor.runAfterA2ARequestCallbacks(ctx, event, err); /*...*/ { // L270  ← callbacks here
    event = cbResp
}
// ...
for _, toEmit := range processor.aggregatePartial(ctx, a2aEvent, event) {  // L283
    if !yield(toEmit, nil) { return false }                                 // L284  ← yielded directly, no callbacks
}

When aggregatePartial (a2a_agent_run_processor.go) accumulates partial artifact chunks and, on the last chunk, builds a brand-new aggregated event via buildNonPartialAggregation, that event is returned and yielded to the caller without ever passing back through runAfterA2ARequestCallbacks:

// a2a_agent_run_processor.go (v1.4.0)
// emit partial last chunk and follow by the non-partial aggregated event
p.removeAggregation(update.Artifact.ID)
return []*session.Event{event, p.buildNonPartialAggregation(ctx, aggregation)} // L116  ← aggregated event bypasses callbacks

buildNonPartialAggregation is reached from two sites, and both bypass the callback: the last-chunk path above (a2a_agent_run_processor.go:116) and the terminal-status flush path (a2a_agent_run_processor.go:71-78, the Status.State.Terminal() branch). A fix should cover both.

Because an artifact streamed as chunks arrives entirely as Partial events, a callback that (as is typical) only acts on non-partial events skips every chunk, and the one non-partial event that carries the reassembled artifact — the aggregated event — is never handed to the callback at all. The callback silently no-ops for any chunked artifact.

Steps to Reproduce:

  1. Build an A2A AgentExecutor that, for a single task, emits one artifact as multiple TaskArtifactUpdateEvents sharing one ArtifactID — a sequence of chunks where continuation chunks set Append=true and the final chunk sets Append=true, LastChunk=true — each Part carrying a slice of the artifact bytes (the standard way to stream an artifact larger than the SSE line limit). (Append=true on the last chunk is what routes the stream into the aggregation path: a non-append last chunk takes the early return at a2a_agent_run_processor.go:94-98, is marked non-partial, and never reaches buildNonPartialAggregation.)
  2. Wire that server into a consumer with remoteagent.NewA2A(remoteagent.A2AConfig{ /* ... */ AfterRequestCallbacks: []remoteagent.AfterA2ARequestCallback{cb} }), where cb records (or mutates) the events it receives.
  3. Run the remote agent and drive the task to completion.
  4. Observe that cb is invoked for the partial chunk events but is never invoked for the final non-partial aggregated event that carries the concatenated artifact.

Expected Behavior:

AfterA2ARequestCallbacks should be invoked on every event the remote agent emits to the caller, including the non-partial event synthesized from partial artifact chunks. A callback registered to inspect or transform response content should be able to act on the reassembled artifact, consistently with how it acts on a single-event (non-chunked) artifact.

Observed Behavior:

The aggregated event produced by aggregatePartial / buildNonPartialAggregation is yielded to the caller without passing through runAfterA2ARequestCallbacks. Callbacks run only on the raw incoming A2A events, so:

  • For a single-event (non-chunked) artifact, the callback runs and any transformation takes effect.
  • For a chunked artifact, the callback never sees the reassembled event, so its transformation is silently dropped.

Where this bites in practice (framed generically): consider an after-request callback that moves large InlineData parts out of events into the artifact service so persisted events stay small. For files small enough to arrive as a single event this works; for larger files the remote agent streams them as chunks, the reassembled event bypasses the callback and retains the full bytes inline, and persisting that event to a size-limited session backend then fails (e.g. the Vertex AI managed session service rejects events over ~10 MiB).

Environment Details:

  • ADK Library Version: v1.4.0 (latest). The bug is present in v1.3.0 and v1.4.0 — i.e., since the agent/remoteagent/v2 package was introduced in v1.3.0 — and remains on main.
  • OS: macOS (platform-independent — this is a control-flow bug)
  • Go Version: go1.25

Model Information:

  • N/A — the bug is in remote-agent event aggregation / callback dispatch and is independent of the model.

🟡 Optional Information

Regression:
No. The synthesized aggregated event appears never to have been routed through AfterA2ARequestCallbacks. The agent/remoteagent/v2 package was introduced in v1.3.0 already carrying this behavior, and the identical aggregation/callback logic (same function names and line numbers) existed in the predecessor agent/remoteagent package back to at least v1.1.0 — that package's run-processor was later removed in favor of v2. We don't believe it ever worked.

Logs:
The adk-go layer surfaces no error — the callback simply isn't called. The downstream symptom when the un-stripped aggregated event is persisted to a size-limited session backend:

rpc error: code = InvalidArgument desc = Request payload size exceeds the limit: 10486784 bytes

Additional Context:

  • Relevant code (v1.4.0 / main): agent/remoteagent/v2/a2a_agent.go processEvent (runAfterA2ARequestCallbacks at L270 on the incoming event; aggregatePartial(...) result yielded at L283-284); agent/remoteagent/v2/a2a_agent_run_processor.go aggregatePartial (returns the synthesized aggregated event from buildNonPartialAggregation at L116) and buildNonPartialAggregation (L175-184), which constructs a fresh event that never re-enters the callback pipeline.
  • buildNonPartialAggregation is invoked from two sites and both bypass the callback: the last-chunk path (a2a_agent_run_processor.go:116) and the terminal-status flush path (a2a_agent_run_processor.go:71-78).
  • Searched existing issues/PRs; the closest are PR fix: interleaved thought&text aggregation in remoteagent #655 (merged — "fix: interleaved thought&text aggregation in remoteagent": refactored the same aggregation code for a different aspect — thought/text ordering) and issue Vertex AI session service drops Actions.ArtifactDelta during AppendEvent / ListEvents round-trip #902 (open — Vertex AI session service drops Actions.ArtifactDelta on round-trip; adjacent artifact-persistence area, different mechanism). Neither addresses this callback-dispatch gap.

Minimal Reproduction Code:
Sketch isolating the issue (server setup elided for brevity):

// A callback registered on the remote-agent config that records every event it is handed.
var seen []*session.Event
cb := func(
    ctx agent.CallbackContext,
    req *a2a.SendMessageRequest,
    resp *session.Event,
    err error,
) (*session.Event, error) {
    seen = append(seen, resp)
    return nil, nil // leave the event unchanged
}

remoteAgent, _ := remoteagent.NewA2A(remoteagent.A2AConfig{
    Name:              "remote",
    AgentCardProvider: remoteagent.NewAgentCardProvider(serverURL), // server streams ONE chunked artifact
    ClientProvider:    remoteagent.NewA2AClientProvider(clientFactory),
    AfterRequestCallbacks: []remoteagent.AfterA2ARequestCallback{cb},
})

// Drive remoteAgent to run a task whose server emits ONE artifact as N TaskArtifactUpdateEvents
// sharing one ArtifactID: continuation chunks Append=true, final chunk Append=true + LastChunk=true.
// ... run ...

// BUG: `seen` contains the partial chunk events but NOT the final non-partial
// aggregated event (the one carrying the concatenated artifact).
// Expected: the aggregated event is also handed to `cb`.

How often has this issue occurred?:

  • Always (100%) — for any artifact the remote agent delivers as partial chunks.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions