acontext 0.1.2__py3-none-any.whl → 0.1.4__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.
@@ -0,0 +1,532 @@
1
+ """Agent tools for sandbox operations using the Acontext Sandbox API."""
2
+
3
+ import json
4
+ import posixpath
5
+ from dataclasses import dataclass, field
6
+ from typing import TypedDict
7
+
8
+ from .base import BaseContext, BaseTool, BaseToolPool
9
+ from .prompts import SANDBOX_TEXT_EDITOR_REMINDER, SANDBOX_BASH_REMINDER, SKILL_REMINDER
10
+ from ..client import AcontextClient
11
+ from ..async_client import AcontextAsyncClient
12
+
13
+
14
+ class MountedSkill(TypedDict):
15
+ name: str
16
+ description: str
17
+ base_path: str
18
+
19
+
20
+ @dataclass
21
+ class SandboxContext(BaseContext):
22
+ """Context for sandbox tools containing the client, sandbox ID, and disk ID."""
23
+
24
+ client: AcontextClient
25
+ sandbox_id: str
26
+ disk_id: str
27
+ mounted_skill_paths: dict[str, MountedSkill] = field(default_factory=dict)
28
+
29
+ def format_mounted_skills(self) -> str:
30
+ """Format mounted skills as XML for prompt injection.
31
+
32
+ Returns:
33
+ XML-formatted string of all mounted skills, sorted by name.
34
+ """
35
+ if not self.mounted_skill_paths:
36
+ return ""
37
+
38
+ # Sort by skill name
39
+ sorted_skills = sorted(
40
+ self.mounted_skill_paths.values(),
41
+ key=lambda s: s["name"],
42
+ )
43
+
44
+ skill_entries = []
45
+ for skill in sorted_skills:
46
+ location = posixpath.join(skill["base_path"], "SKILL.md")
47
+
48
+ skill_xml = f"""<skill>
49
+ <name>{skill["name"]}</name>
50
+ <description>{skill["description"]}</description>
51
+ <location>{location}</location>
52
+ </skill>"""
53
+ skill_entries.append(skill_xml)
54
+
55
+ return "\n".join(skill_entries)
56
+
57
+ def mount_skills(self, skill_ids: list[str]) -> None:
58
+ """Download skills to the sandbox.
59
+
60
+ Downloads each skill to /skills/{skill_name}/ in the sandbox and
61
+ updates mounted_skill_ids and mounted_skill_paths.
62
+
63
+ Args:
64
+ skill_ids: List of skill UUIDs to download to the sandbox.
65
+ """
66
+ for skill_id in skill_ids:
67
+ if skill_id in self.mounted_skill_paths:
68
+ # Skip already mounted skills
69
+ continue
70
+ result = self.client.skills.download_to_sandbox(
71
+ skill_id=skill_id,
72
+ sandbox_id=self.sandbox_id,
73
+ )
74
+ if result.success:
75
+ self.mounted_skill_paths[skill_id] = {
76
+ "base_path": result.dir_path,
77
+ "name": result.name,
78
+ "description": result.description,
79
+ }
80
+
81
+ def get_context_prompt(self) -> str:
82
+ base_body = f"""<text_editor_sandbox>
83
+ {SANDBOX_TEXT_EDITOR_REMINDER}
84
+ </text_editor_sandbox>
85
+ <bash_execution_sandbox>
86
+ {SANDBOX_BASH_REMINDER}
87
+ </bash_execution_sandbox>"""
88
+ if len(self.mounted_skill_paths) > 0:
89
+ formatted_skills = self.format_mounted_skills()
90
+ base_body += f"""
91
+ <skills>
92
+ {SKILL_REMINDER}
93
+ <available_skills>
94
+ {formatted_skills}
95
+ </available_skills>
96
+ </skills>"""
97
+ return f"""<sandbox>
98
+ By default, you are in `/workspace`.
99
+ {base_body}
100
+ </sandbox>
101
+ """
102
+
103
+
104
+ @dataclass
105
+ class AsyncSandboxContext(SandboxContext):
106
+ """Async context for sandbox tools containing the client, sandbox ID, and disk ID."""
107
+
108
+ client: AcontextAsyncClient
109
+
110
+ async def mount_skills(self, skill_ids: list[str]) -> None: # type: ignore[override]
111
+ """Download skills to the sandbox (async).
112
+
113
+ Downloads each skill to /skills/{skill_name}/ in the sandbox and
114
+ updates mounted_skill_ids and mounted_skill_paths.
115
+
116
+ Args:
117
+ skill_ids: List of skill UUIDs to download to the sandbox.
118
+ """
119
+ for skill_id in skill_ids:
120
+ if skill_id in self.mounted_skill_paths:
121
+ # Skip already mounted skills
122
+ continue
123
+ result = await self.client.skills.download_to_sandbox(
124
+ skill_id=skill_id,
125
+ sandbox_id=self.sandbox_id,
126
+ )
127
+ if result.success:
128
+ self.mounted_skill_paths[skill_id] = {
129
+ "base_path": result.dir_path,
130
+ "name": result.name,
131
+ "description": result.description,
132
+ }
133
+
134
+
135
+ class BashTool(BaseTool):
136
+ """Tool for executing bash commands in a sandbox environment."""
137
+
138
+ def __init__(self, timeout: float | None = None):
139
+ """Initialize the BashTool.
140
+
141
+ Args:
142
+ timeout: Optional default timeout in seconds for command execution.
143
+ If not provided, uses the client's default timeout.
144
+ """
145
+ self._timeout = timeout
146
+
147
+ @property
148
+ def name(self) -> str:
149
+ return "bash_execution_sandbox"
150
+
151
+ @property
152
+ def description(self) -> str:
153
+ return "The bash_execution_sandbox tool enables execution of bash scripts in a secure sandboxed container environment."
154
+
155
+ @property
156
+ def arguments(self) -> dict:
157
+ return {
158
+ "command": {
159
+ "type": "string",
160
+ "description": (
161
+ "The bash command to execute. "
162
+ "Examples: 'ls -la', 'python3 script.py', 'sed -i 's/old_string/new_string/g' file.py'"
163
+ ),
164
+ },
165
+ "timeout": {
166
+ "type": ["number", "null"],
167
+ "description": (
168
+ "Optional timeout in seconds for this command. "
169
+ "Use for long-running commands that may exceed the default timeout."
170
+ ),
171
+ },
172
+ }
173
+
174
+ @property
175
+ def required_arguments(self) -> list[str]:
176
+ return ["command"]
177
+
178
+ def execute(self, ctx: SandboxContext, llm_arguments: dict) -> str:
179
+ """Execute a bash command in the sandbox."""
180
+ command = llm_arguments.get("command")
181
+ timeout = llm_arguments.get("timeout", self._timeout)
182
+
183
+ if not command:
184
+ raise ValueError("command is required")
185
+
186
+ result = ctx.client.sandboxes.exec_command(
187
+ sandbox_id=ctx.sandbox_id,
188
+ command=command,
189
+ timeout=timeout,
190
+ )
191
+
192
+ return json.dumps(
193
+ {
194
+ "stdout": result.stdout,
195
+ "stderr": result.stderr,
196
+ "exit_code": result.exit_code,
197
+ }
198
+ )
199
+
200
+ async def async_execute(self, ctx: AsyncSandboxContext, llm_arguments: dict) -> str:
201
+ """Execute a bash command in the sandbox (async)."""
202
+ command = llm_arguments.get("command")
203
+ timeout = llm_arguments.get("timeout", self._timeout)
204
+
205
+ if not command:
206
+ raise ValueError("command is required")
207
+
208
+ result = await ctx.client.sandboxes.exec_command(
209
+ sandbox_id=ctx.sandbox_id,
210
+ command=command,
211
+ timeout=timeout,
212
+ )
213
+
214
+ return json.dumps(
215
+ {
216
+ "stdout": result.stdout,
217
+ "stderr": result.stderr,
218
+ "exit_code": result.exit_code,
219
+ }
220
+ )
221
+
222
+
223
+ class TextEditorTool(BaseTool):
224
+ """Tool for file operations (view, create, str_replace) in the sandbox."""
225
+
226
+ def __init__(self, timeout: float | None = None):
227
+ """Initialize the TextEditorTool.
228
+
229
+ Args:
230
+ timeout: Optional default timeout in seconds for command execution.
231
+ If not provided, uses the client's default timeout.
232
+ """
233
+ self._timeout = timeout
234
+
235
+ @property
236
+ def name(self) -> str:
237
+ return "text_editor_sandbox"
238
+
239
+ @property
240
+ def description(self) -> str:
241
+ return (
242
+ """A tool for viewing, creating, and editing text files in the sandbox."""
243
+ )
244
+
245
+ @property
246
+ def arguments(self) -> dict:
247
+ return {
248
+ "command": {
249
+ "type": "string",
250
+ "enum": ["view", "create", "str_replace"],
251
+ "description": "The operation to perform: 'view', 'create', or 'str_replace'",
252
+ },
253
+ "path": {
254
+ "type": "string",
255
+ "description": "The file path in the sandbox (e.g., '/workspace/script.py')",
256
+ },
257
+ "file_text": {
258
+ "type": ["string", "null"],
259
+ "description": "For 'create' command: the content to write to the file",
260
+ },
261
+ "old_str": {
262
+ "type": ["string", "null"],
263
+ "description": "For 'str_replace' command: the exact string to find and replace",
264
+ },
265
+ "new_str": {
266
+ "type": ["string", "null"],
267
+ "description": "For 'str_replace' command: the string to replace old_str with",
268
+ },
269
+ "view_range": {
270
+ "type": ["array", "null"],
271
+ "description": "For 'view' command: optional [start_line, end_line] to view specific lines",
272
+ },
273
+ }
274
+
275
+ @property
276
+ def required_arguments(self) -> list[str]:
277
+ return ["command", "path"]
278
+
279
+ def execute(self, ctx: SandboxContext, llm_arguments: dict) -> str:
280
+ """Execute a text editor command."""
281
+ from .text_editor import view_file, create_file, str_replace
282
+
283
+ command = llm_arguments.get("command")
284
+ path = llm_arguments.get("path")
285
+
286
+ if not command:
287
+ raise ValueError("command is required")
288
+ if not path:
289
+ raise ValueError("path is required")
290
+
291
+ if command == "view":
292
+ view_range = llm_arguments.get("view_range")
293
+ result = view_file(ctx, path, view_range, self._timeout)
294
+ elif command == "create":
295
+ file_text = llm_arguments.get("file_text")
296
+ if file_text is None:
297
+ raise ValueError("file_text is required for create command")
298
+ result = create_file(ctx, path, file_text, self._timeout)
299
+ elif command == "str_replace":
300
+ old_str = llm_arguments.get("old_str")
301
+ new_str = llm_arguments.get("new_str")
302
+ if old_str is None:
303
+ raise ValueError("old_str is required for str_replace command")
304
+ if new_str is None:
305
+ raise ValueError("new_str is required for str_replace command")
306
+ result = str_replace(ctx, path, old_str, new_str, self._timeout)
307
+ else:
308
+ raise ValueError(
309
+ f"Unknown command: {command}. Must be 'view', 'create', or 'str_replace'"
310
+ )
311
+
312
+ return json.dumps(result)
313
+
314
+ async def async_execute(self, ctx: AsyncSandboxContext, llm_arguments: dict) -> str:
315
+ """Execute a text editor command (async)."""
316
+ from .text_editor import async_view_file, async_create_file, async_str_replace
317
+
318
+ command = llm_arguments.get("command")
319
+ path = llm_arguments.get("path")
320
+
321
+ if not command:
322
+ raise ValueError("command is required")
323
+ if not path:
324
+ raise ValueError("path is required")
325
+
326
+ if command == "view":
327
+ view_range = llm_arguments.get("view_range")
328
+ result = await async_view_file(ctx, path, view_range, self._timeout)
329
+ elif command == "create":
330
+ file_text = llm_arguments.get("file_text")
331
+ if file_text is None:
332
+ raise ValueError("file_text is required for create command")
333
+ result = await async_create_file(ctx, path, file_text, self._timeout)
334
+ elif command == "str_replace":
335
+ old_str = llm_arguments.get("old_str")
336
+ new_str = llm_arguments.get("new_str")
337
+ if old_str is None:
338
+ raise ValueError("old_str is required for str_replace command")
339
+ if new_str is None:
340
+ raise ValueError("new_str is required for str_replace command")
341
+ result = await async_str_replace(ctx, path, old_str, new_str, self._timeout)
342
+ else:
343
+ raise ValueError(
344
+ f"Unknown command: {command}. Must be 'view', 'create', or 'str_replace'"
345
+ )
346
+
347
+ return json.dumps(result)
348
+
349
+
350
+ class ExportSandboxFileTool(BaseTool):
351
+ """Tool for exporting files from sandbox to disk storage."""
352
+
353
+ @property
354
+ def name(self) -> str:
355
+ return "export_file_sandbox"
356
+
357
+ @property
358
+ def description(self) -> str:
359
+ return """Export a file from the sandbox to persistent, shared disk storage, and return you a public download URL.
360
+ If the sandbox file is changed, the disk file won't be updated unless you export the file again."""
361
+
362
+ @property
363
+ def arguments(self) -> dict:
364
+ return {
365
+ "sandbox_path": {
366
+ "type": "string",
367
+ "description": (
368
+ "The directory path in the sandbox where the file is located. "
369
+ "Must end with '/'. Examples: '/workspace/', '/home/user/output/'"
370
+ ),
371
+ },
372
+ "sandbox_filename": {
373
+ "type": "string",
374
+ "description": "The name of the file to export from the sandbox. ",
375
+ },
376
+ }
377
+
378
+ @property
379
+ def required_arguments(self) -> list[str]:
380
+ return ["sandbox_path", "sandbox_filename"]
381
+
382
+ def _normalize_path(self, path: str | None) -> str:
383
+ """Normalize a file path to ensure it starts and ends with '/'."""
384
+ if not path:
385
+ return "/"
386
+ normalized = path if path.startswith("/") else f"/{path}"
387
+ if not normalized.endswith("/"):
388
+ normalized += "/"
389
+ return normalized
390
+
391
+ def execute(self, ctx: SandboxContext, llm_arguments: dict) -> str:
392
+ """Export a file from sandbox to disk."""
393
+ sandbox_path = llm_arguments.get("sandbox_path")
394
+ sandbox_filename = llm_arguments.get("sandbox_filename")
395
+ disk_path = "/artifacts/"
396
+
397
+ if not sandbox_path:
398
+ raise ValueError("sandbox_path is required")
399
+ if not sandbox_filename:
400
+ raise ValueError("sandbox_filename is required")
401
+
402
+ normalized_sandbox_path = self._normalize_path(sandbox_path)
403
+ normalized_disk_path = self._normalize_path(disk_path)
404
+
405
+ artifact = ctx.client.disks.artifacts.upload_from_sandbox(
406
+ disk_id=ctx.disk_id,
407
+ sandbox_id=ctx.sandbox_id,
408
+ sandbox_path=normalized_sandbox_path,
409
+ sandbox_filename=sandbox_filename,
410
+ file_path=normalized_disk_path,
411
+ )
412
+
413
+ # Get the public URL for the uploaded artifact
414
+ artifact_info = ctx.client.disks.artifacts.get(
415
+ disk_id=ctx.disk_id,
416
+ file_path=artifact.path,
417
+ filename=artifact.filename,
418
+ with_public_url=True,
419
+ with_content=False,
420
+ )
421
+
422
+ return json.dumps(
423
+ {
424
+ "message": "successfully exported file to disk",
425
+ "public_url": artifact_info.public_url,
426
+ }
427
+ )
428
+
429
+ async def async_execute(self, ctx: AsyncSandboxContext, llm_arguments: dict) -> str:
430
+ """Export a file from sandbox to disk (async)."""
431
+ sandbox_path = llm_arguments.get("sandbox_path")
432
+ sandbox_filename = llm_arguments.get("sandbox_filename")
433
+ disk_path = "/artifacts/"
434
+
435
+ if not sandbox_path:
436
+ raise ValueError("sandbox_path is required")
437
+ if not sandbox_filename:
438
+ raise ValueError("sandbox_filename is required")
439
+
440
+ normalized_sandbox_path = self._normalize_path(sandbox_path)
441
+ normalized_disk_path = self._normalize_path(disk_path)
442
+
443
+ artifact = await ctx.client.disks.artifacts.upload_from_sandbox(
444
+ disk_id=ctx.disk_id,
445
+ sandbox_id=ctx.sandbox_id,
446
+ sandbox_path=normalized_sandbox_path,
447
+ sandbox_filename=sandbox_filename,
448
+ file_path=normalized_disk_path,
449
+ )
450
+
451
+ # Get the public URL for the uploaded artifact
452
+ artifact_info = await ctx.client.disks.artifacts.get(
453
+ disk_id=ctx.disk_id,
454
+ file_path=artifact.path,
455
+ filename=artifact.filename,
456
+ with_public_url=True,
457
+ with_content=False,
458
+ )
459
+
460
+ return json.dumps(
461
+ {
462
+ "message": "successfully exported file to disk",
463
+ "public_url": artifact_info.public_url,
464
+ }
465
+ )
466
+
467
+
468
+ class SandboxToolPool(BaseToolPool):
469
+ """Tool pool for sandbox operations."""
470
+
471
+ def format_context(
472
+ self,
473
+ client: AcontextClient,
474
+ sandbox_id: str,
475
+ disk_id: str,
476
+ mount_skills: list[str] | None = None,
477
+ ) -> SandboxContext:
478
+ """Create a sync sandbox context.
479
+
480
+ Args:
481
+ client: The Acontext client instance.
482
+ sandbox_id: The UUID of the sandbox.
483
+ disk_id: The UUID of the disk for file exports.
484
+ mount_skills: Optional list of skill IDs to download to the sandbox.
485
+ Skills are downloaded to /skills/{skill_name}/ in the sandbox.
486
+
487
+ Returns:
488
+ SandboxContext for use with sandbox tools.
489
+ """
490
+ ctx = SandboxContext(
491
+ client=client,
492
+ sandbox_id=sandbox_id,
493
+ disk_id=disk_id,
494
+ )
495
+ if mount_skills:
496
+ ctx.mount_skills(mount_skills)
497
+ return ctx
498
+
499
+ async def async_format_context(
500
+ self,
501
+ client: AcontextAsyncClient,
502
+ sandbox_id: str,
503
+ disk_id: str,
504
+ mount_skills: list[str] | None = None,
505
+ ) -> AsyncSandboxContext:
506
+ """Create an async sandbox context.
507
+
508
+ Args:
509
+ client: The Acontext async client instance.
510
+ sandbox_id: The UUID of the sandbox.
511
+ disk_id: The UUID of the disk for file exports.
512
+ mount_skills: Optional list of skill IDs to download to the sandbox.
513
+ Skills are downloaded to /skills/{skill_name}/ in the sandbox.
514
+
515
+ Returns:
516
+ AsyncSandboxContext for use with sandbox tools.
517
+ """
518
+ ctx = AsyncSandboxContext(
519
+ client=client,
520
+ sandbox_id=sandbox_id,
521
+ disk_id=disk_id,
522
+ )
523
+ if mount_skills:
524
+ await ctx.mount_skills(mount_skills)
525
+ return ctx
526
+
527
+
528
+ # Pre-configured tool pool with sandbox tools
529
+ SANDBOX_TOOLS = SandboxToolPool()
530
+ SANDBOX_TOOLS.add_tool(BashTool())
531
+ SANDBOX_TOOLS.add_tool(TextEditorTool())
532
+ SANDBOX_TOOLS.add_tool(ExportSandboxFileTool())
acontext/agent/skill.py CHANGED
@@ -17,6 +17,20 @@ class SkillContext(BaseContext):
17
17
  client: AcontextClient
18
18
  skills: dict[str, Skill] = field(default_factory=dict)
19
19
 
20
+ def get_context_prompt(self) -> str:
21
+ """Return available skills formatted as XML."""
22
+ if not self.skills:
23
+ return ""
24
+
25
+ lines = ["<available_skills>"]
26
+ for skill_name, skill in self.skills.items():
27
+ lines.append("<skill>")
28
+ lines.append(f"<name>{skill_name}</name>")
29
+ lines.append(f"<description>{skill.description}</description>")
30
+ lines.append("</skill>")
31
+ lines.append("</available_skills>")
32
+ return "\n".join(lines)
33
+
20
34
  @classmethod
21
35
  def create(cls, client: AcontextClient, skill_ids: list[str]) -> "SkillContext":
22
36
  """Create a SkillContext by preloading skills from a list of skill IDs.
@@ -73,6 +87,26 @@ class AsyncSkillContext(BaseContext):
73
87
  client: AcontextAsyncClient
74
88
  skills: dict[str, Skill] = field(default_factory=dict)
75
89
 
90
+ def get_context_prompt(self) -> str:
91
+ """Return available skills formatted as XML."""
92
+ if not self.skills:
93
+ return ""
94
+
95
+ lines = ["<available_skills>"]
96
+ for skill_name, skill in self.skills.items():
97
+ lines.append("<skill>")
98
+ lines.append(f"<name>{skill_name}</name>")
99
+ lines.append(f"<description>{skill.description}</description>")
100
+ lines.append("</skill>")
101
+ lines.append("</available_skills>")
102
+ skill_section = "\n".join(lines)
103
+ return f"""<skill_view>
104
+ Use get_skill and get_skill_file to view the available skills and their contexts.
105
+ Below is the list of available skills:
106
+ {skill_section}
107
+ </skill_view>
108
+ """
109
+
76
110
  @classmethod
77
111
  async def create(
78
112
  cls, client: AcontextAsyncClient, skill_ids: list[str]
@@ -237,7 +271,7 @@ class GetSkillFileTool(BaseTool):
237
271
  "description": "Relative path to the file within the skill (e.g., 'scripts/extract_text.json').",
238
272
  },
239
273
  "expire": {
240
- "type": "integer",
274
+ "type": ["integer", "null"],
241
275
  "description": "URL expiration time in seconds (only used for non-parseable files). Defaults to 900 (15 minutes).",
242
276
  },
243
277
  }
@@ -325,48 +359,6 @@ class GetSkillFileTool(BaseTool):
325
359
  return "\n".join(output_parts)
326
360
 
327
361
 
328
- class ListSkillsTool(BaseTool):
329
- """Tool for listing available skills in the context."""
330
-
331
- @property
332
- def name(self) -> str:
333
- return "list_skills"
334
-
335
- @property
336
- def description(self) -> str:
337
- return "List all available skills in the current context with their names and descriptions."
338
-
339
- @property
340
- def arguments(self) -> dict:
341
- return {}
342
-
343
- @property
344
- def required_arguments(self) -> list[str]:
345
- return []
346
-
347
- def execute(self, ctx: SkillContext, llm_arguments: dict) -> str:
348
- """List all available skills."""
349
- if not ctx.skills:
350
- return "No skills available in the current context."
351
-
352
- skill_list = []
353
- for skill_name, skill in ctx.skills.items():
354
- skill_list.append(f"- {skill_name}: {skill.description}")
355
-
356
- return f"Available skills ({len(ctx.skills)}):\n" + "\n".join(skill_list)
357
-
358
- async def async_execute(self, ctx: AsyncSkillContext, llm_arguments: dict) -> str:
359
- """List all available skills (async)."""
360
- if not ctx.skills:
361
- return "No skills available in the current context."
362
-
363
- skill_list = []
364
- for skill_name, skill in ctx.skills.items():
365
- skill_list.append(f"- {skill_name}: {skill.description}")
366
-
367
- return f"Available skills ({len(ctx.skills)}):\n" + "\n".join(skill_list)
368
-
369
-
370
362
  class SkillToolPool(BaseToolPool):
371
363
  """Tool pool for skill operations on Acontext skills."""
372
364
 
@@ -400,6 +392,5 @@ class SkillToolPool(BaseToolPool):
400
392
 
401
393
 
402
394
  SKILL_TOOLS = SkillToolPool()
403
- SKILL_TOOLS.add_tool(ListSkillsTool())
404
395
  SKILL_TOOLS.add_tool(GetSkillTool())
405
396
  SKILL_TOOLS.add_tool(GetSkillFileTool())