Skip to content

Commit 8aaf62a

Browse files
GWealecopybara-github
authored andcommitted
fix(workflow): store a single-turn node's synthetic input in the session
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 974056680
1 parent dc99a58 commit 8aaf62a

3 files changed

Lines changed: 138 additions & 22 deletions

File tree

src/google/adk/workflow/_llm_agent_wrapper.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -302,15 +302,18 @@ def prepare_llm_agent_context(agent: LlmAgent, ctx: Context) -> Context:
302302
return agent_ctx
303303

304304

305-
def prepare_llm_agent_input(
305+
async def prepare_llm_agent_input(
306306
agent: LlmAgent, ctx: Context, node_input: object
307307
) -> None:
308308
"""Prepares the input for running LlmAgent as a node.
309309
310-
For ``single_turn`` mode, append a user-role event with the input
311-
directly to session.events (legacy behavior). When resuming with
312-
``resume_inputs``, skip appending to avoid injecting duplicate synthetic
313-
user events that shadow user function responses.
310+
For ``single_turn`` mode, write a user-role event carrying the input
311+
through the session service. Appending to ``session.events`` by hand
312+
instead reaches only the in-memory list, so the input never becomes part
313+
of the stored session. Nothing is written when the input is the
314+
invocation's own user message, because the runner has already recorded
315+
that turn, nor when resuming with ``resume_inputs``, because a synthetic
316+
user event would shadow the user's function responses.
314317
315318
For ``task`` mode, the input is the parent's task-delegation FC
316319
args. Those are NOT appended here — the content-builder
@@ -336,17 +339,32 @@ def prepare_llm_agent_input(
336339
or bool(ctx.resume_inputs)
337340
):
338341
return
342+
from ..runners import _apply_run_config_custom_metadata
343+
339344
agent_input = to_user_content(node_input)
340-
user_event = Event(author='user', message=agent_input)
345+
ic = ctx._invocation_context
346+
# A node wired directly to START is handed the invocation's own user
347+
# message, which the runner already recorded. Matching on rendered content
348+
# is approximate: a mid-workflow node whose input happens to equal the
349+
# user's turn is skipped too.
350+
if (
351+
ic.user_content is not None
352+
and to_user_content(ic.user_content) == agent_input
353+
):
354+
return
355+
user_event = Event(
356+
author='user', message=agent_input, invocation_id=ic.invocation_id
357+
)
341358
if user_event.content is not None:
342359
user_event.content.role = 'user'
360+
_apply_run_config_custom_metadata(user_event, ic.run_config)
343361
iso = getattr(ctx, 'isolation_scope', None)
344362
if iso:
345363
user_event.isolation_scope = iso
346-
branch = ctx._invocation_context.branch
364+
branch = ic.branch
347365
if branch:
348366
user_event.branch = branch
349-
ctx.session.events.append(user_event)
367+
await ic.session_service.append_event(session=ic.session, event=user_event)
350368

351369

352370
def process_llm_agent_output(
@@ -404,7 +422,7 @@ async def run_llm_agent_as_node(
404422
agent.include_contents = 'none'
405423

406424
agent_ctx = prepare_llm_agent_context(agent, ctx)
407-
prepare_llm_agent_input(agent, agent_ctx, node_input)
425+
await prepare_llm_agent_input(agent, agent_ctx, node_input)
408426

409427
ic = agent_ctx.get_invocation_context()
410428
update: dict[str, object] = {'agent': agent}

tests/unittests/workflow/test_llm_agent_as_node.py

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -231,15 +231,34 @@ async def test_single_turn_input_event_inherits_branch_and_scope(
231231
ctx = Context(invocation_context=ic)
232232
ctx.isolation_scope = 'scope-1'
233233

234-
prepare_llm_agent_input(agent, ctx, 'hello')
234+
await prepare_llm_agent_input(agent, ctx, 'hello')
235235

236236
event = ic.session.events[-1]
237237
assert event.author == 'user'
238+
assert event.invocation_id == ic.invocation_id
238239
assert event.content and event.content.role == 'user'
239240
assert event.branch == 'parent.worker@1'
240241
assert event.isolation_scope == 'scope-1'
241242

242243

244+
@pytest.mark.asyncio
245+
async def test_single_turn_input_event_carries_run_custom_metadata(
246+
request: pytest.FixtureRequest,
247+
):
248+
"""Run-level custom metadata tags the synthetic input like any other event."""
249+
from google.adk.agents.run_config import RunConfig
250+
from google.adk.workflow._llm_agent_wrapper import prepare_llm_agent_input
251+
252+
agent = _make_agent(mode='single_turn')
253+
ic = await create_parent_invocation_context(request.function.__name__, agent)
254+
ic.run_config = RunConfig(custom_metadata={'tenant': 'acme'})
255+
ctx = Context(invocation_context=ic)
256+
257+
await prepare_llm_agent_input(agent, ctx, 'hello')
258+
259+
assert ic.session.events[-1].custom_metadata == {'tenant': 'acme'}
260+
261+
243262
@pytest.mark.asyncio
244263
async def test_single_turn_input_skipped_when_resuming(
245264
request: pytest.FixtureRequest,
@@ -265,14 +284,92 @@ async def test_single_turn_input_skipped_when_resuming(
265284
)
266285

267286
initial_len = len(ic.session.events)
268-
prepare_llm_agent_input(agent, ctx, 'hello')
287+
await prepare_llm_agent_input(agent, ctx, 'hello')
269288

270289
# Verify no duplicate user input was appended on resume
271290
assert len(ic.session.events) == initial_len
272291
# Verify the resumed node still sees the initial user input from turn 1
273292
assert ic.session.events[-1].content.parts[0].text == 'turn 1 initial input'
274293

275294

295+
@pytest.mark.asyncio
296+
async def test_single_turn_input_skipped_when_it_is_the_user_message(
297+
request: pytest.FixtureRequest,
298+
):
299+
"""The runner already recorded the invocation's own user message."""
300+
from google.adk.workflow._llm_agent_wrapper import prepare_llm_agent_input
301+
302+
agent = _make_agent(mode='single_turn')
303+
ic = await create_parent_invocation_context(request.function.__name__, agent)
304+
ic.user_content = types.Content(
305+
role='user', parts=[types.Part(text='who am i')]
306+
)
307+
ctx = Context(invocation_context=ic)
308+
309+
await prepare_llm_agent_input(agent, ctx, ic.user_content)
310+
311+
assert not ic.session.events
312+
313+
314+
@pytest.mark.asyncio
315+
async def test_single_turn_input_event_reaches_the_stored_session(
316+
request: pytest.FixtureRequest,
317+
):
318+
"""A single-turn node's input becomes part of the stored session.
319+
320+
Appending to the in-memory events list reaches only the Session object the
321+
node happens to hold, so the input is missing from the session anyone loads
322+
afterwards and the model turn it prompted has no prompt.
323+
"""
324+
from . import testing_utils
325+
326+
async def brief(node_input: Any) -> str:
327+
return 'write the brief'
328+
329+
wrapper = build_node(_make_agent(mode='single_turn'))
330+
wf = Workflow(name='wf', edges=[(START, brief), (brief, wrapper)])
331+
runner = _new_workflow_runner(wf, request.function.__name__)
332+
333+
agent_clone = next(n for n in wf.graph.nodes if n.name == wrapper.name)
334+
with _mock_leaf_run(agent_clone, content_text='Done.'):
335+
await runner.run_async(testing_utils.get_user_content('start'))
336+
337+
stored = runner.session
338+
assert any(
339+
event.author == 'user'
340+
and event.content
341+
and event.content.parts
342+
and event.content.parts[0].text == 'write the brief'
343+
for event in stored.events
344+
)
345+
346+
347+
@pytest.mark.asyncio
348+
async def test_first_node_does_not_store_the_user_message_twice(
349+
request: pytest.FixtureRequest,
350+
):
351+
"""A node wired to START is fed the turn the runner already stored."""
352+
from . import testing_utils
353+
354+
wrapper = build_node(_make_agent(mode='single_turn'))
355+
wf = Workflow(name='wf', edges=[(START, wrapper)])
356+
runner = _new_workflow_runner(wf, request.function.__name__)
357+
358+
agent_clone = next(n for n in wf.graph.nodes if n.name == wrapper.name)
359+
with _mock_leaf_run(agent_clone, content_text='Done.'):
360+
await runner.run_async(testing_utils.get_user_content('who am i'))
361+
362+
user_turns = [
363+
event
364+
for event in runner.session.events
365+
if event.author == 'user'
366+
and event.content
367+
and event.content.parts
368+
and event.content.parts[0].text == 'who am i'
369+
]
370+
assert len(user_turns) == 1
371+
372+
276373
# --- build_node auto-wrapping ---
277374

278375

@@ -340,17 +437,16 @@ async def mock_run_async(*args, **kwargs):
340437
content=types.Content(parts=[types.Part(text='ok')]),
341438
)
342439

440+
async def skip_input(agent, ctx, node_input):
441+
return None
442+
343443
object.__setattr__(wrapper, 'run_async', mock_run_async)
344444
monkeypatch.setattr(
345445
agent_wrapper,
346446
'prepare_llm_agent_context',
347447
lambda agent, ctx: ctx,
348448
)
349-
monkeypatch.setattr(
350-
agent_wrapper,
351-
'prepare_llm_agent_input',
352-
lambda agent, ctx, node_input: None,
353-
)
449+
monkeypatch.setattr(agent_wrapper, 'prepare_llm_agent_input', skip_input)
354450
ctx = MagicMock(spec=Context)
355451
ic = MagicMock()
356452
ctx.get_invocation_context.return_value = ic

tests/unittests/workflow/test_workflow_llm_agent_interruptions.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -701,14 +701,15 @@ def get_invocation_context(self):
701701

702702
from google.adk.workflow import _llm_agent_wrapper
703703

704+
async def skip_input(a, c, i):
705+
return None
706+
704707
monkeypatch.setattr(
705708
_llm_agent_wrapper,
706709
'prepare_llm_agent_context',
707710
lambda a, c: DummyAgentCtx(ic),
708711
)
709-
monkeypatch.setattr(
710-
_llm_agent_wrapper, 'prepare_llm_agent_input', lambda a, c, i: None
711-
)
712+
monkeypatch.setattr(_llm_agent_wrapper, 'prepare_llm_agent_input', skip_input)
712713

713714
# Simulate Runner adding the event to the correct branch!
714715
_append_function_response(
@@ -823,14 +824,15 @@ def get_invocation_context(self):
823824

824825
from google.adk.workflow import _llm_agent_wrapper
825826

827+
async def skip_input(a, c, i):
828+
return None
829+
826830
monkeypatch.setattr(
827831
_llm_agent_wrapper,
828832
'prepare_llm_agent_context',
829833
lambda a, c: DummyAgentCtx(ic),
830834
)
831-
monkeypatch.setattr(
832-
_llm_agent_wrapper, 'prepare_llm_agent_input', lambda a, c, i: None
833-
)
835+
monkeypatch.setattr(_llm_agent_wrapper, 'prepare_llm_agent_input', skip_input)
834836

835837
# Simulate Runner adding the events to the correct branches!
836838
_append_function_response(

0 commit comments

Comments
 (0)