Description
What happens
Microsoft.Agents.AI.Hosting.A2A.A2AAgentHandler.HandleNewMessageStreamingAsync enqueues one A2A Message event for every AgentResponseUpdate emitted by _hostAgent.RunStreamingAsync(...). This produces two distinct wire-protocol problems against spec-compliant A2A clients (notably the Python a2a-sdk used by the A2A Explorer):
-
Empty Parts arrays. Many OpenAI streaming chunks have no convertible content (role-only first delta, finish-reason-only last delta, function-call-only intermediate chunks). For those updates, MessageConverter.ToParts(AgentResponseUpdate) returns []. The handler still calls CreateMessageFromUpdate and enqueues a Message with Parts = []. The A2A spec marks Message.parts as REQUIRED (google.api.field_behavior = REQUIRED on a repeated field, which per AIP-203 means "must be present and non-empty"). The generated Python pydantic models enforce this with min_items=1, so the client rejects the event with:
Message object must have a non-empty 'parts' array.
-
Duplicate messageId across consecutive events. CreateMessageFromUpdate sets MessageId = update.ResponseId ?? Guid.NewGuid().ToString("N"). With the OpenAI chat-client adapter, every chunk in a single completion shares the same chatcmpl-... id, so every emitted Message ends up with the same messageId. A2A clients that treat messageId as the unique identifier of an agent turn (e.g. dedup, replace-on-update) collapse the entire stream down to whichever single chunk wins. In practice that ends up being the empty bookend chunk, so the UI shows an empty agent reply even though the wire stream contained text deltas.
This is the A2A analogue of the AG-UI bug tracked in #3962, where MapAGUI reuses the same messageId across consecutive TOOL_CALL_RESULT events.
What I expected
Either:
- the handler aggregates updates server-side and emits a single
Message with the concatenated parts (matching the A2A "lightweight message response" pattern); or
- each emitted
Message carries a unique messageId (e.g. update.MessageId rather than update.ResponseId, or a freshly generated GUID per update), AND empty-Contents updates are skipped so spec-compliant clients don't receive parts: [].
Either resolution would interoperate with the Python a2a-sdk and other strict A2A clients.
Repro steps
- Register a
ChatClientAgent over an OpenAI / Azure OpenAI chat client.
- Wire it with
services.AddA2AServer("agent-name") and endpoints.MapA2AJsonRpc("agent-name", "/a2a/agent-name") (or the v0.3 compatibility shim).
- Advertise
capabilities.streaming: true on the agent card.
- Connect with A2A Explorer (or any Python
a2a-sdk client) and call message/stream.
Result: every SSE event the server emits is either rejected with "Message object must have a non-empty 'parts' array" or collapses by shared messageId to an empty-text message.
Workaround
Wrap the per-agent A2AServer with a custom IA2ARequestHandler that filters Message events whose Parts array is empty out of SendStreamingMessageAsync/SubscribeToTaskAsync. This silences the pydantic error but does not fix the duplicate-messageId collapse, so streaming still ends up showing a single (often empty) message. Setting Capabilities.Streaming = false on the agent card so clients pick the non-streaming message/send path is the only fully working workaround today, since HandleNewMessageAsync emits a single complete Message via response.Messages.ToParts().
Related
Code Sample
// From Microsoft.Agents.AI.Hosting.A2A.A2AAgentHandler.HandleNewMessageStreamingAsync
await foreach (var update in this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken)
.ConfigureAwait(false))
{
var message = CreateMessageFromUpdate(contextId, update);
await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
// CreateMessageFromUpdate:
private static Message CreateMessageFromUpdate(string contextId, AgentResponseUpdate update) =>
new()
{
MessageId = update.ResponseId ?? Guid.NewGuid().ToString("N"), // (1) shared across all chunks of a single OpenAI completion
ContextId = contextId,
Role = Role.Agent,
Parts = update.ToParts(), // (2) returns [] for role-only and finish-only chunks
Metadata = update.AdditionalProperties?.ToA2AMetadata(),
};
Error Messages / Stack Traces
# Python a2a-sdk pydantic validation error on receiving the SSE event:
Failed to send message: HTTP Error 400: Message object must have a non-empty 'parts' array.
# When parts validation is relaxed (e.g. our FilteringA2ARequestHandler workaround),
# the dedup-by-messageId collapse instead surfaces as a single empty agent reply:
{
"contextId": "9be41ff144044165a506999434b41e62",
"kind": "message",
"messageId": "chatcmpl-DfXt5qcZZyztaUWi1lpDnnmnwq21m",
"parts": [ { "kind": "text", "text": "" } ],
"role": "agent"
}
Package Versions
Microsoft.Agents.AI 1.6.1-preview.260514.1, Microsoft.Agents.AI.Hosting.A2A 1.6.1-preview.260514.1, Microsoft.Agents.AI.Hosting.A2A.AspNetCore 1.6.1-preview.260514.1, A2A 1.0.0-preview2, A2A.AspNetCore 1.0.0-preview2
.NET Version
.NET 8.0
Additional Context
The agent card pattern used here advertises capabilities.streaming: true to expose the streaming endpoint to interactive clients. Disabling streaming on the card forces clients onto message/send which works correctly, but loses the streaming UX. A proper server-side fix (either aggregation or unique per-update ids + empty-parts filtering) would let agents safely advertise streaming again.
Description
What happens
Microsoft.Agents.AI.Hosting.A2A.A2AAgentHandler.HandleNewMessageStreamingAsyncenqueues one A2AMessageevent for everyAgentResponseUpdateemitted by_hostAgent.RunStreamingAsync(...). This produces two distinct wire-protocol problems against spec-compliant A2A clients (notably the Pythona2a-sdkused by the A2A Explorer):Empty
Partsarrays. Many OpenAI streaming chunks have no convertible content (role-only first delta, finish-reason-only last delta, function-call-only intermediate chunks). For those updates,MessageConverter.ToParts(AgentResponseUpdate)returns[]. The handler still callsCreateMessageFromUpdateand enqueues aMessagewithParts = []. The A2A spec marksMessage.partsasREQUIRED(google.api.field_behavior = REQUIREDon arepeatedfield, which per AIP-203 means "must be present and non-empty"). The generated Python pydantic models enforce this withmin_items=1, so the client rejects the event with:Duplicate
messageIdacross consecutive events.CreateMessageFromUpdatesetsMessageId = update.ResponseId ?? Guid.NewGuid().ToString("N"). With the OpenAI chat-client adapter, every chunk in a single completion shares the samechatcmpl-...id, so every emittedMessageends up with the samemessageId. A2A clients that treatmessageIdas the unique identifier of an agent turn (e.g. dedup, replace-on-update) collapse the entire stream down to whichever single chunk wins. In practice that ends up being the empty bookend chunk, so the UI shows an empty agent reply even though the wire stream contained text deltas.This is the A2A analogue of the AG-UI bug tracked in #3962, where
MapAGUIreuses the samemessageIdacross consecutiveTOOL_CALL_RESULTevents.What I expected
Either:
Messagewith the concatenated parts (matching the A2A "lightweight message response" pattern); orMessagecarries a uniquemessageId(e.g.update.MessageIdrather thanupdate.ResponseId, or a freshly generated GUID per update), AND empty-Contentsupdates are skipped so spec-compliant clients don't receiveparts: [].Either resolution would interoperate with the Python
a2a-sdkand other strict A2A clients.Repro steps
ChatClientAgentover an OpenAI / Azure OpenAI chat client.services.AddA2AServer("agent-name")andendpoints.MapA2AJsonRpc("agent-name", "/a2a/agent-name")(or the v0.3 compatibility shim).capabilities.streaming: trueon the agent card.a2a-sdkclient) and callmessage/stream.Result: every SSE event the server emits is either rejected with "Message object must have a non-empty 'parts' array" or collapses by shared
messageIdto an empty-text message.Workaround
Wrap the per-agent
A2AServerwith a customIA2ARequestHandlerthat filters Message events whosePartsarray is empty out ofSendStreamingMessageAsync/SubscribeToTaskAsync. This silences the pydantic error but does not fix the duplicate-messageIdcollapse, so streaming still ends up showing a single (often empty) message. SettingCapabilities.Streaming = falseon the agent card so clients pick the non-streamingmessage/sendpath is the only fully working workaround today, sinceHandleNewMessageAsyncemits a single completeMessageviaresponse.Messages.ToParts().Related
Code Sample
Error Messages / Stack Traces
Package Versions
Microsoft.Agents.AI 1.6.1-preview.260514.1, Microsoft.Agents.AI.Hosting.A2A 1.6.1-preview.260514.1, Microsoft.Agents.AI.Hosting.A2A.AspNetCore 1.6.1-preview.260514.1, A2A 1.0.0-preview2, A2A.AspNetCore 1.0.0-preview2
.NET Version
.NET 8.0
Additional Context
The agent card pattern used here advertises
capabilities.streaming: trueto expose the streaming endpoint to interactive clients. Disabling streaming on the card forces clients ontomessage/sendwhich works correctly, but loses the streaming UX. A proper server-side fix (either aggregation or unique per-update ids + empty-parts filtering) would let agents safely advertise streaming again.