Skip to content

Commit d215033

Browse files
authored
Merge branch 'main' into fix/stream-reasoning-engine-stopiteration
2 parents 502ba17 + 2e28e5d commit d215033

12 files changed

Lines changed: 613 additions & 105 deletions

File tree

.github/workflows/analyze-releases-for-adk-docs-updates.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ jobs:
5252
uses: actions/cache/restore@v4
5353
with:
5454
path: contributing/samples/adk_team/adk_documentation/adk_release_analyzer/sessions.db
55-
key: analyzer-session-db
55+
key: analyzer-session-db-${{ github.run_id }}-${{ github.run_attempt }}
56+
restore-keys: |
57+
analyzer-session-db-
5658
5759
- name: Run Analyzing Script
5860
env:
@@ -88,4 +90,4 @@ jobs:
8890
uses: actions/cache/save@v4
8991
with:
9092
path: contributing/samples/adk_team/adk_documentation/adk_release_analyzer/sessions.db
91-
key: analyzer-session-db
93+
key: analyzer-session-db-${{ github.run_id }}-${{ github.run_attempt }}

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ part before or alongside your code PR.
133133
1. **Clone the repository:**
134134

135135
```shell
136-
gh repo clone google/adk-python -- -b v2
136+
gh repo clone google/adk-python
137137
cd adk-python
138138
```
139139

contributing/samples/mcp/mcp_sse_mtls_agent/generate_mtls_certs.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
11
#!/bin/bash
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
216
set -e
317

418
# Directory where this script is located

src/google/adk/a2a/executor/a2a_agent_executor.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,10 @@ async def _handle_request(
251251
)
252252

253253
task_result_aggregator = TaskResultAggregator()
254+
last_adk_event = None
254255
async with Aclosing(runner.run_async(**vars(run_request))) as agen:
255256
async for adk_event in agen:
257+
last_adk_event = adk_event
256258
for a2a_event in self._config.event_converter(
257259
adk_event,
258260
invocation_context,
@@ -270,6 +272,22 @@ async def _handle_request(
270272
task_result_aggregator.process_event(e)
271273
await event_queue.enqueue_event(e)
272274

275+
# Build metadata for final event to preserve invocation_id and event_id.
276+
final_metadata = {
277+
_get_adk_metadata_key('app_name'): runner.app_name,
278+
_get_adk_metadata_key('user_id'): run_request.user_id,
279+
_get_adk_metadata_key('session_id'): run_request.session_id,
280+
}
281+
if last_adk_event:
282+
for key, attr in [
283+
('invocation_id', 'invocation_id'),
284+
('author', 'author'),
285+
('event_id', 'id'),
286+
]:
287+
val = getattr(last_adk_event, attr, None)
288+
if val is not None:
289+
final_metadata[_get_adk_metadata_key(key)] = val
290+
273291
# publish the task result event - this is final
274292
if (
275293
task_result_aggregator.task_state == TaskState.working
@@ -287,6 +305,7 @@ async def _handle_request(
287305
artifact_id=platform_uuid.new_uuid(),
288306
parts=task_result_aggregator.task_status_message.parts,
289307
),
308+
metadata=final_metadata,
290309
)
291310
)
292311
# public the final status update event
@@ -299,6 +318,7 @@ async def _handle_request(
299318
).isoformat(),
300319
),
301320
context_id=context.context_id,
321+
metadata=final_metadata,
302322
final=True,
303323
)
304324
else:
@@ -312,6 +332,7 @@ async def _handle_request(
312332
message=task_result_aggregator.task_status_message,
313333
),
314334
context_id=context.context_id,
335+
metadata=final_metadata,
315336
final=True,
316337
)
317338

src/google/adk/apps/compaction.py

Lines changed: 20 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -266,13 +266,7 @@ def _events_to_compact_for_token_threshold(
266266
event_retention_size=event_retention_size,
267267
)
268268
events_to_compact = candidate_events[:split_index]
269-
pending_ids = _pending_function_call_ids(events)
270-
events_to_compact = _truncate_events_before_pending_function_call(
271-
events_to_compact, pending_ids
272-
)
273-
events_to_compact = _truncate_events_before_hitl_signal(
274-
events_to_compact, _resolved_hitl_call_ids(events)
275-
)
269+
events_to_compact = _longest_self_contained_prefix(events_to_compact)
276270
if not events_to_compact:
277271
return []
278272

@@ -313,76 +307,28 @@ def _event_function_response_ids(event: Event) -> set[str]:
313307
return function_response_ids
314308

315309

316-
def _pending_function_call_ids(events: list[Event]) -> set[str]:
317-
"""Returns function call IDs that have no matching response in the session.
310+
def _longest_self_contained_prefix(events: list[Event]) -> list[Event]:
311+
"""Returns the longest prefix of `events` that is safe to compact.
318312
319-
Scans the session once, collecting function call IDs and response IDs, then
320-
returns the call IDs that are not covered by any response. Events containing
321-
these IDs represent pending (unanswered) function calls that must not be
322-
compacted.
313+
Performs a single left-to-right pass tracking "open" obligations keyed by call
314+
id: a function call or a tool-confirmation / auth request opens one, and a
315+
function response with the same id closes it. Responses are applied before
316+
opens within each event so a response only closes an obligation opened by an
317+
earlier event. The prefix is safe to summarize only at points where no
318+
obligation is open, so the longest prefix ending at such a balanced point is
319+
returned (empty if the window never reaches a balanced point).
323320
"""
324-
all_call_ids: set[str] = set()
325-
all_response_ids: set[str] = set()
326-
for event in events:
327-
all_call_ids.update(_event_function_call_ids(event))
328-
all_response_ids.update(_event_function_response_ids(event))
329-
330-
return all_call_ids - all_response_ids
331-
332-
333-
def _has_pending_function_call(event: Event, pending_ids: set[str]) -> bool:
334-
"""Returns True if the event contains any pending function call."""
335-
call_ids = _event_function_call_ids(event)
336-
return bool(call_ids and not call_ids.isdisjoint(pending_ids))
337-
338-
339-
def _truncate_events_before_pending_function_call(
340-
events: list[Event], pending_ids: set[str]
341-
) -> list[Event]:
342-
"""Returns the leading contiguous events that avoid pending function calls."""
343-
for index, event in enumerate(events):
344-
if _has_pending_function_call(event, pending_ids):
345-
return events[:index]
346-
return events
347-
348-
349-
def _resolved_hitl_call_ids(events: list[Event]) -> set[str]:
350-
"""Returns HITL call ids resolved by a later function_response in `events`."""
351-
hitl_position: dict[str, int] = {}
352-
resolved: set[str] = set()
321+
open_ids: set[str] = set()
322+
safe_length = 0
353323
for index, event in enumerate(events):
324+
open_ids -= _event_function_response_ids(event)
325+
open_ids |= _event_function_call_ids(event)
354326
if event.actions:
355-
for call_id in event.actions.requested_tool_confirmations:
356-
hitl_position.setdefault(call_id, index)
357-
for call_id in event.actions.requested_auth_configs:
358-
hitl_position.setdefault(call_id, index)
359-
for resp_id in _event_function_response_ids(event):
360-
hitl_pos = hitl_position.get(resp_id)
361-
if hitl_pos is not None and index > hitl_pos:
362-
resolved.add(resp_id)
363-
return resolved
364-
365-
366-
def _is_pending_hitl(event: Event, resolved_call_ids: set[str]) -> bool:
367-
"""Returns True if the event has an HITL request not in `resolved_call_ids`."""
368-
if not event.actions:
369-
return False
370-
requested = set(event.actions.requested_tool_confirmations) | set(
371-
event.actions.requested_auth_configs
372-
)
373-
if not requested:
374-
return False
375-
return bool(requested - resolved_call_ids)
376-
377-
378-
def _truncate_events_before_hitl_signal(
379-
events: list[Event], resolved_call_ids: set[str]
380-
) -> list[Event]:
381-
"""Returns the leading contiguous events before any pending HITL request."""
382-
for index, event in enumerate(events):
383-
if _is_pending_hitl(event, resolved_call_ids):
384-
return events[:index]
385-
return events
327+
open_ids |= set(event.actions.requested_tool_confirmations)
328+
open_ids |= set(event.actions.requested_auth_configs)
329+
if not open_ids:
330+
safe_length = index + 1
331+
return events[:safe_length]
386332

387333

388334
def _safe_token_compaction_split_index(
@@ -664,13 +610,7 @@ async def _run_compaction_for_sliding_window(
664610
events_to_compact = [
665611
e for e in events_to_compact if not e.actions.compaction
666612
]
667-
pending_ids = _pending_function_call_ids(events)
668-
events_to_compact = _truncate_events_before_pending_function_call(
669-
events_to_compact, pending_ids
670-
)
671-
events_to_compact = _truncate_events_before_hitl_signal(
672-
events_to_compact, _resolved_hitl_call_ids(events)
673-
)
613+
events_to_compact = _longest_self_contained_prefix(events_to_compact)
674614

675615
if not events_to_compact:
676616
return None

src/google/adk/flows/llm_flows/contents.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@ def _rearrange_events_for_async_function_responses_in_history(
108108
events: list[Event],
109109
) -> list[Event]:
110110
"""Rearrange the async function_response events in the history."""
111-
112111
function_call_id_to_response_events_index: dict[str, int] = {}
113112
for i, event in enumerate(events):
114113
function_responses = event.get_function_responses()
@@ -117,6 +116,9 @@ def _rearrange_events_for_async_function_responses_in_history(
117116
function_call_id = function_response.id
118117
function_call_id_to_response_events_index[function_call_id] = i
119118

119+
if not function_call_id_to_response_events_index:
120+
return events
121+
120122
result_events: list[Event] = []
121123
for event in events:
122124
if event.get_function_responses():

src/google/adk/models/anthropic_llm.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -553,11 +553,14 @@ async def generate_content_async(
553553
else NOT_GIVEN
554554
)
555555
thinking = _build_anthropic_thinking_param(llm_request.config)
556+
system = NOT_GIVEN
557+
if llm_request.config.system_instruction is not None:
558+
system = llm_request.config.system_instruction
556559

557560
if not stream:
558561
message = await self._anthropic_client.messages.create(
559562
model=model_to_use,
560-
system=llm_request.config.system_instruction,
563+
system=system,
561564
messages=messages,
562565
tools=tools,
563566
tool_choice=tool_choice,
@@ -567,14 +570,15 @@ async def generate_content_async(
567570
yield message_to_generate_content_response(message)
568571
else:
569572
async for response in self._generate_content_streaming(
570-
llm_request, messages, tools, tool_choice, thinking
573+
llm_request, messages, system, tools, tool_choice, thinking
571574
):
572575
yield response
573576

574577
async def _generate_content_streaming(
575578
self,
576579
llm_request: LlmRequest,
577580
messages: list[anthropic_types.MessageParam],
581+
system: Union[str, types.Content, NotGiven],
578582
tools: Union[Iterable[anthropic_types.ToolUnionParam], NotGiven],
579583
tool_choice: Union[anthropic_types.ToolChoiceParam, NotGiven],
580584
thinking: Union[
@@ -591,7 +595,7 @@ async def _generate_content_streaming(
591595
model_to_use = self._resolve_model_name(llm_request.model)
592596
raw_stream = await self._anthropic_client.messages.create(
593597
model=model_to_use,
594-
system=llm_request.config.system_instruction,
598+
system=system,
595599
messages=messages,
596600
tools=tools,
597601
tool_choice=tool_choice,

tests/unittests/a2a/executor/test_a2a_agent_executor.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,3 +1072,79 @@ async def mock_run_async(**kwargs):
10721072
assert (
10731073
modified_a2a_event in enqueued_events
10741074
), "The modified event should have been enqueued"
1075+
1076+
@pytest.mark.asyncio
1077+
async def test_handle_request_preserves_metadata_in_final_events(
1078+
self,
1079+
) -> None:
1080+
"""Test that final events preserve invocation_id, author, and event_id in metadata."""
1081+
# Setup context with task_id
1082+
self.mock_context.task_id = "test-task-id"
1083+
self.mock_context.context_id = "test-context-id"
1084+
1085+
# Setup detailed mocks
1086+
self.mock_request_converter.return_value = AgentRunRequest(
1087+
user_id="test-user",
1088+
session_id="test-session",
1089+
new_message=Mock(spec=Content),
1090+
run_config=Mock(spec=RunConfig),
1091+
)
1092+
1093+
# Mock session service
1094+
mock_session = Mock()
1095+
mock_session.id = "test-session"
1096+
self.mock_runner.session_service.get_session = AsyncMock(
1097+
return_value=mock_session
1098+
)
1099+
1100+
# Mock invocation context
1101+
mock_invocation_context = Mock()
1102+
self.mock_runner._new_invocation_context.return_value = (
1103+
mock_invocation_context
1104+
)
1105+
1106+
# Mock ADK event with specific metadata to preserve
1107+
mock_adk_event = Mock(spec=Event)
1108+
mock_adk_event.invocation_id = "test-invocation-id"
1109+
mock_adk_event.author = "test-author"
1110+
mock_adk_event.id = "test-event-id"
1111+
1112+
# Configure run_async to yield our mock ADK event
1113+
async def mock_run_async(**kwargs):
1114+
async for item in self._create_async_generator([mock_adk_event]):
1115+
yield item
1116+
1117+
self.mock_runner.run_async = mock_run_async
1118+
self.mock_event_converter.return_value = [Mock()]
1119+
1120+
with patch(
1121+
"google.adk.a2a.executor.a2a_agent_executor.TaskResultAggregator"
1122+
) as mock_aggregator_class:
1123+
mock_aggregator = Mock()
1124+
mock_aggregator.task_state = TaskState.completed
1125+
mock_aggregator.task_status_message = Mock(spec=Message)
1126+
mock_aggregator_class.return_value = mock_aggregator
1127+
1128+
# Execute
1129+
await self.executor._handle_request(
1130+
self.mock_context, self.mock_event_queue
1131+
)
1132+
1133+
# Verify final status event was published and has correct metadata
1134+
final_events = [
1135+
call[0][0]
1136+
for call in self.mock_event_queue.enqueue_event.call_args_list
1137+
if hasattr(call[0][0], "final") and call[0][0].final == True
1138+
]
1139+
assert len(final_events) >= 1
1140+
final_event = final_events[-1]
1141+
1142+
assert final_event.metadata is not None
1143+
assert (
1144+
final_event.metadata.get("adk_invocation_id") == "test-invocation-id"
1145+
)
1146+
assert final_event.metadata.get("adk_author") == "test-author"
1147+
assert final_event.metadata.get("adk_event_id") == "test-event-id"
1148+
assert final_event.metadata.get("adk_app_name") == "test-app"
1149+
assert final_event.metadata.get("adk_user_id") == "test-user"
1150+
assert final_event.metadata.get("adk_session_id") == "test-session"

0 commit comments

Comments
 (0)