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.
Files changed (81) hide show
  1. toolplane/__init__.py +106 -0
  2. toolplane/common/__init__.py +93 -0
  3. toolplane/common/base_config.py +129 -0
  4. toolplane/common/base_connection_manager.py +171 -0
  5. toolplane/common/base_session_manager.py +321 -0
  6. toolplane/common/base_tool_manager.py +347 -0
  7. toolplane/common/constants.py +47 -0
  8. toolplane/common/utils.py +310 -0
  9. toolplane/core/__init__.py +67 -0
  10. toolplane/core/config.py +107 -0
  11. toolplane/core/connection.py +285 -0
  12. toolplane/core/errors.py +298 -0
  13. toolplane/core/machine.py +480 -0
  14. toolplane/core/request.py +775 -0
  15. toolplane/core/session.py +332 -0
  16. toolplane/core/session_context.py +514 -0
  17. toolplane/core/task.py +130 -0
  18. toolplane/core/tool.py +329 -0
  19. toolplane/http_core/__init__.py +37 -0
  20. toolplane/http_core/http_config.py +97 -0
  21. toolplane/http_core/http_connection.py +409 -0
  22. toolplane/http_core/http_machine.py +298 -0
  23. toolplane/http_core/http_request.py +748 -0
  24. toolplane/http_core/http_session.py +348 -0
  25. toolplane/http_core/http_session_context.py +491 -0
  26. toolplane/http_core/http_task.py +101 -0
  27. toolplane/http_core/http_tool.py +400 -0
  28. toolplane/interfaces/__init__.py +27 -0
  29. toolplane/interfaces/client_interface.py +122 -0
  30. toolplane/interfaces/connection_interface.py +193 -0
  31. toolplane/interfaces/event_interface.py +290 -0
  32. toolplane/interfaces/request_interface.py +439 -0
  33. toolplane/interfaces/session_interface.py +288 -0
  34. toolplane/interfaces/tool_interface.py +441 -0
  35. toolplane/proto/__init__.py +0 -0
  36. toolplane/proto/service_pb2.py +315 -0
  37. toolplane/proto/service_pb2_grpc.py +2240 -0
  38. toolplane/provider_cli.py +268 -0
  39. toolplane/provider_registry.py +77 -0
  40. toolplane/provider_runtime.py +302 -0
  41. toolplane/toolkits/__init__.py +0 -0
  42. toolplane/toolkits/standalone_tools/__init__.py +0 -0
  43. toolplane/toolkits/standalone_tools/create_directory.py +94 -0
  44. toolplane/toolkits/standalone_tools/create_file.py +124 -0
  45. toolplane/toolkits/standalone_tools/file_search.py +229 -0
  46. toolplane/toolkits/standalone_tools/grep_search.py +372 -0
  47. toolplane/toolkits/standalone_tools/launcher.py +146 -0
  48. toolplane/toolkits/standalone_tools/list_dir.py +395 -0
  49. toolplane/toolkits/standalone_tools/read_file.py +346 -0
  50. toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
  51. toolplane/toolkits/standalone_tools/run_tests.py +66 -0
  52. toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
  53. toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
  54. toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
  55. toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
  56. toolplane/toolkits/swe/__init__.py +35 -0
  57. toolplane/toolkits/swe/create_directory.py +15 -0
  58. toolplane/toolkits/swe/create_file.py +15 -0
  59. toolplane/toolkits/swe/descriptions.py +273 -0
  60. toolplane/toolkits/swe/execute_bash.py +93 -0
  61. toolplane/toolkits/swe/file_editor.py +775 -0
  62. toolplane/toolkits/swe/file_search.py +16 -0
  63. toolplane/toolkits/swe/finish.py +50 -0
  64. toolplane/toolkits/swe/grep_search.py +19 -0
  65. toolplane/toolkits/swe/list_dir.py +407 -0
  66. toolplane/toolkits/swe/read_file.py +18 -0
  67. toolplane/toolkits/swe/replace_string_in_file.py +17 -0
  68. toolplane/toolkits/swe/search.py +260 -0
  69. toolplane/toolkits/swe/semantic_search.py +20 -0
  70. toolplane/toolkits/swe/str_replace_editor.py +647 -0
  71. toolplane/toolkits/swe/submit.py +29 -0
  72. toolplane/toolkits/swe/swe_toolkit.py +1296 -0
  73. toolplane/toolplane_client.py +686 -0
  74. toolplane/toolplane_http_client.py +681 -0
  75. toolplane/utils/__init__.py +3 -0
  76. toolplane/utils/schema.py +146 -0
  77. toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
  78. toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
  79. toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
  80. toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
  81. 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()