Skip to content

Commit 8f85107

Browse files
DABHcopybara-github
authored andcommitted
fix: route workflow IDs and retry jitter through platform seams
Merge #6468 PiperOrigin-RevId: 966214760
1 parent bf2efd7 commit 8f85107

11 files changed

Lines changed: 279 additions & 30 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: 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+
"""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. Note
35+
that the provider callable is evaluated on every `get_random()` call; to
36+
preserve RNG sequence state across calls, return an existing
37+
`random.Random` instance rather than constructing a new one inside the
38+
callable (e.g., `rng = random.Random(42); set_random_provider(lambda:
39+
rng)` instead of `set_random_provider(lambda: random.Random(42))`).
40+
"""
41+
_random_provider_context_var.set(provider)
42+
43+
44+
def reset_random_provider() -> None:
45+
"""Resets the random provider to its default implementation."""
46+
_random_provider_context_var.set(_default_random_provider)
47+
48+
49+
def get_random() -> random.Random:
50+
"""Returns the random number generator."""
51+
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

@@ -86,7 +85,9 @@ def _get_retry_delay(
8685
# bound but collapse every overshooting draw onto exactly max_delay,
8786
# firing the retries jitter exists to spread out at the same instant.
8887
delay = min(delay, max_delay / (1.0 + jitter))
89-
random_offset = random.uniform(-jitter * delay, jitter * delay)
88+
random_offset = platform_random.get_random().uniform(
89+
-jitter * delay, jitter * delay
90+
)
9091
delay = max(0.0, delay + random_offset)
9192

9293
return min(delay, max_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: 19 additions & 15 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
@@ -29,8 +30,8 @@
2930
from google.adk.workflow import START
3031
from google.adk.workflow._errors import NodeTimeoutError
3132
from google.adk.workflow._graph import Graph
32-
from google.adk.workflow._node import node
3333
from google.adk.workflow._node import Node
34+
from google.adk.workflow._node import node
3435
from google.adk.workflow._node_status import NodeStatus
3536
from google.adk.workflow._retry_config import RetryConfig
3637
from google.adk.workflow._workflow import Workflow
@@ -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])

0 commit comments

Comments
 (0)