Skip to content

A scoped single_turn LlmAgent node gets the workflow's user message as its first turn, before its node input #7944

Open

Description

@TomShlaim

🔴 Required Information

Describe the Bug:

Run an LlmAgent as a single_turn workflow node with an isolation scope (ctx.run_node(agent, node_input=..., override_isolation_scope=...)). Its model request starts with the invocation's user message plus the single-turn nudge; the node input comes second. Without the scope, the request holds the node input only.

Cause (2.13.0, under src/google/adk/): for a scoped agent the content builder inserts a leading user turn (flows/llm_flows/context/_contents.py:659-667). It is meant to be the delegated task, but a workflow node has no delegation call, so it falls back to ic.user_content (:340-345), the workflow's message. Task mode overwrites ic.user_content with the node input (workflow/_llm_agent_wrapper.py:451-452); single_turn does not.

Steps to Reproduce:

  1. pip install google-adk==2.13.0
  2. Run the script below (scripted model, no API key). It runs the same agent once without and once with a scope.

Expected Behavior:

Both runs print model saw ['user: node input: add 2 and 3'].

Observed Behavior:

google-adk 2.13.0
isolation scope=None: model saw ['user: node input: add 2 and 3']
isolation scope='worker-scope': model saw ['user: workflow message | Important: You will not receive any user replies or clarifications. Complete the task using only the information provided above.', 'user: node input: add 2 and 3']

Environment Details:

  • ADK Library Version (pip show google-adk): 2.13.0 (same on 2.3.0, 2.11.0 and 2.12.0)
  • Desktop OS: macOS 15
  • Python Version (python -V): Python 3.12.8

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: N/A (scripted BaseLlm)

🟡 Optional Information

Regression: No, unchanged since 2.3.0.

Logs: See Observed Behavior.

Screenshots / Video: N/A

Additional Context: #7650 reports the same fallback after a tool-confirmation resume. Workaround in use: set _invocation_context.user_content = None on the scoped single_turn node after prepare_llm_agent_context.

Minimal Reproduction Code:

import asyncio

import google.adk
from google.adk.agents import Context, LlmAgent
from google.adk.apps import App
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.workflow import START, Workflow, node
from google.genai import types

WORKFLOW_MESSAGE = "workflow message"
NODE_INPUT = "node input: add 2 and 3"
model_calls: list[list[str]] = []


class RecordingLlm(BaseLlm):
    model: str = "recording"

    async def generate_content_async(self, llm_request, stream=False):
        model_calls.append(
            [f"{c.role}: {' | '.join(p.text for p in c.parts if p.text)}" for c in llm_request.contents]
        )
        yield LlmResponse(content=types.Content(role="model", parts=[types.Part(text="5")]))


def make_workflow(scope: str | None) -> Workflow:
    worker = LlmAgent(name="worker", model=RecordingLlm(), instruction="Add the numbers.", mode="single_turn")

    @node(rerun_on_resume=True)
    async def orchestrator(ctx: Context, node_input):
        yield await ctx.run_node(worker, node_input=NODE_INPUT, override_isolation_scope=scope)

    return Workflow(name="root", edges=[(START, orchestrator)])


async def run(scope: str | None) -> None:
    model_calls.clear()
    sessions = InMemorySessionService()
    runner = Runner(app=App(name="repro", root_agent=make_workflow(scope)), session_service=sessions)
    session = await sessions.create_session(app_name="repro", user_id="u")
    async for _ in runner.run_async(
        user_id="u", session_id=session.id, new_message=types.UserContent(WORKFLOW_MESSAGE)
    ):
        pass
    print(f"isolation scope={scope!r}: model saw {model_calls[0]}")


async def main():
    print("google-adk", google.adk.__version__)
    await run(None)
    await run("worker-scope")


asyncio.run(main())

Activity

  1. surajksharma07 commented on Nov 2, 2026

    @surajksharma07
    Collaborator

    @TomShlaim. Reproduced this on 2.13.0 and on current main: with a scope set, the single_turn worker gets the workflow message plus the nudge before its node input. It looks like the same user_content fallback as #7650.

    As a workaround, clearing user_content on the scoped single_turn node’s own context, like you’re already doing, gives the same contents as the unscoped run in my test. A proper fix would probably be to set user_content to None for scoped single_turn nodes in run_llm_agent_as_node, right next to the task-mode override.

    Could you check whether that works for your real workflows too? If you’d like to raise a PR please test it well first (scoped/unscoped single_turn, task mode and the resume case from #7738) and add a unit test.

  2. willie3838 commented on Nov 2, 2026

    @willie3838

    An internal team will take this on! Thanks for flagging this @TomShlaim

  3. added
    wip[Status] This issue is being worked on. Either there is a pending PR or is planned to be fixed
    on Nov 2, 2026
  4. TomShlaim commented on Nov 3, 2026

    @TomShlaim
    Author

    Hi @surajksharma07 @willie3838
    Perfect, thanks a lot!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

wip[Status] This issue is being worked on. Either there is a pending PR or is planned to be fixed

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions