Skip to content

Commit 1f24553

Browse files
committed
fix: update EditFileTool to handle cross-platform line breaks and escape regex characters
Change-Id: Id155218fa6feb7e004684d8f96cd0598fa4d2766
1 parent 1b86d8e commit 1f24553

2 files changed

Lines changed: 159 additions & 2 deletions

File tree

src/google/adk/tools/environment/_tools.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import logging
20+
import re
2021
from typing import Any
2122
from typing import Optional
2223
from typing import TYPE_CHECKING
@@ -381,7 +382,13 @@ async def run_async(
381382
except FileNotFoundError:
382383
return {'status': 'error', 'error': f'File not found: {path}'}
383384

384-
count = content.count(old_string)
385+
# Normalize line breaks in old_string to \n and use regex for flexible matching
386+
normalized_old = old_string.replace('\r\n', '\n')
387+
pattern = re.escape(normalized_old).replace('\n', '\r?\n')
388+
389+
matches = re.findall(pattern, content)
390+
count = len(matches)
391+
385392
if count == 0:
386393
return {
387394
'status': 'error',
@@ -399,6 +406,6 @@ async def run_async(
399406
),
400407
}
401408

402-
new_content = content.replace(old_string, new_string, 1)
409+
new_content = re.sub(pattern, lambda m: new_string, content, count=1)
403410
await self._environment.write_file(path, new_content)
404411
return {'status': 'ok', 'message': f'Edited {path}'}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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+
"""Tests for EditFileTool.
16+
17+
Verifies that EditFileTool correctly handles line break differences.
18+
"""
19+
20+
from pathlib import Path
21+
22+
from google.adk.environment._local_environment import LocalEnvironment
23+
from google.adk.tools.environment._tools import EditFileTool
24+
import pytest
25+
import pytest_asyncio
26+
27+
28+
@pytest_asyncio.fixture(name="env")
29+
async def _env(tmp_path: Path):
30+
"""Create and initialize a LocalEnvironment backed by a temp directory."""
31+
environment = LocalEnvironment(working_dir=tmp_path)
32+
await environment.initialize()
33+
yield environment
34+
await environment.close()
35+
36+
37+
class TestEditFileTool:
38+
"""Tests for EditFileTool behavior."""
39+
40+
@pytest.mark.asyncio
41+
async def test_edit_file_handles_line_breaks_linux_file_windows_search(
42+
self, env: LocalEnvironment
43+
):
44+
"""File has \\n, search string has \\r\\n."""
45+
# Arrange
46+
tool = EditFileTool(env)
47+
await env.write_file("test.txt", "line1\nline2\nline3")
48+
49+
args = {
50+
"path": "test.txt",
51+
"old_string": "line1\r\nline2",
52+
"new_string": "line1_replaced\nline2_replaced",
53+
}
54+
55+
# Act
56+
result = await tool.run_async(args=args, tool_context=None)
57+
58+
# Assert
59+
assert result["status"] == "ok"
60+
data = await env.read_file("test.txt")
61+
assert data == b"line1_replaced\nline2_replaced\nline3"
62+
63+
@pytest.mark.asyncio
64+
async def test_edit_file_handles_line_breaks_windows_file_linux_search(
65+
self, env: LocalEnvironment
66+
):
67+
"""File has \\r\\n, search string has \\n."""
68+
# Arrange
69+
tool = EditFileTool(env)
70+
await env.write_file("test.txt", "line1\r\nline2\r\nline3")
71+
72+
args = {
73+
"path": "test.txt",
74+
"old_string": "line1\nline2",
75+
"new_string": "line1_replaced\r\nline2_replaced",
76+
}
77+
78+
# Act
79+
result = await tool.run_async(args=args, tool_context=None)
80+
81+
# Assert
82+
assert result["status"] == "ok"
83+
data = await env.read_file("test.txt")
84+
assert data == b"line1_replaced\r\nline2_replaced\r\nline3"
85+
86+
@pytest.mark.asyncio
87+
async def test_edit_file_fails_on_multiple_matches(
88+
self, env: LocalEnvironment
89+
):
90+
"""Tool fails if old_string appears multiple times."""
91+
# Arrange
92+
tool = EditFileTool(env)
93+
await env.write_file("test.txt", "line1\nline2\nline1\nline2")
94+
95+
args = {
96+
"path": "test.txt",
97+
"old_string": "line1\nline2",
98+
"new_string": "replaced",
99+
}
100+
101+
# Act
102+
result = await tool.run_async(args=args, tool_context=None)
103+
104+
# Assert
105+
assert result["status"] == "error"
106+
assert "appears 2 times" in result["error"]
107+
108+
@pytest.mark.asyncio
109+
async def test_edit_file_exact_match_works(self, env: LocalEnvironment):
110+
"""Exact match works as before."""
111+
# Arrange
112+
tool = EditFileTool(env)
113+
await env.write_file("test.txt", "line1\nline2\nline3")
114+
115+
args = {
116+
"path": "test.txt",
117+
"old_string": "line1\nline2",
118+
"new_string": "replaced",
119+
}
120+
121+
# Act
122+
result = await tool.run_async(args=args, tool_context=None)
123+
124+
# Assert
125+
assert result["status"] == "ok"
126+
data = await env.read_file("test.txt")
127+
assert data == b"replaced\nline3"
128+
129+
@pytest.mark.asyncio
130+
async def test_edit_file_handles_special_regex_chars(
131+
self, env: LocalEnvironment
132+
):
133+
"""Special regex characters in old_string are escaped."""
134+
# Arrange
135+
tool = EditFileTool(env)
136+
await env.write_file("test.txt", "line1.content\nline2")
137+
138+
args = {
139+
"path": "test.txt",
140+
"old_string": "line1.content",
141+
"new_string": "replaced",
142+
}
143+
144+
# Act
145+
result = await tool.run_async(args=args, tool_context=None)
146+
147+
# Assert
148+
assert result["status"] == "ok"
149+
data = await env.read_file("test.txt")
150+
assert data == b"replaced\nline2"

0 commit comments

Comments
 (0)