Skip to content

Commit 2aeb1e1

Browse files
nicolasmotaGWeale
authored andcommitted
feat: add model_input_context for transient context in LLM requests
Merge #5991 # Add Transient Model Input Context to RunConfig ### Link to Issue or Description of Change **1. Link to an existing issue (if applicable):** - Closes: #5990 **Problem:** Host applications sometimes need to provide request-scoped context to an agent for a single invocation without persisting that context into `session.events`. Before this change, callers had to either append synthetic session events or merge application context into the user message. Both approaches blur the boundary between durable conversation history and transient model input. **Solution:** This change adds `RunConfig.model_input_context`, a list of `google.genai.types.Content` values that are injected into the LLM request for the current invocation only. The context is deep-copied before insertion, added before the invocation user content, and never appended to `session.events`. The insertion path is separate from instruction-related content so the transient context keeps a stable position across tool-call loops and multi-agent `include_contents="none"` flows. ### Testing Plan **Unit Tests:** - [x] I have added or updated unit tests for my change. - [x] All unit tests pass locally. Passed locally: ```text .venv/bin/python -m pytest tests/unittests/agents/test_llm_agent_include_contents.py tests/unittests/agents/test_run_config.py 14 passed ``` Additional checks: ```text .venv/bin/pyink --check --diff --config pyproject.toml src/google/adk/flows/llm_flows/contents.py tests/unittests/agents/test_llm_agent_include_contents.py tests/unittests/agents/test_run_config.py .venv/bin/isort --check-only src/google/adk/flows/llm_flows/contents.py tests/unittests/agents/test_llm_agent_include_contents.py tests/unittests/agents/test_run_config.py src/google/adk/agents/run_config.py git diff --check ``` **Manual End-to-End (E2E) Tests:** Not run. This change is covered by focused unit tests around LLM request construction and session persistence. ### 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. - [x] I have manually tested my changes end-to-end. - [ ] Any dependent changes have been merged and published in downstream modules. ### Additional context The unit coverage verifies that transient context: - is sent to the model without being persisted to the session; - stays before the invocation user message after a tool call; - works for a sub-agent using `include_contents="none"` in a sequential flow; - is accepted by `RunConfig` as `types.Content`. Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=#5991 from nicolasmota:spike/model-input-context c835972 PiperOrigin-RevId: 943484488
1 parent 3009ec1 commit 2aeb1e1

4 files changed

Lines changed: 238 additions & 0 deletions

File tree

src/google/adk/agents/run_config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,14 @@ class RunConfig(BaseModel):
371371
)
372372
"""
373373

374+
model_input_context: list[types.Content] | None = None
375+
"""Transient context to include in the model input for this invocation.
376+
377+
The Runner does not persist these contents to the session. They are only
378+
added to the LLM request assembled for the current invocation, which lets
379+
callers provide per-turn context without changing the conversation history.
380+
"""
381+
374382
@model_validator(mode='before')
375383
@classmethod
376384
def check_for_deprecated_save_live_audio(cls, data: Any) -> Any:

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import copy
1718
import logging
1819
from typing import AsyncGenerator
1920
from typing import Optional
@@ -105,6 +106,16 @@ async def run_async(
105106
user_content=invocation_context.user_content,
106107
)
107108

109+
if (
110+
invocation_context.run_config
111+
and invocation_context.run_config.model_input_context
112+
):
113+
_add_model_input_context_to_user_content(
114+
invocation_context,
115+
llm_request,
116+
copy.deepcopy(invocation_context.run_config.model_input_context),
117+
)
118+
108119
# Add instruction-related contents to proper position in conversation
109120
await _add_instructions_to_user_content(
110121
invocation_context, llm_request, instruction_related_contents
@@ -1039,6 +1050,26 @@ def _content_contains_function_response(content: types.Content) -> bool:
10391050
return False
10401051

10411052

1053+
def _add_model_input_context_to_user_content(
1054+
invocation_context: InvocationContext,
1055+
llm_request: LlmRequest,
1056+
model_input_context: list[types.Content],
1057+
) -> None:
1058+
"""Insert transient model input context before the invocation user content."""
1059+
if not model_input_context:
1060+
return
1061+
1062+
insert_index = 0
1063+
user_content = invocation_context.user_content
1064+
if user_content:
1065+
for i in range(len(llm_request.contents) - 1, -1, -1):
1066+
if llm_request.contents[i] == user_content:
1067+
insert_index = i
1068+
break
1069+
1070+
llm_request.contents[insert_index:insert_index] = model_input_context
1071+
1072+
10421073
async def _add_instructions_to_user_content(
10431074
invocation_context: InvocationContext,
10441075
llm_request: LlmRequest,

tests/unittests/agents/test_llm_agent_include_contents.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""Unit tests for LlmAgent include_contents field behavior."""
1616

1717
from google.adk.agents.llm_agent import LlmAgent
18+
from google.adk.agents.run_config import RunConfig
1819
from google.adk.agents.sequential_agent import SequentialAgent
1920
from google.genai import types
2021
import pytest
@@ -189,6 +190,196 @@ def simple_tool(message: str) -> dict:
189190
assert len(mock_model.requests[0].config.tools) > 0
190191

191192

193+
def test_model_input_context_is_sent_to_model_without_persisting_to_session():
194+
mock_model = testing_utils.MockModel.create(responses=["Answer"])
195+
agent = LlmAgent(name="test_agent", model=mock_model)
196+
runner = testing_utils.InMemoryRunner(agent)
197+
session = runner.session
198+
199+
list(
200+
runner.runner.run(
201+
user_id=session.user_id,
202+
session_id=session.id,
203+
new_message=testing_utils.get_user_content("Question"),
204+
run_config=RunConfig(
205+
model_input_context=[
206+
types.UserContent("Relevant context for this turn")
207+
]
208+
),
209+
)
210+
)
211+
212+
assert testing_utils.simplify_contents(mock_model.requests[0].contents) == [
213+
("user", "Relevant context for this turn"),
214+
("user", "Question"),
215+
]
216+
assert testing_utils.simplify_events(runner.session.events) == [
217+
("user", "Question"),
218+
("test_agent", "Answer"),
219+
]
220+
221+
222+
def test_model_input_context_stays_before_user_message_after_tool_call():
223+
def simple_tool(message: str) -> dict:
224+
return {"result": f"Tool processed: {message}"}
225+
226+
mock_model = testing_utils.MockModel.create(
227+
responses=[
228+
types.Part.from_function_call(
229+
name="simple_tool", args={"message": "payload"}
230+
),
231+
"Answer",
232+
]
233+
)
234+
agent = LlmAgent(name="test_agent", model=mock_model, tools=[simple_tool])
235+
runner = testing_utils.InMemoryRunner(agent)
236+
session = runner.session
237+
238+
list(
239+
runner.runner.run(
240+
user_id=session.user_id,
241+
session_id=session.id,
242+
new_message=testing_utils.get_user_content("Question"),
243+
run_config=RunConfig(
244+
model_input_context=[
245+
types.UserContent("Relevant context for this turn")
246+
]
247+
),
248+
)
249+
)
250+
251+
assert testing_utils.simplify_contents(mock_model.requests[0].contents) == [
252+
("user", "Relevant context for this turn"),
253+
("user", "Question"),
254+
]
255+
assert testing_utils.simplify_contents(mock_model.requests[1].contents) == [
256+
("user", "Relevant context for this turn"),
257+
("user", "Question"),
258+
(
259+
"model",
260+
types.Part.from_function_call(
261+
name="simple_tool", args={"message": "payload"}
262+
),
263+
),
264+
(
265+
"user",
266+
types.Part.from_function_response(
267+
name="simple_tool",
268+
response={"result": "Tool processed: payload"},
269+
),
270+
),
271+
]
272+
assert testing_utils.simplify_events(runner.session.events) == [
273+
("user", "Question"),
274+
(
275+
"test_agent",
276+
types.Part.from_function_call(
277+
name="simple_tool", args={"message": "payload"}
278+
),
279+
),
280+
(
281+
"test_agent",
282+
types.Part.from_function_response(
283+
name="simple_tool",
284+
response={"result": "Tool processed: payload"},
285+
),
286+
),
287+
("test_agent", "Answer"),
288+
]
289+
290+
291+
def test_model_input_context_with_include_contents_none_sub_agent():
292+
agent1_model = testing_utils.MockModel.create(
293+
responses=["Agent1 response: XYZ"]
294+
)
295+
agent1 = LlmAgent(name="agent1", model=agent1_model)
296+
297+
agent2_model = testing_utils.MockModel.create(
298+
responses=["Agent2 final response"]
299+
)
300+
agent2 = LlmAgent(
301+
name="agent2",
302+
model=agent2_model,
303+
include_contents="none",
304+
)
305+
sequential_agent = SequentialAgent(
306+
name="sequential_test_agent", sub_agents=[agent1, agent2]
307+
)
308+
runner = testing_utils.InMemoryRunner(sequential_agent)
309+
session = runner.session
310+
311+
list(
312+
runner.runner.run(
313+
user_id=session.user_id,
314+
session_id=session.id,
315+
new_message=testing_utils.get_user_content("Original user request"),
316+
run_config=RunConfig(
317+
model_input_context=[
318+
types.UserContent("Relevant context for this turn")
319+
]
320+
),
321+
)
322+
)
323+
324+
assert testing_utils.simplify_contents(agent1_model.requests[0].contents) == [
325+
("user", "Relevant context for this turn"),
326+
("user", "Original user request"),
327+
]
328+
assert testing_utils.simplify_contents(agent2_model.requests[0].contents) == [
329+
("user", "Relevant context for this turn"),
330+
(
331+
"user",
332+
[
333+
types.Part(text="For context:"),
334+
types.Part(text="[agent1] said: Agent1 response: XYZ"),
335+
],
336+
),
337+
]
338+
339+
340+
def test_model_input_context_without_user_message_is_prepended_before_history():
341+
mock_model = testing_utils.MockModel.create(
342+
responses=["First answer", "Second answer"]
343+
)
344+
agent = LlmAgent(name="test_agent", model=mock_model)
345+
runner = testing_utils.InMemoryRunner(agent)
346+
session = runner.session
347+
348+
list(
349+
runner.runner.run(
350+
user_id=session.user_id,
351+
session_id=session.id,
352+
new_message=testing_utils.get_user_content("First question"),
353+
)
354+
)
355+
# No new_message, so the invocation has no user content to anchor before
356+
# (e.g. live mode or a re-run over existing history). The transient context
357+
# falls back to the front of the request, before all prior history.
358+
list(
359+
runner.runner.run(
360+
user_id=session.user_id,
361+
session_id=session.id,
362+
new_message=None,
363+
run_config=RunConfig(
364+
model_input_context=[
365+
types.UserContent("Relevant context for this turn")
366+
]
367+
),
368+
)
369+
)
370+
371+
assert testing_utils.simplify_contents(mock_model.requests[1].contents) == [
372+
("user", "Relevant context for this turn"),
373+
("user", "First question"),
374+
("model", "First answer"),
375+
]
376+
assert testing_utils.simplify_events(runner.session.events) == [
377+
("user", "First question"),
378+
("test_agent", "First answer"),
379+
("test_agent", "Second answer"),
380+
]
381+
382+
192383
@pytest.mark.asyncio
193384
async def test_include_contents_none_sequential_agents():
194385
"""Test include_contents='none' with sequential agents."""

tests/unittests/agents/test_run_config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,11 @@ def test_avatar_config_with_name():
129129
assert run_config.avatar_config == avatar_config
130130
assert run_config.avatar_config.avatar_name == "test_avatar"
131131
assert run_config.avatar_config.customized_avatar is None
132+
133+
134+
def test_model_input_context_accepts_transient_contents():
135+
context_content = types.UserContent("Relevant context for this turn")
136+
137+
run_config = RunConfig(model_input_context=[context_content])
138+
139+
assert run_config.model_input_context == [context_content]

0 commit comments

Comments
 (0)