acontext 0.1.5__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.5 → acontext-0.1.6}/PKG-INFO +1 -1
  2. {acontext-0.1.5 → acontext-0.1.6}/pyproject.toml +1 -1
  3. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/agent/sandbox.py +1 -0
  4. acontext-0.1.6/src/acontext/agent/text_editor.py +420 -0
  5. acontext-0.1.5/src/acontext/agent/text_editor.py +0 -449
  6. {acontext-0.1.5 → acontext-0.1.6}/README.md +0 -0
  7. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/__init__.py +0 -0
  8. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/_constants.py +0 -0
  9. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/_utils.py +0 -0
  10. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/agent/__init__.py +0 -0
  11. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/agent/base.py +0 -0
  12. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/agent/disk.py +0 -0
  13. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/agent/prompts.py +0 -0
  14. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/agent/skill.py +0 -0
  15. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/async_client.py +0 -0
  16. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/client.py +0 -0
  17. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/client_types.py +0 -0
  18. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/errors.py +0 -0
  19. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/messages.py +0 -0
  20. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/py.typed +0 -0
  21. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/__init__.py +0 -0
  22. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/async_disks.py +0 -0
  23. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/async_sandboxes.py +0 -0
  24. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/async_sessions.py +0 -0
  25. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/async_skills.py +0 -0
  26. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/async_tools.py +0 -0
  27. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/async_users.py +0 -0
  28. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/disks.py +0 -0
  29. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/sandboxes.py +0 -0
  30. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/sessions.py +0 -0
  31. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/skills.py +0 -0
  32. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/tools.py +0 -0
  33. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/resources/users.py +0 -0
  34. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/types/__init__.py +0 -0
  35. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/types/common.py +0 -0
  36. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/types/disk.py +0 -0
  37. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/types/sandbox.py +0 -0
  38. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/types/session.py +0 -0
  39. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/types/skill.py +0 -0
  40. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/types/tool.py +0 -0
  41. {acontext-0.1.5 → acontext-0.1.6}/src/acontext/types/user.py +0 -0
  42. {acontext-0.1.5 → 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.5
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.5"
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"
@@ -283,6 +283,7 @@ class TextEditorTool(BaseTool):
283
283
  },
284
284
  "view_range": {
285
285
  "type": ["array", "null"],
286
+ "items": {"type": "integer"},
286
287
  "description": "Optional for 'view' command. An array [start_line, end_line] to view specific lines. If not provided, shows the first 200 lines.",
287
288
  },
288
289
  }
@@ -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,449 +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
- MAX_CONTENT_CHARS = 20000
10
-
11
-
12
- def truncate_content(text: str, max_chars: int = MAX_CONTENT_CHARS) -> str:
13
- """Truncate text to max_chars, appending a truncation flag if needed."""
14
- if len(text) > max_chars:
15
- return text[:max_chars] + "...[truncated]"
16
- return text
17
-
18
-
19
- def escape_for_shell(s: str) -> str:
20
- """Escape a string for safe use in shell commands."""
21
- # Use single quotes and escape any single quotes in the string
22
- return "'" + s.replace("'", "'\"'\"'") + "'"
23
-
24
-
25
- # ============================================================================
26
- # Sync Operations
27
- # ============================================================================
28
-
29
-
30
- def view_file(
31
- ctx: "SandboxContext", path: str, view_range: list | None, timeout: float | None
32
- ) -> dict:
33
- """View file content with line numbers.
34
-
35
- Args:
36
- ctx: The sandbox context.
37
- path: The file path to view.
38
- view_range: Optional [start_line, end_line] to view specific lines.
39
- timeout: Optional timeout for command execution.
40
-
41
- Returns:
42
- A dict with file content and metadata, or error information.
43
- """
44
- # First check if file exists and get total lines
45
- check_cmd = f"wc -l < {escape_for_shell(path)} 2>/dev/null || echo 'FILE_NOT_FOUND'"
46
- result = ctx.client.sandboxes.exec_command(
47
- sandbox_id=ctx.sandbox_id,
48
- command=check_cmd,
49
- timeout=timeout,
50
- )
51
-
52
- if "FILE_NOT_FOUND" in result.stdout or result.exit_code != 0:
53
- return {
54
- "error": f"File not found: {path}",
55
- "stderr": result.stderr,
56
- }
57
-
58
- total_lines = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 0
59
-
60
- # Build the view command with line numbers
61
- if view_range and len(view_range) == 2:
62
- start_line, end_line = view_range
63
- cmd = f"sed -n '{start_line},{end_line}p' {escape_for_shell(path)} | nl -ba -v {start_line}"
64
- else:
65
- # Default to first 200 lines if no range specified
66
- max_lines = 200
67
- cmd = f"head -n {max_lines} {escape_for_shell(path)} | nl -ba"
68
- start_line = 1
69
-
70
- result = ctx.client.sandboxes.exec_command(
71
- sandbox_id=ctx.sandbox_id,
72
- command=cmd,
73
- timeout=timeout,
74
- )
75
-
76
- if result.exit_code != 0:
77
- return {
78
- "error": f"Failed to view file: {path}",
79
- "stderr": result.stderr,
80
- }
81
-
82
- # Count lines in output
83
- content_lines = (
84
- result.stdout.rstrip("\n").split("\n") if result.stdout.strip() else []
85
- )
86
- num_lines = len(content_lines)
87
-
88
- return {
89
- "file_type": "text",
90
- "content": truncate_content(result.stdout),
91
- "numLines": num_lines,
92
- "startLine": start_line if view_range else 1,
93
- "totalLines": total_lines + 1, # wc -l doesn't count last line without newline
94
- }
95
-
96
-
97
- def create_file(
98
- ctx: "SandboxContext", path: str, file_text: str, timeout: float | None
99
- ) -> dict:
100
- """Create a new file with content.
101
-
102
- Args:
103
- ctx: The sandbox context.
104
- path: The file path to create.
105
- file_text: The content to write to the file.
106
- timeout: Optional timeout for command execution.
107
-
108
- Returns:
109
- A dict with creation status or error information.
110
- """
111
- # Check if file already exists
112
- check_cmd = f"test -f {escape_for_shell(path)} && echo 'EXISTS' || echo 'NEW'"
113
- check_result = ctx.client.sandboxes.exec_command(
114
- sandbox_id=ctx.sandbox_id,
115
- command=check_cmd,
116
- timeout=timeout,
117
- )
118
- is_update = "EXISTS" in check_result.stdout
119
-
120
- # Create directory if needed
121
- dir_path = "/".join(path.split("/")[:-1])
122
- if dir_path:
123
- mkdir_cmd = f"mkdir -p {escape_for_shell(dir_path)}"
124
- ctx.client.sandboxes.exec_command(
125
- sandbox_id=ctx.sandbox_id,
126
- command=mkdir_cmd,
127
- timeout=timeout,
128
- )
129
-
130
- # Write file using base64 encoding to safely transfer content
131
- encoded_content = base64.b64encode(file_text.encode()).decode()
132
- write_cmd = f"echo {escape_for_shell(encoded_content)} | base64 -d > {escape_for_shell(path)}"
133
-
134
- result = ctx.client.sandboxes.exec_command(
135
- sandbox_id=ctx.sandbox_id,
136
- command=write_cmd,
137
- timeout=timeout,
138
- )
139
-
140
- if result.exit_code != 0:
141
- return {
142
- "error": f"Failed to create file: {path}",
143
- "stderr": result.stderr,
144
- }
145
-
146
- return {
147
- "is_file_update": is_update,
148
- "message": f"File {'updated' if is_update else 'created'}: {path}",
149
- }
150
-
151
-
152
- def str_replace(
153
- ctx: "SandboxContext", path: str, old_str: str, new_str: str, timeout: float | None
154
- ) -> dict:
155
- """Replace a string in a file.
156
-
157
- Args:
158
- ctx: The sandbox context.
159
- path: The file path to modify.
160
- old_str: The exact string to find and replace.
161
- new_str: The string to replace old_str with.
162
- timeout: Optional timeout for command execution.
163
-
164
- Returns:
165
- A dict with diff information or error details.
166
- """
167
- # First read the file content
168
- read_cmd = f"cat {escape_for_shell(path)}"
169
- result = ctx.client.sandboxes.exec_command(
170
- sandbox_id=ctx.sandbox_id,
171
- command=read_cmd,
172
- timeout=timeout,
173
- )
174
-
175
- if result.exit_code != 0:
176
- return {
177
- "error": f"File not found: {path}",
178
- "stderr": result.stderr,
179
- }
180
-
181
- original_content = result.stdout
182
-
183
- # Check if old_str exists in the file
184
- if old_str not in original_content:
185
- return {
186
- "error": f"String not found in file: {old_str[:50]}...",
187
- }
188
-
189
- # Count occurrences
190
- occurrences = original_content.count(old_str)
191
- if occurrences > 1:
192
- return {
193
- "error": f"Multiple occurrences ({occurrences}) of the string found. Please provide more context to make the match unique.",
194
- }
195
-
196
- # Perform the replacement
197
- new_content = original_content.replace(old_str, new_str, 1)
198
-
199
- # Find the line numbers affected
200
- old_lines = original_content.split("\n")
201
- new_lines = new_content.split("\n")
202
-
203
- # Find where the change starts
204
- old_start = 1
205
- for i, (old_line, new_line) in enumerate(zip(old_lines, new_lines)):
206
- if old_line != new_line:
207
- old_start = i + 1
208
- break
209
-
210
- # Write the new content
211
- encoded_content = base64.b64encode(new_content.encode()).decode()
212
- write_cmd = f"echo {escape_for_shell(encoded_content)} | base64 -d > {escape_for_shell(path)}"
213
-
214
- result = ctx.client.sandboxes.exec_command(
215
- sandbox_id=ctx.sandbox_id,
216
- command=write_cmd,
217
- timeout=timeout,
218
- )
219
-
220
- if result.exit_code != 0:
221
- return {
222
- "error": f"Failed to write file: {path}",
223
- "stderr": result.stderr,
224
- }
225
-
226
- # Calculate diff info
227
- old_str_lines = old_str.count("\n") + 1
228
- new_str_lines = new_str.count("\n") + 1
229
-
230
- # Build diff lines
231
- diff_lines = []
232
- for line in old_str.split("\n"):
233
- diff_lines.append(f"-{line}")
234
- for line in new_str.split("\n"):
235
- diff_lines.append(f"+{line}")
236
-
237
- return {
238
- "oldStart": old_start,
239
- "oldLines": old_str_lines,
240
- "newStart": old_start,
241
- "newLines": new_str_lines,
242
- "lines": diff_lines,
243
- }
244
-
245
-
246
- # ============================================================================
247
- # Async Operations
248
- # ============================================================================
249
-
250
-
251
- async def async_view_file(
252
- ctx: "AsyncSandboxContext", path: str, view_range: list | None, timeout: float | None
253
- ) -> dict:
254
- """View file content with line numbers (async).
255
-
256
- Args:
257
- ctx: The async sandbox context.
258
- path: The file path to view.
259
- view_range: Optional [start_line, end_line] to view specific lines.
260
- timeout: Optional timeout for command execution.
261
-
262
- Returns:
263
- A dict with file content and metadata, or error information.
264
- """
265
- check_cmd = f"wc -l < {escape_for_shell(path)} 2>/dev/null || echo 'FILE_NOT_FOUND'"
266
- result = await ctx.client.sandboxes.exec_command(
267
- sandbox_id=ctx.sandbox_id,
268
- command=check_cmd,
269
- timeout=timeout,
270
- )
271
-
272
- if "FILE_NOT_FOUND" in result.stdout or result.exit_code != 0:
273
- return {
274
- "error": f"File not found: {path}",
275
- "stderr": result.stderr,
276
- }
277
-
278
- total_lines = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 0
279
-
280
- if view_range and len(view_range) == 2:
281
- start_line, end_line = view_range
282
- cmd = f"sed -n '{start_line},{end_line}p' {escape_for_shell(path)} | nl -ba -v {start_line}"
283
- else:
284
- # Default to first 200 lines if no range specified
285
- max_lines = 200
286
- cmd = f"head -n {max_lines} {escape_for_shell(path)} | nl -ba"
287
- start_line = 1
288
-
289
- result = await ctx.client.sandboxes.exec_command(
290
- sandbox_id=ctx.sandbox_id,
291
- command=cmd,
292
- timeout=timeout,
293
- )
294
-
295
- if result.exit_code != 0:
296
- return {
297
- "error": f"Failed to view file: {path}",
298
- "stderr": result.stderr,
299
- }
300
-
301
- content_lines = (
302
- result.stdout.rstrip("\n").split("\n") if result.stdout.strip() else []
303
- )
304
- num_lines = len(content_lines)
305
-
306
- return {
307
- "file_type": "text",
308
- "content": truncate_content(result.stdout),
309
- "numLines": num_lines,
310
- "startLine": start_line if view_range else 1,
311
- "totalLines": total_lines + 1,
312
- }
313
-
314
-
315
- async def async_create_file(
316
- ctx: "AsyncSandboxContext", path: str, file_text: str, timeout: float | None
317
- ) -> dict:
318
- """Create a new file with content (async).
319
-
320
- Args:
321
- ctx: The async sandbox context.
322
- path: The file path to create.
323
- file_text: The content to write to the file.
324
- timeout: Optional timeout for command execution.
325
-
326
- Returns:
327
- A dict with creation status or error information.
328
- """
329
- check_cmd = f"test -f {escape_for_shell(path)} && echo 'EXISTS' || echo 'NEW'"
330
- check_result = await ctx.client.sandboxes.exec_command(
331
- sandbox_id=ctx.sandbox_id,
332
- command=check_cmd,
333
- timeout=timeout,
334
- )
335
- is_update = "EXISTS" in check_result.stdout
336
-
337
- dir_path = "/".join(path.split("/")[:-1])
338
- if dir_path:
339
- mkdir_cmd = f"mkdir -p {escape_for_shell(dir_path)}"
340
- await ctx.client.sandboxes.exec_command(
341
- sandbox_id=ctx.sandbox_id,
342
- command=mkdir_cmd,
343
- timeout=timeout,
344
- )
345
-
346
- encoded_content = base64.b64encode(file_text.encode()).decode()
347
- write_cmd = f"echo {escape_for_shell(encoded_content)} | base64 -d > {escape_for_shell(path)}"
348
-
349
- result = await ctx.client.sandboxes.exec_command(
350
- sandbox_id=ctx.sandbox_id,
351
- command=write_cmd,
352
- timeout=timeout,
353
- )
354
-
355
- if result.exit_code != 0:
356
- return {
357
- "error": f"Failed to create file: {path}",
358
- "stderr": result.stderr,
359
- }
360
-
361
- return {
362
- "is_file_update": is_update,
363
- "message": f"File {'updated' if is_update else 'created'}: {path}",
364
- }
365
-
366
-
367
- async def async_str_replace(
368
- ctx: "AsyncSandboxContext", path: str, old_str: str, new_str: str, timeout: float | None
369
- ) -> dict:
370
- """Replace a string in a file (async).
371
-
372
- Args:
373
- ctx: The async sandbox context.
374
- path: The file path to modify.
375
- old_str: The exact string to find and replace.
376
- new_str: The string to replace old_str with.
377
- timeout: Optional timeout for command execution.
378
-
379
- Returns:
380
- A dict with diff information or error details.
381
- """
382
- read_cmd = f"cat {escape_for_shell(path)}"
383
- result = await ctx.client.sandboxes.exec_command(
384
- sandbox_id=ctx.sandbox_id,
385
- command=read_cmd,
386
- timeout=timeout,
387
- )
388
-
389
- if result.exit_code != 0:
390
- return {
391
- "error": f"File not found: {path}",
392
- "stderr": result.stderr,
393
- }
394
-
395
- original_content = result.stdout
396
-
397
- if old_str not in original_content:
398
- return {
399
- "error": f"String not found in file: {old_str[:50]}...",
400
- }
401
-
402
- occurrences = original_content.count(old_str)
403
- if occurrences > 1:
404
- return {
405
- "error": f"Multiple occurrences ({occurrences}) of the string found. Please provide more context to make the match unique.",
406
- }
407
-
408
- new_content = original_content.replace(old_str, new_str, 1)
409
-
410
- old_lines = original_content.split("\n")
411
- new_lines = new_content.split("\n")
412
-
413
- old_start = 1
414
- for i, (old_line, new_line) in enumerate(zip(old_lines, new_lines)):
415
- if old_line != new_line:
416
- old_start = i + 1
417
- break
418
-
419
- encoded_content = base64.b64encode(new_content.encode()).decode()
420
- write_cmd = f"echo {escape_for_shell(encoded_content)} | base64 -d > {escape_for_shell(path)}"
421
-
422
- result = await ctx.client.sandboxes.exec_command(
423
- sandbox_id=ctx.sandbox_id,
424
- command=write_cmd,
425
- timeout=timeout,
426
- )
427
-
428
- if result.exit_code != 0:
429
- return {
430
- "error": f"Failed to write file: {path}",
431
- "stderr": result.stderr,
432
- }
433
-
434
- old_str_lines = old_str.count("\n") + 1
435
- new_str_lines = new_str.count("\n") + 1
436
-
437
- diff_lines = []
438
- for line in old_str.split("\n"):
439
- diff_lines.append(f"-{line}")
440
- for line in new_str.split("\n"):
441
- diff_lines.append(f"+{line}")
442
-
443
- return {
444
- "oldStart": old_start,
445
- "oldLines": old_str_lines,
446
- "newStart": old_start,
447
- "newLines": new_str_lines,
448
- "lines": diff_lines,
449
- }
File without changes
File without changes