Skip to content

[Bug]: gRPC historyLength defaults to 0, causing different behavior than JSON-RPC/REST #423

@kabir

Description

@kabir

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 AgentExecutor that calls updater.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 null

3. 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 MessageEvent and extract messageEvent.getMessage().getParts()
  • They don't test TaskEvent with artifacts from AgentExecutor.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: 10 is not 0, 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 -1 means "use default/unlimited"
  • Update client to send -1 instead of null
  • Update server to interpret -1 as "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:

  1. Check if this is fixed in newer A2A spec versions
  2. Consider opening an issue in A2A project if not fixed
  3. Determine if this is intentional spec behavior or a bug

Steps to Reproduce

  1. Create AgentExecutor that uses updater.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();
}
  1. Create client with default config (no explicit historyLength):
ClientConfig config = new ClientConfig.Builder()
    .setAcceptedOutputModes(List.of("text"))
    .build();
  1. Test with gRPC transport:
client.sendMessage(message, consumer, null, null);
// TaskEvent.getTask().getArtifacts() will be empty
// Response will be in TaskEvent.getTask().getHistory()
  1. Test with JSON-RPC transport:
// Same code, different transport
// TaskEvent.getTask().getArtifacts() will be populated correctly

Related Files

  • spec-grpc/src/main/proto/a2a.proto - Proto definition with issue
  • spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java - ToProto conversion
  • client/base/src/main/java/io/a2a/client/config/ClientConfig.java - Config with nullable historyLength
  • client/base/src/main/java/io/a2a/client/Client.java:160 - Uses historyLength from config
  • client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java - gRPC transport
  • tests/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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions