a2aproject/a2a-python

[Bug]: Serialization: Artifacts contain stringified response objects instead of TextPart content

allenday opened this issue · 2 comments

What happened?

The A2A SDK has a critical serialization bug where TextPart.text content is not properly extracted during transport. Instead of receiving the actual text content, clients receive the stringified representation of the entire response object containing artifacts.

Expected Behavior

  1. Agent creates JSON content: json_content = structured_response.model_dump_json()
  2. Agent wraps in TextPart: TextPart(text=json_content)
  3. Agent adds to artifact: Part(root=TextPart(text=json_content))
  4. Client receives: The original json_content string

Actual Behavior

  1. Agent creates JSON content: json_content = structured_response.model_dump_json()
  2. Agent wraps in TextPart: TextPart(text=json_content)
  3. Agent adds to artifact: Part(root=TextPart(text=json_content))
  4. Client receives: artifacts=[Artifact(artifact_id='...', description=None, extensions=None, metadata=None, name='consensus_analysis_result', parts=[Part(root=TextPart(kind='text', metad...

Evidence

The received content shows Python's default string representation of a list containing Artifact objects, not the intended JSON content.

Debug output from client:

  DEBUG: Found 1 artifacts
  DEBUG: Artifact 0: has parts = True
  DEBUG: Artifact 0 has 1 parts
  DEBUG: Part 0: has root = True, root type = <class 'a2a.types.TextPart'>
  DEBUG: Found text content: artifacts=[Artifact(artifact_id='91f810b7-a027-40d9-9ad5-36d5dbce09f1', description=None, extensions...

Environment

  • A2A SDK Version: a2a-sdk==0.3.0
  • Python Version: 3.13
  • Transport: JsonRpcTransport
  • Setup: Docker containers with leader/follower agents

Reproduction Steps

  1. Create an agent that generates structured JSON content
  2. Wrap content in TextPart(text=json_content)
  3. Add to artifact via updater.add_artifact([Part(root=TextPart(text=content))])
  4. Send through A2A transport to client
  5. Client extracts part.root.text - receives artifact structure instead of original content

Minimal Reproduction Code

Agent (sender):

  # Generate structured content
  json_content = {"title": "Test", "data": "content"}
  json_string = json.dumps(json_content)

  # Create artifact with TextPart
  await updater.add_artifact(
      [Part(root=TextPart(text=json_string))],
      name="test_result"
  )

Client (receiver):

  # Extract content from artifact
  for artifact in response.artifacts:
      for part in artifact.parts:
          if hasattr(part, 'root') and hasattr(part.root, 'text'):
              content = part.root.text
              print(f"Received: {content}")
              # Expected: '{"title": "Test", "data": "content"}'
              # Actual: 'artifacts=[Artifact(artifact_id=...'

Impact

  • Severity: Critical
  • Scope: Any A2A system passing structured content through artifacts
  • Workaround: Complex pattern matching to extract JSON from corrupted strings
  • Affects: Content extraction, structured data exchange, API responses

Root Cause Analysis

The bug appears to be in the A2A transport/serialization layer where:

  1. The entire response object gets converted to string using Python's str() method
  2. Instead of extracting TextPart.text content during serialization
  3. This happens between agent artifact creation and client reception
  4. Likely in JsonRpcTransport or related serialization components

Proposed Fix

The serialization layer should extract TextPart.text content rather than stringifying the entire response object. The transport mechanism needs to:

  1. Properly traverse the artifact structure
  2. Extract text content from TextPart objects
  3. Preserve the original text content during transport
  4. Ensure clients receive the intended content, not object representations

Workaround Implemented

We've implemented client-side detection and recovery:

  def extract_json_from_corrupted_artifact(corrupted_content: str) -> str:
      """Extract actual JSON from A2A serialization bug artifacts."""
      # Pattern matching to recover JSON from artifact structure strings
      # Multiple fallback strategies for different corruption patterns

This workaround allows systems to function despite the bug, but the SDK should be fixed to prevent the corruption from occurring.

Relevant log output

Debug output from client:
  DEBUG: Found 1 artifacts
  DEBUG: Artifact 0: has parts = True
  DEBUG: Artifact 0 has 1 parts
  DEBUG: Part 0: has root = True, root type = <class 'a2a.types.TextPart'>
  DEBUG: Found text content: artifacts=[Artifact(artifact_id='91f810b7-a027-40d9-9ad5-36d5dbce09f1', description=None, extensions...

Code of Conduct

  • I agree to follow this project's Code of Conduct

Hi, I tried reproducing the issue, but couldn't reproduce this issue:

I have updated the HelloWorld agent as below to return stringified JSON response:

from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.tasks import TaskUpdater
from a2a.types import Part, TextPart
from a2a.utils import (
        new_task,
)
from pydantic import BaseModel


class HelloResponse(BaseModel):
    """Hello Response."""
    message: str


class HelloWorldAgent:
    """Hello World Agent."""

    async def invoke(self) -> str:
        response = HelloResponse(message="Hello World")
        return response.model_dump_json()

class HelloWorldAgentExecutor(AgentExecutor):
    """Test AgentProxy Implementation."""

    def __init__(self):
        self.agent = HelloWorldAgent()

    async def execute(
        self,
        context: RequestContext,
        event_queue: EventQueue,
    ) -> None:

        task = new_task(context.message) # type: ignore
        await event_queue.enqueue_event(task)

        result = await self.agent.invoke()

        updater = TaskUpdater(event_queue, task.id, task.context_id)
        await updater.add_artifact(
                        [Part(root=TextPart(text=result))],
                        name='result',
                    )
        await updater.complete()

    async def cancel(
        self, context: RequestContext, event_queue: EventQueue
    ) -> None:
        raise Exception('cancel not supported')

Following is the client code:

client = A2AClient(
            httpx_client=httpx_client, agent_card=final_agent_card_to_use
        )
logger.info('A2AClient initialized.')

send_message_payload: dict[str, Any] = {
    'message': {
        'role': 'user',
        'parts': [
            {'kind': 'text', 'text': 'hello'}
        ],
        'messageId': uuid4().hex,
    },
}
request = SendMessageRequest(
    id=str(uuid4()), params=MessageSendParams(**send_message_payload)
)

response = await client.send_message(request)
for artifact in response.root.result.artifacts:
    for part in artifact.parts:
        if hasattr(part, 'root') and hasattr(part.root, 'text'):
            content = part.root.text
            print(f"Received: {content}")

streaming_request = SendStreamingMessageRequest(
    id=str(uuid4()), params=MessageSendParams(**send_message_payload)
)

stream_response = client.send_message_streaming(streaming_request)

async for chunk in stream_response:
    if isinstance(chunk.root.result, TaskArtifactUpdateEvent):
        artifact = chunk.root.result.artifact
        if artifact:
            for part in artifact.parts:
                if hasattr(part, 'root') and hasattr(part.root, 'text'):
                    content = part.root.text
                    print(f"Received: {content}")

Following is the output I see for both cases:

Received: {"message":"Hello World"}

Can you please try with the above test if you still see the issue?

Alternatively you can try calling the agent endpoint with curl as below

curl --request POST \
  --url http://localhost:9999/ \
  --header 'content-type: application/json' \
  --data '{"jsonrpc": "2.0","id": 33,"method": "message/send","params": {"message": {"role": "user","parts": [{ "type": "text", "text": "hello" }],"messageId":"foo4","kind": "message"}}}'

Here's the example response you should see:

{
  "id": 33,
  "jsonrpc": "2.0",
  "result": {
    "artifacts": [
      {
        "artifactId": "765122f0-765b-4275-809c-fe624dfa4f2a",
        "name": "result",
        "parts": [
          {
            "kind": "text",
            "text": "{\"message\":\"Hello World\"}"
          }
        ]
      }
    ],
    "contextId": "762e892e-5d55-47bc-9e3c-51db498ea018",
    "history": [
      {
        "contextId": "762e892e-5d55-47bc-9e3c-51db498ea018",
        "kind": "message",
        "messageId": "foo4",
        "parts": [
          {
            "kind": "text",
            "text": "hello"
          }
        ],
        "role": "user",
        "taskId": "92e1ec09-4c60-4a2f-b2f2-56ccd9c5c77d"
      }
    ],
    "id": "92e1ec09-4c60-4a2f-b2f2-56ccd9c5c77d",
    "kind": "task",
    "status": {
      "state": "completed",
      "timestamp": "2025-08-08T19:57:04.170392+00:00"
    }
  }
}