Skip to content

Commit 6093899

Browse files
committed
feat(agents): add include_contents='current' for invocation-scoped context
Add 'current' as a third value for include_contents alongside 'default' and 'none'. Where 'none' anchors at the last turn boundary (user OR other-agent event), 'current' anchors at the last user message, giving the agent everything from the user's request through all sibling agent outputs within the same invocation. This fixes the composition footgun with include_sources: 'none' + include_sources=['user'] returns empty context when the last event is a filtered other-agent output. With 'current', the boundary is always a user event so filtering never produces an empty context. A UserWarning is raised at construction time when include_contents='none' is combined with include_sources, since the turn boundary may land on a filtered event and produce empty context at runtime.
1 parent 0bfc109 commit 6093899

5 files changed

Lines changed: 318 additions & 19 deletions

File tree

src/google/adk/agents/llm_agent.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -343,13 +343,15 @@ class LlmAgent(BaseAgent, abc.ABC):
343343
"""Disallows LLM-controlled transferring to the peer agents."""
344344
# LLM-based agent transfer configs - End
345345

346-
include_contents: Literal['default', 'none'] = 'default'
346+
include_contents: Literal['default', 'current', 'none'] = 'default'
347347
"""Controls content inclusion in model requests.
348348
349349
Options:
350-
default: Model receives relevant conversation history
351-
none: Model receives no prior history, operates solely on current
352-
instruction and input
350+
default: Model receives full conversation history.
351+
current: Model receives all events since the last user message,
352+
including outputs from agents that ran earlier in this pipeline.
353+
none: Model receives only the most recent agent or user input, with no
354+
prior conversation history.
353355
"""
354356

355357
include_sources: Optional[list[str]] = None
@@ -976,6 +978,16 @@ def __maybe_save_output_to_state(self, event: Event):
976978

977979
@model_validator(mode='after')
978980
def __model_validator_after(self) -> LlmAgent:
981+
if self.include_contents == 'none' and self.include_sources is not None:
982+
warnings.warn(
983+
"include_contents='none' with include_sources may produce empty"
984+
' context: the turn boundary is the last user OR other-agent event,'
985+
' and if that event is filtered by include_sources the context will'
986+
" be empty. Use include_contents='current' to anchor at the last"
987+
' user message instead.',
988+
UserWarning,
989+
stacklevel=2,
990+
)
979991
return self
980992

981993
@field_validator('include_sources', mode='after') # type: ignore[misc]

src/google/adk/agents/llm_agent_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def _validate_model_sources(self) -> LlmAgentConfig:
121121
default=None, description='Optional. LlmAgent.output_key.'
122122
)
123123

124-
include_contents: Literal['default', 'none'] = Field(
124+
include_contents: Literal['default', 'current', 'none'] = Field(
125125
default='default', description='Optional. LlmAgent.include_contents.'
126126
)
127127

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

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ async def run_async(
8282
source_filter=source_filter,
8383
)
8484
else:
85-
# Include current turn context only (no conversation history)
85+
# 'current': anchor at last user message — all sibling agent outputs
86+
# within this invocation are included.
87+
# 'none': anchor at last turn boundary (user OR other-agent event).
88+
stop_at_user_only = agent.include_contents == 'current'
8689
llm_request.contents = _get_current_turn_contents(
8790
invocation_context.branch,
8891
invocation_context.session.events,
@@ -92,6 +95,7 @@ async def run_async(
9295
is_single_turn=is_single_turn,
9396
user_content=invocation_context.user_content,
9497
source_filter=source_filter,
98+
stop_at_user_only=stop_at_user_only,
9599
)
96100

97101
# Add instruction-related contents to proper position in conversation
@@ -718,33 +722,41 @@ def _get_current_turn_contents(
718722
isolation_scope: Optional[str] = None,
719723
user_content: Optional[types.Content] = None,
720724
source_filter: Optional[list[str]] = None,
725+
stop_at_user_only: bool = False,
721726
) -> list[types.Content]:
722727
"""Get contents for the current turn only (no conversation history).
723728
724-
When include_contents='none', we want to include:
725-
- The current user input
726-
- Tool calls and responses from the current turn
727-
But exclude conversation history from previous turns.
728-
729-
In multi-agent scenarios, the "current turn" for an agent starts from an
730-
actual user or from another agent.
729+
Used by include_contents='none' and 'current'. Both exclude prior-session
730+
history; they differ in the turn boundary:
731+
'none' (stop_at_user_only=False): last user OR other-agent event.
732+
'current' (stop_at_user_only=True): last user event only.
731733
732734
Args:
733735
current_branch: The current branch of the agent.
734736
events: A list of all session events.
735737
agent_name: The name of the agent.
736738
preserve_function_call_ids: Whether to preserve function call ids.
739+
stop_at_user_only: When True, anchor only at user events ('current' mode).
737740
738741
Returns:
739-
A list of contents for the current turn only, preserving context needed
740-
for proper tool execution while excluding conversation history.
742+
A list of contents from the turn boundary forward. Returns [] if no
743+
qualifying boundary event is found.
741744
"""
742-
# Find the latest event that starts the current turn and process from there
745+
# Find the latest event that starts the current turn and process from there.
746+
# stop_at_user_only=True ('current' mode): anchor at last user message,
747+
# so all sibling agent outputs within this invocation are included.
748+
# stop_at_user_only=False ('none' mode): anchor at last user OR other-agent.
743749
for i in range(len(events) - 1, -1, -1):
744750
event = events[i]
745-
if _should_include_event_in_context(
746-
current_branch, event, isolation_scope=isolation_scope
747-
) and (event.author == 'user' or _is_other_agent_reply(agent_name, event)):
751+
is_turn_start = event.author == 'user' or (
752+
not stop_at_user_only and _is_other_agent_reply(agent_name, event)
753+
)
754+
if (
755+
_should_include_event_in_context(
756+
current_branch, event, isolation_scope=isolation_scope
757+
)
758+
and is_turn_start
759+
):
748760
return _get_contents(
749761
current_branch,
750762
events[i:],

tests/unittests/agents/test_llm_agent_include_contents.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,195 @@ async def test_include_sources_user_self_drops_upstream_across_turns():
355355
"upstream reply" in str(c).lower() for _, c in agent2_second_contents
356356
)
357357
assert not any("For context:" in str(c) for _, c in agent2_second_contents)
358+
359+
360+
# ---------------------------------------------------------------------------
361+
# include_contents='default' + include_sources combinations
362+
# ---------------------------------------------------------------------------
363+
364+
365+
@pytest.mark.asyncio
366+
async def test_include_contents_default_with_source_filter_user():
367+
"""include_contents='default' + include_sources=['user'] keeps only user messages across full history."""
368+
agent1_model = testing_utils.MockModel.create(
369+
responses=["Agent1 result turn1", "Agent1 result turn2"]
370+
)
371+
agent1 = LlmAgent(
372+
name="agent1", model=agent1_model, instruction="You are agent1"
373+
)
374+
375+
agent2_model = testing_utils.MockModel.create(
376+
responses=["Turn1 response", "Turn2 response"]
377+
)
378+
agent2 = LlmAgent(
379+
name="agent2",
380+
model=agent2_model,
381+
include_sources=["user"],
382+
instruction="You are agent2",
383+
)
384+
385+
runner = testing_utils.InMemoryRunner(
386+
SequentialAgent(name="pipeline", sub_agents=[agent1, agent2])
387+
)
388+
runner.run("First user message")
389+
runner.run("Second user message")
390+
391+
# Second invocation: full history, but only user messages kept
392+
agent2_second_contents = testing_utils.simplify_contents(
393+
agent2_model.requests[1].contents
394+
)
395+
assert any("First user message" in str(c) for _, c in agent2_second_contents)
396+
assert any("Second user message" in str(c) for _, c in agent2_second_contents)
397+
assert not any("Agent1 result" in str(c) for _, c in agent2_second_contents)
398+
assert not any("For context:" in str(c) for _, c in agent2_second_contents)
399+
400+
401+
# ---------------------------------------------------------------------------
402+
# include_contents='current'
403+
# ---------------------------------------------------------------------------
404+
405+
406+
@pytest.mark.asyncio
407+
async def test_include_contents_current_sees_user_and_all_upstream_agents():
408+
"""include_contents='current' anchors at user message — all sibling agents visible."""
409+
agent1_model = testing_utils.MockModel.create(responses=["Agent1 result"])
410+
agent1 = LlmAgent(
411+
name="agent1", model=agent1_model, instruction="You are agent1"
412+
)
413+
414+
agent2_model = testing_utils.MockModel.create(responses=["Agent2 result"])
415+
agent2 = LlmAgent(
416+
name="agent2", model=agent2_model, instruction="You are agent2"
417+
)
418+
419+
agent3_model = testing_utils.MockModel.create(responses=["Agent3 done"])
420+
agent3 = LlmAgent(
421+
name="agent3",
422+
model=agent3_model,
423+
include_contents="current",
424+
instruction="You are agent3",
425+
)
426+
427+
runner = testing_utils.InMemoryRunner(
428+
SequentialAgent(name="pipeline", sub_agents=[agent1, agent2, agent3])
429+
)
430+
runner.run("Original user request")
431+
432+
agent3_contents = testing_utils.simplify_contents(
433+
agent3_model.requests[0].contents
434+
)
435+
436+
# User message must be present
437+
assert any("Original user request" in str(c) for _, c in agent3_contents)
438+
# Both upstream agents' narrative entries must be present
439+
assert any("Agent1 result" in str(c) for _, c in agent3_contents)
440+
assert any("Agent2 result" in str(c) for _, c in agent3_contents)
441+
442+
443+
@pytest.mark.asyncio
444+
async def test_include_contents_current_with_source_filter_user_not_empty():
445+
"""include_contents='current' + include_sources=['user'] → user message, not empty.
446+
447+
Contrast with include_contents='none' + include_sources=['user'] which
448+
produces empty context when the last event is a peer agent's output.
449+
"""
450+
agent1_model = testing_utils.MockModel.create(responses=["Agent1 result"])
451+
agent1 = LlmAgent(
452+
name="agent1", model=agent1_model, instruction="You are agent1"
453+
)
454+
455+
agent2_model = testing_utils.MockModel.create(responses=["Agent2 done"])
456+
agent2 = LlmAgent(
457+
name="agent2",
458+
model=agent2_model,
459+
include_contents="current",
460+
include_sources=["user"],
461+
instruction="You are agent2",
462+
)
463+
464+
runner = testing_utils.InMemoryRunner(
465+
SequentialAgent(name="pipeline", sub_agents=[agent1, agent2])
466+
)
467+
runner.run("Hello from user")
468+
469+
agent2_contents = testing_utils.simplify_contents(
470+
agent2_model.requests[0].contents
471+
)
472+
473+
# User message must be present and result must not be empty
474+
assert len(agent2_contents) > 0
475+
assert any("Hello from user" in str(c) for _, c in agent2_contents)
476+
# Upstream agent narrative must be filtered out
477+
assert not any("Agent1 result" in str(c) for _, c in agent2_contents)
478+
assert not any("For context:" in str(c) for _, c in agent2_contents)
479+
480+
481+
@pytest.mark.asyncio
482+
async def test_include_contents_current_with_source_filter_user_and_self():
483+
"""include_contents='current' + include_sources=['user', 'self'] keeps user + own turns only."""
484+
agent1_model = testing_utils.MockModel.create(
485+
responses=["Agent1 result turn1", "Agent1 result turn2"]
486+
)
487+
agent1 = LlmAgent(
488+
name="agent1", model=agent1_model, instruction="You are agent1"
489+
)
490+
491+
agent2_model = testing_utils.MockModel.create(
492+
responses=["Agent2 first turn", "Agent2 second turn"]
493+
)
494+
agent2 = LlmAgent(
495+
name="agent2",
496+
model=agent2_model,
497+
include_contents="current",
498+
include_sources=["user", "self"],
499+
instruction="You are agent2",
500+
)
501+
502+
runner = testing_utils.InMemoryRunner(
503+
SequentialAgent(name="pipeline", sub_agents=[agent1, agent2])
504+
)
505+
runner.run("First user message")
506+
runner.run("Second user message")
507+
508+
# Second invocation: current window starts at 'Second user message'.
509+
# Agent2's own prior turn from invocation 1 is outside that window — naturally absent.
510+
# What we verify: user present, upstream agent filtered by include_sources.
511+
agent2_second_contents = testing_utils.simplify_contents(
512+
agent2_model.requests[1].contents
513+
)
514+
assert any("Second user message" in str(c) for _, c in agent2_second_contents)
515+
assert not any("Agent1 result" in str(c) for _, c in agent2_second_contents)
516+
assert not any("For context:" in str(c) for _, c in agent2_second_contents)
517+
518+
519+
def test_include_contents_none_with_include_sources_warns():
520+
"""include_contents='none' + include_sources triggers a UserWarning."""
521+
import warnings as _warnings
522+
523+
with _warnings.catch_warnings(record=True) as w:
524+
_warnings.simplefilter("always")
525+
LlmAgent(
526+
name="agent",
527+
model="gemini-2.5-flash",
528+
include_contents="none",
529+
include_sources=["user"],
530+
)
531+
assert len(w) == 1
532+
assert issubclass(w[0].category, UserWarning)
533+
assert "include_contents='current'" in str(w[0].message)
534+
535+
536+
def test_include_contents_none_with_agent_name_in_sources_still_warns():
537+
"""Warning fires even with a concrete agent name — still risky at runtime."""
538+
import warnings as _warnings
539+
540+
with _warnings.catch_warnings(record=True) as w:
541+
_warnings.simplefilter("always")
542+
LlmAgent(
543+
name="agent",
544+
model="gemini-2.5-flash",
545+
include_contents="none",
546+
include_sources=["user", "upstream_agent"],
547+
)
548+
assert len(w) == 1
549+
assert issubclass(w[0].category, UserWarning)

0 commit comments

Comments
 (0)