Skip to content

Commit 3009ec1

Browse files
DeanChensjcopybara-github
authored andcommitted
feat: Add sub-branch creation and segment appending to _BranchPath
Introduce `append` method and `create_sub_branch` classmethod on `_BranchPath` to standardize dynamic branch path creation across workflow node runners, agent tools, and parallel agent execution. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 943430872
1 parent 07aa1e0 commit 3009ec1

5 files changed

Lines changed: 122 additions & 11 deletions

File tree

src/google/adk/agents/parallel_agent.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from typing_extensions import deprecated
2626
from typing_extensions import override
2727

28+
from ..events._branch_path import _BranchPath
2829
from ..events.event import Event
2930
from ..utils.context_utils import Aclosing
3031
from .base_agent import BaseAgent
@@ -44,10 +45,8 @@ def _create_branch_ctx_for_sub_agent(
4445
"""Create isolated branch for every sub-agent."""
4546
invocation_context = invocation_context.model_copy()
4647
branch_suffix = f'{agent.name}.{sub_agent.name}'
47-
invocation_context.branch = (
48-
f'{invocation_context.branch}.{branch_suffix}'
49-
if invocation_context.branch
50-
else branch_suffix
48+
invocation_context.branch = _BranchPath.create_sub_branch(
49+
invocation_context.branch, name=branch_suffix
5150
)
5251
return invocation_context
5352

src/google/adk/events/_branch_path.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ def from_string(cls, path_str: str | None) -> _BranchPath:
3737
"""Parses a _BranchPath from a dot-separated string representation."""
3838
if not path_str:
3939
return cls([])
40-
return cls(path_str.split('.'))
40+
return cls(path_str.split("."))
4141

4242
def __str__(self) -> str:
4343
"""Returns the dot-separated string representation of the path."""
44-
return '.'.join(self._segments)
44+
return ".".join(self._segments)
4545

4646
def __eq__(self, other: object) -> bool:
4747
"""Returns True if segments are equal."""
@@ -64,7 +64,7 @@ def run_ids(self) -> set[str]:
6464
"""
6565
ids = set()
6666
for segment in self._segments:
67-
parts = segment.rsplit('@', 1)
67+
parts = segment.rsplit("@", 1)
6868
if len(parts) > 1 and parts[1]:
6969
ids.add(parts[1])
7070
return ids
@@ -99,3 +99,53 @@ def common_prefix(paths: list[_BranchPath]) -> _BranchPath:
9999
else:
100100
break
101101
return _BranchPath(common_segments)
102+
103+
def append(
104+
self,
105+
segment_or_path: str | _BranchPath,
106+
run_id: str | None = None,
107+
) -> _BranchPath:
108+
"""Returns a new _BranchPath with segment(s) appended.
109+
110+
Args:
111+
segment_or_path: A segment name (str), dot-separated path (str), or another
112+
_BranchPath instance to append.
113+
run_id: Optional run ID (or function_call_id) to format segment as
114+
'name@run_id'.
115+
"""
116+
if isinstance(segment_or_path, _BranchPath):
117+
if run_id is not None:
118+
raise ValueError(
119+
"run_id cannot be provided when segment_or_path is a _BranchPath"
120+
" instance."
121+
)
122+
return _BranchPath(self._segments + segment_or_path.segments)
123+
124+
if run_id is not None:
125+
if "." in segment_or_path:
126+
raise ValueError(
127+
"run_id cannot be provided when segment_or_path is a dot-separated"
128+
" path."
129+
)
130+
segment = f"{segment_or_path}@{run_id}"
131+
return _BranchPath(self._segments + [segment])
132+
133+
new_segments = [s for s in segment_or_path.split(".") if s]
134+
return _BranchPath(self._segments + new_segments)
135+
136+
@classmethod
137+
def create_sub_branch(
138+
cls,
139+
base_branch: str | None,
140+
*,
141+
name: str,
142+
run_id: str | None = None,
143+
) -> str:
144+
"""Creates a new dot-separated branch path string by appending a segment.
145+
146+
Example:
147+
_BranchPath.create_sub_branch('parent', name='child', run_id='1') ->
148+
'parent.child@1'
149+
_BranchPath.create_sub_branch(None, name='agent') -> 'agent'
150+
"""
151+
return str(cls.from_string(base_branch).append(name, run_id=run_id))

src/google/adk/tools/agent_tool.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from . import _automatic_function_calling_util
2929
from ..agents.common_configs import AgentRefConfig
30+
from ..events._branch_path import _BranchPath
3031
from ..features import FeatureName
3132
from ..features import is_feature_enabled
3233
from ..memory.in_memory_memory_service import InMemoryMemoryService
@@ -364,8 +365,9 @@ async def run_async(
364365
# Align subagent branch scoping with node execution (Node as Tool) using function_call_id.
365366
fc_id = tool_context.function_call_id
366367
base_branch = tool_context.get_invocation_context().branch
367-
segment = f'{self.agent.name}@{fc_id}'
368-
tool_branch = f'{base_branch}.{segment}' if base_branch else segment
368+
tool_branch = _BranchPath.create_sub_branch(
369+
base_branch, name=self.agent.name, run_id=fc_id
370+
)
369371

370372
try:
371373
return await tool_context.run_node(

src/google/adk/workflow/_node_runner.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from typing import Any
2929
from typing import TYPE_CHECKING
3030

31+
from ..events._branch_path import _BranchPath
3132
from ..telemetry import node_tracing
3233

3334
if TYPE_CHECKING:
@@ -206,8 +207,9 @@ def _create_child_context(
206207
)
207208

208209
if self._use_sub_branch:
209-
segment = f"{self._node.name}@{self._run_id}"
210-
branch = f"{base_branch}.{segment}" if base_branch else segment
210+
branch = _BranchPath.create_sub_branch(
211+
base_branch, name=self._node.name, run_id=self._run_id
212+
)
211213
ic = ic.model_copy(update={"branch": branch})
212214
elif self._override_branch is not None:
213215
ic = ic.model_copy(update={"branch": self._override_branch})

tests/unittests/events/test_branch_path.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,64 @@ def test_constructor_copies_segments_list():
153153
assert path.segments == ["parent", "child"]
154154

155155

156+
def test_append_single_segment_returns_new_path():
157+
"""append adds a single segment to an existing path."""
158+
path = _BranchPath.from_string("parent")
159+
new_path = path.append("child")
160+
161+
assert new_path == _BranchPath.from_string("parent.child")
162+
assert path == _BranchPath.from_string("parent") # Immutability check
163+
164+
165+
def test_append_with_run_id_formats_segment():
166+
"""append formats the segment as 'name@run_id' when run_id is provided."""
167+
path = _BranchPath.from_string("parent")
168+
new_path = path.append("child", run_id="call_123")
169+
170+
assert new_path == _BranchPath.from_string("parent.child@call_123")
171+
172+
173+
def test_append_another_branch_path():
174+
"""append combines segments from another _BranchPath instance."""
175+
path1 = _BranchPath.from_string("parent")
176+
path2 = _BranchPath.from_string("child.grandchild")
177+
new_path = path1.append(path2)
178+
179+
assert new_path == _BranchPath.from_string("parent.child.grandchild")
180+
181+
182+
def test_create_sub_branch_formats_string_correctly():
183+
"""create_sub_branch constructs sub-branch strings safely."""
184+
# With base branch and run ID
185+
res1 = _BranchPath.create_sub_branch(
186+
"parent.sub", name="child", run_id="run_1"
187+
)
188+
assert res1 == "parent.sub.child@run_1"
189+
190+
# Without base branch (None or empty)
191+
res2 = _BranchPath.create_sub_branch(None, name="agent", run_id="fc_456")
192+
assert res2 == "agent@fc_456"
193+
194+
# Dot-separated sub-path without run ID
195+
res3 = _BranchPath.create_sub_branch("parent", name="agent.sub_agent")
196+
assert res3 == "parent.agent.sub_agent"
197+
198+
199+
def test_append_with_run_id_and_branch_path_raises_value_error():
200+
"""append raises ValueError when run_id is provided with a _BranchPath."""
201+
path1 = _BranchPath.from_string("parent")
202+
path2 = _BranchPath.from_string("child")
203+
with pytest.raises(ValueError, match="run_id cannot be provided"):
204+
path1.append(path2, run_id="123")
205+
206+
207+
def test_append_with_run_id_and_dot_separated_path_raises_value_error():
208+
"""append raises ValueError when run_id is provided with a dot-separated path."""
209+
path = _BranchPath.from_string("parent")
210+
with pytest.raises(ValueError, match="run_id cannot be provided"):
211+
path.append("child.sub", run_id="123")
212+
213+
156214
def test_run_ids_filters_out_empty_run_ids():
157215
"""run_ids filters out segments with empty run IDs (e.g. ending with '@')."""
158216
path = _BranchPath.from_string("parent@.child@2.node@")

0 commit comments

Comments
 (0)