Skip to content

feat(a2a_client): expose extension metadata in a2a_send_message#398

Open
prashant1rana wants to merge 1 commit intostrands-agents:mainfrom
prashant1rana:main
Open

feat(a2a_client): expose extension metadata in a2a_send_message#398
prashant1rana wants to merge 1 commit intostrands-agents:mainfrom
prashant1rana:main

Conversation

@prashant1rana
Copy link

@prashant1rana prashant1rana commented Feb 13, 2026

Summary

Pass A2A request metadata to sub-agents via tool_context instead of exposing it as an LLM parameter.

Motivation

The A2A protocol's MessageSendParams.metadata field enables passing metadata data between agents.

The original approach exposed this as an LLM-facing parameter, allowing the model to fabricate values that could cause:

  • Wrong session routing
  • Failed authentication
  • Misleading trace data
  • Billing issues on remote servers

While hallucinated metadata won't break the protocol (it's optional JSON), it creates semantic risks when remote servers interpret fabricated values.

Solution

Use @tool(context=True) to hide tool_context from the LLM schema and extract metadata transparently:

python
@tool(context=True)
async def a2a_send_message(
    self,
    message_text: str,
    target_agent_url: str,
    message_id: str | None = None,
    tool_context: ToolContext | None = None,  # Framework-injected, not LLM-visible
) -> dict[str, Any]:
    metadata = None
    if tool_context is not None:
        metadata = tool_context.get("invocation_state", {}).get("metadata")

    return await self._send_message(message_text, target_agent_url, message_id, metadata)

How it works:

  1. Parent A2A request arrives with MessageSendParams.metadata = {"session_id": "abc"}
  2. Strands framework stores in tool_context.invocation_state.metadata
  3. LLM calls a2a_send_message (only sees message_text, target_agent_url, message_id)
  4. Framework injects real tool_context at runtime
  5. Tool extracts and forwards metadata to sub-agent
  6. LLM never sees or controls metadata—transparent passthrough

Testing

  • ✅ 36/36 tests passing
  • ✅ Validates extraction from tool_context
  • ✅ Handles None and empty tool_context cases
  • ✅ Proper ToolContext type annotations

afarntrog
afarntrog previously approved these changes Feb 13, 2026
@mkmeral
Copy link
Contributor

mkmeral commented Feb 13, 2026

Hi, can I ask what the use case is? The way you are adding the metadata right now, it's another parameter for LLM to add. Is that the desired behavior?

Would it be better to get it through tool_context.invocation_state["a2a_metadata"]?

@prashant1rana
Copy link
Author

prashant1rana commented Feb 13, 2026

Hi, can I ask what the use case is? The way you are adding the metadata right now, it's another parameter for LLM to add. Is that the desired behavior?

I was considering an approach like this on the client side to have greater control over the metadata.

class MetadataAwareA2AClient(A2AClientToolProvider):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._original_send_message = self._send_message

    @tool
    async def a2a_send_message(
        self,
        message_text: str,
        target_agent_url: str,
        message_id: str | None = None,
        metadata: dict[str, Any] | None = None,
        tool_context: dict[str, Any] | None = None,
        timestamp: str | None = None,
    ) -> dict[str, Any]:
        if metadata is None and tool_context:
            invocation_state = tool_context.get("invocation_state", {})
            metadata = invocation_state.get("metadata", {})

        updated_metadata = {**(metadata or {}), "timestamp": timestamp}

        return await self._original_send_message(message_text, target_agent_url, message_id, updated_metadata)

@prashant1rana
Copy link
Author

prashant1rana commented Feb 13, 2026

Would it be better to get it through tool_context.invocation_state["a2a_metadata"]?

If no metadata is provided, I think we could auto-detect it as a fallback.

async def a2a_send_message(
    self,
    message_text: str,
    target_agent_url: str,
    message_id: str | None = None,
    metadata: dict[str, Any] | None = None,
    tool_context: ToolContext | None = None, 
) -> dict[str, Any]:
    # Auto-extract metadata from tool_context
    if metadata is None and tool_context is not None:
        invocation_state = tool_context.invocation_state
        metadata = invocation_state.get("metadata")
    
    return await self._send_message(message_text, target_agent_url, message_id, metadata)

@prashant1rana
Copy link
Author

Hi, can I ask what the use case is?

My use case involves passing a portion of the metadata to the A2A sub-agents, which can be solved directly using tool_context.

@mkmeral
Copy link
Contributor

mkmeral commented Feb 13, 2026

So, I have done a bit more research (see below). The concern is now that we are adding this tool, it can break anyone who uses it, because now LLM can hallucinate metadata, and it will be sent to the A2A servers. It can have unintended consequences.

I think we should to move to tool context method only, unless there is an actual need to generate metadata from LLM, but even then, I'd consider it risky for our core SDK, because it might impact other developers who already use the tool

I was considering an approach like this on the client side to have greater control over the metadata.

I think we are aligned in the sense that we want devs to have greater control over metadata, but this approach gives the control to LLM by default, and it's risky

Agent investigation: A2A metadata hallucination risk analysis

What the PR does: Adds an optional metadata: dict[str, Any] | None parameter to a2a_send_message, which gets passed through to the A2A SDK's client.send_message(message, request_metadata=metadata). This ends up in MessageSendParams.metadata in the JSON-RPC request.

What A2A metadata actually is: In the A2A protocol spec, metadata on MessageSendParams is defined as an optional google.protobuf.Struct (basically a free-form JSON dict). It's described as "Extensions Metadata attached to the request" in the a2a-python SDK. It's meant for passing extension-specific data alongside the message, things like session IDs, trace IDs, or custom extension payloads. The receiving A2A server can read it or ignore it.

Your core question: if the LLM hallucinates metadata, would it break stuff?

Short answer: it won't break the protocol, but it's a real concern.

Here's why:

  1. Protocol-level safety: The metadata field is optional and free-form (dict[str, Any]). The A2A spec and SDK treat it as opaque extension data. Unknown keys are simply ignored by servers that don't care about them. So hallucinated metadata like {"priority": "urgent", "session_id": "abc123"} won't cause protocol errors or crashes.

  2. The real risk is semantic, not structural. If the LLM fabricates metadata that a receiving server does interpret (for example, a session_id that doesn't exist, or a trace_id that's garbage), the remote agent could behave unexpectedly. It won't crash, but it might route to the wrong session, log misleading trace data, or trigger extension logic with nonsensical inputs.

  3. Your comment on the PR is spot on. Exposing metadata as a direct tool parameter means the LLM decides what to put in it. That's the fundamental issue. The LLM has no way to know what metadata keys the remote A2A server expects or cares about. It'll just make something up based on the parameter description. Using tool_context.invocation_state["a2a_metadata"] instead would let the developer control the metadata programmatically, keeping the LLM out of the loop for this field.

  4. Extension-specific metadata can have real consequences. The A2A protocol's extension system means servers can define custom behaviors keyed on metadata. If an extension expects {"auth_token": "..."} and the LLM hallucinates a fake token, that's not just noise, it's a failed auth attempt. If it expects {"billing_account": "..."}, hallucinated values could cause billing issues on the remote side.

Bottom line: Hallucinated metadata won't break the wire protocol (it's just JSON in an optional field), but it can cause incorrect behavior on servers that actually consume that metadata. The safer pattern is what you suggested: source metadata from tool_context.invocation_state so developers control it, rather than letting the LLM populate a free-form dict it doesn't understand.

Copy link
Contributor

@mkmeral mkmeral left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not allow LLM to send metadata by default, as it can impact. I am leaving the review for now to make sure we do not unintentionally merge this

@prashant1rana
Copy link
Author

We should not allow LLM to send metadata by default, as it can impact. I am leaving the review for now to make sure we do not unintentionally merge this

Thanks for calling out, hallucinating metadata could cause real issues. Let me revise the PR.

…llucination

Remove metadata as LLM-facing parameter and source it exclusively from
tool_context to prevent LLM from hallucinating metadata values that could
cause issues on remote A2A servers.

- Add @tool(context=True) to exclude tool_context from LLM schema
- Remove metadata parameter from a2a_send_message signature
- Use ToolContext type annotation for type safety
- Extract metadata from tool_context.invocation_state.metadata
- Update docstring to clarify metadata sourcing
- Update tests to pass metadata via tool_context with ToolContext type
- Add tests for None and empty tool_context cases
- All 36 tests passing
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants