Skip to content

Commit e10a1be

Browse files
KrshnKushcopybara-github
authored andcommitted
fix: allow forwarding session ID as context_id for remote A2A agents
Merge #4023 PiperOrigin-RevId: 981320716
1 parent 75bf1dc commit e10a1be

3 files changed

Lines changed: 150 additions & 1 deletion

File tree

src/google/adk/a2a/agent/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ class A2aRemoteAgentConfig(BaseModel):
138138
card_request_interceptors: list[CardRequestInterceptor] | None = None
139139
"""Interceptors that inject headers into the remote agent card fetch."""
140140

141+
forward_session_id_as_context_id: bool = False
142+
"""Whether to forward the local session ID as context_id when no context_id is present."""
143+
141144
def __deepcopy__(
142145
self, memo: dict[int, Any] | None = None
143146
) -> A2aRemoteAgentConfig:

src/google/adk/agents/remote_a2a_agent.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1611,11 +1611,18 @@ async def _run_async_impl(
16111611
)
16121612
return
16131613

1614+
session_id = (
1615+
getattr(ctx.session, "id", None)
1616+
if self._config.forward_session_id_as_context_id
1617+
and ctx
1618+
and getattr(ctx, "session", None)
1619+
else None
1620+
)
16141621
a2a_request = A2AMessage(
16151622
message_id=platform_uuid.new_uuid(),
16161623
parts=message_parts,
16171624
role=_compat.ROLE_USER,
1618-
context_id=context_id,
1625+
context_id=context_id or session_id,
16191626
)
16201627

16211628
logger.debug(build_a2a_request_log(a2a_request))

tests/unittests/agents/test_remote_a2a_agent.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3421,6 +3421,7 @@ def setup_method(self):
34213421
self.mock_config.a2a_artifact_update_converter = Mock()
34223422
self.mock_config.a2a_message_converter = Mock()
34233423
self.mock_config.card_request_interceptors = None
3424+
self.mock_config.forward_session_id_as_context_id = False
34243425

34253426
self.agent = RemoteA2aAgent(
34263427
name="test_agent",
@@ -3998,6 +3999,138 @@ async def test_run_async_impl_successful_request(self):
39983999
in mock_event.custom_metadata
39994000
)
40004001

4002+
async def _run_context_id_test(
4003+
self,
4004+
mock_context_id: str | None,
4005+
expected_context_id: str | None,
4006+
*,
4007+
forward_session_id: bool = False,
4008+
):
4009+
"""Helper to test context_id handling in _run_async_impl.
4010+
4011+
Args:
4012+
mock_context_id: The context_id to return from
4013+
_construct_message_parts_from_session.
4014+
expected_context_id: The expected context_id in the A2AMessage.
4015+
forward_session_id: Value for forward_session_id_as_context_id config.
4016+
"""
4017+
self.agent._config.forward_session_id_as_context_id = forward_session_id
4018+
with patch.object(self.agent, "_ensure_resolved") as mock_ensure_resolved:
4019+
with patch.object(
4020+
self.agent, "_create_a2a_request_for_user_function_response"
4021+
) as mock_create_func:
4022+
mock_create_func.return_value = None
4023+
4024+
with patch.object(
4025+
self.agent, "_construct_message_parts_from_session"
4026+
) as mock_construct:
4027+
mock_a2a_part = _compat.make_text_part("test")
4028+
mock_construct.return_value = ([mock_a2a_part], mock_context_id)
4029+
4030+
# Mock A2A client
4031+
mock_a2a_client = create_autospec(spec=A2AClient, instance=True)
4032+
mock_response = _make_stream_message(
4033+
A2AMessage(
4034+
message_id="m1",
4035+
role=_compat.ROLE_USER,
4036+
parts=[mock_a2a_part],
4037+
)
4038+
)
4039+
mock_send_message = AsyncMock()
4040+
mock_send_message.__aiter__.return_value = [mock_response]
4041+
mock_a2a_client.send_message.return_value = mock_send_message
4042+
self.agent._a2a_client = mock_a2a_client
4043+
mock_ensure_resolved.return_value = mock_a2a_client
4044+
4045+
mock_event = Event(
4046+
author=self.agent.name,
4047+
invocation_id=self.mock_context.invocation_id,
4048+
branch=self.mock_context.branch,
4049+
)
4050+
4051+
with patch.object(self.agent, "_handle_a2a_response") as mock_handle:
4052+
mock_handle.return_value = mock_event
4053+
4054+
with patch(
4055+
"google.adk.agents.remote_a2a_agent.build_a2a_request_log"
4056+
) as mock_req_log:
4057+
with patch(
4058+
"google.adk.agents.remote_a2a_agent.build_a2a_response_log"
4059+
) as mock_resp_log:
4060+
mock_req_log.return_value = "Mock request log"
4061+
mock_resp_log.return_value = "Mock response log"
4062+
4063+
with patch(
4064+
"google.adk.a2a._compat.a2a_to_dict",
4065+
return_value={"k": "v"},
4066+
):
4067+
with patch(
4068+
"google.adk.agents.remote_a2a_agent.A2AMessage"
4069+
) as mock_message_class:
4070+
mock_message = Mock(spec=A2AMessage)
4071+
mock_message_class.return_value = mock_message
4072+
4073+
# Execute
4074+
events = []
4075+
async for event in self.agent._run_async_impl(
4076+
self.mock_context
4077+
):
4078+
events.append(event)
4079+
4080+
# Verify A2AMessage was called with expected context_id
4081+
mock_message_class.assert_called_once()
4082+
call_kwargs = mock_message_class.call_args[1]
4083+
assert call_kwargs["context_id"] == expected_context_id
4084+
4085+
@pytest.mark.asyncio
4086+
async def test_run_async_impl_does_not_forward_session_id_by_default(self):
4087+
"""Test that session ID is not used as context_id by default.
4088+
4089+
When forward_session_id_as_context_id is False (default) and
4090+
_construct_message_parts_from_session returns None for context_id,
4091+
the agent should not set context_id.
4092+
"""
4093+
await self._run_context_id_test(
4094+
mock_context_id=None,
4095+
expected_context_id=None,
4096+
forward_session_id=False,
4097+
)
4098+
4099+
@pytest.mark.asyncio
4100+
async def test_run_async_impl_uses_session_id_when_opted_in(self):
4101+
"""Test that session ID is used as context_id when opted in.
4102+
4103+
When forward_session_id_as_context_id is True and
4104+
_construct_message_parts_from_session returns None for context_id,
4105+
the agent should use ctx.session.id to maintain session identity across
4106+
local and remote agents.
4107+
"""
4108+
await self._run_context_id_test(
4109+
mock_context_id=None,
4110+
expected_context_id=self.mock_session.id,
4111+
forward_session_id=True,
4112+
)
4113+
4114+
@pytest.mark.asyncio
4115+
async def test_run_async_impl_preserves_existing_context_id(self):
4116+
"""Test that existing context_id is preserved when available.
4117+
4118+
When _construct_message_parts_from_session returns a context_id from
4119+
a previous remote agent response, that context_id should be used
4120+
for conversation continuity regardless of forward_session_id_as_context_id.
4121+
"""
4122+
existing_context_id = "existing-context-456"
4123+
await self._run_context_id_test(
4124+
mock_context_id=existing_context_id,
4125+
expected_context_id=existing_context_id,
4126+
forward_session_id=False,
4127+
)
4128+
await self._run_context_id_test(
4129+
mock_context_id=existing_context_id,
4130+
expected_context_id=existing_context_id,
4131+
forward_session_id=True,
4132+
)
4133+
40014134
@pytest.mark.asyncio
40024135
async def test_run_async_impl_closes_stream_when_abandoned(self):
40034136
"""The A2A stream is closed when the caller stops consuming early."""
@@ -5147,6 +5280,11 @@ def test_deepcopy_config(self):
51475280
is not config.request_interceptors[0]
51485281
)
51495282

5283+
# Verify forward_session_id_as_context_id default and deepcopy
5284+
assert copied_config.forward_session_id_as_context_id is False
5285+
config.forward_session_id_as_context_id = True
5286+
assert copy.deepcopy(config).forward_session_id_as_context_id is True
5287+
51505288

51515289
class TestFindFinishTaskArgsFromHistory:
51525290
"""Test _find_finish_task_args_from_history helper function."""
@@ -6374,6 +6512,7 @@ def _make_agent():
63746512
def _make_ctx(events):
63756513
ctx = create_autospec(InvocationContext, instance=True)
63766514
ctx.session = create_autospec(Session, instance=True)
6515+
ctx.session.id = "session-123"
63776516
ctx.session.events = events
63786517
ctx.invocation_id = "inv-1"
63796518
ctx.branch = None

0 commit comments

Comments
 (0)