fix: Include current turn context when include_contents='none'

The intended behavior for include_contents='none' is to:
- Exclude conversation history from previous turns
- Still include current turn context (user input, tool calls/responses within current turn)

https://google.github.io/adk-docs/agents/llm-agents/#managing-context-include_contents

This resolves https://github.com/google/adk-python/issues/1124

PiperOrigin-RevId: 775400036
This commit is contained in:
Google Team Member
2025-06-24 15:11:33 -07:00
committed by Copybara-Service
parent acbdca0d84
commit 9e473e0abd
3 changed files with 296 additions and 7 deletions
+5 -3
View File
@@ -161,10 +161,12 @@ class LlmAgent(BaseAgent):
# LLM-based agent transfer configs - End
include_contents: Literal['default', 'none'] = 'default'
"""Whether to include contents in the model request.
"""Controls content inclusion in model requests.
When set to 'none', the model request will not include any contents, such as
user messages, tool results, etc.
Options:
default: Model receives relevant conversation history
none: Model receives no prior history, operates solely on current
instruction and input
"""
# Controlled input/output configurations - Start
+49 -4
View File
@@ -43,12 +43,20 @@ class _ContentLlmRequestProcessor(BaseLlmRequestProcessor):
if not isinstance(agent, LlmAgent):
return
if agent.include_contents != 'none':
if agent.include_contents == 'default':
# Include full conversation history
llm_request.contents = _get_contents(
invocation_context.branch,
invocation_context.session.events,
agent.name,
)
else:
# Include current turn context only (no conversation history)
llm_request.contents = _get_current_turn_contents(
invocation_context.branch,
invocation_context.session.events,
agent.name,
)
# Maintain async generator behavior
if False: # Ensures it behaves as a generator
@@ -190,13 +198,15 @@ def _get_contents(
) -> list[types.Content]:
"""Get the contents for the LLM request.
Applies filtering, rearrangement, and content processing to events.
Args:
current_branch: The current branch of the agent.
events: A list of events.
events: Events to process.
agent_name: The name of the agent.
Returns:
A list of contents.
A list of processed contents.
"""
filtered_events = []
# Parse the events, leaving the contents and the function calls and
@@ -211,12 +221,13 @@ def _get_contents(
# Skip events without content, or generated neither by user nor by model
# or has empty text.
# E.g. events purely for mutating session states.
continue
if not _is_event_belongs_to_branch(current_branch, event):
# Skip events not belong to current branch.
continue
if _is_auth_event(event):
# skip auth event
# Skip auth events.
continue
filtered_events.append(
_convert_foreign_event(event)
@@ -224,12 +235,15 @@ def _get_contents(
else event
)
# Rearrange events for proper function call/response pairing
result_events = _rearrange_events_for_latest_function_response(
filtered_events
)
result_events = _rearrange_events_for_async_function_responses_in_history(
result_events
)
# Convert events to contents
contents = []
for event in result_events:
content = copy.deepcopy(event.content)
@@ -238,6 +252,37 @@ def _get_contents(
return contents
def _get_current_turn_contents(
current_branch: Optional[str], events: list[Event], agent_name: str = ''
) -> list[types.Content]:
"""Get contents for the current turn only (no conversation history).
When include_contents='none', we want to include:
- The current user input
- Tool calls and responses from the current turn
But exclude conversation history from previous turns.
In multi-agent scenarios, the "current turn" for an agent starts from an
actual user or from another agent.
Args:
current_branch: The current branch of the agent.
events: A list of all session events.
agent_name: The name of the agent.
Returns:
A list of contents for the current turn only, preserving context needed
for proper tool execution while excluding conversation history.
"""
# Find the latest event that starts the current turn and process from there
for i in range(len(events) - 1, -1, -1):
event = events[i]
if event.author == 'user' or _is_other_agent_reply(agent_name, event):
return _get_contents(current_branch, events[i:], agent_name)
return []
def _is_other_agent_reply(current_agent_name: str, event: Event) -> bool:
"""Whether the event is a reply from another agent."""
return bool(
@@ -0,0 +1,242 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for LlmAgent include_contents field behavior."""
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.sequential_agent import SequentialAgent
from google.genai import types
import pytest
from .. import testing_utils
@pytest.mark.asyncio
async def test_include_contents_default_behavior():
"""Test that include_contents='default' preserves conversation history including tool interactions."""
def simple_tool(message: str) -> dict:
return {"result": f"Tool processed: {message}"}
mock_model = testing_utils.MockModel.create(
responses=[
types.Part.from_function_call(
name="simple_tool", args={"message": "first"}
),
"First response",
types.Part.from_function_call(
name="simple_tool", args={"message": "second"}
),
"Second response",
]
)
agent = LlmAgent(
name="test_agent",
model=mock_model,
include_contents="default",
instruction="You are a helpful assistant",
tools=[simple_tool],
)
runner = testing_utils.InMemoryRunner(agent)
runner.run("First message")
runner.run("Second message")
# First turn requests
assert testing_utils.simplify_contents(mock_model.requests[0].contents) == [
("user", "First message")
]
assert testing_utils.simplify_contents(mock_model.requests[1].contents) == [
("user", "First message"),
(
"model",
types.Part.from_function_call(
name="simple_tool", args={"message": "first"}
),
),
(
"user",
types.Part.from_function_response(
name="simple_tool", response={"result": "Tool processed: first"}
),
),
]
# Second turn should include full conversation history
assert testing_utils.simplify_contents(mock_model.requests[2].contents) == [
("user", "First message"),
(
"model",
types.Part.from_function_call(
name="simple_tool", args={"message": "first"}
),
),
(
"user",
types.Part.from_function_response(
name="simple_tool", response={"result": "Tool processed: first"}
),
),
("model", "First response"),
("user", "Second message"),
]
# Second turn with tool should include full history + current tool interaction
assert testing_utils.simplify_contents(mock_model.requests[3].contents) == [
("user", "First message"),
(
"model",
types.Part.from_function_call(
name="simple_tool", args={"message": "first"}
),
),
(
"user",
types.Part.from_function_response(
name="simple_tool", response={"result": "Tool processed: first"}
),
),
("model", "First response"),
("user", "Second message"),
(
"model",
types.Part.from_function_call(
name="simple_tool", args={"message": "second"}
),
),
(
"user",
types.Part.from_function_response(
name="simple_tool", response={"result": "Tool processed: second"}
),
),
]
@pytest.mark.asyncio
async def test_include_contents_none_behavior():
"""Test that include_contents='none' excludes conversation history but includes current input."""
def simple_tool(message: str) -> dict:
return {"result": f"Tool processed: {message}"}
mock_model = testing_utils.MockModel.create(
responses=[
types.Part.from_function_call(
name="simple_tool", args={"message": "first"}
),
"First response",
"Second response",
]
)
agent = LlmAgent(
name="test_agent",
model=mock_model,
include_contents="none",
instruction="You are a helpful assistant",
tools=[simple_tool],
)
runner = testing_utils.InMemoryRunner(agent)
runner.run("First message")
runner.run("Second message")
# First turn behavior
assert testing_utils.simplify_contents(mock_model.requests[0].contents) == [
("user", "First message")
]
assert testing_utils.simplify_contents(mock_model.requests[1].contents) == [
("user", "First message"),
(
"model",
types.Part.from_function_call(
name="simple_tool", args={"message": "first"}
),
),
(
"user",
types.Part.from_function_response(
name="simple_tool", response={"result": "Tool processed: first"}
),
),
]
# Second turn should only have current input, no history
assert testing_utils.simplify_contents(mock_model.requests[2].contents) == [
("user", "Second message")
]
# System instruction and tools should be preserved
assert (
"You are a helpful assistant"
in mock_model.requests[0].config.system_instruction
)
assert len(mock_model.requests[0].config.tools) > 0
@pytest.mark.asyncio
async def test_include_contents_none_sequential_agents():
"""Test include_contents='none' with sequential agents."""
agent1_model = testing_utils.MockModel.create(
responses=["Agent1 response: XYZ"]
)
agent1 = LlmAgent(
name="agent1",
model=agent1_model,
instruction="You are Agent1",
)
agent2_model = testing_utils.MockModel.create(
responses=["Agent2 final response"]
)
agent2 = LlmAgent(
name="agent2",
model=agent2_model,
include_contents="none",
instruction="You are Agent2",
)
sequential_agent = SequentialAgent(
name="sequential_test_agent", sub_agents=[agent1, agent2]
)
runner = testing_utils.InMemoryRunner(sequential_agent)
events = runner.run("Original user request")
assert len(events) == 2
assert events[0].author == "agent1"
assert events[1].author == "agent2"
# Agent1 sees original user request
agent1_contents = testing_utils.simplify_contents(
agent1_model.requests[0].contents
)
assert ("user", "Original user request") in agent1_contents
# Agent2 with include_contents='none' should not see original request
agent2_contents = testing_utils.simplify_contents(
agent2_model.requests[0].contents
)
assert not any(
"Original user request" in str(content) for _, content in agent2_contents
)
assert any(
"Agent1 response" in str(content) for _, content in agent2_contents
)