toolplane-python-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolplane/__init__.py +106 -0
- toolplane/common/__init__.py +93 -0
- toolplane/common/base_config.py +129 -0
- toolplane/common/base_connection_manager.py +171 -0
- toolplane/common/base_session_manager.py +321 -0
- toolplane/common/base_tool_manager.py +347 -0
- toolplane/common/constants.py +47 -0
- toolplane/common/utils.py +310 -0
- toolplane/core/__init__.py +67 -0
- toolplane/core/config.py +107 -0
- toolplane/core/connection.py +285 -0
- toolplane/core/errors.py +298 -0
- toolplane/core/machine.py +480 -0
- toolplane/core/request.py +775 -0
- toolplane/core/session.py +332 -0
- toolplane/core/session_context.py +514 -0
- toolplane/core/task.py +130 -0
- toolplane/core/tool.py +329 -0
- toolplane/http_core/__init__.py +37 -0
- toolplane/http_core/http_config.py +97 -0
- toolplane/http_core/http_connection.py +409 -0
- toolplane/http_core/http_machine.py +298 -0
- toolplane/http_core/http_request.py +748 -0
- toolplane/http_core/http_session.py +348 -0
- toolplane/http_core/http_session_context.py +491 -0
- toolplane/http_core/http_task.py +101 -0
- toolplane/http_core/http_tool.py +400 -0
- toolplane/interfaces/__init__.py +27 -0
- toolplane/interfaces/client_interface.py +122 -0
- toolplane/interfaces/connection_interface.py +193 -0
- toolplane/interfaces/event_interface.py +290 -0
- toolplane/interfaces/request_interface.py +439 -0
- toolplane/interfaces/session_interface.py +288 -0
- toolplane/interfaces/tool_interface.py +441 -0
- toolplane/proto/__init__.py +0 -0
- toolplane/proto/service_pb2.py +315 -0
- toolplane/proto/service_pb2_grpc.py +2240 -0
- toolplane/provider_cli.py +268 -0
- toolplane/provider_registry.py +77 -0
- toolplane/provider_runtime.py +302 -0
- toolplane/toolkits/__init__.py +0 -0
- toolplane/toolkits/standalone_tools/__init__.py +0 -0
- toolplane/toolkits/standalone_tools/create_directory.py +94 -0
- toolplane/toolkits/standalone_tools/create_file.py +124 -0
- toolplane/toolkits/standalone_tools/file_search.py +229 -0
- toolplane/toolkits/standalone_tools/grep_search.py +372 -0
- toolplane/toolkits/standalone_tools/launcher.py +146 -0
- toolplane/toolkits/standalone_tools/list_dir.py +395 -0
- toolplane/toolkits/standalone_tools/read_file.py +346 -0
- toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
- toolplane/toolkits/standalone_tools/run_tests.py +66 -0
- toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
- toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
- toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
- toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
- toolplane/toolkits/swe/__init__.py +35 -0
- toolplane/toolkits/swe/create_directory.py +15 -0
- toolplane/toolkits/swe/create_file.py +15 -0
- toolplane/toolkits/swe/descriptions.py +273 -0
- toolplane/toolkits/swe/execute_bash.py +93 -0
- toolplane/toolkits/swe/file_editor.py +775 -0
- toolplane/toolkits/swe/file_search.py +16 -0
- toolplane/toolkits/swe/finish.py +50 -0
- toolplane/toolkits/swe/grep_search.py +19 -0
- toolplane/toolkits/swe/list_dir.py +407 -0
- toolplane/toolkits/swe/read_file.py +18 -0
- toolplane/toolkits/swe/replace_string_in_file.py +17 -0
- toolplane/toolkits/swe/search.py +260 -0
- toolplane/toolkits/swe/semantic_search.py +20 -0
- toolplane/toolkits/swe/str_replace_editor.py +647 -0
- toolplane/toolkits/swe/submit.py +29 -0
- toolplane/toolkits/swe/swe_toolkit.py +1296 -0
- toolplane/toolplane_client.py +686 -0
- toolplane/toolplane_http_client.py +681 -0
- toolplane/utils/__init__.py +3 -0
- toolplane/utils/schema.py +146 -0
- toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
- toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
- toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
- toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
- toolplane_python_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
# Control what commands are visible to agents
|
|
4
|
+
ALLOWED_STR_REPLACE_EDITOR_COMMANDS = ["view", "create", "str_replace", "insert"]
|
|
5
|
+
# To add undo functionality for agents: ["view", "create", "str_replace", "insert", "undo_edit"]
|
|
6
|
+
# To make read-only: ["view"]
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
Description: Custom editing tool for viewing, creating and editing files
|
|
10
|
+
* State is persistent across command calls and discussions with the user
|
|
11
|
+
* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
|
|
12
|
+
* The `create` command cannot be used if the specified `path` already exists as a file
|
|
13
|
+
* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`
|
|
14
|
+
|
|
15
|
+
Notes for using the `str_replace` command:
|
|
16
|
+
* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
|
|
17
|
+
* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique
|
|
18
|
+
* The `new_str` parameter should contain the edited lines that should replace the `old_str`
|
|
19
|
+
|
|
20
|
+
Parameters:
|
|
21
|
+
(1) command (string, required): The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.
|
|
22
|
+
Allowed values: [`view`, `create`, `str_replace`, `insert`]
|
|
23
|
+
(2) path (string, required): Absolute path to file or directory, e.g. `/testbed/file.py` or `/testbed`.
|
|
24
|
+
(3) file_text (string, optional): Required parameter of `create` command, with the content of the file to be created.
|
|
25
|
+
(4) old_str (string, optional): Required parameter of `str_replace` command containing the string in `path` to replace.
|
|
26
|
+
(5) new_str (string, optional): Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.
|
|
27
|
+
(6) insert_line (integer, optional): Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.
|
|
28
|
+
(7) view_range (array, optional): Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.
|
|
29
|
+
(8) enable_linting (boolean, optional): Optional parameter to enable Python linting checks before saving changes. Default is `false`.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import tempfile
|
|
38
|
+
import warnings
|
|
39
|
+
from collections import defaultdict
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Dict, List, Optional, Tuple
|
|
42
|
+
|
|
43
|
+
import chardet
|
|
44
|
+
|
|
45
|
+
# sys.stdout.reconfigure(encoding='utf-8')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_temp_dir():
|
|
49
|
+
"""Get platform-appropriate temp directory"""
|
|
50
|
+
return Path(tempfile.gettempdir())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_state_file_path(filename="editor_state.json"):
|
|
54
|
+
"""Get the editor state file path.
|
|
55
|
+
|
|
56
|
+
The default is a per-user cache directory, NOT the shared world-writable
|
|
57
|
+
temp dir: the state holds full pre-edit copies of every edited file, so a
|
|
58
|
+
predictable shared path both leaks file contents to other local users and
|
|
59
|
+
is poisonable (undo_edit writes history bytes back to disk). Override via
|
|
60
|
+
TOOLPLANE_EDITOR_STATE_FILE (or the legacy EDITOR_STATE_FILE).
|
|
61
|
+
"""
|
|
62
|
+
override = os.environ.get("TOOLPLANE_EDITOR_STATE_FILE") or os.environ.get(
|
|
63
|
+
"EDITOR_STATE_FILE"
|
|
64
|
+
)
|
|
65
|
+
if override:
|
|
66
|
+
return Path(override)
|
|
67
|
+
return Path.home() / ".cache" / "toolplane" / filename
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
STATE_FILE = str(get_state_file_path())
|
|
71
|
+
SNIPPET_LINES = 4
|
|
72
|
+
|
|
73
|
+
# We ignore certain warnings from tree_sitter (optional).
|
|
74
|
+
warnings.simplefilter("ignore", category=FutureWarning)
|
|
75
|
+
|
|
76
|
+
_LINT_ERROR_TEMPLATE = """Your proposed edit has introduced new syntax error(s).
|
|
77
|
+
Please read this error message carefully and then retry editing the file.
|
|
78
|
+
ERRORS:
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
TRUNCATED_MESSAGE = (
|
|
82
|
+
"<response clipped><NOTE>To save on context only part of this file has been "
|
|
83
|
+
"shown to you. You should retry this tool after you have searched inside the file "
|
|
84
|
+
"with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>"
|
|
85
|
+
)
|
|
86
|
+
MAX_RESPONSE_LEN = 10000 # 4000 #12000 # 16000
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
import io
|
|
90
|
+
import sys
|
|
91
|
+
|
|
92
|
+
# NOTE: no module-level sys.stdout replacement: wrapping the caller's
|
|
93
|
+
# stdout at import time closes downstream capture buffers (pytest
|
|
94
|
+
# capture, provider result capture) when the wrapper is collected.
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def safe_print(x):
|
|
98
|
+
try:
|
|
99
|
+
print(x)
|
|
100
|
+
except UnicodeEncodeError:
|
|
101
|
+
print(x.encode("utf-8", errors="replace").decode("utf-8", errors="replace"))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def maybe_truncate(content: str, truncate_after: Optional[int] = MAX_RESPONSE_LEN):
|
|
105
|
+
if not truncate_after or len(content) <= truncate_after:
|
|
106
|
+
return content
|
|
107
|
+
return content[:truncate_after] + TRUNCATED_MESSAGE
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class EditorError(Exception):
|
|
111
|
+
"""Raised for usage or file system errors within the editor tool."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class EditorResult:
|
|
115
|
+
"""
|
|
116
|
+
Simple container for output and optional error messages.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, output: str, error: str = ""):
|
|
120
|
+
self.output = output
|
|
121
|
+
self.error = error
|
|
122
|
+
|
|
123
|
+
def __str__(self):
|
|
124
|
+
if self.error:
|
|
125
|
+
return f"ERROR: {self.error}\n\n{self.output}"
|
|
126
|
+
return self.output
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_history() -> Dict[str, List[str]]:
|
|
130
|
+
"""
|
|
131
|
+
Load the file edit history from STATE_FILE if it exists.
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
|
135
|
+
data = json.load(f)
|
|
136
|
+
return {k: v for k, v in data.items()}
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
return {}
|
|
139
|
+
except Exception as e:
|
|
140
|
+
safe_print(f"Warning: Could not load editor history from {STATE_FILE}: {e}")
|
|
141
|
+
return {}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def save_history(history: Dict[str, List[str]]):
|
|
145
|
+
"""
|
|
146
|
+
Save the file edit history to STATE_FILE as JSON.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
Path(STATE_FILE).parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
|
151
|
+
json.dump(history, f)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
safe_print(f"Warning: Could not write editor history to {STATE_FILE}: {e}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class StrReplaceEditor:
|
|
157
|
+
"""
|
|
158
|
+
A file editor that supports the following commands:
|
|
159
|
+
- view
|
|
160
|
+
- create
|
|
161
|
+
- str_replace
|
|
162
|
+
- insert
|
|
163
|
+
- undo_edit
|
|
164
|
+
|
|
165
|
+
The edit history is kept in memory (self.file_history) and also persisted to disk.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(
|
|
169
|
+
self, file_history: Dict[str, List[str]], enable_linting: bool = False
|
|
170
|
+
):
|
|
171
|
+
self.file_history = defaultdict(list, file_history)
|
|
172
|
+
self.enable_linting = enable_linting
|
|
173
|
+
|
|
174
|
+
def run(
|
|
175
|
+
self,
|
|
176
|
+
command: str,
|
|
177
|
+
path_str: str,
|
|
178
|
+
file_text: str = None,
|
|
179
|
+
view_range: List[int] = None,
|
|
180
|
+
old_str: str = None,
|
|
181
|
+
new_str: str = None,
|
|
182
|
+
insert_line: int = None,
|
|
183
|
+
python_only: bool = True,
|
|
184
|
+
) -> EditorResult:
|
|
185
|
+
path = Path(path_str)
|
|
186
|
+
self.validate_path(command, path)
|
|
187
|
+
|
|
188
|
+
if command == "view":
|
|
189
|
+
return self.view(path, view_range, python_only=python_only)
|
|
190
|
+
elif command == "create":
|
|
191
|
+
return self.create(path, file_text)
|
|
192
|
+
elif command == "str_replace":
|
|
193
|
+
return self.str_replace(path, old_str, new_str)
|
|
194
|
+
elif command == "insert":
|
|
195
|
+
return self.insert(path, insert_line, new_str)
|
|
196
|
+
elif command == "undo_edit":
|
|
197
|
+
return self.undo_edit(path)
|
|
198
|
+
else:
|
|
199
|
+
raise EditorError(
|
|
200
|
+
f"Unrecognized command '{command}'. "
|
|
201
|
+
"Allowed commands: view, create, str_replace, insert, undo_edit."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def validate_path(self, command: str, path: Path):
|
|
205
|
+
# Optional workspace jail: when TOOLPLANE_WORKSPACE_ROOT is set, every
|
|
206
|
+
# editor operation must resolve inside it (symlinks resolved first).
|
|
207
|
+
root = os.environ.get("TOOLPLANE_WORKSPACE_ROOT", "").strip()
|
|
208
|
+
if root:
|
|
209
|
+
try:
|
|
210
|
+
Path(path).resolve().relative_to(Path(root).resolve())
|
|
211
|
+
except ValueError:
|
|
212
|
+
raise EditorError(
|
|
213
|
+
f"Path '{path}' is outside TOOLPLANE_WORKSPACE_ROOT ({root})."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if command == "create":
|
|
217
|
+
if path.exists():
|
|
218
|
+
raise EditorError(
|
|
219
|
+
f"File already exists at: {path}. Cannot overwrite with 'create'."
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
if not path.exists():
|
|
223
|
+
raise EditorError(f"The path '{path}' does not exist.")
|
|
224
|
+
|
|
225
|
+
if path.is_dir() and command != "view":
|
|
226
|
+
raise EditorError(
|
|
227
|
+
f"The path '{path}' is a directory. Only 'view' can be used on directories."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def read_path(path: Path) -> str:
|
|
232
|
+
encoding = chardet.detect(path.read_bytes())["encoding"]
|
|
233
|
+
if encoding is None:
|
|
234
|
+
encoding = "utf-8"
|
|
235
|
+
return path.read_text(encoding=encoding)
|
|
236
|
+
|
|
237
|
+
def view(
|
|
238
|
+
self,
|
|
239
|
+
path: Path,
|
|
240
|
+
view_range: Optional[List[int]] = None,
|
|
241
|
+
python_only: bool = True,
|
|
242
|
+
) -> EditorResult:
|
|
243
|
+
"""
|
|
244
|
+
If path is a directory, list contents (2 levels deep, excluding hidden).
|
|
245
|
+
If path is a file, display the file contents with line numbers.
|
|
246
|
+
Then apply [start_line, end_line] slicing if provided.
|
|
247
|
+
"""
|
|
248
|
+
if path.is_dir():
|
|
249
|
+
if not python_only:
|
|
250
|
+
cmd = ["find", str(path), "-maxdepth", "2", "-not", "-path", "*/.*"]
|
|
251
|
+
else:
|
|
252
|
+
# Use `-type d -o -name '*.py'` to only list directories or *.py files
|
|
253
|
+
cmd = [
|
|
254
|
+
"find",
|
|
255
|
+
str(path),
|
|
256
|
+
"-maxdepth",
|
|
257
|
+
"2",
|
|
258
|
+
"-not",
|
|
259
|
+
"-path",
|
|
260
|
+
"*/.*",
|
|
261
|
+
"(",
|
|
262
|
+
"-type",
|
|
263
|
+
"d",
|
|
264
|
+
"-o",
|
|
265
|
+
"-name",
|
|
266
|
+
"*.py",
|
|
267
|
+
")",
|
|
268
|
+
]
|
|
269
|
+
try:
|
|
270
|
+
# Try using the newer parameters first (Python 3.7+)
|
|
271
|
+
try:
|
|
272
|
+
proc = subprocess.run(
|
|
273
|
+
cmd, capture_output=True, text=True, check=False
|
|
274
|
+
)
|
|
275
|
+
except TypeError:
|
|
276
|
+
# Fallback for Python 3.5 and 3.6 where capture_output and text are not supported
|
|
277
|
+
proc = subprocess.run(
|
|
278
|
+
cmd,
|
|
279
|
+
stdout=subprocess.PIPE,
|
|
280
|
+
stderr=subprocess.PIPE,
|
|
281
|
+
universal_newlines=True,
|
|
282
|
+
check=False,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
stderr = proc.stderr.strip()
|
|
286
|
+
stdout = proc.stdout
|
|
287
|
+
if stderr:
|
|
288
|
+
return EditorResult(output="", error=stderr)
|
|
289
|
+
|
|
290
|
+
msg = (
|
|
291
|
+
f"Here's the files and directories up to 2 levels deep in {path}, "
|
|
292
|
+
"excluding hidden:\n" + stdout
|
|
293
|
+
)
|
|
294
|
+
msg = maybe_truncate(msg)
|
|
295
|
+
return EditorResult(output=msg)
|
|
296
|
+
except Exception as e:
|
|
297
|
+
return EditorResult(
|
|
298
|
+
output="",
|
|
299
|
+
error=f"Ran into {e} while trying to list directory contents of {path}.",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# ====================
|
|
303
|
+
# NEW RESTRICTION: only .py files are allowed for viewing
|
|
304
|
+
if path.suffix != ".py" and python_only:
|
|
305
|
+
error_msg = (
|
|
306
|
+
f"ERROR: Viewing non-Python files is disallowed for saving context. "
|
|
307
|
+
f"File '{path.name}' is not a .py file."
|
|
308
|
+
)
|
|
309
|
+
return EditorResult(output="", error=error_msg)
|
|
310
|
+
# ====================
|
|
311
|
+
|
|
312
|
+
# For a file - always use normal reading
|
|
313
|
+
file_text = self.read_path(path)
|
|
314
|
+
file_text = file_text.expandtabs()
|
|
315
|
+
lines_with_original_numbers = [
|
|
316
|
+
(i, line) for i, line in enumerate(file_text.splitlines())
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
# Optionally slice by [start_line, end_line]
|
|
320
|
+
total_lines = len(lines_with_original_numbers)
|
|
321
|
+
if view_range and len(view_range) == 2:
|
|
322
|
+
start, end = view_range
|
|
323
|
+
if not (1 <= start <= total_lines):
|
|
324
|
+
return EditorResult(
|
|
325
|
+
output="",
|
|
326
|
+
error=(
|
|
327
|
+
f"Invalid view_range {view_range}: start line must be in [1, {total_lines}]"
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
if end != -1 and (end < start or end > total_lines):
|
|
331
|
+
return EditorResult(
|
|
332
|
+
output="",
|
|
333
|
+
error=(
|
|
334
|
+
f"Invalid view_range {view_range}: end must be >= start "
|
|
335
|
+
f"and <= {total_lines}, or -1 to view until end."
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Filter lines by 1-based index
|
|
340
|
+
sliced_lines = []
|
|
341
|
+
for i, text in lines_with_original_numbers:
|
|
342
|
+
one_based = i + 1
|
|
343
|
+
if one_based < start:
|
|
344
|
+
continue
|
|
345
|
+
if end != -1 and one_based > end:
|
|
346
|
+
continue
|
|
347
|
+
sliced_lines.append((i, text))
|
|
348
|
+
else:
|
|
349
|
+
# No slicing
|
|
350
|
+
sliced_lines = lines_with_original_numbers
|
|
351
|
+
|
|
352
|
+
# Now produce a cat-like output (line numbering = i+1)
|
|
353
|
+
final_output = f"Here's the result of running `cat -n` on the file: {path}:\n"
|
|
354
|
+
# Then maybe truncate
|
|
355
|
+
output_str_list = []
|
|
356
|
+
for i, text in sliced_lines:
|
|
357
|
+
# i is 0-based
|
|
358
|
+
output_str_list.append(f"{i+1:6d} {text}")
|
|
359
|
+
|
|
360
|
+
final_output += "\n".join(output_str_list)
|
|
361
|
+
final_output = maybe_truncate(final_output)
|
|
362
|
+
return EditorResult(output=final_output)
|
|
363
|
+
|
|
364
|
+
def create(self, path: Path, file_text: str) -> EditorResult:
|
|
365
|
+
if file_text is None:
|
|
366
|
+
raise EditorError("Cannot create file without 'file_text' parameter.")
|
|
367
|
+
|
|
368
|
+
if self.enable_linting and path.suffix == ".py":
|
|
369
|
+
lint_error = self._lint_check(file_text, str(path))
|
|
370
|
+
if lint_error:
|
|
371
|
+
return EditorResult(output="", error=_LINT_ERROR_TEMPLATE + lint_error)
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
path.write_text(file_text, encoding="utf-8")
|
|
375
|
+
self.file_history[str(path)].append("")
|
|
376
|
+
except Exception as e:
|
|
377
|
+
raise EditorError(f"Error creating file at {path}: {e}")
|
|
378
|
+
|
|
379
|
+
success_msg = f"File created at {path}. "
|
|
380
|
+
success_msg += self._make_output(file_text, str(path))
|
|
381
|
+
success_msg += "Review the file and make sure that it is as expected. Edit the file if necessary."
|
|
382
|
+
|
|
383
|
+
return EditorResult(output=f"{success_msg}")
|
|
384
|
+
|
|
385
|
+
def str_replace(self, path: Path, old_str: str, new_str: str) -> EditorResult:
|
|
386
|
+
if old_str is None:
|
|
387
|
+
raise EditorError("Missing required parameter 'old_str' for 'str_replace'.")
|
|
388
|
+
|
|
389
|
+
file_content = self.read_file(path).expandtabs()
|
|
390
|
+
old_str = old_str.expandtabs()
|
|
391
|
+
new_str = new_str.expandtabs() if new_str else ""
|
|
392
|
+
occurrences = file_content.count(old_str)
|
|
393
|
+
if occurrences == 0:
|
|
394
|
+
raise EditorError(
|
|
395
|
+
f"No occurrences of '{old_str}' found in {path} for replacement."
|
|
396
|
+
)
|
|
397
|
+
if occurrences > 1:
|
|
398
|
+
raise EditorError(
|
|
399
|
+
f"Multiple occurrences of '{old_str}' found in {path}. "
|
|
400
|
+
"Please ensure it is unique before using str_replace."
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
old_text = file_content
|
|
404
|
+
updated_text = file_content.replace(old_str, new_str if new_str else "")
|
|
405
|
+
|
|
406
|
+
if self.enable_linting and path.suffix == ".py":
|
|
407
|
+
lint_error = self._lint_check(updated_text, str(path))
|
|
408
|
+
if lint_error:
|
|
409
|
+
return EditorResult(output="", error=_LINT_ERROR_TEMPLATE + lint_error)
|
|
410
|
+
|
|
411
|
+
self.file_history[str(path)].append(old_text)
|
|
412
|
+
self.write_file(path, updated_text)
|
|
413
|
+
|
|
414
|
+
# Original snippet logic
|
|
415
|
+
replacement_line = file_content.split(old_str)[0].count("\n")
|
|
416
|
+
start_line = max(0, replacement_line - SNIPPET_LINES)
|
|
417
|
+
end_line = replacement_line + SNIPPET_LINES + (new_str or "").count("\n")
|
|
418
|
+
snippet = "\n".join(updated_text.split("\n")[start_line : end_line + 1])
|
|
419
|
+
|
|
420
|
+
success_msg = f"The file {path} has been edited. "
|
|
421
|
+
success_msg += self._make_output(
|
|
422
|
+
snippet, f"a snippet of {path}", start_line + 1
|
|
423
|
+
)
|
|
424
|
+
success_msg += "Review the changes and make sure they are as expected. Edit the file again if necessary."
|
|
425
|
+
|
|
426
|
+
return EditorResult(output=success_msg)
|
|
427
|
+
|
|
428
|
+
def insert(self, path: Path, insert_line: int, new_str: str) -> EditorResult:
|
|
429
|
+
if new_str is None:
|
|
430
|
+
raise EditorError("Missing required parameter 'new_str' for 'insert'.")
|
|
431
|
+
|
|
432
|
+
old_text = self.read_file(path).expandtabs()
|
|
433
|
+
new_str = new_str.expandtabs()
|
|
434
|
+
file_text_lines = old_text.split("\n")
|
|
435
|
+
|
|
436
|
+
if insert_line < 0 or insert_line > len(file_text_lines):
|
|
437
|
+
raise EditorError(
|
|
438
|
+
f"Invalid insert_line {insert_line}. Must be in [0, {len(file_text_lines)}]."
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
new_str_lines = new_str.split("\n")
|
|
442
|
+
new_file_text_lines = (
|
|
443
|
+
file_text_lines[:insert_line]
|
|
444
|
+
+ new_str_lines
|
|
445
|
+
+ file_text_lines[insert_line:]
|
|
446
|
+
)
|
|
447
|
+
updated_text = "\n".join(new_file_text_lines)
|
|
448
|
+
|
|
449
|
+
if self.enable_linting and path.suffix == ".py":
|
|
450
|
+
lint_error = self._lint_check(updated_text, str(path))
|
|
451
|
+
if lint_error:
|
|
452
|
+
return EditorResult(output="", error=_LINT_ERROR_TEMPLATE + lint_error)
|
|
453
|
+
|
|
454
|
+
self.file_history[str(path)].append(old_text)
|
|
455
|
+
self.write_file(path, updated_text)
|
|
456
|
+
|
|
457
|
+
# Original snippet logic
|
|
458
|
+
snippet_lines = (
|
|
459
|
+
file_text_lines[max(0, insert_line - SNIPPET_LINES) : insert_line]
|
|
460
|
+
+ new_str_lines
|
|
461
|
+
+ file_text_lines[insert_line : insert_line + SNIPPET_LINES]
|
|
462
|
+
)
|
|
463
|
+
snippet = "\n".join(snippet_lines)
|
|
464
|
+
|
|
465
|
+
success_msg = f"The file {path} has been edited. "
|
|
466
|
+
success_msg += self._make_output(
|
|
467
|
+
snippet,
|
|
468
|
+
"a snippet of the edited file",
|
|
469
|
+
max(1, insert_line - SNIPPET_LINES + 1),
|
|
470
|
+
)
|
|
471
|
+
success_msg += (
|
|
472
|
+
"Review the changes and make sure they are as expected "
|
|
473
|
+
"(correct indentation, no duplicate lines, etc). Edit the file again if necessary."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
return EditorResult(output=success_msg)
|
|
477
|
+
|
|
478
|
+
def undo_edit(self, path: Path) -> EditorResult:
|
|
479
|
+
path_str = str(path)
|
|
480
|
+
if not self.file_history[path_str]:
|
|
481
|
+
raise EditorError(f"No previous edits found for {path} to undo.")
|
|
482
|
+
|
|
483
|
+
old_text = self.file_history[path_str].pop()
|
|
484
|
+
self.write_file(path, old_text)
|
|
485
|
+
|
|
486
|
+
return EditorResult(
|
|
487
|
+
output=(
|
|
488
|
+
f"Last edit to {path} undone successfully. "
|
|
489
|
+
+ self._make_output(old_text, str(path))
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
def read_file(self, path: Path) -> str:
|
|
494
|
+
try:
|
|
495
|
+
return self.read_path(path)
|
|
496
|
+
except Exception as e:
|
|
497
|
+
raise EditorError(f"Failed to read file {path}: {e}")
|
|
498
|
+
|
|
499
|
+
def write_file(self, path: Path, content: str):
|
|
500
|
+
try:
|
|
501
|
+
path.write_text(content, encoding="utf-8")
|
|
502
|
+
except Exception as e:
|
|
503
|
+
raise EditorError(f"Failed to write file {path}: {e}")
|
|
504
|
+
|
|
505
|
+
def _make_output(
|
|
506
|
+
self,
|
|
507
|
+
file_content: str,
|
|
508
|
+
file_descriptor: str,
|
|
509
|
+
init_line: int = 1,
|
|
510
|
+
expand_tabs: bool = True,
|
|
511
|
+
) -> str:
|
|
512
|
+
"""
|
|
513
|
+
Mimics cat -n style numbering, plus maybe_truncate to avoid huge outputs.
|
|
514
|
+
"""
|
|
515
|
+
file_content = maybe_truncate(file_content)
|
|
516
|
+
if expand_tabs:
|
|
517
|
+
file_content = file_content.expandtabs()
|
|
518
|
+
|
|
519
|
+
lines = file_content.split("\n")
|
|
520
|
+
numbered = "\n".join(
|
|
521
|
+
f"{i + init_line:6}\t{line}" for i, line in enumerate(lines)
|
|
522
|
+
)
|
|
523
|
+
return (
|
|
524
|
+
f"Here's the result of running `cat -n` on {file_descriptor}:\n"
|
|
525
|
+
+ numbered
|
|
526
|
+
+ "\n"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
def _lint_check(self, new_content: str, file_path: str) -> str:
|
|
530
|
+
import ast
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
ast.parse(new_content, filename=file_path)
|
|
534
|
+
return ""
|
|
535
|
+
except SyntaxError as e:
|
|
536
|
+
return str(e)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def main():
|
|
540
|
+
def parse_view_range(range_str: str):
|
|
541
|
+
# Remove surrounding brackets if present
|
|
542
|
+
range_str = range_str.strip().strip("[]()")
|
|
543
|
+
|
|
544
|
+
# Split on commas or whitespace
|
|
545
|
+
parts = range_str.replace(",", " ").split()
|
|
546
|
+
|
|
547
|
+
if len(parts) != 2:
|
|
548
|
+
raise argparse.ArgumentTypeError(f"Expected two numbers, got: {range_str}")
|
|
549
|
+
try:
|
|
550
|
+
start_line = int(parts[0])
|
|
551
|
+
end_line = int(parts[1])
|
|
552
|
+
except ValueError:
|
|
553
|
+
raise argparse.ArgumentTypeError(f"Could not convert {parts} to integers.")
|
|
554
|
+
return [start_line, end_line]
|
|
555
|
+
|
|
556
|
+
parser = argparse.ArgumentParser(
|
|
557
|
+
description=(
|
|
558
|
+
"A disk-backed file editing tool (view, create, str_replace, insert, undo_edit) "
|
|
559
|
+
"with optional linting."
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
parser.add_argument(
|
|
563
|
+
"command",
|
|
564
|
+
type=str,
|
|
565
|
+
help="One of: view, create, str_replace, insert, undo_edit",
|
|
566
|
+
)
|
|
567
|
+
parser.add_argument(
|
|
568
|
+
"--path",
|
|
569
|
+
type=str,
|
|
570
|
+
help="Path to the target file or directory (absolute path recommended)",
|
|
571
|
+
)
|
|
572
|
+
parser.add_argument(
|
|
573
|
+
"--file_text",
|
|
574
|
+
type=str,
|
|
575
|
+
default=None,
|
|
576
|
+
help="File content (for 'create')",
|
|
577
|
+
)
|
|
578
|
+
parser.add_argument(
|
|
579
|
+
"--view_range",
|
|
580
|
+
type=parse_view_range,
|
|
581
|
+
default=None,
|
|
582
|
+
help="Line range to view [start_line, end_line], use -1 for end.",
|
|
583
|
+
)
|
|
584
|
+
parser.add_argument(
|
|
585
|
+
"--old_str",
|
|
586
|
+
type=str,
|
|
587
|
+
default=None,
|
|
588
|
+
help="Old string (for 'str_replace')",
|
|
589
|
+
)
|
|
590
|
+
parser.add_argument(
|
|
591
|
+
"--new_str",
|
|
592
|
+
type=str,
|
|
593
|
+
default=None,
|
|
594
|
+
help="New string (for 'str_replace' or 'insert')",
|
|
595
|
+
)
|
|
596
|
+
parser.add_argument(
|
|
597
|
+
"--insert_line",
|
|
598
|
+
type=int,
|
|
599
|
+
default=None,
|
|
600
|
+
help="Line number to insert text at (for 'insert')",
|
|
601
|
+
)
|
|
602
|
+
parser.add_argument(
|
|
603
|
+
"--enable_linting",
|
|
604
|
+
type=bool,
|
|
605
|
+
default=False,
|
|
606
|
+
help="Enable linting checks for Python files before saving changes.",
|
|
607
|
+
)
|
|
608
|
+
parser.add_argument(
|
|
609
|
+
"--python_only",
|
|
610
|
+
type=bool,
|
|
611
|
+
default=True,
|
|
612
|
+
help="If True, attempts to limit view (for both dir and file level) to Python files only.",
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
args = parser.parse_args()
|
|
616
|
+
|
|
617
|
+
file_history = load_history()
|
|
618
|
+
editor = StrReplaceEditor(file_history, enable_linting=args.enable_linting)
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
result = editor.run(
|
|
622
|
+
command=args.command,
|
|
623
|
+
path_str=args.path,
|
|
624
|
+
file_text=args.file_text,
|
|
625
|
+
view_range=args.view_range,
|
|
626
|
+
old_str=args.old_str,
|
|
627
|
+
new_str=args.new_str,
|
|
628
|
+
insert_line=args.insert_line,
|
|
629
|
+
python_only=args.python_only,
|
|
630
|
+
)
|
|
631
|
+
safe_print(result.output)
|
|
632
|
+
if result.error:
|
|
633
|
+
safe_print(f"ERROR: {result.error}")
|
|
634
|
+
|
|
635
|
+
except EditorError as e:
|
|
636
|
+
safe_print(f"ERROR: {e}")
|
|
637
|
+
except Exception as e:
|
|
638
|
+
safe_print(f"ERROR: Unhandled exception: {e}")
|
|
639
|
+
import traceback
|
|
640
|
+
|
|
641
|
+
traceback.print_exc()
|
|
642
|
+
|
|
643
|
+
save_history(dict(editor.file_history))
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
if __name__ == "__main__":
|
|
647
|
+
main()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Description: A simple submit tool to finish tasks.
|
|
4
|
+
|
|
5
|
+
This tool signals completion of a task or submission of results.
|
|
6
|
+
No parameters required - simply call to indicate task completion.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def submit():
|
|
14
|
+
"""
|
|
15
|
+
Submits completion signal.
|
|
16
|
+
"""
|
|
17
|
+
print("<<<Finished>>>")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
parser = argparse.ArgumentParser(description="Submit tool: Signal task completion.")
|
|
22
|
+
# No arguments needed
|
|
23
|
+
args = parser.parse_args()
|
|
24
|
+
|
|
25
|
+
submit()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|