Skip to content

Commit 6290aec

Browse files
lorenzbaraldiGWeale
authored andcommitted
fix(litellm): preserve reasoning replay and optionally share agent thoughts
Merge #5572 **Please ensure you have read the [contribution guide](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) before creating a pull request.** ### Link to Issue or Description of Change **1. Link to an existing issue (if applicable):** - Closes: N/A - Related: N/A **2. Or, if no issue exists, describe the change:** **Problem:** LiteLLM-backed reasoning content can be streamed as small text fragments, notably with vLLM. ADK stores those fragments as separate `Part(thought=True)` entries. When replaying prior assistant/model thoughts back into LiteLLM, ADK previously joined those fragments with newlines, which changed the original reasoning text. In multi-agent flows, ADK also always excluded thoughts from other agents when presenting their messages as context. That default is privacy-preserving, but it removes a useful coordination signal for advanced multi-agent systems. For example, an orchestrator, reviewer, planner, or supervisor agent may benefit from seeing another agent’s reasoning trail when deciding whether to continue, retry, delegate, or synthesize results. **Solution:** This PR updates LiteLLM reasoning replay so plain thought text fragments are concatenated without inserting separators. This preserves vLLM-style streamed reasoning chunks when ADK sends previous model output back to LiteLLM. The PR also adds an opt-in `RunConfig.include_thoughts_from_other_agents` flag. The default remains `False`, so existing behavior and privacy expectations are preserved. When enabled for full-history context, other-agent thoughts are converted into explicit user-context text, e.g. `[agent] thought: ...`, so downstream model adapters do not drop them as user-side `thought=True` parts. The option is intentionally developer-controlled because whether reasoning should cross agent boundaries is application-specific. It can be valuable in orchestrated systems where agents are trusted collaborators and reasoning improves handoff quality, but it should not be enabled silently for all apps. ### Testing Plan **Unit Tests:** - [x] I have added or updated unit tests for my change. - [x] All unit tests pass locally. Passed locally: - `uv run --extra test pytest tests/unittests/models/test_litellm.py` - `257 passed` - `uv run --extra test pytest tests/unittests/flows/llm_flows/test_contents_other_agent.py` - `12 passed` **Manual End-to-End (E2E) Tests:** Not run. The change is covered by focused unit tests for LiteLLM reasoning replay and other-agent context construction. ### Checklist - [x] I have read the [CONTRIBUTING.md](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) document. - [x] I have performed a self-review of my own code. - [x] I have commented my code, particularly in hard-to-understand areas. - [x] I have added tests that prove my fix is effective or that my feature works. - [x] New and existing unit tests pass locally with my changes. - [ ] I have manually tested my changes end-to-end. - [ ] Any dependent changes have been merged and published in downstream modules. ### Additional context The other-agent thought sharing behavior is deliberately opt-in. In many apps, thoughts should remain private and excluded from cross-agent context. In more controlled multi-agent systems, however, exposing reasoning to orchestrator-like agents can improve routing, recovery, auditing, and synthesis decisions. This may not be the final API shape for the capability, but having an explicit option to include other agents’ reasoning is useful and worth discussing. Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=#5572 from lorenzbaraldi:feature/pass_reasoning_content 7d6d3e6 PiperOrigin-RevId: 944562507
1 parent 7cb5ebe commit 6290aec

5 files changed

Lines changed: 211 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/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)