Skip to content

Commit aa9bfb9

Browse files
DABHclaude
andcommitted
fix: route workflow IDs and retry jitter through platform seams
ADK already routes event IDs, invocation IDs, timestamps, and client function-call IDs through the injectable google.adk.platform providers so that frameworks which replay or resume agent workflows (e.g. durable execution engines) can supply deterministic values. Three call sites on the workflow HITL/retry path still reach for the stdlib directly, which makes replays diverge: - RequestInput.interrupt_id defaults to uuid.uuid4(). Recorded user responses reference this ID, so a regenerated ID breaks resume matching. - _ToolNode mints ToolContext.function_call_id with uuid.uuid4(). - Retry backoff jitter draws from the global random module (jitter defaults to 1.0 for any RetryConfig), making computed delays unreproducible. Add a google.adk.platform random seam (get_random / set_random_provider / reset_random_provider, exported via google.adk.platform per the visibility convention) mirroring the existing time and uuid providers, and route the three call sites through the platform seams. Default behavior is unchanged. The two existing jitter tests patched random.uniform globally; they now inject a mock through set_random_provider, exercising the new seam. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 40cf97b commit aa9bfb9

11 files changed

Lines changed: 270 additions & 28 deletions

File tree

src/google/adk/events/request_input.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515

1616
from typing import Any
1717
from typing import Optional
18-
import uuid
1918

2019
from pydantic import alias_generators
2120
from pydantic import BaseModel
2221
from pydantic import ConfigDict
2322
from pydantic import Field
2423

24+
from ..platform import uuid as platform_uuid
2525
from ..utils._schema_utils import SchemaType
2626

2727

@@ -39,7 +39,7 @@ class RequestInput(BaseModel):
3939
"The ID of the interrupt, usually a function call ID. This is used"
4040
" to identify the interrupt that the input is for."
4141
),
42-
default_factory=lambda: str(uuid.uuid4()),
42+
default_factory=platform_uuid.new_uuid,
4343
)
4444
"""The ID of the interrupt, usually a function call ID.
4545

src/google/adk/platform/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,13 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
15+
from ._random import get_random
16+
from ._random import reset_random_provider
17+
from ._random import set_random_provider
18+
19+
__all__ = [
20+
'get_random',
21+
'reset_random_provider',
22+
'set_random_provider',
23+
]

src/google/adk/platform/_random.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Platform module for abstracting random number generation."""
16+
17+
from __future__ import annotations
18+
19+
from contextvars import ContextVar
20+
import random
21+
from typing import Callable
22+
23+
_default_random: random.Random = random.Random()
24+
_default_random_provider: Callable[[], random.Random] = lambda: _default_random
25+
_random_provider_context_var: ContextVar[Callable[[], random.Random]] = (
26+
ContextVar("random_provider", default=_default_random_provider)
27+
)
28+
29+
30+
def set_random_provider(provider: Callable[[], random.Random]) -> None:
31+
"""Sets the provider for the random number generator.
32+
33+
Args:
34+
provider: A callable that returns the `random.Random` instance to use.
35+
"""
36+
_random_provider_context_var.set(provider)
37+
38+
39+
def reset_random_provider() -> None:
40+
"""Resets the random provider to its default implementation."""
41+
_random_provider_context_var.set(_default_random_provider)
42+
43+
44+
def get_random() -> random.Random:
45+
"""Returns the random number generator."""
46+
return _random_provider_context_var.get()()

src/google/adk/workflow/_tool_node.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from collections.abc import AsyncGenerator
2020
import json
2121
from typing import Any
22-
import uuid
2322

2423
from google.genai import types
2524
from pydantic import ConfigDict
@@ -28,6 +27,7 @@
2827

2928
from ..agents.context import Context
3029
from ..events.event import Event
30+
from ..platform import uuid as platform_uuid
3131
from ..tools.base_tool import BaseTool
3232
from ..tools.tool_context import ToolContext
3333
from ..utils.content_utils import extract_text_from_content
@@ -66,7 +66,7 @@ async def _run_impl(
6666
) -> AsyncGenerator[Any, None]:
6767
tool_context = ToolContext(
6868
invocation_context=ctx.get_invocation_context(),
69-
function_call_id=str(uuid.uuid4()),
69+
function_call_id=platform_uuid.new_uuid(),
7070
)
7171

7272
args = node_input

src/google/adk/workflow/utils/_retry_utils.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616

1717
"""Utility functions for retrying nodes in a workflow."""
1818

19-
import random
20-
19+
from ...platform import _random as platform_random
2120
from .._node_state import NodeState
2221
from .._retry_config import RetryConfig
2322

@@ -82,7 +81,9 @@ def _get_retry_delay(
8281
delay = min(delay, max_delay)
8382

8483
if jitter > 0.0:
85-
random_offset = random.uniform(-jitter * delay, jitter * delay)
84+
random_offset = platform_random.get_random().uniform(
85+
-jitter * delay, jitter * delay
86+
)
8687
delay = max(0.0, delay + random_offset)
8788

8889
return delay
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Unit tests for the RequestInput event model."""
16+
17+
from __future__ import annotations
18+
19+
import uuid
20+
21+
from google.adk.events.request_input import RequestInput
22+
from google.adk.platform import uuid as platform_uuid
23+
24+
25+
class TestRequestInputInterruptId:
26+
27+
def teardown_method(self) -> None:
28+
platform_uuid.reset_id_provider()
29+
30+
def test_default_interrupt_id_is_a_uuid(self):
31+
"""Without a custom provider, the default interrupt_id is a uuid."""
32+
request = RequestInput()
33+
34+
# Should be parseable as uuid
35+
uuid.UUID(request.interrupt_id)
36+
37+
def test_default_interrupt_id_uses_platform_id_provider(self):
38+
"""The default interrupt_id is minted via the platform uuid seam.
39+
40+
Frameworks that replay agent workflows (e.g. durable execution engines)
41+
install a deterministic id provider; the generated interrupt_id must be
42+
stable across replays because recorded user responses reference it.
43+
"""
44+
platform_uuid.set_id_provider(lambda: "deterministic-id")
45+
46+
request = RequestInput()
47+
48+
assert request.interrupt_id == "deterministic-id"
49+
50+
def test_explicit_interrupt_id_is_preserved(self):
51+
"""An explicitly provided interrupt_id bypasses the provider."""
52+
platform_uuid.set_id_provider(lambda: "deterministic-id")
53+
54+
request = RequestInput(interrupt_id="explicit-id")
55+
56+
assert request.interrupt_id == "explicit-id"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Unit tests for the platform random module."""
16+
17+
import random
18+
import unittest
19+
20+
from google.adk import platform as adk_platform
21+
22+
23+
class TestRandom(unittest.TestCase):
24+
25+
def tearDown(self) -> None:
26+
# Reset provider to default after each test
27+
adk_platform.reset_random_provider()
28+
29+
def test_default_random_provider(self) -> None:
30+
# Verify it returns a random.Random instance producing values in range
31+
rng = adk_platform.get_random()
32+
self.assertIsInstance(rng, random.Random)
33+
value = rng.uniform(0.0, 1.0)
34+
self.assertGreaterEqual(value, 0.0)
35+
self.assertLessEqual(value, 1.0)
36+
37+
def test_custom_random_provider(self) -> None:
38+
# Test override
39+
seeded = random.Random(42)
40+
adk_platform.set_random_provider(lambda: seeded)
41+
self.assertIs(adk_platform.get_random(), seeded)
42+
expected = random.Random(42).uniform(0.0, 1.0)
43+
self.assertEqual(adk_platform.get_random().uniform(0.0, 1.0), expected)
44+
45+
def test_reset_random_provider(self) -> None:
46+
seeded = random.Random(42)
47+
adk_platform.set_random_provider(lambda: seeded)
48+
adk_platform.reset_random_provider()
49+
self.assertIsNot(adk_platform.get_random(), seeded)
50+
# Default provider returns a stable module-level instance
51+
self.assertIs(adk_platform.get_random(), adk_platform.get_random())

tests/unittests/workflow/test_node_runner_failure.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from typing import AsyncGenerator
2121
from unittest import mock
2222

23+
from google.adk import platform as adk_platform
2324
from google.adk.agents.context import Context
2425
from google.adk.events.event import Event
2526
from google.adk.runners import Runner
@@ -715,20 +716,23 @@ async def test_retry_applies_random_jitter(request: pytest.FixtureRequest):
715716
session = await ss.create_session(app_name=agent.name, user_id='u')
716717
msg = types.Content(parts=[types.Part(text='start')], role='user')
717718

718-
with (
719-
mock.patch('asyncio.sleep', new_callable=mock.AsyncMock) as mock_sleep,
720-
mock.patch('random.uniform', return_value=-1.0) as mock_random,
721-
):
722-
events = []
723-
async for event in runner.run_async(
724-
user_id='u', session_id=session.id, new_message=msg
725-
):
726-
events.append(event)
727-
728-
# 4.0 + (-1.0) = 3.0
729-
mock_sleep.assert_any_await(3.0)
730-
# Called with -0.5 * 4.0, 0.5 * 4.0
731-
mock_random.assert_called_once_with(-2.0, 2.0)
719+
mock_random = mock.Mock()
720+
mock_random.uniform = mock.Mock(return_value=-1.0)
721+
adk_platform.set_random_provider(lambda: mock_random)
722+
try:
723+
with mock.patch('asyncio.sleep', new_callable=mock.AsyncMock) as mock_sleep:
724+
events = []
725+
async for event in runner.run_async(
726+
user_id='u', session_id=session.id, new_message=msg
727+
):
728+
events.append(event)
729+
730+
# 4.0 + (-1.0) = 3.0
731+
mock_sleep.assert_any_await(3.0)
732+
# Called with -0.5 * 4.0, 0.5 * 4.0
733+
mock_random.uniform.assert_called_once_with(-2.0, 2.0)
734+
finally:
735+
adk_platform.reset_random_provider()
732736

733737
results = simplify_events_with_node(events)
734738
filtered_results = [

tests/unittests/workflow/test_tool_node.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414

1515
"""Tests for ToolNode input parsing and execution."""
1616

17+
import itertools
18+
import re
1719
from typing import Any
1820

1921
from google.adk.events.event import Event
22+
from google.adk.platform import uuid as platform_uuid
2023
from google.adk.tools.base_tool import BaseTool
2124
from google.adk.workflow import START
2225
from google.adk.workflow._tool_node import _ToolNode as ToolNode
@@ -139,3 +142,48 @@ async def test_tool_node_rejects_non_dict_content():
139142
TypeError, match="The input to ToolNode must be a dictionary"
140143
):
141144
await _run_tool_node_wf(content)
145+
146+
147+
@pytest.mark.asyncio
148+
async def test_tool_node_function_call_id_uses_platform_id_provider():
149+
"""Tests that the tool's function_call_id is minted via the platform seam.
150+
151+
Frameworks that replay agent workflows (e.g. durable execution engines)
152+
install a deterministic id provider; the generated function_call_id must be
153+
stable across replays.
154+
"""
155+
captured_ids: list[str] = []
156+
157+
class CapturingTool(BaseTool):
158+
"""A tool that records the function_call_id it was invoked with."""
159+
160+
def __init__(self):
161+
super().__init__(name="capturing_tool", description="Captures ids")
162+
163+
async def run_async(self, *, args: dict[str, Any], tool_context) -> Any:
164+
captured_ids.append(tool_context.function_call_id)
165+
return {}
166+
167+
tool_node = ToolNode(tool=CapturingTool())
168+
169+
def start_node():
170+
return Event(output={"param_a": 1})
171+
172+
wf = Workflow(
173+
name="tool_node_id_wf",
174+
edges=[
175+
(START, start_node),
176+
(start_node, tool_node),
177+
],
178+
)
179+
counter = itertools.count()
180+
platform_uuid.set_id_provider(lambda: f"fixed-{next(counter)}")
181+
try:
182+
app_instance = testing_utils.App(name="test_app", root_agent=wf)
183+
runner = testing_utils.InMemoryRunner(app=app_instance)
184+
await runner.run_async("start")
185+
finally:
186+
platform_uuid.reset_id_provider()
187+
188+
assert len(captured_ids) == 1
189+
assert re.fullmatch(r"fixed-\d+", captured_ids[0])

tests/unittests/workflow/test_workflow_failures.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from typing import AsyncGenerator
2020
from unittest import mock
2121

22+
from google.adk import platform as adk_platform
2223
from google.adk.agents.context import Context
2324
from google.adk.apps.app import App
2425
from google.adk.events.event import Event
@@ -586,13 +587,16 @@ async def test_retry_with_jitter(request: pytest.FixtureRequest):
586587
app = App(name=request.function.__name__, root_agent=agent)
587588
runner = testing_utils.InMemoryRunner(app=app)
588589

589-
with (
590-
mock.patch('asyncio.sleep', new_callable=mock.AsyncMock) as mock_sleep,
591-
mock.patch('random.uniform', return_value=-1.0) as mock_random,
592-
):
593-
events = await runner.run_async(testing_utils.get_user_content('start'))
594-
mock_sleep.assert_any_await(3.0)
595-
mock_random.assert_called_once_with(-2.0, 2.0)
590+
mock_random = mock.Mock()
591+
mock_random.uniform = mock.Mock(return_value=-1.0)
592+
adk_platform.set_random_provider(lambda: mock_random)
593+
try:
594+
with mock.patch('asyncio.sleep', new_callable=mock.AsyncMock) as mock_sleep:
595+
events = await runner.run_async(testing_utils.get_user_content('start'))
596+
mock_sleep.assert_any_await(3.0)
597+
mock_random.uniform.assert_called_once_with(-2.0, 2.0)
598+
finally:
599+
adk_platform.reset_random_provider()
596600

597601
assert simplify_events_with_node(events) == [
598602
(

0 commit comments

Comments
 (0)