Skip to content

Commit cdea975

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix-thought-signature-pruning
2 parents e354840 + 6290aec commit cdea975

6 files changed

Lines changed: 248 additions & 25 deletions

File tree

src/google/adk/agents/run_config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,14 @@ class RunConfig(BaseModel):
379379
callers provide per-turn context without changing the conversation history.
380380
"""
381381

382+
include_thoughts_from_other_agents: bool = False
383+
"""Whether to include other agents' thought parts in LLM context.
384+
385+
By default, thoughts from other agents are excluded when their messages are
386+
reformatted as user context for the current agent. Enable this only when
387+
agents are expected to share internal reasoning with one another.
388+
"""
389+
382390
@model_validator(mode='before')
383391
@classmethod
384392
def check_for_deprecated_save_live_audio(cls, data: Any) -> Any:

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

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ async def run_async(
8181
# Preserve all contents that were added by instruction processor
8282
# (since llm_request.contents will be completely reassigned below)
8383
instruction_related_contents = llm_request.contents
84+
run_config = invocation_context.run_config
85+
include_thoughts_from_other_agents = (
86+
run_config.include_thoughts_from_other_agents
87+
if run_config is not None
88+
else False
89+
)
8490

8591
is_single_turn = getattr(agent, 'mode', None) == 'single_turn'
8692
if agent.include_contents == 'default':
@@ -93,6 +99,7 @@ async def run_async(
9399
isolation_scope=invocation_context.isolation_scope,
94100
is_single_turn=is_single_turn,
95101
user_content=invocation_context.user_content,
102+
include_thoughts_from_other_agents=include_thoughts_from_other_agents,
96103
)
97104
else:
98105
# Include current turn context only (no conversation history)
@@ -104,6 +111,7 @@ async def run_async(
104111
isolation_scope=invocation_context.isolation_scope,
105112
is_single_turn=is_single_turn,
106113
user_content=invocation_context.user_content,
114+
include_thoughts_from_other_agents=False,
107115
)
108116

109117
if (
@@ -274,7 +282,9 @@ def _rearrange_events_for_latest_function_response(
274282
return result_events
275283

276284

277-
def _is_part_invisible(p: types.Part) -> bool:
285+
def _is_part_invisible(
286+
p: types.Part, *, include_thoughts: bool = False
287+
) -> bool:
278288
"""Returns whether a part is invisible for LLM context.
279289
280290
A part is invisible if:
@@ -294,7 +304,7 @@ def _is_part_invisible(p: types.Part) -> bool:
294304
if p.function_call or p.function_response:
295305
return False
296306

297-
return p.thought or not (
307+
return (p.thought and not include_thoughts) or not (
298308
p.text
299309
or p.inline_data
300310
or p.file_data
@@ -303,7 +313,9 @@ def _is_part_invisible(p: types.Part) -> bool:
303313
)
304314

305315

306-
def _contains_empty_content(event: Event) -> bool:
316+
def _contains_empty_content(
317+
event: Event, *, include_thoughts: bool = False
318+
) -> bool:
307319
"""Check if an event should be skipped due to missing or empty content.
308320
309321
This can happen to the events that only changed session state.
@@ -325,7 +337,10 @@ def _contains_empty_content(event: Event) -> bool:
325337
not event.content
326338
or not event.content.role
327339
or not event.content.parts
328-
or all(_is_part_invisible(p) for p in event.content.parts)
340+
or all(
341+
_is_part_invisible(p, include_thoughts=include_thoughts)
342+
for p in event.content.parts
343+
)
329344
) and (not event.output_transcription and not event.input_transcription)
330345

331346

@@ -393,6 +408,8 @@ def _should_include_event_in_context(
393408
current_branch: Optional[str],
394409
event: Event,
395410
isolation_scope: Optional[str] = None,
411+
*,
412+
include_thoughts: bool = False,
396413
) -> bool:
397414
"""Determines if an event should be included in the LLM context.
398415
@@ -418,7 +435,7 @@ def _should_include_event_in_context(
418435
if ev_iso != isolation_scope:
419436
return False
420437
return not (
421-
_contains_empty_content(event)
438+
_contains_empty_content(event, include_thoughts=include_thoughts)
422439
or not _is_event_belongs_to_branch(current_branch, event)
423440
or _is_adk_framework_event(event)
424441
or _is_auth_event(event)
@@ -577,6 +594,7 @@ def _get_contents(
577594
isolation_scope: Optional[str] = None,
578595
is_single_turn: bool = False,
579596
user_content: Optional[types.Content] = None,
597+
include_thoughts_from_other_agents: bool = False,
580598
) -> list[types.Content]:
581599
"""Get the contents for the LLM request.
582600
@@ -592,6 +610,8 @@ def _get_contents(
592610
user_content: Fallback first user turn for task agents whose
593611
originating delegation FC is not in session (workflow-node
594612
task case).
613+
include_thoughts_from_other_agents: Whether to include thought parts from
614+
other agents when presenting their messages as user context.
595615
596616
Returns:
597617
A list of processed contents.
@@ -624,7 +644,13 @@ def _get_contents(
624644
e
625645
for e in rewind_filtered_events
626646
if _should_include_event_in_context(
627-
current_branch, e, isolation_scope=isolation_scope
647+
current_branch,
648+
e,
649+
isolation_scope=isolation_scope,
650+
include_thoughts=(
651+
include_thoughts_from_other_agents
652+
and _is_other_agent_reply(agent_name, e)
653+
),
628654
)
629655
]
630656

@@ -699,7 +725,9 @@ def _get_contents(
699725
break
700726

701727
if is_other_reply:
702-
if converted_event := _present_other_agent_message(event):
728+
if converted_event := _present_other_agent_message(
729+
event, include_thoughts=include_thoughts_from_other_agents
730+
):
703731
filtered_events.append(converted_event)
704732
else:
705733
filtered_events.append(event)
@@ -752,6 +780,7 @@ def _get_current_turn_contents(
752780
is_single_turn: bool = False,
753781
isolation_scope: Optional[str] = None,
754782
user_content: Optional[types.Content] = None,
783+
include_thoughts_from_other_agents: bool = False,
755784
) -> list[types.Content]:
756785
"""Get contents for the current turn only (no conversation history).
757786
@@ -768,6 +797,8 @@ def _get_current_turn_contents(
768797
events: A list of all session events.
769798
agent_name: The name of the agent.
770799
preserve_function_call_ids: Whether to preserve function call ids.
800+
include_thoughts_from_other_agents: Whether to include thought parts from
801+
other agents when presenting their messages as user context.
771802
772803
Returns:
773804
A list of contents for the current turn only, preserving context needed
@@ -778,7 +809,13 @@ def _get_current_turn_contents(
778809
event = events[i]
779810
if (
780811
_should_include_event_in_context(
781-
current_branch, event, isolation_scope=isolation_scope
812+
current_branch,
813+
event,
814+
isolation_scope=isolation_scope,
815+
include_thoughts=(
816+
include_thoughts_from_other_agents
817+
and _is_other_agent_reply(agent_name, event)
818+
),
782819
)
783820
and (event.author == 'user' or _is_other_agent_reply(agent_name, event))
784821
and not _is_direct_transfer(event)
@@ -791,6 +828,7 @@ def _get_current_turn_contents(
791828
isolation_scope=isolation_scope,
792829
is_single_turn=is_single_turn,
793830
user_content=user_content,
831+
include_thoughts_from_other_agents=include_thoughts_from_other_agents,
794832
)
795833

796834
return []
@@ -851,14 +889,18 @@ def _is_other_agent_reply(current_agent_name: str, event: Event) -> bool:
851889
)
852890

853891

854-
def _present_other_agent_message(event: Event) -> Optional[Event]:
892+
def _present_other_agent_message(
893+
event: Event, *, include_thoughts: bool = False
894+
) -> Optional[Event]:
855895
"""Presents another agent's message as user context for the current agent.
856896
857897
Reformats the event with role='user' and adds '[agent_name] said:' prefix
858898
to provide context without confusion about authorship.
859899
860900
Args:
861901
event: The event from another agent to present as context.
902+
include_thoughts: Whether to include thought parts as explicit text
903+
context.
862904
863905
Returns:
864906
Event reformatted as user-role context with agent attribution, or None
@@ -872,7 +914,10 @@ def _present_other_agent_message(event: Event) -> Optional[Event]:
872914
content.parts = [types.Part(text='For context:')]
873915
for part in event.content.parts:
874916
if part.thought:
875-
# Exclude thoughts from the context.
917+
if include_thoughts and part.text is not None and part.text.strip():
918+
content.parts.append(
919+
types.Part(text=f'[{event.author}] thought: {part.text}')
920+
)
876921
continue
877922
elif part.text is not None and part.text.strip():
878923
content.parts.append(

src/google/adk/models/lite_llm.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,28 @@ def _extract_reasoning_tokens(usage: Any) -> int:
882882
return 0
883883

884884

885+
def _merge_reasoning_texts(reasoning_parts: Iterable[types.Part]) -> str:
886+
"""Merges reasoning text fragments into a single provider payload.
887+
888+
Streaming providers such as vLLM can emit reasoning as token-sized chunks.
889+
ADK stores those chunks as consecutive thought parts, so inserting separators
890+
here changes the model's original reasoning text.
891+
"""
892+
reasoning_texts = []
893+
for part in reasoning_parts:
894+
if part.text:
895+
reasoning_texts.append(part.text)
896+
elif (
897+
part.inline_data
898+
and part.inline_data.data
899+
and part.inline_data.mime_type
900+
and part.inline_data.mime_type.startswith("text/")
901+
):
902+
reasoning_texts.append(_decode_inline_text_data(part.inline_data.data))
903+
904+
return "".join(reasoning_texts)
905+
906+
885907
def _extract_thought_signature_from_tool_call(
886908
tool_call: ChatCompletionMessageToolCall,
887909
) -> Optional[bytes]:
@@ -1088,18 +1110,6 @@ async def _content_to_message_param(
10881110
msg["thinking_blocks"] = thinking_blocks # type: ignore[typeddict-unknown-key]
10891111
return msg
10901112

1091-
reasoning_texts = []
1092-
for part in reasoning_parts:
1093-
if part.text:
1094-
reasoning_texts.append(part.text)
1095-
elif (
1096-
part.inline_data
1097-
and part.inline_data.data
1098-
and part.inline_data.mime_type
1099-
and part.inline_data.mime_type.startswith("text/")
1100-
):
1101-
reasoning_texts.append(_decode_inline_text_data(part.inline_data.data))
1102-
11031113
# Anthropic routes require thinking blocks to be embedded directly in the
11041114
# message content list. LiteLLM's prompt template for Anthropic drops the
11051115
# top-level reasoning_content field, so thinking blocks disappear from
@@ -1128,9 +1138,7 @@ async def _content_to_message_param(
11281138
tool_calls=tool_calls or None,
11291139
)
11301140

1131-
# Preserve reasoning deltas exactly as received. Injecting separators
1132-
# between fragments can corrupt provider-streamed thinking text.
1133-
reasoning_content = "".join(text for text in reasoning_texts if text)
1141+
reasoning_content = _merge_reasoning_texts(reasoning_parts)
11341142
return ChatCompletionAssistantMessage(
11351143
role=role,
11361144
content=final_content,

tests/unittests/agents/test_invocation_context.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,3 +655,40 @@ def test_stamp_event_branch_context_does_not_overwrite_existing_scope(
655655
invocation_context.stamp_event_branch_context(fr_event)
656656
assert fr_event.branch == 'root@1'
657657
assert fr_event.isolation_scope == 'task_123'
658+
659+
def test_find_matching_function_call_when_response_is_not_last_event(
660+
self, test_invocation_context
661+
):
662+
"""Tests that matching function call is found even when response is not the last event in history."""
663+
fc = Part.from_function_call(name='some_tool', args={})
664+
fc.function_call.id = 'test_function_call_id'
665+
fc_event = Event(
666+
invocation_id='inv_1',
667+
author='agent',
668+
content=testing_utils.ModelContent([fc]),
669+
)
670+
fr = Part.from_function_response(
671+
name='some_tool', response={'result': 'ok'}
672+
)
673+
fr.function_response.id = 'test_function_call_id'
674+
fr_event = Event(
675+
invocation_id='inv_1',
676+
author='agent',
677+
content=Content(role='user', parts=[fr]),
678+
)
679+
# Add a subsequent event to the history so that fr_event is NOT the last one
680+
subsequent_event = Event(
681+
invocation_id='inv_1',
682+
author='user',
683+
content=Content(role='user', parts=[Part(text='next user message')]),
684+
)
685+
invocation_context = test_invocation_context(
686+
[fc_event, fr_event, subsequent_event]
687+
)
688+
689+
matching_fc_event = invocation_context._find_matching_function_call(
690+
fr_event
691+
)
692+
assert testing_utils.simplify_content(
693+
matching_fc_event.content
694+
) == testing_utils.simplify_content(fc_event.content)

0 commit comments

Comments
 (0)