Skip to content

Commit 1ff803e

Browse files
committed
feat: add option to include thoughts from other agents in LLM context and merge reasoning chunks
1 parent 69fa777 commit 1ff803e

5 files changed

Lines changed: 213 additions & 26 deletions

File tree

src/google/adk/agents/run_config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,14 @@ class RunConfig(BaseModel):
344344
)
345345
"""
346346

347+
include_thoughts_from_other_agents: bool = False
348+
"""Whether to include other agents' thought parts in LLM context.
349+
350+
By default, thoughts from other agents are excluded when their messages are
351+
reformatted as user context for the current agent. Enable this only when
352+
agents are expected to share internal reasoning with one another.
353+
"""
354+
347355
@model_validator(mode='before')
348356
@classmethod
349357
def check_for_deprecated_save_live_audio(cls, data: Any) -> Any:

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

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ async def run_async(
6767
# Preserve all contents that were added by instruction processor
6868
# (since llm_request.contents will be completely reassigned below)
6969
instruction_related_contents = llm_request.contents
70+
include_thoughts_from_other_agents = (
71+
invocation_context.run_config.include_thoughts_from_other_agents
72+
)
7073

7174
if agent.include_contents == 'default':
7275
# Include full conversation history
@@ -75,6 +78,7 @@ async def run_async(
7578
invocation_context.session.events,
7679
agent.name,
7780
preserve_function_call_ids=preserve_function_call_ids,
81+
include_thoughts_from_other_agents=include_thoughts_from_other_agents,
7882
)
7983
else:
8084
# Include current turn context only (no conversation history)
@@ -83,6 +87,7 @@ async def run_async(
8387
invocation_context.session.events,
8488
agent.name,
8589
preserve_function_call_ids=preserve_function_call_ids,
90+
include_thoughts_from_other_agents=False,
8691
)
8792

8893
# Add instruction-related contents to proper position in conversation
@@ -241,7 +246,9 @@ def _rearrange_events_for_latest_function_response(
241246
return result_events
242247

243248

244-
def _is_part_invisible(p: types.Part) -> bool:
249+
def _is_part_invisible(
250+
p: types.Part, *, include_thoughts: bool = False
251+
) -> bool:
245252
"""Returns whether a part is invisible for LLM context.
246253
247254
A part is invisible if:
@@ -261,7 +268,7 @@ def _is_part_invisible(p: types.Part) -> bool:
261268
if p.function_call or p.function_response:
262269
return False
263270

264-
return p.thought or not (
271+
return (p.thought and not include_thoughts) or not (
265272
p.text
266273
or p.inline_data
267274
or p.file_data
@@ -270,7 +277,9 @@ def _is_part_invisible(p: types.Part) -> bool:
270277
)
271278

272279

273-
def _contains_empty_content(event: Event) -> bool:
280+
def _contains_empty_content(
281+
event: Event, *, include_thoughts: bool = False
282+
) -> bool:
274283
"""Check if an event should be skipped due to missing or empty content.
275284
276285
This can happen to the events that only changed session state.
@@ -292,12 +301,18 @@ def _contains_empty_content(event: Event) -> bool:
292301
not event.content
293302
or not event.content.role
294303
or not event.content.parts
295-
or all(_is_part_invisible(p) for p in event.content.parts)
304+
or all(
305+
_is_part_invisible(p, include_thoughts=include_thoughts)
306+
for p in event.content.parts
307+
)
296308
) and (not event.output_transcription and not event.input_transcription)
297309

298310

299311
def _should_include_event_in_context(
300-
current_branch: Optional[str], event: Event
312+
current_branch: Optional[str],
313+
event: Event,
314+
*,
315+
include_thoughts: bool = False,
301316
) -> bool:
302317
"""Determines if an event should be included in the LLM context.
303318
@@ -313,7 +328,7 @@ def _should_include_event_in_context(
313328
True if the event should be included in the context, False otherwise.
314329
"""
315330
return not (
316-
_contains_empty_content(event)
331+
_contains_empty_content(event, include_thoughts=include_thoughts)
317332
or not _is_event_belongs_to_branch(current_branch, event)
318333
or _is_adk_framework_event(event)
319334
or _is_auth_event(event)
@@ -424,6 +439,7 @@ def _get_contents(
424439
agent_name: str = '',
425440
*,
426441
preserve_function_call_ids: bool = False,
442+
include_thoughts_from_other_agents: bool = False,
427443
) -> list[types.Content]:
428444
"""Get the contents for the LLM request.
429445
@@ -434,6 +450,8 @@ def _get_contents(
434450
events: Events to process.
435451
agent_name: The name of the agent.
436452
preserve_function_call_ids: Whether to preserve function call ids.
453+
include_thoughts_from_other_agents: Whether to include thought parts from
454+
other agents when presenting their messages as user context.
437455
438456
Returns:
439457
A list of processed contents.
@@ -465,7 +483,14 @@ def _get_contents(
465483
raw_filtered_events = [
466484
e
467485
for e in rewind_filtered_events
468-
if _should_include_event_in_context(current_branch, e)
486+
if _should_include_event_in_context(
487+
current_branch,
488+
e,
489+
include_thoughts=(
490+
include_thoughts_from_other_agents
491+
and _is_other_agent_reply(agent_name, e)
492+
),
493+
)
469494
]
470495

471496
has_compaction_events = any(
@@ -515,7 +540,10 @@ def _get_contents(
515540
accumulated_output_transcription = ''
516541

517542
if _is_other_agent_reply(agent_name, event):
518-
if converted_event := _present_other_agent_message(event):
543+
if converted_event := _present_other_agent_message(
544+
event,
545+
include_thoughts=include_thoughts_from_other_agents,
546+
):
519547
filtered_events.append(converted_event)
520548
else:
521549
filtered_events.append(event)
@@ -545,6 +573,7 @@ def _get_current_turn_contents(
545573
agent_name: str = '',
546574
*,
547575
preserve_function_call_ids: bool = False,
576+
include_thoughts_from_other_agents: bool = False,
548577
) -> list[types.Content]:
549578
"""Get contents for the current turn only (no conversation history).
550579
@@ -561,6 +590,8 @@ def _get_current_turn_contents(
561590
events: A list of all session events.
562591
agent_name: The name of the agent.
563592
preserve_function_call_ids: Whether to preserve function call ids.
593+
include_thoughts_from_other_agents: Whether to include thought parts from
594+
other agents when presenting their messages as user context.
564595
565596
Returns:
566597
A list of contents for the current turn only, preserving context needed
@@ -569,14 +600,20 @@ def _get_current_turn_contents(
569600
# Find the latest event that starts the current turn and process from there
570601
for i in range(len(events) - 1, -1, -1):
571602
event = events[i]
572-
if _should_include_event_in_context(current_branch, event) and (
573-
event.author == 'user' or _is_other_agent_reply(agent_name, event)
574-
):
603+
if _should_include_event_in_context(
604+
current_branch,
605+
event,
606+
include_thoughts=(
607+
include_thoughts_from_other_agents
608+
and _is_other_agent_reply(agent_name, event)
609+
),
610+
) and (event.author == 'user' or _is_other_agent_reply(agent_name, event)):
575611
return _get_contents(
576612
current_branch,
577613
events[i:],
578614
agent_name,
579615
preserve_function_call_ids=preserve_function_call_ids,
616+
include_thoughts_from_other_agents=include_thoughts_from_other_agents,
580617
)
581618

582619
return []
@@ -613,14 +650,18 @@ def _is_other_agent_reply(current_agent_name: str, event: Event) -> bool:
613650
)
614651

615652

616-
def _present_other_agent_message(event: Event) -> Optional[Event]:
653+
def _present_other_agent_message(
654+
event: Event, *, include_thoughts: bool = False
655+
) -> Optional[Event]:
617656
"""Presents another agent's message as user context for the current agent.
618657
619658
Reformats the event with role='user' and adds '[agent_name] said:' prefix
620659
to provide context without confusion about authorship.
621660
622661
Args:
623662
event: The event from another agent to present as context.
663+
include_thoughts: Whether to include thought parts as explicit text
664+
context.
624665
625666
Returns:
626667
Event reformatted as user-role context with agent attribution, or None
@@ -634,7 +675,10 @@ def _present_other_agent_message(event: Event) -> Optional[Event]:
634675
content.parts = [types.Part(text='For context:')]
635676
for part in event.content.parts:
636677
if part.thought:
637-
# Exclude thoughts from the context.
678+
if include_thoughts and part.text is not None and part.text.strip():
679+
content.parts.append(
680+
types.Part(text=f'[{event.author}] thought: {part.text}')
681+
)
638682
continue
639683
elif part.text is not None and part.text.strip():
640684
content.parts.append(

src/google/adk/models/lite_llm.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,28 @@ def _extract_reasoning_tokens(usage: Any) -> int:
718718
return 0
719719

720720

721+
def _merge_reasoning_texts(reasoning_parts: Iterable[types.Part]) -> str:
722+
"""Merges reasoning text fragments into a single provider payload.
723+
724+
Streaming providers such as vLLM can emit reasoning as token-sized chunks.
725+
ADK stores those chunks as consecutive thought parts, so inserting separators
726+
here changes the model's original reasoning text.
727+
"""
728+
reasoning_texts = []
729+
for part in reasoning_parts:
730+
if part.text:
731+
reasoning_texts.append(part.text)
732+
elif (
733+
part.inline_data
734+
and part.inline_data.data
735+
and part.inline_data.mime_type
736+
and part.inline_data.mime_type.startswith("text/")
737+
):
738+
reasoning_texts.append(_decode_inline_text_data(part.inline_data.data))
739+
740+
return "".join(reasoning_texts)
741+
742+
721743
def _extract_thought_signature_from_tool_call(
722744
tool_call: ChatCompletionMessageToolCall,
723745
) -> Optional[bytes]:
@@ -913,19 +935,7 @@ async def _content_to_message_param(
913935
msg["thinking_blocks"] = thinking_blocks # type: ignore[typeddict-unknown-key]
914936
return msg
915937

916-
reasoning_texts = []
917-
for part in reasoning_parts:
918-
if part.text:
919-
reasoning_texts.append(part.text)
920-
elif (
921-
part.inline_data
922-
and part.inline_data.data
923-
and part.inline_data.mime_type
924-
and part.inline_data.mime_type.startswith("text/")
925-
):
926-
reasoning_texts.append(_decode_inline_text_data(part.inline_data.data))
927-
928-
reasoning_content = _NEW_LINE.join(text for text in reasoning_texts if text)
938+
reasoning_content = _merge_reasoning_texts(reasoning_parts)
929939
return ChatCompletionAssistantMessage(
930940
role=role,
931941
content=final_content,

tests/unittests/flows/llm_flows/test_contents_other_agent.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""Behavioral tests for other agent message processing in contents module."""
1616

1717
from google.adk.agents.llm_agent import Agent
18+
from google.adk.agents.run_config import RunConfig
1819
from google.adk.events.event import Event
1920
from google.adk.flows.llm_flows.contents import request_processor
2021
from google.adk.models.llm_request import LlmRequest
@@ -85,6 +86,111 @@ async def test_other_agent_thoughts_are_excluded():
8586
]
8687

8788

89+
@pytest.mark.asyncio
90+
async def test_other_agent_thoughts_can_be_included_as_context():
91+
"""Test opt-in inclusion of thoughts from other agents."""
92+
agent = Agent(model="gemini-2.5-flash", name="current_agent")
93+
llm_request = LlmRequest(model="gemini-2.5-flash")
94+
invocation_context = await testing_utils.create_invocation_context(
95+
agent=agent,
96+
run_config=RunConfig(include_thoughts_from_other_agents=True),
97+
)
98+
other_agent_event = Event(
99+
invocation_id="test_inv",
100+
author="other_agent",
101+
content=types.ModelContent([
102+
types.Part(text="Public message", thought=False),
103+
types.Part(text="Private thought", thought=True),
104+
types.Part(text="Another public message"),
105+
]),
106+
)
107+
invocation_context.session.events = [other_agent_event]
108+
109+
async for _ in request_processor.run_async(invocation_context, llm_request):
110+
pass
111+
112+
assert llm_request.contents[0].role == "user"
113+
assert llm_request.contents[0].parts == [
114+
types.Part(text="For context:"),
115+
types.Part(text="[other_agent] said: Public message"),
116+
types.Part(text="[other_agent] thought: Private thought"),
117+
types.Part(text="[other_agent] said: Another public message"),
118+
]
119+
120+
121+
@pytest.mark.asyncio
122+
async def test_other_agent_thought_only_message_can_be_included_as_context():
123+
"""Test opt-in inclusion of thought-only messages from other agents."""
124+
agent = Agent(model="gemini-2.5-flash", name="current_agent")
125+
llm_request = LlmRequest(model="gemini-2.5-flash")
126+
invocation_context = await testing_utils.create_invocation_context(
127+
agent=agent,
128+
run_config=RunConfig(include_thoughts_from_other_agents=True),
129+
)
130+
other_agent_event = Event(
131+
invocation_id="test_inv",
132+
author="other_agent",
133+
content=types.ModelContent([
134+
types.Part(text="First private thought", thought=True),
135+
types.Part(text="Second private thought", thought=True),
136+
]),
137+
)
138+
invocation_context.session.events = [other_agent_event]
139+
140+
async for _ in request_processor.run_async(invocation_context, llm_request):
141+
pass
142+
143+
assert llm_request.contents[0].role == "user"
144+
assert llm_request.contents[0].parts == [
145+
types.Part(text="For context:"),
146+
types.Part(text="[other_agent] thought: First private thought"),
147+
types.Part(text="[other_agent] thought: Second private thought"),
148+
]
149+
150+
151+
@pytest.mark.asyncio
152+
async def test_other_agent_thoughts_excluded_from_current_turn_only_context():
153+
"""Test include_contents='none' does not include other-agent thoughts."""
154+
agent = Agent(
155+
model="gemini-2.5-flash",
156+
name="current_agent",
157+
include_contents="none",
158+
)
159+
llm_request = LlmRequest(model="gemini-2.5-flash")
160+
invocation_context = await testing_utils.create_invocation_context(
161+
agent=agent,
162+
run_config=RunConfig(include_thoughts_from_other_agents=True),
163+
)
164+
invocation_context.session.events = [
165+
Event(
166+
invocation_id="inv1",
167+
author="user",
168+
content=types.UserContent("Earlier user message"),
169+
),
170+
Event(
171+
invocation_id="inv2",
172+
author="other_agent",
173+
content=types.ModelContent([
174+
types.Part(text="Private thought", thought=True),
175+
types.Part(text="Visible handoff"),
176+
]),
177+
),
178+
]
179+
180+
async for _ in request_processor.run_async(invocation_context, llm_request):
181+
pass
182+
183+
assert llm_request.contents == [
184+
types.Content(
185+
role="user",
186+
parts=[
187+
types.Part(text="For context:"),
188+
types.Part(text="[other_agent] said: Visible handoff"),
189+
],
190+
)
191+
]
192+
193+
88194
@pytest.mark.asyncio
89195
async def test_other_agent_function_calls():
90196
"""Test that function calls from other agents are preserved in context."""

0 commit comments

Comments
 (0)