Skip to content

Commit 43170f7

Browse files
committed
feat(agents): add include_sources for per-agent content source filtering
Add `include_sources: list[str] | None` to `LlmAgent` as an orthogonal axis to the existing `include_contents` temporal-window control. Where `include_contents` answers "how far back?", `include_sources` answers "from whom?" — allowing agents in a multi-agent pipeline to declare an allowlist of content sources rather than receiving every narrative-cast peer output. Reserved source names: 'user' (plain human messages), 'self' (this agent's own prior model turns), and any agent name matched directly against event.author before narrative casting occurs. Filtering runs at the event level inside _get_contents(), before _present_other_agent_message() converts authorship into embedded text, so source identity is read from structured metadata rather than parsed from "[agent_name] said:" strings. Function call/response pairing is preserved: FC responses for the current agent's own calls are tied to 'self' (dropped together with their calls when 'self' is absent), and another agent's FC responses are dropped when that agent's call is also filtered. Live-mode events are handled by mapping event.author == agent_name to the 'self' reserved name, since _is_other_agent_reply() returns True for all non-user events in live sessions. Raises ValueError when include_sources=[] (use None to disable).
1 parent aa51512 commit 43170f7

4 files changed

Lines changed: 596 additions & 1 deletion

File tree

src/google/adk/agents/llm_agent.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,27 @@ class LlmAgent(BaseAgent):
349349
instruction and input
350350
"""
351351

352+
include_sources: Optional[list[str]] = None
353+
"""Allowlist of content sources to include in model requests.
354+
355+
Orthogonal to include_contents (temporal window); this controls which
356+
sources are kept from within that window.
357+
358+
Options:
359+
None (default): all sources pass through — backward-compatible.
360+
list[str]: only content from the listed sources is kept.
361+
362+
Reserved source names:
363+
'user' — plain human user messages (not tool outputs)
364+
'self' — this agent's own prior model outputs
365+
<name> — any other string is matched against event.author (agent name)
366+
367+
Example — keep full history but only user + this agent's turns:
368+
include_contents='default', include_sources=['user', 'self']
369+
370+
Raises ValueError if set to [] (use None to disable filtering).
371+
"""
372+
352373
# Controlled input/output configurations - Start
353374
input_schema: Optional[type[BaseModel]] = None
354375
"""The input schema when agent is used as a tool."""
@@ -954,6 +975,17 @@ def __maybe_save_output_to_state(self, event: Event):
954975
def __model_validator_after(self) -> LlmAgent:
955976
return self
956977

978+
@field_validator('include_sources', mode='after')
979+
@classmethod
980+
def _validate_include_sources(
981+
cls, v: Optional[list[str]]
982+
) -> Optional[list[str]]:
983+
if v is not None and len(v) == 0:
984+
raise ValueError(
985+
"include_sources=[] keeps nothing. Use None to disable filtering."
986+
)
987+
return v
988+
957989
@field_validator('generate_content_config', mode='after')
958990
@classmethod
959991
def validate_generate_content_config(

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ async def run_async(
6868
instruction_related_contents = llm_request.contents
6969

7070
is_single_turn = getattr(agent, 'mode', None) == 'single_turn'
71+
source_filter = getattr(agent, 'include_sources', None)
7172
if agent.include_contents == 'default':
7273
# Include full conversation history
7374
llm_request.contents = _get_contents(
@@ -78,6 +79,7 @@ async def run_async(
7879
isolation_scope=invocation_context.isolation_scope,
7980
is_single_turn=is_single_turn,
8081
user_content=invocation_context.user_content,
82+
source_filter=source_filter,
8183
)
8284
else:
8385
# Include current turn context only (no conversation history)
@@ -89,6 +91,7 @@ async def run_async(
8991
isolation_scope=invocation_context.isolation_scope,
9092
is_single_turn=is_single_turn,
9193
user_content=invocation_context.user_content,
94+
source_filter=source_filter,
9295
)
9396

9497
# Add instruction-related contents to proper position in conversation
@@ -504,6 +507,7 @@ def _get_contents(
504507
isolation_scope: Optional[str] = None,
505508
is_single_turn: bool = False,
506509
user_content: Optional[types.Content] = None,
510+
source_filter: Optional[list[str]] = None,
507511
) -> list[types.Content]:
508512
"""Get the contents for the LLM request.
509513
@@ -610,6 +614,7 @@ def _get_contents(
610614
accumulated_output_transcription = ''
611615

612616
is_other_reply = _is_other_agent_reply(agent_name, event)
617+
other_fc_author = None # set when is_other_reply via FC attribution
613618

614619
# Check if it's a FunctionResponse for another agent
615620
if not is_other_reply and event.content:
@@ -623,8 +628,43 @@ def _get_contents(
623628
and call_author != 'user'
624629
):
625630
is_other_reply = True
631+
other_fc_author = call_author
626632
break
627633

634+
if source_filter is not None:
635+
if is_other_reply:
636+
if event.author != 'user':
637+
# In live mode the current agent's own events are also classified as
638+
# other_reply (see _is_other_agent_reply). Map the actual agent name
639+
# to the 'self' reserved name so source_filter=['self'] works.
640+
effective_source = (
641+
'self' if event.author == agent_name else event.author
642+
)
643+
if effective_source not in source_filter:
644+
continue
645+
else:
646+
# 'user'-authored FC response to another agent's call.
647+
# other_fc_author was resolved above — no second iteration needed.
648+
# _present_other_agent_message converts it to text, so no raw
649+
# function_response survives — but drop it when its call author is
650+
# filtered to avoid "[agent_b] returned X" with no visible preceding
651+
# "[agent_b] called tool Y".
652+
if other_fc_author and other_fc_author not in source_filter:
653+
continue
654+
elif event.content:
655+
if event.content.role == 'model':
656+
if 'self' not in source_filter:
657+
continue
658+
elif event.content.role == 'user':
659+
if _content_contains_function_response(event.content):
660+
# FC responses are paired with the current agent's own tool calls
661+
# (role='model'). Tie them to 'self' so dropping 'self' drops both
662+
# sides of the pair and avoids orphaned function_response parts.
663+
if 'self' not in source_filter:
664+
continue
665+
elif 'user' not in source_filter:
666+
continue
667+
628668
if is_other_reply:
629669
if converted_event := _present_other_agent_message(event):
630670
filtered_events.append(converted_event)
@@ -677,6 +717,7 @@ def _get_current_turn_contents(
677717
is_single_turn: bool = False,
678718
isolation_scope: Optional[str] = None,
679719
user_content: Optional[types.Content] = None,
720+
source_filter: Optional[list[str]] = None,
680721
) -> list[types.Content]:
681722
"""Get contents for the current turn only (no conversation history).
682723
@@ -712,6 +753,7 @@ def _get_current_turn_contents(
712753
isolation_scope=isolation_scope,
713754
is_single_turn=is_single_turn,
714755
user_content=user_content,
756+
source_filter=source_filter,
715757
)
716758

717759
return []

tests/unittests/agents/test_llm_agent_include_contents.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
"""Unit tests for LlmAgent include_contents field behavior."""
15+
"""Unit tests for LlmAgent include_contents and include_sources field behavior."""
1616

1717
from google.adk.agents.llm_agent import LlmAgent
1818
from google.adk.agents.sequential_agent import SequentialAgent
@@ -241,3 +241,131 @@ async def test_include_contents_none_sequential_agents():
241241
assert any(
242242
"Agent1 response" in str(content) for _, content in agent2_contents
243243
)
244+
245+
246+
# ---------------------------------------------------------------------------
247+
# include_sources: field validation
248+
# ---------------------------------------------------------------------------
249+
250+
251+
def test_include_sources_empty_list_raises():
252+
"""include_sources=[] must raise ValueError — use None to disable filtering."""
253+
with pytest.raises(ValueError, match='include_sources=\\[\\]'):
254+
LlmAgent(
255+
name='agent',
256+
model='gemini-2.5-flash',
257+
include_sources=[],
258+
)
259+
260+
261+
def test_include_sources_none_is_accepted():
262+
"""include_sources=None (default) must not raise."""
263+
agent = LlmAgent(
264+
name='agent', model='gemini-2.5-flash', include_sources=None
265+
)
266+
assert agent.include_sources is None
267+
268+
269+
# ---------------------------------------------------------------------------
270+
# include_sources: integration — user-only in sequential pipeline
271+
# ---------------------------------------------------------------------------
272+
273+
274+
@pytest.mark.asyncio
275+
async def test_include_sources_user_only_drops_upstream_agent_entries():
276+
"""Downstream agent with include_sources=['user'] receives only the human user message."""
277+
agent1_model = testing_utils.MockModel.create(
278+
responses=['Upstream agent reply']
279+
)
280+
agent1 = LlmAgent(
281+
name='upstream',
282+
model=agent1_model,
283+
instruction='You are upstream',
284+
)
285+
286+
agent2_model = testing_utils.MockModel.create(
287+
responses=['Downstream response']
288+
)
289+
agent2 = LlmAgent(
290+
name='downstream',
291+
model=agent2_model,
292+
include_sources=['user'],
293+
instruction='You are downstream',
294+
)
295+
296+
sequential = SequentialAgent(
297+
name='pipeline', sub_agents=[agent1, agent2]
298+
)
299+
runner = testing_utils.InMemoryRunner(sequential)
300+
runner.run('Original user request')
301+
302+
agent2_contents = testing_utils.simplify_contents(
303+
agent2_model.requests[0].contents
304+
)
305+
306+
# User message must be present
307+
assert any(
308+
'Original user request' in str(c) for _, c in agent2_contents
309+
)
310+
# Upstream agent's narrative entry must be absent
311+
assert not any(
312+
'Upstream agent reply' in str(c) for _, c in agent2_contents
313+
)
314+
assert not any('For context:' in str(c) for _, c in agent2_contents)
315+
316+
317+
# ---------------------------------------------------------------------------
318+
# include_sources: composing with include_contents='default' — multi-turn
319+
# ---------------------------------------------------------------------------
320+
321+
322+
@pytest.mark.asyncio
323+
async def test_include_sources_user_self_drops_upstream_across_turns():
324+
"""include_sources=['user','self'] + include_contents='default' (full history):
325+
downstream agent sees all user messages and its own prior turns, but no
326+
narrative entries from the upstream agent across multiple invocations.
327+
"""
328+
agent1_model = testing_utils.MockModel.create(
329+
responses=['Turn1 upstream reply', 'Turn2 upstream reply']
330+
)
331+
agent1 = LlmAgent(
332+
name='upstream',
333+
model=agent1_model,
334+
instruction='You are upstream',
335+
)
336+
337+
agent2_model = testing_utils.MockModel.create(
338+
responses=['Turn1 downstream', 'Turn2 downstream']
339+
)
340+
agent2 = LlmAgent(
341+
name='downstream',
342+
model=agent2_model,
343+
include_sources=['user', 'self'],
344+
instruction='You are downstream',
345+
)
346+
347+
sequential = SequentialAgent(
348+
name='pipeline', sub_agents=[agent1, agent2]
349+
)
350+
runner = testing_utils.InMemoryRunner(sequential)
351+
runner.run('Turn 1 user message')
352+
runner.run('Turn 2 user message')
353+
354+
# Second invocation of downstream agent — should see user messages + own
355+
# prior turn, but not upstream's narrative entries.
356+
agent2_second_contents = testing_utils.simplify_contents(
357+
agent2_model.requests[1].contents
358+
)
359+
360+
# User messages must be present
361+
assert any(
362+
'Turn 1 user message' in str(c) for _, c in agent2_second_contents
363+
)
364+
assert any(
365+
'Turn 2 user message' in str(c) for _, c in agent2_second_contents
366+
)
367+
# Upstream agent's narrative entries must be absent
368+
assert not any(
369+
'upstream reply' in str(c).lower() for _, c in agent2_second_contents
370+
)
371+
assert not any('For context:' in str(c) for _, c in agent2_second_contents)

0 commit comments

Comments
 (0)