-
Notifications
You must be signed in to change notification settings - Fork 123
Description
What happened?
DISCLAIMER
The below analysis was done in a bit of a rush, so take with a grain of salt. It definitely needs further investigation and discussion.
Summary
When a client doesn't explicitly set historyLength in ClientConfig, the gRPC transport sends historyLength: 0 to the server, while JSON-RPC and REST transports omit the field entirely. This causes different server-side behavior between transports.
Investigation Details
Observable Behavior
Simple Example Context:
- Server uses
AgentExecutorthat callsupdater.addArtifact()to add response - Client expects response in
TaskEvent.getTask().getArtifacts()
gRPC Transport:
MessageSendParams received by server:
{
"configuration": {
"acceptedOutputModes": ["text"],
"historyLength": 0, // <-- Present with value 0
"blocking": true
}
}
Task returned to client:
{
"artifacts": [], // <-- Empty!
"history": [{ // <-- Response appears here instead
"role": "user",
"parts": [{"text": "Kabir"}]
}]
}JSON-RPC Transport:
MessageSendParams received by server:
{
"configuration": {
"acceptedOutputModes": ["text"],
"blocking": true
// historyLength not present at all
}
}
Task returned to client:
{
"artifacts": [{ // <-- Response appears here as expected
"parts": [{"text": "Hello Kabir"}]
}],
"history": [...]
}Root Cause Analysis
1. Protobuf Default Values
File: a2a-java/spec-grpc/src/main/proto/a2a.proto
message SendMessageConfiguration {
repeated string accepted_output_modes = 1;
PushNotificationConfig push_notification = 2;
int32 history_length = 3; // Proto3: defaults to 0 when not set
bool blocking = 4;
}In protobuf3, scalar fields like int32 have default values (0 for integers). Even if the Java code doesn't explicitly set the field, protobuf serialization includes it with the default value.
2. Client Configuration
File: a2a-java/client/base/src/main/java/io/a2a/client/config/ClientConfig.java
private final @Nullable Integer historyLength; // Defaults to null3. ToProto Conversion
File: a2a-java/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java
public static io.a2a.grpc.SendMessageConfiguration messageSendConfiguration(...) {
io.a2a.grpc.SendMessageConfiguration.Builder builder = ...;
// ...
if (messageSendConfiguration.historyLength() != null) {
builder.setHistoryLength(messageSendConfiguration.historyLength());
}
// If null, field is not set, but protobuf still serializes as 0
builder.setBlocking(messageSendConfiguration.blocking());
return builder.build();
}The check if (historyLength != null) means the field isn't explicitly set, but protobuf3 still serializes unset int32 fields as 0.
Why Tests Don't Catch This
File: a2a-java/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java
The existing tests don't use the updater.addArtifact() pattern:
- Tests check for
MessageEventand extractmessageEvent.getMessage().getParts() - They don't test
TaskEventwith artifacts fromAgentExecutor.execute()→updater.addArtifact()
Example from AbstractA2AServerTest line 256:
BiConsumer<ClientEvent, AgentCard> consumer = (event, agentCard) -> {
if (event instanceof MessageEvent messageEvent) { // Expects MessageEvent
receivedMessage.set(messageEvent.getMessage());
// ...
}
};vs. our simple example (line 70):
BiConsumer<ClientEvent, AgentCard> consumer = (event, agentCard) -> {
if (event instanceof TaskEvent taskEvent) { // Expects TaskEvent
Task task = taskEvent.getTask();
if (task.getArtifacts() != null) { // Expects artifacts
// Extract from artifacts
}
}
};Workaround Applied
File: a2a-java-sdk-server-jakarta/examples/simple/client/.../SimpleExampleClient.java:46
ClientConfig config = new ClientConfig.Builder()
.setAcceptedOutputModes(List.of("text"))
.setUseClientPreference(true)
// WORKAROUND: Set historyLength to avoid gRPC protobuf default of 0
// which causes empty artifacts. This should be fixed in a2a-java by making
// the proto field optional: optional int32 history_length = 3;
.setHistoryLength(10)
.build();This works because:
historyLength: 10is not0, so server behaves like JSON-RPC- Artifacts are populated correctly
Proposed Solutions
Option 1: Make proto field optional (Recommended)
message SendMessageConfiguration {
repeated string accepted_output_modes = 1;
PushNotificationConfig push_notification = 2;
optional int32 history_length = 3; // Add 'optional' keyword
bool blocking = 4;
}This allows protobuf to distinguish:
- Not set: Field absent from serialization
- Set to 0: Field present with value
0
Option 2: Use google.protobuf.Int32Value wrapper
import "google/protobuf/wrappers.proto";
message SendMessageConfiguration {
repeated string accepted_output_modes = 1;
PushNotificationConfig push_notification = 2;
google.protobuf.Int32Value history_length = 3; // Nullable wrapper
bool blocking = 4;
}Wrapper types are nullable in protobuf.
Option 3: Sentinel value convention
If proto cannot change (because it's from A2A spec):
- Document that
-1means "use default/unlimited" - Update client to send
-1instead ofnull - Update server to interpret
-1as "not set"
Suggested Test Case
Add to AbstractA2AServerTest:
@Test
public void testSendMessageWithArtifactsInResponse() throws Exception {
// Test that AgentExecutor.execute() with updater.addArtifact()
// results in artifacts being populated in the TaskEvent on the client
Message message = A2A.toUserMessage("test");
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Task> receivedTask = new AtomicReference<>();
BiConsumer<ClientEvent, AgentCard> consumer = (event, agentCard) -> {
if (event instanceof TaskEvent taskEvent) {
receivedTask.set(taskEvent.getTask());
latch.countDown();
}
};
getClient().sendMessage(message, List.of(consumer), null, null);
assertTrue(latch.await(10, TimeUnit.SECONDS));
Task task = receivedTask.get();
assertNotNull(task);
assertNotNull(task.getArtifacts());
assertFalse(task.getArtifacts().isEmpty(),
"Artifacts should be populated when AgentExecutor uses updater.addArtifact()");
// Verify the artifact content
Artifact artifact = task.getArtifacts().get(0);
Part<?> part = artifact.parts().get(0);
assertEquals(Part.Kind.TEXT, part.getKind());
assertNotNull(((TextPart) part).getText());
}This test would fail with gRPC if historyLength defaults to 0.
Upstream Considerations
Proto Source: The proto file is copied from https://github.com/a2aproject/A2A/tree/v0.2.6/specification
Before changing:
- Check if this is fixed in newer A2A spec versions
- Consider opening an issue in A2A project if not fixed
- Determine if this is intentional spec behavior or a bug
Steps to Reproduce
- Create
AgentExecutorthat usesupdater.addArtifact():
@Override
public void execute(RequestContext context, EventQueue eventQueue) {
TaskUpdater updater = new TaskUpdater(context, eventQueue);
updater.startWork();
updater.addArtifact(Collections.singletonList(new TextPart("response")),
null, "response", null);
updater.complete();
}- Create client with default config (no explicit
historyLength):
ClientConfig config = new ClientConfig.Builder()
.setAcceptedOutputModes(List.of("text"))
.build();- Test with gRPC transport:
client.sendMessage(message, consumer, null, null);
// TaskEvent.getTask().getArtifacts() will be empty
// Response will be in TaskEvent.getTask().getHistory()- Test with JSON-RPC transport:
// Same code, different transport
// TaskEvent.getTask().getArtifacts() will be populated correctlyRelated Files
spec-grpc/src/main/proto/a2a.proto- Proto definition with issuespec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java- ToProto conversionclient/base/src/main/java/io/a2a/client/config/ClientConfig.java- Config with nullable historyLengthclient/base/src/main/java/io/a2a/client/Client.java:160- Uses historyLength from configclient/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java- gRPC transporttests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java- Tests missing artifact scenario
Impact
- Transport inconsistency: Same client code behaves differently with gRPC vs JSON-RPC
- Unexpected behavior: Artifacts unexpectedly empty with gRPC when using
updater.addArtifact() - Debugging difficulty: Issue is subtle and not immediately obvious
- Test gap: Existing test suite doesn't catch this pattern
Priority
Medium-High - Affects core functionality and transport consistency, but workaround exists.
Relevant log output
Code of Conduct
- I agree to follow this project's Code of Conduct