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.
- acontext/__init__.py +0 -8
- acontext/agent/__init__.py +2 -0
- acontext/agent/base.py +2 -1
- acontext/agent/disk.py +25 -18
- acontext/agent/prompts.py +96 -0
- acontext/agent/sandbox.py +532 -0
- acontext/agent/skill.py +35 -44
- acontext/agent/text_editor.py +436 -0
- acontext/async_client.py +6 -5
- acontext/client.py +6 -5
- acontext/client_types.py +2 -0
- acontext/resources/__init__.py +4 -8
- acontext/resources/async_disks.py +92 -0
- acontext/resources/async_sandboxes.py +85 -0
- acontext/resources/async_sessions.py +0 -41
- acontext/resources/async_skills.py +40 -0
- acontext/resources/async_users.py +2 -2
- acontext/resources/disks.py +131 -33
- acontext/resources/sandboxes.py +85 -0
- acontext/resources/sessions.py +0 -41
- acontext/resources/skills.py +40 -0
- acontext/resources/users.py +2 -2
- acontext/types/__init__.py +15 -22
- acontext/types/disk.py +6 -3
- acontext/types/sandbox.py +47 -0
- acontext/types/session.py +0 -16
- acontext/types/skill.py +11 -0
- acontext/types/tool.py +0 -6
- acontext/types/user.py +0 -1
- {acontext-0.1.2.dist-info → acontext-0.1.4.dist-info}/METADATA +1 -1
- acontext-0.1.4.dist-info/RECORD +41 -0
- acontext/resources/async_blocks.py +0 -164
- acontext/resources/async_spaces.py +0 -200
- acontext/resources/blocks.py +0 -163
- acontext/resources/spaces.py +0 -198
- acontext/types/block.py +0 -26
- acontext/types/space.py +0 -70
- acontext-0.1.2.dist-info/RECORD +0 -41
- {acontext-0.1.2.dist-info → acontext-0.1.4.dist-info}/WHEEL +0 -0
|
@@ -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())
|