Skip to content

state_delta is silently ignored when resuming an invocation without a new_message #6644

Description

@lwangverizon

🔴 Required Information

Describe the Bug:

Runner.run_async accepts a state_delta argument, documented as "Optional state
changes to apply to the session". When an invocation is resumed by invocation_id
with no new_message, that delta is accepted and then silently discarded — no
warning, no error, no event.

This is the sibling of #5763 / #5767, which fixed the new_message path. The delta
is still only ever applied while appending the user message event, so both dispatch
paths remain gated on if new_message::

  • node pathrunners.py:628, feeding _append_user_event(..., state_delta=...)
  • legacy pathrunners.py:2142 in _setup_context_for_resumed_invocation,
    feeding _handle_new_message(..., state_delta=...)

Resuming with no new_message is a supported call — run_async explicitly permits it
when the app is resumable and an invocation_id is given (the "A new message is
required when no invocation can be resumed" guard passes). So the caller is using the
API as documented and losing data.

Steps to Reproduce:

  1. Check out main @ a5864a0e (version.py reports 2.6.2).
  2. Save the script below as repro.py.
  3. python repro.py — it needs no network, API key, or model access.
"""state_delta when resuming an invocation without a new_message.

Covers both dispatch paths in Runner.run_async:
  A) node path   -> _run_node_async                        (LlmAgent, a BaseNode)
  B) legacy path -> _setup_context_for_resumed_invocation  (plain BaseAgent)
"""

import asyncio
from typing import AsyncGenerator

from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.llm_agent import LlmAgent
from google.adk.apps.app import App, ResumabilityConfig
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.events.event import Event
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.in_memory_session_service import InMemorySessionService
from google.genai import types

USER, SESSION = "u1", "s1"


class EchoLlm(BaseLlm):
  """Minimal offline model so the repro needs no network or API key."""

  model: str = "echo"

  async def generate_content_async(
      self, llm_request, stream: bool = False
  ) -> AsyncGenerator[LlmResponse, None]:
    yield LlmResponse(
        content=types.Content(role="model", parts=[types.Part(text="ok")])
    )


class EchoAgent(BaseAgent):
  """Plain BaseAgent (not a BaseNode) -> takes the legacy runner path."""

  async def _run_async_impl(
      self, ctx: InvocationContext
  ) -> AsyncGenerator[Event, None]:
    yield Event(
        invocation_id=ctx.invocation_id,
        author=self.name,
        content=types.Content(role="model", parts=[types.Part(text="ok")]),
    )


async def check(app_name: str, label: str, agent: BaseAgent) -> bool:
  session_service = InMemorySessionService()
  runner = Runner(
      app=App(
          name=app_name,
          root_agent=agent,
          resumability_config=ResumabilityConfig(is_resumable=True),
      ),
      session_service=session_service,
      artifact_service=InMemoryArtifactService(),
  )
  await session_service.create_session(
      app_name=app_name, user_id=USER, session_id=SESSION
  )

  # Turn 1: a normal run, so there is an invocation to resume.
  async for _ in runner.run_async(
      user_id=USER,
      session_id=SESSION,
      new_message=types.Content(role="user", parts=[types.Part(text="hi")]),
  ):
    pass

  session = await session_service.get_session(
      app_name=app_name, user_id=USER, session_id=SESSION
  )
  invocation_id = session.events[0].invocation_id

  # Turn 2: resume by invocation_id, with a state_delta and NO new_message.
  async for _ in runner.run_async(
      user_id=USER,
      session_id=SESSION,
      invocation_id=invocation_id,
      state_delta={"resumed_key": "resumed_value"},
  ):
    pass

  session = await session_service.get_session(
      app_name=app_name, user_id=USER, session_id=SESSION
  )
  applied = session.state.get("resumed_key") == "resumed_value"
  print(f"  {label:<28} state={dict(session.state)!r:<34} applied={applied}")
  return applied


async def main():
  print("resume by invocation_id + state_delta, no new_message:")
  a = await check(
      "app_node", "A) node path (LlmAgent)", LlmAgent(name="root", model=EchoLlm())
  )
  b = await check(
      "app_legacy", "B) legacy path (BaseAgent)", EchoAgent(name="root")
  )
  print(f"\nRESULT: {'PASS' if (a and b) else 'FAIL (state_delta dropped)'}")


asyncio.run(main())

Expected Behavior:

The supplied state_delta is applied to session.state and persisted as an
EventActions payload, the same as it is when a new_message is present.

Observed Behavior:

The delta is silently dropped on both paths. session.state stays empty.

resume by invocation_id + state_delta, no new_message:
  A) node path (LlmAgent)      state={}                                 applied=False
  B) legacy path (BaseAgent)   state={}                                 applied=False

RESULT: FAIL (state_delta dropped)

Environment Details:

  • ADK Library Version: main @ a5864a0e (version.py = 2.6.2)
  • Desktop OS: macOS
  • Python Version: 3.13.7

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: N/A — the repro uses an offline stub model, so the bug is
    independent of the model.

🟡 Optional Information

Regression:

Not a regression on this specific path — resuming without a new_message has never
applied the delta. #5763 was the new_message half of the same root cause, fixed in
#5767; this is the remaining half.

Suggested Fix:

Apply the delta via a content-less event when there is no user message to carry it.
This mirrors the existing rewind path, which already appends
Event(author='user', actions=EventActions(state_delta=...)) with no content
(runners.py:1373-1381).

Happy to send the PR — I have the fix, a unit test that fails without it, and E2E
evidence for both paths.

How often has this issue occurred?:

  • Always (100%)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions