Skip to content

Commit a5abf68

Browse files
committed
feat: add option to include thoughts from other agents in LLM context and merge reasoning chunks
1 parent 62bcdd3 commit a5abf68

5 files changed

Lines changed: 202 additions & 23 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: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ async def run_async(
6666
# Preserve all contents that were added by instruction processor
6767
# (since llm_request.contents will be completely reassigned below)
6868
instruction_related_contents = llm_request.contents
69+
include_thoughts_from_other_agents = (
70+
invocation_context.run_config.include_thoughts_from_other_agents
71+
)
6972

7073
is_single_turn = getattr(agent, 'mode', None) == 'single_turn'
7174
if agent.include_contents == 'default':
@@ -78,6 +81,7 @@ async def run_async(
7881
isolation_scope=invocation_context.isolation_scope,
7982
is_single_turn=is_single_turn,
8083
user_content=invocation_context.user_content,
84+
include_thoughts_from_other_agents=include_thoughts_from_other_agents,
8185
)
8286
else:
8387
# Include current turn context only (no conversation history)
@@ -89,6 +93,7 @@ async def run_async(
8993
isolation_scope=invocation_context.isolation_scope,
9094
is_single_turn=is_single_turn,
9195
user_content=invocation_context.user_content,
96+
include_thoughts_from_other_agents=False,
9297
)
9398

9499
# Add instruction-related contents to proper position in conversation
@@ -247,7 +252,9 @@ def _rearrange_events_for_latest_function_response(
247252
return result_events
248253

249254

250-
def _is_part_invisible(p: types.Part) -> bool:
255+
def _is_part_invisible(
256+
p: types.Part, *, include_thoughts: bool = False
257+
) -> bool:
251258
"""Returns whether a part is invisible for LLM context.
252259
253260
A part is invisible if:
@@ -267,7 +274,7 @@ def _is_part_invisible(p: types.Part) -> bool:
267274
if p.function_call or p.function_response:
268275
return False
269276

270-
return p.thought or not (
277+
return (p.thought and not include_thoughts) or not (
271278
p.text
272279
or p.inline_data
273280
or p.file_data
@@ -276,7 +283,9 @@ def _is_part_invisible(p: types.Part) -> bool:
276283
)
277284

278285

279-
def _contains_empty_content(event: Event) -> bool:
286+
def _contains_empty_content(
287+
event: Event, *, include_thoughts: bool = False
288+
) -> bool:
280289
"""Check if an event should be skipped due to missing or empty content.
281290
282291
This can happen to the events that only changed session state.
@@ -298,7 +307,10 @@ def _contains_empty_content(event: Event) -> bool:
298307
not event.content
299308
or not event.content.role
300309
or not event.content.parts
301-
or all(_is_part_invisible(p) for p in event.content.parts)
310+
or all(
311+
_is_part_invisible(p, include_thoughts=include_thoughts)
312+
for p in event.content.parts
313+
)
302314
) and (not event.output_transcription and not event.input_transcription)
303315

304316

@@ -366,6 +378,8 @@ def _should_include_event_in_context(
366378
current_branch: Optional[str],
367379
event: Event,
368380
isolation_scope: Optional[str] = None,
381+
*,
382+
include_thoughts: bool = False,
369383
) -> bool:
370384
"""Determines if an event should be included in the LLM context.
371385
@@ -391,7 +405,7 @@ def _should_include_event_in_context(
391405
if ev_iso != isolation_scope:
392406
return False
393407
return not (
394-
_contains_empty_content(event)
408+
_contains_empty_content(event, include_thoughts=include_thoughts)
395409
or not _is_event_belongs_to_branch(current_branch, event)
396410
or _is_adk_framework_event(event)
397411
or _is_auth_event(event)
@@ -504,6 +518,7 @@ def _get_contents(
504518
isolation_scope: Optional[str] = None,
505519
is_single_turn: bool = False,
506520
user_content: Optional[types.Content] = None,
521+
include_thoughts_from_other_agents: bool = False,
507522
) -> list[types.Content]:
508523
"""Get the contents for the LLM request.
509524
@@ -519,6 +534,8 @@ def _get_contents(
519534
user_content: Fallback first user turn for task agents whose
520535
originating delegation FC is not in session (workflow-node
521536
task case).
537+
include_thoughts_from_other_agents: Whether to include thought parts from
538+
other agents when presenting their messages as user context.
522539
523540
Returns:
524541
A list of processed contents.
@@ -551,7 +568,11 @@ def _get_contents(
551568
e
552569
for e in rewind_filtered_events
553570
if _should_include_event_in_context(
554-
current_branch, e, isolation_scope=isolation_scope
571+
current_branch, e, isolation_scope=isolation_scope,
572+
include_thoughts=(
573+
include_thoughts_from_other_agents
574+
and _is_other_agent_reply(agent_name, e)
575+
)
555576
)
556577
]
557578

@@ -626,7 +647,7 @@ def _get_contents(
626647
break
627648

628649
if is_other_reply:
629-
if converted_event := _present_other_agent_message(event):
650+
if converted_event := _present_other_agent_message(event, include_thoughts=include_thoughts_from_other_agents):
630651
filtered_events.append(converted_event)
631652
else:
632653
filtered_events.append(event)
@@ -677,6 +698,7 @@ def _get_current_turn_contents(
677698
is_single_turn: bool = False,
678699
isolation_scope: Optional[str] = None,
679700
user_content: Optional[types.Content] = None,
701+
include_thoughts_from_other_agents: bool = False,
680702
) -> list[types.Content]:
681703
"""Get contents for the current turn only (no conversation history).
682704
@@ -693,6 +715,8 @@ def _get_current_turn_contents(
693715
events: A list of all session events.
694716
agent_name: The name of the agent.
695717
preserve_function_call_ids: Whether to preserve function call ids.
718+
include_thoughts_from_other_agents: Whether to include thought parts from
719+
other agents when presenting their messages as user context.
696720
697721
Returns:
698722
A list of contents for the current turn only, preserving context needed
@@ -702,7 +726,11 @@ def _get_current_turn_contents(
702726
for i in range(len(events) - 1, -1, -1):
703727
event = events[i]
704728
if _should_include_event_in_context(
705-
current_branch, event, isolation_scope=isolation_scope
729+
current_branch, event, isolation_scope=isolation_scope,
730+
include_thoughts=(
731+
include_thoughts_from_other_agents
732+
and _is_other_agent_reply(agent_name, event)
733+
),
706734
) and (event.author == 'user' or _is_other_agent_reply(agent_name, event)):
707735
return _get_contents(
708736
current_branch,
@@ -712,6 +740,7 @@ def _get_current_turn_contents(
712740
isolation_scope=isolation_scope,
713741
is_single_turn=is_single_turn,
714742
user_content=user_content,
743+
include_thoughts_from_other_agents=include_thoughts_from_other_agents,
715744
)
716745

717746
return []
@@ -748,14 +777,18 @@ def _is_other_agent_reply(current_agent_name: str, event: Event) -> bool:
748777
)
749778

750779

751-
def _present_other_agent_message(event: Event) -> Optional[Event]:
780+
def _present_other_agent_message(
781+
event: Event, *, include_thoughts: bool = False
782+
) -> Optional[Event]:
752783
"""Presents another agent's message as user context for the current agent.
753784
754785
Reformats the event with role='user' and adds '[agent_name] said:' prefix
755786
to provide context without confusion about authorship.
756787
757788
Args:
758789
event: The event from another agent to present as context.
790+
include_thoughts: Whether to include thought parts as explicit text
791+
context.
759792
760793
Returns:
761794
Event reformatted as user-role context with agent attribution, or None
@@ -769,7 +802,10 @@ def _present_other_agent_message(event: Event) -> Optional[Event]:
769802
content.parts = [types.Part(text='For context:')]
770803
for part in event.content.parts:
771804
if part.thought:
772-
# Exclude thoughts from the context.
805+
if include_thoughts and part.text is not None and part.text.strip():
806+
content.parts.append(
807+
types.Part(text=f'[{event.author}] thought: {part.text}')
808+
)
773809
continue
774810
elif part.text is not None and part.text.strip():
775811
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]:
@@ -919,19 +941,7 @@ async def _content_to_message_param(
919941
msg["thinking_blocks"] = thinking_blocks # type: ignore[typeddict-unknown-key]
920942
return msg
921943

922-
reasoning_texts = []
923-
for part in reasoning_parts:
924-
if part.text:
925-
reasoning_texts.append(part.text)
926-
elif (
927-
part.inline_data
928-
and part.inline_data.data
929-
and part.inline_data.mime_type
930-
and part.inline_data.mime_type.startswith("text/")
931-
):
932-
reasoning_texts.append(_decode_inline_text_data(part.inline_data.data))
933-
934-
reasoning_content = _NEW_LINE.join(text for text in reasoning_texts if text)
944+
reasoning_content = _merge_reasoning_texts(reasoning_parts)
935945
return ChatCompletionAssistantMessage(
936946
role=role,
937947
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)