acontext 0.1.4__tar.gz → 0.1.6__tar.gz

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 (42) hide show
  1. {acontext-0.1.4 → acontext-0.1.6}/PKG-INFO +1 -1
  2. {acontext-0.1.4 → acontext-0.1.6}/pyproject.toml +1 -1
  3. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/agent/prompts.py +4 -1
  4. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/agent/sandbox.py +26 -10
  5. acontext-0.1.6/src/acontext/agent/text_editor.py +420 -0
  6. acontext-0.1.4/src/acontext/agent/text_editor.py +0 -436
  7. {acontext-0.1.4 → acontext-0.1.6}/README.md +0 -0
  8. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/__init__.py +0 -0
  9. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/_constants.py +0 -0
  10. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/_utils.py +0 -0
  11. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/agent/__init__.py +0 -0
  12. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/agent/base.py +0 -0
  13. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/agent/disk.py +0 -0
  14. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/agent/skill.py +0 -0
  15. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/async_client.py +0 -0
  16. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/client.py +0 -0
  17. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/client_types.py +0 -0
  18. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/errors.py +0 -0
  19. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/messages.py +0 -0
  20. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/py.typed +0 -0
  21. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/__init__.py +0 -0
  22. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/async_disks.py +0 -0
  23. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/async_sandboxes.py +0 -0
  24. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/async_sessions.py +0 -0
  25. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/async_skills.py +0 -0
  26. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/async_tools.py +0 -0
  27. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/async_users.py +0 -0
  28. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/disks.py +0 -0
  29. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/sandboxes.py +0 -0
  30. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/sessions.py +0 -0
  31. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/skills.py +0 -0
  32. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/tools.py +0 -0
  33. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/resources/users.py +0 -0
  34. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/types/__init__.py +0 -0
  35. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/types/common.py +0 -0
  36. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/types/disk.py +0 -0
  37. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/types/sandbox.py +0 -0
  38. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/types/session.py +0 -0
  39. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/types/skill.py +0 -0
  40. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/types/tool.py +0 -0
  41. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/types/user.py +0 -0
  42. {acontext-0.1.4 → acontext-0.1.6}/src/acontext/uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: acontext
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Python SDK for the Acontext API
5
5
  Keywords: acontext,sdk,client,api
6
6
  Requires-Dist: httpx>=0.28.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acontext"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Python SDK for the Acontext API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -92,5 +92,8 @@ Container environment:
92
92
  - Standard Unix utilities available (grep, sed, awk, etc.)
93
93
  - Archive tools: tar, unzip, zip
94
94
  - Additional tools: ripgrep, fd, sqlite3, jq, imagemagick
95
- - Do not try to install new packages and libraries with pip as there is no internet access
95
+ - You can install new packages with pip if needed (internet access is available)
96
+
97
+
98
+ Remember to always export your artifacts at the end of your task so that the user can view them.
96
99
  """
@@ -10,6 +10,15 @@ from .prompts import SANDBOX_TEXT_EDITOR_REMINDER, SANDBOX_BASH_REMINDER, SKILL_
10
10
  from ..client import AcontextClient
11
11
  from ..async_client import AcontextAsyncClient
12
12
 
13
+ MAX_OUTPUT_CHARS = 20000
14
+
15
+
16
+ def truncate_output(text: str, max_chars: int = MAX_OUTPUT_CHARS) -> str:
17
+ """Truncate text to max_chars, appending a truncation flag if needed."""
18
+ if len(text) > max_chars:
19
+ return text[:max_chars] + "...[truncated]"
20
+ return text
21
+
13
22
 
14
23
  class MountedSkill(TypedDict):
15
24
  name: str
@@ -191,8 +200,8 @@ class BashTool(BaseTool):
191
200
 
192
201
  return json.dumps(
193
202
  {
194
- "stdout": result.stdout,
195
- "stderr": result.stderr,
203
+ "stdout": truncate_output(result.stdout),
204
+ "stderr": truncate_output(result.stderr),
196
205
  "exit_code": result.exit_code,
197
206
  }
198
207
  )
@@ -213,8 +222,8 @@ class BashTool(BaseTool):
213
222
 
214
223
  return json.dumps(
215
224
  {
216
- "stdout": result.stdout,
217
- "stderr": result.stderr,
225
+ "stdout": truncate_output(result.stdout),
226
+ "stderr": truncate_output(result.stderr),
218
227
  "exit_code": result.exit_code,
219
228
  }
220
229
  )
@@ -248,27 +257,34 @@ class TextEditorTool(BaseTool):
248
257
  "command": {
249
258
  "type": "string",
250
259
  "enum": ["view", "create", "str_replace"],
251
- "description": "The operation to perform: 'view', 'create', or 'str_replace'",
260
+ "description": (
261
+ "Perform only text operations: 'view', 'create', or 'str_replace'. "
262
+ "Required parameters per command: "
263
+ "'view' requires path (view_range is optional); "
264
+ "'create' requires path and file_text; "
265
+ "'str_replace' requires path, old_str, and new_str."
266
+ ),
252
267
  },
253
268
  "path": {
254
269
  "type": "string",
255
- "description": "The file path in the sandbox (e.g., '/workspace/script.py')",
270
+ "description": "Required for all commands. The file path in the sandbox (e.g., '/workspace/script.py')",
256
271
  },
257
272
  "file_text": {
258
273
  "type": ["string", "null"],
259
- "description": "For 'create' command: the content to write to the file",
274
+ "description": "Required for 'create' command. The content to write to the file.",
260
275
  },
261
276
  "old_str": {
262
277
  "type": ["string", "null"],
263
- "description": "For 'str_replace' command: the exact string to find and replace",
278
+ "description": "Required for 'str_replace' command. The exact string to find and replace.",
264
279
  },
265
280
  "new_str": {
266
281
  "type": ["string", "null"],
267
- "description": "For 'str_replace' command: the string to replace old_str with",
282
+ "description": "Required for 'str_replace' command. The string to replace old_str with.",
268
283
  },
269
284
  "view_range": {
270
285
  "type": ["array", "null"],
271
- "description": "For 'view' command: optional [start_line, end_line] to view specific lines",
286
+ "items": {"type": "integer"},
287
+ "description": "Optional for 'view' command. An array [start_line, end_line] to view specific lines. If not provided, shows the first 200 lines.",
272
288
  },
273
289
  }
274
290
 
@@ -0,0 +1,420 @@
1
+ """Text editor file operations for sandbox environments."""
2
+
3
+ import base64
4
+ import posixpath
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .sandbox import AsyncSandboxContext, SandboxContext
9
+
10
+ MAX_CONTENT_CHARS = 20000
11
+
12
+
13
+ def truncate_content(text: str, max_chars: int = MAX_CONTENT_CHARS) -> str:
14
+ """Truncate text to max_chars, appending a truncation flag if needed."""
15
+ if len(text) > max_chars:
16
+ return text[:max_chars] + "...[truncated]"
17
+ return text
18
+
19
+
20
+ def escape_for_shell(s: str) -> str:
21
+ """Escape a string for safe use in shell commands."""
22
+ # Use single quotes and escape any single quotes in the string
23
+ return "'" + s.replace("'", "'\"'\"'") + "'"
24
+
25
+
26
+ # ============================================================================
27
+ # Sync Operations
28
+ # ============================================================================
29
+
30
+
31
+ def view_file(
32
+ ctx: "SandboxContext", path: str, view_range: list | None, timeout: float | None
33
+ ) -> dict:
34
+ """View file content with line numbers.
35
+
36
+ Args:
37
+ ctx: The sandbox context.
38
+ path: The file path to view.
39
+ view_range: Optional [start_line, end_line] to view specific lines.
40
+ timeout: Optional timeout for command execution.
41
+
42
+ Returns:
43
+ A dict with file content and metadata, or error information.
44
+ """
45
+ escaped_path = escape_for_shell(path)
46
+
47
+ # Build combined command: check existence, get total lines, and view content in one exec
48
+ if view_range and len(view_range) == 2:
49
+ start_line, end_line = view_range
50
+ view_cmd = (
51
+ f"sed -n '{start_line},{end_line}p' {escaped_path} | nl -ba -v {start_line}"
52
+ )
53
+ else:
54
+ max_lines = 200
55
+ view_cmd = f"head -n {max_lines} {escaped_path} | nl -ba"
56
+ start_line = 1
57
+
58
+ # Single combined command: outputs "TOTAL:<n>" on first line, then file content
59
+ cmd = (
60
+ f"if [ ! -f {escaped_path} ]; then echo 'FILE_NOT_FOUND'; exit 1; fi; "
61
+ f'echo "TOTAL:$(wc -l < {escaped_path})"; {view_cmd}'
62
+ )
63
+
64
+ result = ctx.client.sandboxes.exec_command(
65
+ sandbox_id=ctx.sandbox_id,
66
+ command=cmd,
67
+ timeout=timeout,
68
+ )
69
+
70
+ if result.exit_code != 0 or "FILE_NOT_FOUND" in result.stdout:
71
+ return {
72
+ "error": f"File not found: {path}",
73
+ "stderr": result.stderr,
74
+ }
75
+
76
+ # Parse output: first line is "TOTAL:<n>", rest is content
77
+ lines = result.stdout.split("\n", 1)
78
+ total_lines = 0
79
+ content = ""
80
+
81
+ if lines and lines[0].startswith("TOTAL:"):
82
+ total_str = lines[0][6:].strip()
83
+ total_lines = int(total_str) if total_str.isdigit() else 0
84
+ content = lines[1] if len(lines) > 1 else ""
85
+
86
+ content_lines = content.rstrip("\n").split("\n") if content.strip() else []
87
+ num_lines = len(content_lines)
88
+
89
+ return {
90
+ "file_type": "text",
91
+ "content": truncate_content(content),
92
+ "numLines": num_lines,
93
+ "startLine": start_line if view_range else 1,
94
+ "totalLines": total_lines + 1, # wc -l doesn't count last line without newline
95
+ }
96
+
97
+
98
+ def create_file(
99
+ ctx: "SandboxContext", path: str, file_text: str, timeout: float | None
100
+ ) -> dict:
101
+ """Create a new file with content.
102
+
103
+ Args:
104
+ ctx: The sandbox context.
105
+ path: The file path to create.
106
+ file_text: The content to write to the file.
107
+ timeout: Optional timeout for command execution.
108
+
109
+ Returns:
110
+ A dict with creation status or error information.
111
+ """
112
+ escaped_path = escape_for_shell(path)
113
+ encoded_content = base64.b64encode(file_text.encode()).decode()
114
+
115
+ # Get directory path for mkdir
116
+ dir_path = posixpath.dirname(path)
117
+ mkdir_part = f"mkdir -p {escape_for_shell(dir_path)} && " if dir_path else ""
118
+
119
+ # Single combined command: check existence, create dir, write file
120
+ cmd = (
121
+ f"is_update=$(test -f {escaped_path} && echo 1 || echo 0); "
122
+ f"{mkdir_part}"
123
+ f"echo {escape_for_shell(encoded_content)} | base64 -d > {escaped_path} && "
124
+ f'echo "STATUS:$is_update"'
125
+ )
126
+
127
+ result = ctx.client.sandboxes.exec_command(
128
+ sandbox_id=ctx.sandbox_id,
129
+ command=cmd,
130
+ timeout=timeout,
131
+ )
132
+
133
+ if result.exit_code != 0 or "STATUS:" not in result.stdout:
134
+ return {
135
+ "error": f"Failed to create file: {path}",
136
+ "stderr": result.stderr,
137
+ }
138
+
139
+ is_update = "STATUS:1" in result.stdout
140
+
141
+ return {
142
+ "is_file_update": is_update,
143
+ "message": f"File {'updated' if is_update else 'created'}: {path}",
144
+ }
145
+
146
+
147
+ def str_replace(
148
+ ctx: "SandboxContext", path: str, old_str: str, new_str: str, timeout: float | None
149
+ ) -> dict:
150
+ """Replace a string in a file.
151
+
152
+ Uses a Python script on the sandbox to avoid transferring the entire file.
153
+ Only the base64-encoded old_str and new_str are sent.
154
+
155
+ Args:
156
+ ctx: The sandbox context.
157
+ path: The file path to modify.
158
+ old_str: The exact string to find and replace.
159
+ new_str: The string to replace old_str with.
160
+ timeout: Optional timeout for command execution.
161
+
162
+ Returns:
163
+ A dict with success message or error details.
164
+ """
165
+ old_b64 = base64.b64encode(old_str.encode()).decode()
166
+ new_b64 = base64.b64encode(new_str.encode()).decode()
167
+
168
+ # Write Python script to a temp file and execute it
169
+ # This avoids shell escaping issues with inline python -c
170
+ py_script = f'''import sys, base64, os
171
+ old = base64.b64decode("{old_b64}").decode()
172
+ new = base64.b64decode("{new_b64}").decode()
173
+ path = "{path}"
174
+ if not os.path.exists(path):
175
+ print("FILE_NOT_FOUND")
176
+ sys.exit(1)
177
+ with open(path, "r") as f:
178
+ content = f.read()
179
+ count = content.count(old)
180
+ if count == 0:
181
+ print("NOT_FOUND")
182
+ sys.exit(0)
183
+ if count > 1:
184
+ print(f"MULTIPLE:{{count}}")
185
+ sys.exit(0)
186
+ with open(path, "w") as f:
187
+ f.write(content.replace(old, new, 1))
188
+ print("SUCCESS")
189
+ '''
190
+ # Base64 encode the script itself to avoid any escaping issues
191
+ script_b64 = base64.b64encode(py_script.encode()).decode()
192
+ cmd = f"echo {escape_for_shell(script_b64)} | base64 -d | python3"
193
+
194
+ result = ctx.client.sandboxes.exec_command(
195
+ sandbox_id=ctx.sandbox_id,
196
+ command=cmd,
197
+ timeout=timeout,
198
+ )
199
+
200
+ output = result.stdout.strip()
201
+
202
+ if result.exit_code != 0 or output == "FILE_NOT_FOUND":
203
+ return {"error": f"File not found: {path}", "stderr": result.stderr}
204
+
205
+ if output == "NOT_FOUND":
206
+ return {"error": f"String not found in file: {old_str[:50]}..."}
207
+
208
+ if output.startswith("MULTIPLE:"):
209
+ count = output.split(":")[1]
210
+ return {
211
+ "error": f"Multiple occurrences ({count}) of the string found. "
212
+ "Please provide more context to make the match unique."
213
+ }
214
+
215
+ if output == "SUCCESS":
216
+ return {"msg": "Successfully replaced text at exactly one location."}
217
+
218
+ return {"error": f"Unexpected response: {output}", "stderr": result.stderr}
219
+
220
+
221
+ # ============================================================================
222
+ # Async Operations
223
+ # ============================================================================
224
+
225
+
226
+ async def async_view_file(
227
+ ctx: "AsyncSandboxContext",
228
+ path: str,
229
+ view_range: list | None,
230
+ timeout: float | None,
231
+ ) -> dict:
232
+ """View file content with line numbers (async).
233
+
234
+ Args:
235
+ ctx: The async sandbox context.
236
+ path: The file path to view.
237
+ view_range: Optional [start_line, end_line] to view specific lines.
238
+ timeout: Optional timeout for command execution.
239
+
240
+ Returns:
241
+ A dict with file content and metadata, or error information.
242
+ """
243
+ escaped_path = escape_for_shell(path)
244
+
245
+ # Build combined command: check existence, get total lines, and view content in one exec
246
+ if view_range and len(view_range) == 2:
247
+ start_line, end_line = view_range
248
+ view_cmd = (
249
+ f"sed -n '{start_line},{end_line}p' {escaped_path} | nl -ba -v {start_line}"
250
+ )
251
+ else:
252
+ max_lines = 200
253
+ view_cmd = f"head -n {max_lines} {escaped_path} | nl -ba"
254
+ start_line = 1
255
+
256
+ # Single combined command: outputs "TOTAL:<n>" on first line, then file content
257
+ cmd = (
258
+ f"if [ ! -f {escaped_path} ]; then echo 'FILE_NOT_FOUND'; exit 1; fi; "
259
+ f'echo "TOTAL:$(wc -l < {escaped_path})"; {view_cmd}'
260
+ )
261
+
262
+ result = await ctx.client.sandboxes.exec_command(
263
+ sandbox_id=ctx.sandbox_id,
264
+ command=cmd,
265
+ timeout=timeout,
266
+ )
267
+
268
+ if result.exit_code != 0 or "FILE_NOT_FOUND" in result.stdout:
269
+ return {
270
+ "error": f"File not found: {path}",
271
+ "stderr": result.stderr,
272
+ }
273
+
274
+ # Parse output: first line is "TOTAL:<n>", rest is content
275
+ lines = result.stdout.split("\n", 1)
276
+ total_lines = 0
277
+ content = ""
278
+
279
+ if lines and lines[0].startswith("TOTAL:"):
280
+ total_str = lines[0][6:].strip()
281
+ total_lines = int(total_str) if total_str.isdigit() else 0
282
+ content = lines[1] if len(lines) > 1 else ""
283
+
284
+ content_lines = content.rstrip("\n").split("\n") if content.strip() else []
285
+ num_lines = len(content_lines)
286
+
287
+ return {
288
+ "file_type": "text",
289
+ "content": truncate_content(content),
290
+ "numLines": num_lines,
291
+ "startLine": start_line if view_range else 1,
292
+ "totalLines": total_lines + 1,
293
+ }
294
+
295
+
296
+ async def async_create_file(
297
+ ctx: "AsyncSandboxContext", path: str, file_text: str, timeout: float | None
298
+ ) -> dict:
299
+ """Create a new file with content (async).
300
+
301
+ Args:
302
+ ctx: The async sandbox context.
303
+ path: The file path to create.
304
+ file_text: The content to write to the file.
305
+ timeout: Optional timeout for command execution.
306
+
307
+ Returns:
308
+ A dict with creation status or error information.
309
+ """
310
+ escaped_path = escape_for_shell(path)
311
+ encoded_content = base64.b64encode(file_text.encode()).decode()
312
+
313
+ # Get directory path for mkdir
314
+ dir_path = posixpath.dirname(path)
315
+ mkdir_part = f"mkdir -p {escape_for_shell(dir_path)} && " if dir_path else ""
316
+
317
+ # Single combined command: check existence, create dir, write file
318
+ cmd = (
319
+ f"is_update=$(test -f {escaped_path} && echo 1 || echo 0); "
320
+ f"{mkdir_part}"
321
+ f"echo {escape_for_shell(encoded_content)} | base64 -d > {escaped_path} && "
322
+ f'echo "STATUS:$is_update"'
323
+ )
324
+
325
+ result = await ctx.client.sandboxes.exec_command(
326
+ sandbox_id=ctx.sandbox_id,
327
+ command=cmd,
328
+ timeout=timeout,
329
+ )
330
+
331
+ if result.exit_code != 0 or "STATUS:" not in result.stdout:
332
+ return {
333
+ "error": f"Failed to create file: {path}",
334
+ "stderr": result.stderr,
335
+ }
336
+
337
+ is_update = "STATUS:1" in result.stdout
338
+
339
+ return {
340
+ "is_file_update": is_update,
341
+ "message": f"File {'updated' if is_update else 'created'}: {path}",
342
+ }
343
+
344
+
345
+ async def async_str_replace(
346
+ ctx: "AsyncSandboxContext",
347
+ path: str,
348
+ old_str: str,
349
+ new_str: str,
350
+ timeout: float | None,
351
+ ) -> dict:
352
+ """Replace a string in a file (async).
353
+
354
+ Uses a Python script on the sandbox to avoid transferring the entire file.
355
+ Only the base64-encoded old_str and new_str are sent.
356
+
357
+ Args:
358
+ ctx: The async sandbox context.
359
+ path: The file path to modify.
360
+ old_str: The exact string to find and replace.
361
+ new_str: The string to replace old_str with.
362
+ timeout: Optional timeout for command execution.
363
+
364
+ Returns:
365
+ A dict with success message or error details.
366
+ """
367
+ old_b64 = base64.b64encode(old_str.encode()).decode()
368
+ new_b64 = base64.b64encode(new_str.encode()).decode()
369
+
370
+ # Write Python script to a temp file and execute it
371
+ # This avoids shell escaping issues with inline python -c
372
+ py_script = f'''import sys, base64, os
373
+ old = base64.b64decode("{old_b64}").decode()
374
+ new = base64.b64decode("{new_b64}").decode()
375
+ path = "{path}"
376
+ if not os.path.exists(path):
377
+ print("FILE_NOT_FOUND")
378
+ sys.exit(1)
379
+ with open(path, "r") as f:
380
+ content = f.read()
381
+ count = content.count(old)
382
+ if count == 0:
383
+ print("NOT_FOUND")
384
+ sys.exit(0)
385
+ if count > 1:
386
+ print(f"MULTIPLE:{{count}}")
387
+ sys.exit(0)
388
+ with open(path, "w") as f:
389
+ f.write(content.replace(old, new, 1))
390
+ print("SUCCESS")
391
+ '''
392
+ # Base64 encode the script itself to avoid any escaping issues
393
+ script_b64 = base64.b64encode(py_script.encode()).decode()
394
+ cmd = f"echo {escape_for_shell(script_b64)} | base64 -d | python3"
395
+
396
+ result = await ctx.client.sandboxes.exec_command(
397
+ sandbox_id=ctx.sandbox_id,
398
+ command=cmd,
399
+ timeout=timeout,
400
+ )
401
+
402
+ output = result.stdout.strip()
403
+
404
+ if result.exit_code != 0 or output == "FILE_NOT_FOUND":
405
+ return {"error": f"File not found: {path}", "stderr": result.stderr}
406
+
407
+ if output == "NOT_FOUND":
408
+ return {"error": f"String not found in file: {old_str[:50]}..."}
409
+
410
+ if output.startswith("MULTIPLE:"):
411
+ count = output.split(":")[1]
412
+ return {
413
+ "error": f"Multiple occurrences ({count}) of the string found. "
414
+ "Please provide more context to make the match unique."
415
+ }
416
+
417
+ if output == "SUCCESS":
418
+ return {"msg": "Successfully replaced text at exactly one location."}
419
+
420
+ return {"error": f"Unexpected response: {output}", "stderr": result.stderr}
@@ -1,436 +0,0 @@
1
- """Text editor file operations for sandbox environments."""
2
-
3
- import base64
4
- from typing import TYPE_CHECKING
5
-
6
- if TYPE_CHECKING:
7
- from .sandbox import AsyncSandboxContext, SandboxContext
8
-
9
-
10
- def escape_for_shell(s: str) -> str:
11
- """Escape a string for safe use in shell commands."""
12
- # Use single quotes and escape any single quotes in the string
13
- return "'" + s.replace("'", "'\"'\"'") + "'"
14
-
15
-
16
- # ============================================================================
17
- # Sync Operations
18
- # ============================================================================
19
-
20
-
21
- def view_file(
22
- ctx: "SandboxContext", path: str, view_range: list | None, timeout: float | None
23
- ) -> dict:
24
- """View file content with line numbers.
25
-
26
- Args:
27
- ctx: The sandbox context.
28
- path: The file path to view.
29
- view_range: Optional [start_line, end_line] to view specific lines.
30
- timeout: Optional timeout for command execution.
31
-
32
- Returns:
33
- A dict with file content and metadata, or error information.
34
- """
35
- # First check if file exists and get total lines
36
- check_cmd = f"wc -l < {escape_for_shell(path)} 2>/dev/null || echo 'FILE_NOT_FOUND'"
37
- result = ctx.client.sandboxes.exec_command(
38
- sandbox_id=ctx.sandbox_id,
39
- command=check_cmd,
40
- timeout=timeout,
41
- )
42
-
43
- if "FILE_NOT_FOUND" in result.stdout or result.exit_code != 0:
44
- return {
45
- "error": f"File not found: {path}",
46
- "stderr": result.stderr,
47
- }
48
-
49
- total_lines = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 0
50
-
51
- # Build the view command with line numbers
52
- if view_range and len(view_range) == 2:
53
- start_line, end_line = view_range
54
- cmd = f"sed -n '{start_line},{end_line}p' {escape_for_shell(path)} | nl -ba -v {start_line}"
55
- else:
56
- cmd = f"nl -ba {escape_for_shell(path)}"
57
- start_line = 1
58
-
59
- result = ctx.client.sandboxes.exec_command(
60
- sandbox_id=ctx.sandbox_id,
61
- command=cmd,
62
- timeout=timeout,
63
- )
64
-
65
- if result.exit_code != 0:
66
- return {
67
- "error": f"Failed to view file: {path}",
68
- "stderr": result.stderr,
69
- }
70
-
71
- # Count lines in output
72
- content_lines = (
73
- result.stdout.rstrip("\n").split("\n") if result.stdout.strip() else []
74
- )
75
- num_lines = len(content_lines)
76
-
77
- return {
78
- "file_type": "text",
79
- "content": result.stdout,
80
- "numLines": num_lines,
81
- "startLine": start_line if view_range else 1,
82
- "totalLines": total_lines + 1, # wc -l doesn't count last line without newline
83
- }
84
-
85
-
86
- def create_file(
87
- ctx: "SandboxContext", path: str, file_text: str, timeout: float | None
88
- ) -> dict:
89
- """Create a new file with content.
90
-
91
- Args:
92
- ctx: The sandbox context.
93
- path: The file path to create.
94
- file_text: The content to write to the file.
95
- timeout: Optional timeout for command execution.
96
-
97
- Returns:
98
- A dict with creation status or error information.
99
- """
100
- # Check if file already exists
101
- check_cmd = f"test -f {escape_for_shell(path)} && echo 'EXISTS' || echo 'NEW'"
102
- check_result = ctx.client.sandboxes.exec_command(
103
- sandbox_id=ctx.sandbox_id,
104
- command=check_cmd,
105
- timeout=timeout,
106
- )
107
- is_update = "EXISTS" in check_result.stdout
108
-
109
- # Create directory if needed
110
- dir_path = "/".join(path.split("/")[:-1])
111
- if dir_path:
112
- mkdir_cmd = f"mkdir -p {escape_for_shell(dir_path)}"
113
- ctx.client.sandboxes.exec_command(
114
- sandbox_id=ctx.sandbox_id,
115
- command=mkdir_cmd,
116
- timeout=timeout,
117
- )
118
-
119
- # Write file using base64 encoding to safely transfer content
120
- encoded_content = base64.b64encode(file_text.encode()).decode()
121
- write_cmd = f"echo {escape_for_shell(encoded_content)} | base64 -d > {escape_for_shell(path)}"
122
-
123
- result = ctx.client.sandboxes.exec_command(
124
- sandbox_id=ctx.sandbox_id,
125
- command=write_cmd,
126
- timeout=timeout,
127
- )
128
-
129
- if result.exit_code != 0:
130
- return {
131
- "error": f"Failed to create file: {path}",
132
- "stderr": result.stderr,
133
- }
134
-
135
- return {
136
- "is_file_update": is_update,
137
- "message": f"File {'updated' if is_update else 'created'}: {path}",
138
- }
139
-
140
-
141
- def str_replace(
142
- ctx: "SandboxContext", path: str, old_str: str, new_str: str, timeout: float | None
143
- ) -> dict:
144
- """Replace a string in a file.
145
-
146
- Args:
147
- ctx: The sandbox context.
148
- path: The file path to modify.
149
- old_str: The exact string to find and replace.
150
- new_str: The string to replace old_str with.
151
- timeout: Optional timeout for command execution.
152
-
153
- Returns:
154
- A dict with diff information or error details.
155
- """
156
- # First read the file content
157
- read_cmd = f"cat {escape_for_shell(path)}"
158
- result = ctx.client.sandboxes.exec_command(
159
- sandbox_id=ctx.sandbox_id,
160
- command=read_cmd,
161
- timeout=timeout,
162
- )
163
-
164
- if result.exit_code != 0:
165
- return {
166
- "error": f"File not found: {path}",
167
- "stderr": result.stderr,
168
- }
169
-
170
- original_content = result.stdout
171
-
172
- # Check if old_str exists in the file
173
- if old_str not in original_content:
174
- return {
175
- "error": f"String not found in file: {old_str[:50]}...",
176
- }
177
-
178
- # Count occurrences
179
- occurrences = original_content.count(old_str)
180
- if occurrences > 1:
181
- return {
182
- "error": f"Multiple occurrences ({occurrences}) of the string found. Please provide more context to make the match unique.",
183
- }
184
-
185
- # Perform the replacement
186
- new_content = original_content.replace(old_str, new_str, 1)
187
-
188
- # Find the line numbers affected
189
- old_lines = original_content.split("\n")
190
- new_lines = new_content.split("\n")
191
-
192
- # Find where the change starts
193
- old_start = 1
194
- for i, (old_line, new_line) in enumerate(zip(old_lines, new_lines)):
195
- if old_line != new_line:
196
- old_start = i + 1
197
- break
198
-
199
- # Write the new content
200
- encoded_content = base64.b64encode(new_content.encode()).decode()
201
- write_cmd = f"echo {escape_for_shell(encoded_content)} | base64 -d > {escape_for_shell(path)}"
202
-
203
- result = ctx.client.sandboxes.exec_command(
204
- sandbox_id=ctx.sandbox_id,
205
- command=write_cmd,
206
- timeout=timeout,
207
- )
208
-
209
- if result.exit_code != 0:
210
- return {
211
- "error": f"Failed to write file: {path}",
212
- "stderr": result.stderr,
213
- }
214
-
215
- # Calculate diff info
216
- old_str_lines = old_str.count("\n") + 1
217
- new_str_lines = new_str.count("\n") + 1
218
-
219
- # Build diff lines
220
- diff_lines = []
221
- for line in old_str.split("\n"):
222
- diff_lines.append(f"-{line}")
223
- for line in new_str.split("\n"):
224
- diff_lines.append(f"+{line}")
225
-
226
- return {
227
- "oldStart": old_start,
228
- "oldLines": old_str_lines,
229
- "newStart": old_start,
230
- "newLines": new_str_lines,
231
- "lines": diff_lines,
232
- }
233
-
234
-
235
- # ============================================================================
236
- # Async Operations
237
- # ============================================================================
238
-
239
-
240
- async def async_view_file(
241
- ctx: "AsyncSandboxContext", path: str, view_range: list | None, timeout: float | None
242
- ) -> dict:
243
- """View file content with line numbers (async).
244
-
245
- Args:
246
- ctx: The async sandbox context.
247
- path: The file path to view.
248
- view_range: Optional [start_line, end_line] to view specific lines.
249
- timeout: Optional timeout for command execution.
250
-
251
- Returns:
252
- A dict with file content and metadata, or error information.
253
- """
254
- check_cmd = f"wc -l < {escape_for_shell(path)} 2>/dev/null || echo 'FILE_NOT_FOUND'"
255
- result = await ctx.client.sandboxes.exec_command(
256
- sandbox_id=ctx.sandbox_id,
257
- command=check_cmd,
258
- timeout=timeout,
259
- )
260
-
261
- if "FILE_NOT_FOUND" in result.stdout or result.exit_code != 0:
262
- return {
263
- "error": f"File not found: {path}",
264
- "stderr": result.stderr,
265
- }
266
-
267
- total_lines = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 0
268
-
269
- if view_range and len(view_range) == 2:
270
- start_line, end_line = view_range
271
- cmd = f"sed -n '{start_line},{end_line}p' {escape_for_shell(path)} | nl -ba -v {start_line}"
272
- else:
273
- cmd = f"nl -ba {escape_for_shell(path)}"
274
- start_line = 1
275
-
276
- result = await ctx.client.sandboxes.exec_command(
277
- sandbox_id=ctx.sandbox_id,
278
- command=cmd,
279
- timeout=timeout,
280
- )
281
-
282
- if result.exit_code != 0:
283
- return {
284
- "error": f"Failed to view file: {path}",
285
- "stderr": result.stderr,
286
- }
287
-
288
- content_lines = (
289
- result.stdout.rstrip("\n").split("\n") if result.stdout.strip() else []
290
- )
291
- num_lines = len(content_lines)
292
-
293
- return {
294
- "file_type": "text",
295
- "content": result.stdout,
296
- "numLines": num_lines,
297
- "startLine": start_line if view_range else 1,
298
- "totalLines": total_lines + 1,
299
- }
300
-
301
-
302
- async def async_create_file(
303
- ctx: "AsyncSandboxContext", path: str, file_text: str, timeout: float | None
304
- ) -> dict:
305
- """Create a new file with content (async).
306
-
307
- Args:
308
- ctx: The async sandbox context.
309
- path: The file path to create.
310
- file_text: The content to write to the file.
311
- timeout: Optional timeout for command execution.
312
-
313
- Returns:
314
- A dict with creation status or error information.
315
- """
316
- check_cmd = f"test -f {escape_for_shell(path)} && echo 'EXISTS' || echo 'NEW'"
317
- check_result = await ctx.client.sandboxes.exec_command(
318
- sandbox_id=ctx.sandbox_id,
319
- command=check_cmd,
320
- timeout=timeout,
321
- )
322
- is_update = "EXISTS" in check_result.stdout
323
-
324
- dir_path = "/".join(path.split("/")[:-1])
325
- if dir_path:
326
- mkdir_cmd = f"mkdir -p {escape_for_shell(dir_path)}"
327
- await ctx.client.sandboxes.exec_command(
328
- sandbox_id=ctx.sandbox_id,
329
- command=mkdir_cmd,
330
- timeout=timeout,
331
- )
332
-
333
- encoded_content = base64.b64encode(file_text.encode()).decode()
334
- write_cmd = f"echo {escape_for_shell(encoded_content)} | base64 -d > {escape_for_shell(path)}"
335
-
336
- result = await ctx.client.sandboxes.exec_command(
337
- sandbox_id=ctx.sandbox_id,
338
- command=write_cmd,
339
- timeout=timeout,
340
- )
341
-
342
- if result.exit_code != 0:
343
- return {
344
- "error": f"Failed to create file: {path}",
345
- "stderr": result.stderr,
346
- }
347
-
348
- return {
349
- "is_file_update": is_update,
350
- "message": f"File {'updated' if is_update else 'created'}: {path}",
351
- }
352
-
353
-
354
- async def async_str_replace(
355
- ctx: "AsyncSandboxContext", path: str, old_str: str, new_str: str, timeout: float | None
356
- ) -> dict:
357
- """Replace a string in a file (async).
358
-
359
- Args:
360
- ctx: The async sandbox context.
361
- path: The file path to modify.
362
- old_str: The exact string to find and replace.
363
- new_str: The string to replace old_str with.
364
- timeout: Optional timeout for command execution.
365
-
366
- Returns:
367
- A dict with diff information or error details.
368
- """
369
- read_cmd = f"cat {escape_for_shell(path)}"
370
- result = await ctx.client.sandboxes.exec_command(
371
- sandbox_id=ctx.sandbox_id,
372
- command=read_cmd,
373
- timeout=timeout,
374
- )
375
-
376
- if result.exit_code != 0:
377
- return {
378
- "error": f"File not found: {path}",
379
- "stderr": result.stderr,
380
- }
381
-
382
- original_content = result.stdout
383
-
384
- if old_str not in original_content:
385
- return {
386
- "error": f"String not found in file: {old_str[:50]}...",
387
- }
388
-
389
- occurrences = original_content.count(old_str)
390
- if occurrences > 1:
391
- return {
392
- "error": f"Multiple occurrences ({occurrences}) of the string found. Please provide more context to make the match unique.",
393
- }
394
-
395
- new_content = original_content.replace(old_str, new_str, 1)
396
-
397
- old_lines = original_content.split("\n")
398
- new_lines = new_content.split("\n")
399
-
400
- old_start = 1
401
- for i, (old_line, new_line) in enumerate(zip(old_lines, new_lines)):
402
- if old_line != new_line:
403
- old_start = i + 1
404
- break
405
-
406
- encoded_content = base64.b64encode(new_content.encode()).decode()
407
- write_cmd = f"echo {escape_for_shell(encoded_content)} | base64 -d > {escape_for_shell(path)}"
408
-
409
- result = await ctx.client.sandboxes.exec_command(
410
- sandbox_id=ctx.sandbox_id,
411
- command=write_cmd,
412
- timeout=timeout,
413
- )
414
-
415
- if result.exit_code != 0:
416
- return {
417
- "error": f"Failed to write file: {path}",
418
- "stderr": result.stderr,
419
- }
420
-
421
- old_str_lines = old_str.count("\n") + 1
422
- new_str_lines = new_str.count("\n") + 1
423
-
424
- diff_lines = []
425
- for line in old_str.split("\n"):
426
- diff_lines.append(f"-{line}")
427
- for line in new_str.split("\n"):
428
- diff_lines.append(f"+{line}")
429
-
430
- return {
431
- "oldStart": old_start,
432
- "oldLines": old_str_lines,
433
- "newStart": old_start,
434
- "newLines": new_str_lines,
435
- "lines": diff_lines,
436
- }
File without changes
File without changes