Skip to content

Commit 4c2bcb6

Browse files
authored
feat(tools/gemini): declare file-path resources for parallel execution (#2723)
1 parent 157d4ce commit 4c2bcb6

File tree

8 files changed

+201
-3
lines changed

8 files changed

+201
-3
lines changed

openhands-tools/openhands/tools/gemini/edit/definition.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"""Edit tool definition (Gemini-style)."""
22

33
from collections.abc import Sequence
4+
from pathlib import Path
45
from typing import TYPE_CHECKING
56

67
from pydantic import Field, PrivateAttr
78
from rich.text import Text
89

910
from openhands.sdk.tool import (
1011
Action,
12+
DeclaredResources,
1113
Observation,
1214
ToolAnnotations,
1315
ToolDefinition,
@@ -132,6 +134,19 @@ def visualize(self) -> Text:
132134
class EditTool(ToolDefinition[EditAction, EditObservation]):
133135
"""Tool for editing files via find/replace."""
134136

137+
def declared_resources(self, action: Action) -> DeclaredResources:
138+
"""Lock on the target file path so concurrent edits to the same
139+
file are serialized, while edits to different files run in parallel.
140+
"""
141+
assert isinstance(action, EditAction)
142+
path = Path(action.file_path)
143+
if not path.is_absolute():
144+
assert self.meta is not None, (
145+
"workspace_root required to resolve relative paths"
146+
)
147+
path = Path(self.meta["workspace_root"]) / path
148+
return DeclaredResources(keys=(f"file:{path.resolve()}",), declared=True)
149+
135150
@classmethod
136151
def create(
137152
cls,
@@ -166,6 +181,7 @@ def create(
166181
openWorldHint=False,
167182
),
168183
executor=executor,
184+
meta={"workspace_root": working_dir},
169185
)
170186
]
171187

openhands-tools/openhands/tools/gemini/read_file/definition.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"""Read file tool definition (Gemini-style)."""
22

33
from collections.abc import Sequence
4+
from pathlib import Path
45
from typing import TYPE_CHECKING
56

67
from pydantic import Field
78
from rich.text import Text
89

910
from openhands.sdk.tool import (
1011
Action,
12+
DeclaredResources,
1113
Observation,
1214
ToolAnnotations,
1315
ToolDefinition,
@@ -107,6 +109,20 @@ def visualize(self) -> Text:
107109
class ReadFileTool(ToolDefinition[ReadFileAction, ReadFileObservation]):
108110
"""Tool for reading file contents with pagination support."""
109111

112+
def declared_resources(self, action: Action) -> DeclaredResources:
113+
"""Lock on the target file path so a read never sees
114+
partially-written content from a concurrent write.
115+
Reads of different files run in parallel.
116+
"""
117+
assert isinstance(action, ReadFileAction)
118+
path = Path(action.file_path)
119+
if not path.is_absolute():
120+
assert self.meta is not None, (
121+
"workspace_root required to resolve relative paths"
122+
)
123+
path = Path(self.meta["workspace_root"]) / path
124+
return DeclaredResources(keys=(f"file:{path.resolve()}",), declared=True)
125+
110126
@classmethod
111127
def create(
112128
cls,
@@ -141,6 +157,7 @@ def create(
141157
openWorldHint=False,
142158
),
143159
executor=executor,
160+
meta={"workspace_root": working_dir},
144161
)
145162
]
146163

openhands-tools/openhands/tools/gemini/write_file/definition.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"""Write file tool definition (Gemini-style)."""
22

33
from collections.abc import Sequence
4+
from pathlib import Path
45
from typing import TYPE_CHECKING
56

67
from pydantic import Field, PrivateAttr
78
from rich.text import Text
89

910
from openhands.sdk.tool import (
1011
Action,
12+
DeclaredResources,
1113
Observation,
1214
ToolAnnotations,
1315
ToolDefinition,
@@ -99,6 +101,19 @@ def visualize(self) -> Text:
99101
class WriteFileTool(ToolDefinition[WriteFileAction, WriteFileObservation]):
100102
"""Tool for writing complete file contents."""
101103

104+
def declared_resources(self, action: Action) -> DeclaredResources:
105+
"""Lock on the target file path so concurrent writes to the same
106+
file are serialized, while writes to different files run in parallel.
107+
"""
108+
assert isinstance(action, WriteFileAction)
109+
path = Path(action.file_path)
110+
if not path.is_absolute():
111+
assert self.meta is not None, (
112+
"workspace_root required to resolve relative paths"
113+
)
114+
path = Path(self.meta["workspace_root"]) / path
115+
return DeclaredResources(keys=(f"file:{path.resolve()}",), declared=True)
116+
102117
@classmethod
103118
def create(
104119
cls,
@@ -133,6 +148,7 @@ def create(
133148
openWorldHint=False,
134149
),
135150
executor=executor,
151+
meta={"workspace_root": working_dir},
136152
)
137153
]
138154

tests/tools/gemini/conftest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Shared fixtures for Gemini tool tests."""
2+
3+
from unittest.mock import MagicMock
4+
5+
import pytest
6+
7+
8+
@pytest.fixture
9+
def fake_conv_state(tmp_path):
10+
"""Minimal mock ConversationState with a workspace directory."""
11+
cs = MagicMock()
12+
cs.workspace.working_dir = str(tmp_path)
13+
return cs

tests/tools/gemini/edit/test_edit.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Tests for edit tool."""
22

3-
from openhands.tools.gemini.edit.definition import EditAction
3+
from pathlib import Path
4+
5+
from openhands.tools.gemini.edit.definition import EditAction, EditTool
46
from openhands.tools.gemini.edit.impl import EditExecutor
57

68

@@ -148,3 +150,35 @@ def test_edit_multiline_replacement(tmp_path):
148150
assert not obs.is_error
149151
assert obs.replacements_made == 1
150152
assert test_file.read_text() == "def foo():\n print('new')\n return 2\n"
153+
154+
155+
def test_declared_resources_locks_on_file_path(fake_conv_state):
156+
"""declared_resources returns a file-path key for per-file locking."""
157+
tool = EditTool.create(conv_state=fake_conv_state)[0]
158+
action = EditAction(file_path="/a/b.py", old_string="x", new_string="y")
159+
resources = tool.declared_resources(action)
160+
assert resources.declared is True
161+
assert len(resources.keys) == 1
162+
assert resources.keys[0] == f"file:{Path('/a/b.py').resolve()}"
163+
164+
165+
def test_declared_resources_different_files_different_keys(fake_conv_state):
166+
"""Different file paths produce different resource keys."""
167+
tool = EditTool.create(conv_state=fake_conv_state)[0]
168+
a = tool.declared_resources(
169+
EditAction(file_path="/a.py", old_string="", new_string="x")
170+
)
171+
b = tool.declared_resources(
172+
EditAction(file_path="/b.py", old_string="", new_string="x")
173+
)
174+
assert a.keys != b.keys
175+
176+
177+
def test_declared_resources_relative_path_resolves_against_workspace(fake_conv_state):
178+
"""Relative paths must resolve against workspace_root, not process CWD."""
179+
tool = EditTool.create(conv_state=fake_conv_state)[0]
180+
workspace = fake_conv_state.workspace.working_dir
181+
resources = tool.declared_resources(
182+
EditAction(file_path="src/foo.py", old_string="", new_string="x")
183+
)
184+
assert resources.keys[0] == f"file:{(Path(workspace) / 'src' / 'foo.py').resolve()}"

tests/tools/gemini/read_file/test_read_file.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Tests for read_file tool."""
22

3-
from openhands.tools.gemini.read_file.definition import ReadFileAction
3+
from pathlib import Path
4+
5+
from openhands.tools.gemini.read_file.definition import ReadFileAction, ReadFileTool
46
from openhands.tools.gemini.read_file.impl import ReadFileExecutor
57

68

@@ -108,3 +110,29 @@ def test_read_file_offset_beyond_length(tmp_path):
108110

109111
assert obs.is_error
110112
assert "beyond" in obs.text.lower()
113+
114+
115+
def test_declared_resources_locks_on_file_path(fake_conv_state):
116+
"""declared_resources returns a file-path key for per-file locking."""
117+
tool = ReadFileTool.create(conv_state=fake_conv_state)[0]
118+
action = ReadFileAction(file_path="/a/b.py")
119+
resources = tool.declared_resources(action)
120+
assert resources.declared is True
121+
assert len(resources.keys) == 1
122+
assert resources.keys[0] == f"file:{Path('/a/b.py').resolve()}"
123+
124+
125+
def test_declared_resources_different_files_different_keys(fake_conv_state):
126+
"""Different file paths produce different resource keys."""
127+
tool = ReadFileTool.create(conv_state=fake_conv_state)[0]
128+
a = tool.declared_resources(ReadFileAction(file_path="/a.py"))
129+
b = tool.declared_resources(ReadFileAction(file_path="/b.py"))
130+
assert a.keys != b.keys
131+
132+
133+
def test_declared_resources_relative_path_resolves_against_workspace(fake_conv_state):
134+
"""Relative paths must resolve against workspace_root, not process CWD."""
135+
tool = ReadFileTool.create(conv_state=fake_conv_state)[0]
136+
workspace = fake_conv_state.workspace.working_dir
137+
resources = tool.declared_resources(ReadFileAction(file_path="src/foo.py"))
138+
assert resources.keys[0] == f"file:{(Path(workspace) / 'src' / 'foo.py').resolve()}"
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Cross-tool test: Gemini tools and FileEditorTool must produce the same
2+
resource key for the same file so that the parallel executor serializes
3+
access correctly across tool boundaries.
4+
"""
5+
6+
from pathlib import Path
7+
8+
from openhands.tools.file_editor.definition import FileEditorAction, FileEditorTool
9+
from openhands.tools.gemini.edit.definition import EditAction, EditTool
10+
from openhands.tools.gemini.read_file.definition import ReadFileAction, ReadFileTool
11+
from openhands.tools.gemini.write_file.definition import WriteFileAction, WriteFileTool
12+
13+
14+
def test_gemini_and_file_editor_produce_same_key(fake_conv_state):
15+
"""A Gemini relative path and a FileEditorTool absolute path for the same
16+
file must yield identical resource keys."""
17+
workspace = fake_conv_state.workspace.working_dir
18+
abs_path = str(Path(workspace) / "src" / "foo.py")
19+
20+
# Gemini tools with a relative path
21+
edit_tool = EditTool.create(conv_state=fake_conv_state)[0]
22+
read_tool = ReadFileTool.create(conv_state=fake_conv_state)[0]
23+
write_tool = WriteFileTool.create(conv_state=fake_conv_state)[0]
24+
25+
gemini_edit_key = edit_tool.declared_resources(
26+
EditAction(file_path="src/foo.py", old_string="", new_string="x")
27+
).keys[0]
28+
gemini_read_key = read_tool.declared_resources(
29+
ReadFileAction(file_path="src/foo.py")
30+
).keys[0]
31+
gemini_write_key = write_tool.declared_resources(
32+
WriteFileAction(file_path="src/foo.py", content="x")
33+
).keys[0]
34+
35+
# FileEditorTool with an absolute path
36+
file_editor_tool = FileEditorTool.create(conv_state=fake_conv_state)[0]
37+
file_editor_key = file_editor_tool.declared_resources(
38+
FileEditorAction(command="view", path=abs_path)
39+
).keys[0]
40+
41+
# All must agree
42+
assert gemini_edit_key == file_editor_key
43+
assert gemini_read_key == file_editor_key
44+
assert gemini_write_key == file_editor_key

tests/tools/gemini/write_file/test_write_file.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Tests for write_file tool."""
22

3-
from openhands.tools.gemini.write_file.definition import WriteFileAction
3+
from pathlib import Path
4+
5+
from openhands.tools.gemini.write_file.definition import WriteFileAction, WriteFileTool
46
from openhands.tools.gemini.write_file.impl import WriteFileExecutor
57

68

@@ -91,3 +93,31 @@ def test_write_file_empty_content(tmp_path):
9193
assert obs.is_new_file
9294
assert (tmp_path / "empty.txt").exists()
9395
assert (tmp_path / "empty.txt").read_text() == ""
96+
97+
98+
def test_declared_resources_locks_on_file_path(fake_conv_state):
99+
"""declared_resources returns a file-path key for per-file locking."""
100+
tool = WriteFileTool.create(conv_state=fake_conv_state)[0]
101+
action = WriteFileAction(file_path="/a/b.py", content="x")
102+
resources = tool.declared_resources(action)
103+
assert resources.declared is True
104+
assert len(resources.keys) == 1
105+
assert resources.keys[0] == f"file:{Path('/a/b.py').resolve()}"
106+
107+
108+
def test_declared_resources_different_files_different_keys(fake_conv_state):
109+
"""Different file paths produce different resource keys."""
110+
tool = WriteFileTool.create(conv_state=fake_conv_state)[0]
111+
a = tool.declared_resources(WriteFileAction(file_path="/a.py", content="x"))
112+
b = tool.declared_resources(WriteFileAction(file_path="/b.py", content="x"))
113+
assert a.keys != b.keys
114+
115+
116+
def test_declared_resources_relative_path_resolves_against_workspace(fake_conv_state):
117+
"""Relative paths must resolve against workspace_root, not process CWD."""
118+
tool = WriteFileTool.create(conv_state=fake_conv_state)[0]
119+
workspace = fake_conv_state.workspace.working_dir
120+
resources = tool.declared_resources(
121+
WriteFileAction(file_path="src/foo.py", content="x")
122+
)
123+
assert resources.keys[0] == f"file:{(Path(workspace) / 'src' / 'foo.py').resolve()}"

0 commit comments

Comments
 (0)