mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
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:
committed by
Copybara-Service
parent
acbdca0d84
commit
9e473e0abd
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user