Skip to content

AgentTool's wrapped agent can't read the caller's temp: state #7595

Description

@Somay-kousis

🔴 Required Information

Describe the Bug:

An agent wrapped in AgentTool can't read the caller's temp: state. A coordinator tool sets tool_context.state["temp:ticket"], then calls a specialist through AgentTool. The specialist's instruction renders {temp:ticket?} as empty, and {temp:ticket} fails the whole run with KeyError: Context variable not found. Plain keys reach the specialist fine, and the coordinator's own instruction shows the value.

The state guide says temp: keys resolve "for the rest of the invocation that set them", and AgentTool says the wrapped agent "runs as part of the caller's invocation".

Steps to Reproduce:

  1. Checkout of main
  2. Save the script below as repro.py
  3. Run PYTHONPATH=src python repro.py. No network: the models are fakes that replay scripted replies.

Expected Behavior:

The specialist's instruction reads Ticket: T-42 in both runs, and neither run raises.

Observed Behavior:

specialist instruction 'Ticket: {temp:ticket?}'
  coordinator saw: ['Ticket: ', 'Ticket: T-42', 'Ticket: T-42']
  specialist saw:  ['Ticket: ']
specialist instruction 'Ticket: {temp:ticket}'
  coordinator saw: ['Ticket: ', 'Ticket: T-42']
  specialist saw:  []
  run raised:      KeyError: "Context variable not found: `temp:ticket` in agent 'specialist'."

Environment Details:

  • ADK Library Version (pip show google-adk): 2.12.0, main at a6ed859
  • Desktop OS: macOS 26.6.2
  • Python Version (python -V): Python 3.12.13

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: a fake BaseLlm, no real model call

🟡 Optional Information

Root cause:

AgentTool.run_async (src/google/adk/tools/agent_tool.py:284) copies the caller's whole state, temp: keys included, into create_session(state=...) for the nested runner. InMemorySessionService.create_session passes it through _session_util.extract_state_delta, which drops every temp: key because they are never stored. So the nested session starts without them.

The state guide lists this exact pattern under common mistakes: "Seeding temp: in create_session(state=...). Those keys are dropped outright". #4564 covered the same gap between SequentialAgent sub-agents, which share one session; AgentTool builds a new one.

Runner.run_async(state_delta=...) already applies temp: keys for one invocation without storing them, so the nested run can take them that way.

Out of scope here: temp: writes made inside the wrapped agent going back to the caller.

Minimal Reproduction Code:

import asyncio
from typing import AsyncGenerator

from google.adk.agents import LlmAgent
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.adk.tools.agent_tool import AgentTool
from google.adk.tools.tool_context import ToolContext
from google.genai import types


class FakeLlm(BaseLlm):
  """Replays scripted replies and records each system instruction."""

  model: str = "fake"
  replies: list = []
  seen: list = []

  async def generate_content_async(
      self, llm_request: LlmRequest, stream: bool = False
  ) -> AsyncGenerator[LlmResponse, None]:
    self.seen.append(llm_request.config.system_instruction.splitlines()[0])
    yield LlmResponse(content=self.replies.pop(0))


def call(name, **args):
  return types.ModelContent([types.Part.from_function_call(name=name, args=args)])


def open_ticket(tool_context: ToolContext) -> dict:
  """Opens a ticket for this turn only."""
  tool_context.state["temp:ticket"] = "T-42"
  return {"ticket": "T-42"}


async def run(label, specialist_instruction):
  specialist_llm = FakeLlm(replies=[types.ModelContent("triaged")], seen=[])
  specialist = LlmAgent(
      name="specialist", model=specialist_llm, instruction=specialist_instruction
  )
  coordinator = LlmAgent(
      name="coordinator",
      model=FakeLlm(
          replies=[
              call("open_ticket"),
              call("specialist", request="triage it"),
              types.ModelContent("done"),
          ],
          seen=[],
      ),
      instruction="Ticket: {temp:ticket?}",
      tools=[open_ticket, AgentTool(specialist)],
  )
  runner = InMemoryRunner(agent=coordinator, app_name="app")
  session = await runner.session_service.create_session(app_name="app", user_id="u")
  error = None
  try:
    async for _ in runner.run_async(
        user_id="u", session_id=session.id, new_message=types.UserContent("help")
    ):
      pass
  except Exception as e:  # pylint: disable=broad-except
    error = f"{type(e).__name__}: {e}"
  print(f"{label}")
  print(f"  coordinator saw: {coordinator.model.seen}")
  print(f"  specialist saw:  {specialist_llm.seen}")
  if error:
    print(f"  run raised:      {error}")


async def main():
  await run("specialist instruction 'Ticket: {temp:ticket?}'", "Ticket: {temp:ticket?}")
  await run("specialist instruction 'Ticket: {temp:ticket}'", "Ticket: {temp:ticket}")


asyncio.run(main())

How often has this issue occurred?:

  • Always (100%)

Activity

  1. surajksharma07 commented on Oct 19, 2026

    @surajksharma07
    Collaborator

    Reproduced both the empty render and the KeyError on main (2.12.0) @Somay-kousis. Passing the caller’s temp: keys to the nested run through state_delta as in #7596 seems like a good workaround: in my local run the specialist read Ticket: T-42 in both cases and nothing extra got persisted to the stored session.

    Could you double-check it on your end too maybe with a real model and a temp: value set from a callback? Might also be worth confirming the seeded temp: keys don’t get echoed back into the caller’s state through the forwarded state_delta.

    Once you’ve tested it thoroughly and it holds up we can move #7596 ahead for review (it may need a small rebase against #7045).

  2. Somay-kousis commented on Oct 19, 2026

    @Somay-kousis
    ContributorAuthor

    @surajksharma07 thanks for checking. Updated #7596 (1f6451a, rebased on main):

    • temp: set from a callback: test_agent_tool_reads_caller_temp_state already sets all three keys from the caller's before_agent_callback, for each of the 6 ways the wrapped agent reads them. The repro in the issue sets it from a tool. Both are in the real-model run below.
    • Echo: the test now records the caller's state_delta for the AgentTool call (after_tool_callback) and asserts it has no temp: key. If AgentTool wrote the seeded keys back, all 6 cases fail (checked by adding that write). The stored state stays empty.
    • fix(runners): close shared toolsets only from the last runner #7045: it already conflicts with main in agent_tool.py. It re-indents the nested run_async(...) block, where this PR adds one state_delta= argument, so whichever lands second needs a one-line rebase. Happy to do it if fix(runners): close shared toolsets only from the last runner #7045 goes first.

    Real model: Groq openai/gpt-oss-120b through LiteLlm (with include_reasoning=False; without it Groq rejects the second request). The specialist's instruction is Ticket: {temp:ticket?}.

    With #7596:

      temp:ticket set from tool
        specialist's model got: ["Ticket: T-42. Reply with exactly: Triaged ticket <the ticket id after 'Ticket:'>, or 'Triaged ticket NONE' if it is empty."]
        replies: [('coordinator', 'Triaged ticket T-42')]
        specialist returned: Triaged ticket T-42
        caller delta for the AgentTool call: {} | stored state: {}
      temp:ticket set from callback
        specialist's model got: ["Ticket: T-42. Reply with exactly: Triaged ticket <the ticket id after 'Ticket:'>, or 'Triaged ticket NONE' if it is empty."]
        replies: [('coordinator', 'Triaged ticket T-42')]
        specialist returned: Triaged ticket T-42
        caller delta for the AgentTool call: {} | stored state: {}
    

    On main:

      temp:ticket set from tool
        specialist's model got: ["Ticket: . Reply with exactly: Triaged ticket <the ticket id after 'Ticket:'>, or 'Triaged ticket NONE' if it is empty."]
        replies: [('coordinator', 'Triaged ticket NONE')]
        caller delta for the AgentTool call: {} | stored state: {}
      temp:ticket set from callback
        specialist's model got: ["Ticket: . Reply with exactly: Triaged ticket <the ticket id after 'Ticket:'>, or 'Triaged ticket NONE' if it is empty."]
        replies: [('coordinator', 'The specialist has triaged your ticket as: **NONE**.')]
        caller delta for the AgentTool call: {} | stored state: {}
    

    With the fix the specialist's model got Ticket: T-42 in all 3 runs. In one of them the model still answered NONE, so the run above is a clean one. tests/unittests/tools/test_agent_tool.py: 67 passed.

  3. surajksharma07 commented on Oct 19, 2026

    @surajksharma07
    Collaborator

    Pulled 1f6451a onto current main (92bb67f) and it merges cleanly @Somay-kousis. test_agent_tool.py gives 67 passed. With main’s agent_tool.py all 6 temp-state cases fail. The rest of tools/ and sessions/ shows the same 75 failures with and without the PR (Vertex/retrieval tests failing in my Windows env) so nothing new. The repro from the issue now shows Ticket: T-42 for both {temp:ticket?} and {temp:ticket} with no KeyError.

    2.12.0 still copies the whole state into create_session() so this isn’t fixed in any release yet and #7596 is the fix. On #7045: it already conflicts with main in four files so it needs a rebase either way. The one state_delta= line here won’t add much to that.

    One small thing worth mentioning in the PR description: temp: values are now passed to the wrapped agent by reference. If a temp: value is a mutable object and the wrapped agent changes it in place the caller will see that change. That matches “same invocation” but worth stating since temp: write-back is otherwise out of scope.

    From my side #7596 looks ready for a maintainer review. It still needs the 2 pending workflows approved.

  4. added
    core[Component] This issue is related to the core interface and implementation
    on Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

core[Component] This issue is related to the core interface and implementation

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions