Repository navigation
AgentTool's wrapped agent can't read the caller's temp: state #7595
Description
🔴 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:
- Checkout of main
- Save the script below as
repro.py - 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
surajksharma07 commentedon Oct 19, 2026
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).
Somay-kousis commentedon Oct 19, 2026
@surajksharma07 thanks for checking. Updated #7596 (1f6451a, rebased on main):
temp:set from a callback:test_agent_tool_reads_caller_temp_statealready sets all three keys from the caller'sbefore_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.state_deltafor the AgentTool call (after_tool_callback) and asserts it has notemp:key. If AgentTool wrote the seeded keys back, all 6 cases fail (checked by adding that write). The stored state stays empty.agent_tool.py. It re-indents the nestedrun_async(...)block, where this PR adds onestate_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-120bthroughLiteLlm(withinclude_reasoning=False; without it Groq rejects the second request). The specialist's instruction isTicket: {temp:ticket?}.With #7596:
On main:
With the fix the specialist's model got
Ticket: T-42in 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.surajksharma07 commentedon Oct 19, 2026
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.