meshagent-tools 0.24.0__py3-none-any.whl → 0.24.3__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,313 @@
1
+ from meshagent.api import RoomClient
2
+ from meshagent.tools import Toolkit, ToolContext, Tool
3
+
4
+ from meshagent.agents.adapter import (
5
+ ToolkitBuilder,
6
+ )
7
+
8
+ from meshagent.api.specs.service import ContainerMountSpec, RoomStorageMountSpec
9
+ from typing import Literal
10
+ import os
11
+ from typing import Optional
12
+
13
+ import logging
14
+ import asyncio
15
+ from pydantic import BaseModel
16
+
17
+ logger = logging.getLogger("script_tool")
18
+
19
+
20
+ DEFAULT_CONTAINER_MOUNT_SPEC = ContainerMountSpec(
21
+ room=[RoomStorageMountSpec(path="/data")]
22
+ )
23
+
24
+
25
+ class ScriptToolConfig(BaseModel):
26
+ name: Literal["script"] = "script"
27
+ service_id: Optional[str] = None
28
+ commands: list[str] = None
29
+ description: Optional[str] = None
30
+ title: Optional[str] = None
31
+ tool_name: str
32
+
33
+
34
+ class ScriptToolkitBuilder(ToolkitBuilder):
35
+ def __init__(
36
+ self,
37
+ *,
38
+ name: str = "script",
39
+ commands: Optional[list[str]] = None,
40
+ working_directory: Optional[str] = None,
41
+ image: Optional[str] = "python:3.13",
42
+ mounts: Optional[ContainerMountSpec] = DEFAULT_CONTAINER_MOUNT_SPEC,
43
+ input_schema: Optional[dict] = None,
44
+ ):
45
+ super().__init__(name=name, type=ScriptToolConfig)
46
+
47
+ self.working_directory = working_directory
48
+ self.image = image
49
+ self.mounts = mounts
50
+ self.commands = commands
51
+ self.input_schema = input_schema
52
+
53
+ async def make(self, *, room: RoomClient, model: str, config: ScriptToolConfig):
54
+ return Toolkit(
55
+ name=self.name,
56
+ tools=[
57
+ ScriptTool(
58
+ name=config.tool_name,
59
+ description=config.description,
60
+ title=config.title,
61
+ service_id=config.service_id,
62
+ working_directory=self.working_directory,
63
+ image=self.image,
64
+ commands=self.commands or config.commands,
65
+ mounts=self.mounts,
66
+ input_schema=self.input_schema,
67
+ )
68
+ ],
69
+ )
70
+
71
+
72
+ class ScriptTool(Tool):
73
+ def __init__(
74
+ self,
75
+ *,
76
+ name: str,
77
+ commands: list[str],
78
+ description: Optional[str] = None,
79
+ title: Optional[str] = None,
80
+ service_id: Optional[str] = None,
81
+ working_directory: Optional[str] = None,
82
+ image: Optional[str] = "python:3.13",
83
+ mounts: Optional[ContainerMountSpec] = DEFAULT_CONTAINER_MOUNT_SPEC,
84
+ env: Optional[dict[str, str]] = None,
85
+ input_schema: Optional[dict] = None,
86
+ max_output_length: int = 32000,
87
+ timeout_ms: int = 30 * 60 * 1000,
88
+ ):
89
+ self.service_id = service_id
90
+ self.working_directory = working_directory
91
+ self.image = image
92
+ self.mounts = mounts
93
+ self._container_id = None
94
+ self.env = env
95
+ self.max_output_length = max_output_length
96
+ self.timeout_ms = timeout_ms
97
+ self.service_id = service_id
98
+ self.commands = commands
99
+
100
+ super().__init__(
101
+ name=name,
102
+ description=description,
103
+ title=title,
104
+ input_schema=input_schema
105
+ or {
106
+ "type": "object",
107
+ "required": ["prompt"],
108
+ "additionalProperties": False,
109
+ "properties": {"prompt": {"type": "string"}},
110
+ },
111
+ )
112
+
113
+ async def execute(
114
+ self,
115
+ context: ToolContext,
116
+ **kwargs,
117
+ ):
118
+ merged_env = {**os.environ}
119
+
120
+ results = []
121
+ encoding = os.device_encoding(1) or "utf-8"
122
+
123
+ left = self.max_output_length
124
+
125
+ def limit(s: str):
126
+ nonlocal left
127
+ if left is not None:
128
+ s = s[0:left]
129
+ left -= len(s)
130
+ return s
131
+ else:
132
+ return s
133
+
134
+ timeout = float(self.timeout_ms) / 1000.0 if self.timeout_ms else 20 * 1000.0
135
+
136
+ if self.image is not None or self.service_id is not None:
137
+ running = False
138
+
139
+ if self._container_id:
140
+ # make sure container is still running
141
+
142
+ for c in await context.room.containers.list():
143
+ if c.id == self._container_id or (
144
+ self.service_id is not None and c.service_id == self.service_id
145
+ ):
146
+ running = True
147
+
148
+ if not running:
149
+ if self.service_id is not None:
150
+ env = {}
151
+
152
+ for k, v in kwargs.items():
153
+ env[k.upper()] = v
154
+
155
+ logger.info(
156
+ f"executing shell script in container with env {env}"
157
+ )
158
+
159
+ self._container_id = await context.room.containers.run_service(
160
+ service_id=self.service_id,
161
+ env=env,
162
+ )
163
+
164
+ else:
165
+ self._container_id = await context.room.containers.run(
166
+ command="sleep infinity",
167
+ image=self.image,
168
+ mounts=self.mounts,
169
+ writable_root_fs=True,
170
+ env=self.env,
171
+ )
172
+
173
+ container_id = self._container_id
174
+ commands = self.commands
175
+ logger.info(
176
+ f"executing shell script in container {container_id} with timeout {timeout}: {commands}"
177
+ )
178
+ import shlex
179
+
180
+ for line in commands:
181
+ try:
182
+ # TODO: what if container start fails
183
+
184
+ exec = await context.room.containers.exec(
185
+ container_id=container_id,
186
+ command=shlex.join(["bash", "-c", line]),
187
+ tty=False,
188
+ )
189
+
190
+ stdout = bytearray()
191
+ stderr = bytearray()
192
+
193
+ try:
194
+ async with asyncio.timeout(timeout):
195
+ async for se in exec.stderr():
196
+ stderr.extend(se)
197
+
198
+ async for so in exec.stdout():
199
+ stdout.extend(so)
200
+
201
+ exit_code = await exec.result
202
+
203
+ return {
204
+ "outcome": {
205
+ "type": "exit",
206
+ "exit_code": exit_code,
207
+ },
208
+ "stdout": stdout.decode(),
209
+ "stderr": stderr.decode(),
210
+ }
211
+
212
+ except asyncio.TimeoutError:
213
+ logger.info(f"The command timed out after {timeout}s")
214
+ await exec.kill()
215
+
216
+ results.append(
217
+ {
218
+ "outcome": {"type": "timeout"},
219
+ "stdout": limit(
220
+ stdout.decode(encoding, errors="replace")
221
+ ),
222
+ "stderr": limit(
223
+ stderr.decode(encoding, errors="replace")
224
+ ),
225
+ }
226
+ )
227
+ break
228
+
229
+ except Exception as ex:
230
+ results.append(
231
+ {
232
+ "outcome": {
233
+ "type": "exit",
234
+ "exit_code": 1,
235
+ },
236
+ "stdout": "",
237
+ "stderr": f"{ex}",
238
+ }
239
+ )
240
+ break
241
+
242
+ except Exception as ex:
243
+ results.append(
244
+ {
245
+ "outcome": {
246
+ "type": "exit",
247
+ "exit_code": 1,
248
+ },
249
+ "stdout": "",
250
+ "stderr": f"{ex}",
251
+ }
252
+ )
253
+ break
254
+ else:
255
+ for line in self.commands:
256
+ logger.info(f"executing command {line} with timeout: {timeout}s")
257
+
258
+ # Spawn the process
259
+ try:
260
+ import shlex
261
+
262
+ proc = await asyncio.create_subprocess_shell(
263
+ shlex.join(["bash", "-c", line]),
264
+ cwd=self.working_directory or os.getcwd(),
265
+ env=merged_env,
266
+ stdout=asyncio.subprocess.PIPE,
267
+ stderr=asyncio.subprocess.PIPE,
268
+ )
269
+
270
+ stdout, stderr = await asyncio.wait_for(
271
+ proc.communicate(),
272
+ timeout=timeout,
273
+ )
274
+ except asyncio.TimeoutError:
275
+ logger.info(f"The command timed out after {timeout}s")
276
+ proc.kill() # send SIGKILL / TerminateProcess
277
+
278
+ stdout, stderr = await proc.communicate()
279
+
280
+ results.append(
281
+ {
282
+ "outcome": {"type": "timeout"},
283
+ "stdout": limit(stdout.decode(encoding, errors="replace")),
284
+ "stderr": limit(stderr.decode(encoding, errors="replace")),
285
+ }
286
+ )
287
+ break
288
+
289
+ except Exception as ex:
290
+ results.append(
291
+ {
292
+ "outcome": {
293
+ "type": "exit",
294
+ "exit_code": 1,
295
+ },
296
+ "stdout": "",
297
+ "stderr": f"{ex}",
298
+ }
299
+ )
300
+ break
301
+
302
+ results.append(
303
+ {
304
+ "outcome": {
305
+ "type": "exit",
306
+ "exit_code": proc.returncode,
307
+ },
308
+ "stdout": limit(stdout.decode(encoding, errors="replace")),
309
+ "stderr": limit(stderr.decode(encoding, errors="replace")),
310
+ }
311
+ )
312
+
313
+ return {"results": results}
@@ -1,19 +1,470 @@
1
+ import asyncio
2
+ import json
3
+ import mimetypes
4
+ import os
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from pydantic import BaseModel, ConfigDict
11
+
1
12
  from meshagent.api.messaging import JsonResponse, LinkResponse, FileResponse
2
- from meshagent.api import RoomClient
13
+ from meshagent.api import RoomClient, RoomException
14
+ from meshagent.api.room_server_client import StorageEntry
15
+
3
16
  from .config import ToolkitConfig
4
17
  from .tool import Tool
5
18
  from .toolkit import ToolContext, ToolkitBuilder
6
19
  from .hosting import RemoteToolkit, Toolkit
7
- import os
8
- from meshagent.api import RoomException
9
20
  from .blob import get_bytes_from_url
10
- from typing import Literal
11
- import json
12
- import os.path
13
21
 
14
22
 
15
- class ReadFileTool(Tool):
16
- def __init__(self):
23
+ class StorageToolMount(BaseModel):
24
+ path: str
25
+ read_only: bool = False
26
+
27
+ model_config = ConfigDict(extra="forbid")
28
+
29
+ def _ensure_writable(self, path: str) -> None:
30
+ if self.read_only:
31
+ raise RoomException(f"storage mount is read-only: {path}")
32
+
33
+ async def read_file(
34
+ self,
35
+ *,
36
+ context: ToolContext,
37
+ resolved: "_ResolvedStoragePath",
38
+ path: str,
39
+ ) -> FileResponse:
40
+ raise NotImplementedError
41
+
42
+ async def write_text(
43
+ self,
44
+ *,
45
+ context: ToolContext,
46
+ resolved: "_ResolvedStoragePath",
47
+ path: str,
48
+ text: str,
49
+ overwrite: bool,
50
+ ) -> None:
51
+ raise NotImplementedError
52
+
53
+ async def write_bytes(
54
+ self,
55
+ *,
56
+ context: ToolContext,
57
+ resolved: "_ResolvedStoragePath",
58
+ path: str,
59
+ data: bytes,
60
+ overwrite: bool,
61
+ ) -> None:
62
+ raise NotImplementedError
63
+
64
+ async def list_entries(
65
+ self, *, context: ToolContext, resolved: "_ResolvedStoragePath"
66
+ ) -> list[StorageEntry]:
67
+ raise NotImplementedError
68
+
69
+ async def get_download_url(
70
+ self, *, context: ToolContext, resolved: "_ResolvedStoragePath", path: str
71
+ ) -> LinkResponse:
72
+ raise NotImplementedError
73
+
74
+
75
+ class StorageToolLocalMount(StorageToolMount):
76
+ local_path: str
77
+
78
+ async def read_file(
79
+ self,
80
+ *,
81
+ context: ToolContext,
82
+ resolved: "_ResolvedStoragePath",
83
+ path: str,
84
+ ) -> FileResponse:
85
+ local_path = _require_local_path(resolved)
86
+ filename = os.path.basename(path)
87
+ mime_type, _ = mimetypes.guess_type(local_path)
88
+ if mime_type is None:
89
+ mime_type = "application/octet-stream"
90
+ data = await _read_local_file(local_path)
91
+ return FileResponse(mime_type=mime_type, name=filename, data=data)
92
+
93
+ async def write_text(
94
+ self,
95
+ *,
96
+ context: ToolContext,
97
+ resolved: "_ResolvedStoragePath",
98
+ path: str,
99
+ text: str,
100
+ overwrite: bool,
101
+ ) -> None:
102
+ self._ensure_writable(path)
103
+ local_path = _require_local_path(resolved)
104
+ await _write_local_text(local_path, text, overwrite)
105
+
106
+ async def write_bytes(
107
+ self,
108
+ *,
109
+ context: ToolContext,
110
+ resolved: "_ResolvedStoragePath",
111
+ path: str,
112
+ data: bytes,
113
+ overwrite: bool,
114
+ ) -> None:
115
+ self._ensure_writable(path)
116
+ local_path = _require_local_path(resolved)
117
+ await _write_local_bytes(local_path, data, overwrite)
118
+
119
+ async def list_entries(
120
+ self, *, context: ToolContext, resolved: "_ResolvedStoragePath"
121
+ ) -> list[StorageEntry]:
122
+ local_path = _require_local_path(resolved)
123
+ return await _list_local_entries(local_path)
124
+
125
+ async def get_download_url(
126
+ self, *, context: ToolContext, resolved: "_ResolvedStoragePath", path: str
127
+ ) -> LinkResponse:
128
+ local_path = _require_local_path(resolved)
129
+ file_path = Path(local_path)
130
+ if not file_path.exists():
131
+ raise RoomException(f"file not found: {local_path}")
132
+ if not file_path.is_file():
133
+ raise RoomException(f"path is not a file: {local_path}")
134
+ name = os.path.basename(path)
135
+ return LinkResponse(name=name, url=file_path.resolve().as_uri())
136
+
137
+
138
+ class StorageToolRoomMount(StorageToolMount):
139
+ subpath: Optional[str] = None
140
+
141
+ async def read_file(
142
+ self,
143
+ *,
144
+ context: ToolContext,
145
+ resolved: "_ResolvedStoragePath",
146
+ path: str,
147
+ ) -> FileResponse:
148
+ room_path = _require_room_path(resolved)
149
+ filename = os.path.basename(path)
150
+ _, extension = os.path.splitext(path)
151
+ if extension:
152
+ schema_path = _make_room_path(
153
+ resolved.room_root, f".schemas/{extension.lstrip('.')}" + ".json"
154
+ )
155
+ if await context.room.storage.exists(path=schema_path):
156
+ return FileResponse(
157
+ mime_type="application/json",
158
+ name=filename,
159
+ data=json.dumps(
160
+ await context.room.sync.describe(path=room_path)
161
+ ).encode(),
162
+ )
163
+ return await context.room.storage.download(path=room_path)
164
+
165
+ async def write_text(
166
+ self,
167
+ *,
168
+ context: ToolContext,
169
+ resolved: "_ResolvedStoragePath",
170
+ path: str,
171
+ text: str,
172
+ overwrite: bool,
173
+ ) -> None:
174
+ self._ensure_writable(path)
175
+ room_path = _require_room_path(resolved)
176
+ handle = await context.room.storage.open(path=room_path, overwrite=overwrite)
177
+ await context.room.storage.write(handle=handle, data=text.encode("utf-8"))
178
+ await context.room.storage.close(handle=handle)
179
+
180
+ async def write_bytes(
181
+ self,
182
+ *,
183
+ context: ToolContext,
184
+ resolved: "_ResolvedStoragePath",
185
+ path: str,
186
+ data: bytes,
187
+ overwrite: bool,
188
+ ) -> None:
189
+ self._ensure_writable(path)
190
+ room_path = _require_room_path(resolved)
191
+ if not overwrite:
192
+ result = await context.room.storage.exists(path=room_path)
193
+ if result:
194
+ raise RoomException(
195
+ f"a file already exists at the path: {path}, try another filename"
196
+ )
197
+ handle = await context.room.storage.open(path=room_path, overwrite=overwrite)
198
+ try:
199
+ await context.room.storage.write(handle=handle, data=data)
200
+ finally:
201
+ await context.room.storage.close(handle=handle)
202
+
203
+ async def list_entries(
204
+ self, *, context: ToolContext, resolved: "_ResolvedStoragePath"
205
+ ) -> list[StorageEntry]:
206
+ room_path = _require_room_path(resolved)
207
+ return await context.room.storage.list(path=room_path)
208
+
209
+ async def get_download_url(
210
+ self, *, context: ToolContext, resolved: "_ResolvedStoragePath", path: str
211
+ ) -> LinkResponse:
212
+ room_path = _require_room_path(resolved)
213
+ name = os.path.basename(path)
214
+ url = await context.room.storage.download_url(path=room_path)
215
+ return LinkResponse(name=name, url=url)
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class _PreparedMount:
220
+ mount: StorageToolMount
221
+ virtual_path: str
222
+ local_root: Optional[str]
223
+ room_root: Optional[str]
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class _ResolvedStoragePath:
228
+ mount: StorageToolMount
229
+ virtual_path: str
230
+ relative_path: str
231
+ local_path: Optional[str]
232
+ room_path: Optional[str]
233
+ room_root: Optional[str]
234
+
235
+
236
+ def _normalize_mount_path(path: str) -> str:
237
+ if path is None:
238
+ raise RoomException("mount path must be set")
239
+
240
+ cleaned = path.strip()
241
+ if cleaned in ("", "/", "."):
242
+ return "/"
243
+
244
+ cleaned = cleaned.lstrip("/")
245
+ if cleaned == "":
246
+ return "/"
247
+
248
+ parts = cleaned.split("/")
249
+ if any(part in {".", ".."} for part in parts):
250
+ raise RoomException(f"invalid mount path: {path}")
251
+
252
+ return f"/{'/'.join(parts)}"
253
+
254
+
255
+ def _normalize_virtual_path(path: str) -> str:
256
+ if path is None:
257
+ raise RoomException("path must be set")
258
+
259
+ cleaned = path.strip()
260
+ if cleaned in ("", "."):
261
+ return "/"
262
+
263
+ cleaned = cleaned.lstrip("/")
264
+ if cleaned == "":
265
+ return "/"
266
+
267
+ parts = cleaned.split("/")
268
+ if any(part in {".", ".."} for part in parts):
269
+ raise RoomException(f"dot segments not allowed: {path}")
270
+
271
+ return f"/{'/'.join(parts)}"
272
+
273
+
274
+ def _normalize_room_root(subpath: Optional[str]) -> str:
275
+ if subpath is None:
276
+ return ""
277
+
278
+ cleaned = subpath.strip()
279
+ if cleaned in ("", "/", "."):
280
+ return ""
281
+
282
+ cleaned = cleaned.strip("/")
283
+ parts = cleaned.split("/")
284
+ if any(part in {".", ".."} for part in parts):
285
+ raise RoomException(f"dot segments not allowed: {subpath}")
286
+
287
+ return "/".join(parts)
288
+
289
+
290
+ def _prepare_mounts(
291
+ mounts: Optional[list[StorageToolMount]],
292
+ ) -> list[_PreparedMount]:
293
+ if not mounts:
294
+ mounts = [StorageToolRoomMount(path="/")]
295
+
296
+ prepared = []
297
+ for mount in mounts:
298
+ virtual_path = _normalize_mount_path(mount.path)
299
+ if isinstance(mount, StorageToolLocalMount):
300
+ local_root = os.path.abspath(mount.local_path)
301
+ room_root = None
302
+ else:
303
+ local_root = None
304
+ room_root = _normalize_room_root(mount.subpath)
305
+
306
+ prepared.append(
307
+ _PreparedMount(
308
+ mount=mount,
309
+ virtual_path=virtual_path,
310
+ local_root=local_root,
311
+ room_root=room_root,
312
+ )
313
+ )
314
+
315
+ return prepared
316
+
317
+
318
+ def _resolve_storage_path(
319
+ mounts: list[_PreparedMount], path: str
320
+ ) -> _ResolvedStoragePath:
321
+ virtual_path = _normalize_virtual_path(path)
322
+
323
+ matches = []
324
+ for mount in mounts:
325
+ if mount.virtual_path == "/":
326
+ matches.append(mount)
327
+ continue
328
+
329
+ if virtual_path == mount.virtual_path or virtual_path.startswith(
330
+ f"{mount.virtual_path}/"
331
+ ):
332
+ matches.append(mount)
333
+
334
+ if not matches:
335
+ raise RoomException(f"path is not within a storage mount: {path}")
336
+
337
+ selected = max(matches, key=lambda m: len(m.virtual_path))
338
+ relative_path = virtual_path[len(selected.virtual_path) :].lstrip("/")
339
+
340
+ local_path = None
341
+ room_path = None
342
+ room_root = selected.room_root
343
+
344
+ if selected.local_root is not None:
345
+ local_path = os.path.normpath(os.path.join(selected.local_root, relative_path))
346
+ if os.path.commonpath([selected.local_root, local_path]) != selected.local_root:
347
+ raise RoomException(f"path escapes the storage mount: {path}")
348
+ else:
349
+ if room_root and relative_path:
350
+ room_path = f"{room_root}/{relative_path}"
351
+ elif room_root:
352
+ room_path = room_root
353
+ else:
354
+ room_path = relative_path
355
+
356
+ return _ResolvedStoragePath(
357
+ mount=selected.mount,
358
+ virtual_path=virtual_path,
359
+ relative_path=relative_path,
360
+ local_path=local_path,
361
+ room_path=room_path,
362
+ room_root=room_root,
363
+ )
364
+
365
+
366
+ def _make_room_path(room_root: Optional[str], relative_path: str) -> str:
367
+ room_root = room_root or ""
368
+ relative_path = relative_path.lstrip("/")
369
+
370
+ if room_root and relative_path:
371
+ return f"{room_root}/{relative_path}"
372
+
373
+ return room_root or relative_path
374
+
375
+
376
+ def _require_local_path(resolved: _ResolvedStoragePath) -> str:
377
+ if resolved.local_path is None:
378
+ raise RoomException("local path is not available for this mount")
379
+ return resolved.local_path
380
+
381
+
382
+ def _require_room_path(resolved: _ResolvedStoragePath) -> str:
383
+ if resolved.room_path is None:
384
+ raise RoomException("room path is not available for this mount")
385
+ return resolved.room_path
386
+
387
+
388
+ async def _read_local_file(path: str) -> bytes:
389
+ def _read() -> bytes:
390
+ return Path(path).read_bytes()
391
+
392
+ try:
393
+ return await asyncio.to_thread(_read)
394
+ except FileNotFoundError:
395
+ raise RoomException(f"file not found: {path}")
396
+ except IsADirectoryError:
397
+ raise RoomException(f"path is a directory: {path}")
398
+
399
+
400
+ async def _write_local_bytes(path: str, data: bytes, overwrite: bool) -> None:
401
+ def _write() -> None:
402
+ if not overwrite and os.path.exists(path):
403
+ raise RoomException(
404
+ f"a file already exists at the path: {path}, try another filename"
405
+ )
406
+ directory = os.path.dirname(path)
407
+ if directory:
408
+ os.makedirs(directory, exist_ok=True)
409
+ with open(path, "wb") as handle:
410
+ handle.write(data)
411
+
412
+ await asyncio.to_thread(_write)
413
+
414
+
415
+ async def _write_local_text(path: str, text: str, overwrite: bool) -> None:
416
+ def _write() -> None:
417
+ if not overwrite and os.path.exists(path):
418
+ raise RoomException(
419
+ f"a file already exists at the path: {path}, try another filename"
420
+ )
421
+ directory = os.path.dirname(path)
422
+ if directory:
423
+ os.makedirs(directory, exist_ok=True)
424
+ with open(path, "w", encoding="utf-8") as handle:
425
+ handle.write(text)
426
+
427
+ await asyncio.to_thread(_write)
428
+
429
+
430
+ async def _list_local_entries(path: str) -> list[StorageEntry]:
431
+ def _list() -> list[StorageEntry]:
432
+ try:
433
+ entries = []
434
+ for entry in os.scandir(path):
435
+ stat_info = entry.stat()
436
+ entries.append(
437
+ StorageEntry(
438
+ name=entry.name,
439
+ is_folder=entry.is_dir(),
440
+ created_at=datetime.fromtimestamp(
441
+ stat_info.st_ctime, tz=timezone.utc
442
+ ),
443
+ updated_at=datetime.fromtimestamp(
444
+ stat_info.st_mtime, tz=timezone.utc
445
+ ),
446
+ )
447
+ )
448
+ return entries
449
+ except FileNotFoundError:
450
+ return []
451
+ except NotADirectoryError:
452
+ return []
453
+
454
+ return await asyncio.to_thread(_list)
455
+
456
+
457
+ class _StorageTool(Tool):
458
+ def __init__(self, *, mounts: list[_PreparedMount], **kwargs):
459
+ super().__init__(**kwargs)
460
+ self._mounts = mounts
461
+
462
+ def _resolve_path(self, path: str) -> _ResolvedStoragePath:
463
+ return _resolve_storage_path(self._mounts, path)
464
+
465
+
466
+ class ReadFileTool(_StorageTool):
467
+ def __init__(self, *, mounts: list[_PreparedMount]):
17
468
  super().__init__(
18
469
  name="read_file",
19
470
  title="read a file file",
@@ -29,25 +480,19 @@ class ReadFileTool(Tool):
29
480
  }
30
481
  },
31
482
  },
483
+ mounts=mounts,
32
484
  )
33
485
 
34
- async def execute(self, *, context: ToolContext, path: str):
35
- filename = os.path.basename(path)
36
- _, extension = os.path.splitext(path)
37
- if await context.room.storage.exists(
38
- path=f".schemas/{extension.lstrip('.')}.json"
39
- ):
40
- return FileResponse(
41
- mime_type="application/json",
42
- name=filename,
43
- data=json.dumps(await context.room.sync.describe(path=path)).encode(),
44
- )
45
- else:
46
- return await context.room.storage.download(path=path)
486
+ async def execute(self, context: ToolContext, **kwargs):
487
+ path = kwargs["path"]
488
+ resolved = self._resolve_path(path)
489
+ return await resolved.mount.read_file(
490
+ context=context, resolved=resolved, path=path
491
+ )
47
492
 
48
493
 
49
- class WriteFileTool(Tool):
50
- def __init__(self):
494
+ class WriteFileTool(_StorageTool):
495
+ def __init__(self, *, mounts: list[_PreparedMount]):
51
496
  super().__init__(
52
497
  name="write_file",
53
498
  title="write text file",
@@ -71,19 +516,26 @@ class WriteFileTool(Tool):
71
516
  },
72
517
  },
73
518
  },
519
+ mounts=mounts,
74
520
  )
75
521
 
76
- async def execute(
77
- self, *, context: ToolContext, path: str, text: str, overwrite: bool
78
- ):
79
- handle = await context.room.storage.open(path=path, overwrite=overwrite)
80
- await context.room.storage.write(handle=handle, data=text.encode("utf-8"))
81
- await context.room.storage.close(handle=handle)
522
+ async def execute(self, context: ToolContext, **kwargs):
523
+ path = kwargs["path"]
524
+ text = kwargs["text"]
525
+ overwrite = kwargs["overwrite"]
526
+ resolved = self._resolve_path(path)
527
+ await resolved.mount.write_text(
528
+ context=context,
529
+ resolved=resolved,
530
+ path=path,
531
+ text=text,
532
+ overwrite=overwrite,
533
+ )
82
534
  return "the file was saved"
83
535
 
84
536
 
85
- class GetFileDownloadUrl(Tool):
86
- def __init__(self):
537
+ class GetFileDownloadUrl(_StorageTool):
538
+ def __init__(self, *, mounts: list[_PreparedMount]):
87
539
  super().__init__(
88
540
  name="get_file_download_url",
89
541
  title="get file download url",
@@ -99,16 +551,19 @@ class GetFileDownloadUrl(Tool):
99
551
  }
100
552
  },
101
553
  },
554
+ mounts=mounts,
102
555
  )
103
556
 
104
- async def execute(self, *, context: ToolContext, path: str):
105
- name = os.path.basename(path)
106
- url = await context.room.storage.download_url(path=path)
107
- return LinkResponse(name=name, url=url)
557
+ async def execute(self, context: ToolContext, **kwargs):
558
+ path = kwargs["path"]
559
+ resolved = self._resolve_path(path)
560
+ return await resolved.mount.get_download_url(
561
+ context=context, resolved=resolved, path=path
562
+ )
108
563
 
109
564
 
110
- class ListFilesTool(Tool):
111
- def __init__(self):
565
+ class ListFilesTool(_StorageTool):
566
+ def __init__(self, *, mounts: list[_PreparedMount]):
112
567
  super().__init__(
113
568
  name="list_files_in_room",
114
569
  title="list files in room",
@@ -119,17 +574,20 @@ class ListFilesTool(Tool):
119
574
  "required": ["path"],
120
575
  "properties": {"path": {"type": "string"}},
121
576
  },
577
+ mounts=mounts,
122
578
  )
123
579
 
124
- async def execute(self, *, context: ToolContext, path: str):
125
- files = await context.room.storage.list(path=path)
580
+ async def execute(self, context: ToolContext, **kwargs):
581
+ path = kwargs["path"]
582
+ resolved = self._resolve_path(path)
583
+ files = await resolved.mount.list_entries(context=context, resolved=resolved)
126
584
  return JsonResponse(
127
585
  json={"files": list([f.model_dump(mode="json") for f in files])}
128
586
  )
129
587
 
130
588
 
131
- class SaveFileFromUrlTool(Tool):
132
- def __init__(self):
589
+ class SaveFileFromUrlTool(_StorageTool):
590
+ def __init__(self, *, mounts: list[_PreparedMount]):
133
591
  super().__init__(
134
592
  name="save_file_from_url",
135
593
  title="save file from url",
@@ -153,40 +611,48 @@ class SaveFileFromUrlTool(Tool):
153
611
  },
154
612
  },
155
613
  },
614
+ mounts=mounts,
156
615
  )
157
616
 
158
- async def execute(
159
- self, *, context: ToolContext, url: str, path: str, overwrite: bool
160
- ):
617
+ async def execute(self, context: ToolContext, **kwargs):
618
+ url = kwargs["url"]
619
+ path = kwargs["path"]
620
+ overwrite = kwargs["overwrite"]
621
+ resolved = self._resolve_path(path)
161
622
  blob = await get_bytes_from_url(url=url)
162
-
163
- if not overwrite:
164
- result = await context.room.storage.exists(path=path)
165
- if result:
166
- raise RoomException(
167
- f"a file already exists at the path: {path}, try another filename"
168
- )
169
-
170
- handle = await context.room.storage.open(path=path, overwrite=overwrite)
171
- try:
172
- await context.room.storage.write(handle=handle, data=blob.data)
173
- finally:
174
- await context.room.storage.close(handle=handle)
623
+ await resolved.mount.write_bytes(
624
+ context=context,
625
+ resolved=resolved,
626
+ path=path,
627
+ data=blob.data,
628
+ overwrite=overwrite,
629
+ )
175
630
 
176
631
 
177
632
  class StorageToolkit(RemoteToolkit):
178
- def __init__(self, read_only: bool = False):
179
- if not read_only:
633
+ def __init__(
634
+ self,
635
+ read_only: bool = False,
636
+ mounts: Optional[list[StorageToolMount]] = None,
637
+ ):
638
+ prepared_mounts = _prepare_mounts(mounts)
639
+ self._mounts = prepared_mounts
640
+ self._read_only = read_only
641
+ has_writable_mount = any(
642
+ not prepared.mount.read_only for prepared in prepared_mounts
643
+ )
644
+
645
+ if read_only or not has_writable_mount:
180
646
  tools = [
181
- ListFilesTool(),
182
- WriteFileTool(),
183
- ReadFileTool(),
184
- SaveFileFromUrlTool(),
647
+ ListFilesTool(mounts=prepared_mounts),
648
+ ReadFileTool(mounts=prepared_mounts),
185
649
  ]
186
650
  else:
187
651
  tools = [
188
- ListFilesTool(),
189
- ReadFileTool(),
652
+ ListFilesTool(mounts=prepared_mounts),
653
+ WriteFileTool(mounts=prepared_mounts),
654
+ ReadFileTool(mounts=prepared_mounts),
655
+ SaveFileFromUrlTool(mounts=prepared_mounts),
190
656
  ]
191
657
  super().__init__(
192
658
  name="storage",
@@ -195,16 +661,84 @@ class StorageToolkit(RemoteToolkit):
195
661
  tools=tools,
196
662
  )
197
663
 
664
+ def _ensure_writable(self, path: str) -> None:
665
+ if self._read_only:
666
+ raise RoomException(f"storage toolkit is read-only: {path}")
667
+
668
+ def _resolve_path(self, path: str) -> _ResolvedStoragePath:
669
+ return _resolve_storage_path(self._mounts, path)
670
+
671
+ async def read_file(self, *, context: ToolContext, path: str) -> FileResponse:
672
+ resolved = self._resolve_path(path)
673
+ return await resolved.mount.read_file(
674
+ context=context,
675
+ resolved=resolved,
676
+ path=path,
677
+ )
678
+
679
+ async def list_entries(
680
+ self, *, context: ToolContext, path: str
681
+ ) -> list[StorageEntry]:
682
+ resolved = self._resolve_path(path)
683
+ return await resolved.mount.list_entries(context=context, resolved=resolved)
684
+
685
+ async def get_download_url(
686
+ self, *, context: ToolContext, path: str
687
+ ) -> LinkResponse:
688
+ resolved = self._resolve_path(path)
689
+ return await resolved.mount.get_download_url(
690
+ context=context,
691
+ resolved=resolved,
692
+ path=path,
693
+ )
694
+
695
+ async def write_text(
696
+ self,
697
+ *,
698
+ context: ToolContext,
699
+ path: str,
700
+ text: str,
701
+ overwrite: bool,
702
+ ) -> None:
703
+ self._ensure_writable(path)
704
+ resolved = self._resolve_path(path)
705
+ await resolved.mount.write_text(
706
+ context=context,
707
+ resolved=resolved,
708
+ path=path,
709
+ text=text,
710
+ overwrite=overwrite,
711
+ )
712
+
713
+ async def write_bytes(
714
+ self,
715
+ *,
716
+ context: ToolContext,
717
+ path: str,
718
+ data: bytes,
719
+ overwrite: bool,
720
+ ) -> None:
721
+ self._ensure_writable(path)
722
+ resolved = self._resolve_path(path)
723
+ await resolved.mount.write_bytes(
724
+ context=context,
725
+ resolved=resolved,
726
+ path=path,
727
+ data=data,
728
+ overwrite=overwrite,
729
+ )
730
+
198
731
 
199
732
  class StorageToolkitConfig(ToolkitConfig):
200
- name: Literal["storage"] = "storage"
733
+ name: str = "storage"
201
734
 
202
735
 
203
736
  class StorageToolkitBuilder(ToolkitBuilder):
204
- def __init__(self):
737
+ def __init__(self, *, mounts: Optional[list[StorageToolMount]] = None):
205
738
  super().__init__(name="storage", type=StorageToolkitConfig)
739
+ self.mounts = mounts
206
740
 
207
741
  async def make(
208
- self, *, room: RoomClient, model: str, config: ToolkitConfig
742
+ self, *, room: RoomClient, model: str, config: StorageToolkitConfig
209
743
  ) -> Toolkit:
210
- return StorageToolkit()
744
+ return StorageToolkit(mounts=self.mounts)
@@ -1 +1 @@
1
- __version__ = "0.24.0"
1
+ __version__ = "0.24.3"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshagent-tools
3
- Version: 0.24.0
3
+ Version: 0.24.3
4
4
  Summary: Tools for Meshagent
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Documentation, https://docs.meshagent.com
@@ -12,7 +12,7 @@ License-File: LICENSE
12
12
  Requires-Dist: pyjwt~=2.10
13
13
  Requires-Dist: pytest~=8.4
14
14
  Requires-Dist: pytest-asyncio~=0.26
15
- Requires-Dist: meshagent-api~=0.24.0
15
+ Requires-Dist: meshagent-api~=0.24.3
16
16
  Requires-Dist: aiohttp~=3.10
17
17
  Requires-Dist: opentelemetry-distro~=0.54b1
18
18
  Dynamic: license-file
@@ -8,15 +8,16 @@ meshagent/tools/document_tools.py,sha256=LMULXOSBjsvhKjqzxUxe8586t0Vol0v1Btu5v6o
8
8
  meshagent/tools/hosting.py,sha256=l1BCgnSrCJQsWU9Kycq3hEI4ZlYxffDfde6QeJUfko0,10678
9
9
  meshagent/tools/multi_tool.py,sha256=hmWZO18Y2tuFG_7rvUed9er29aXleAC-r3YpXBCZWUY,4040
10
10
  meshagent/tools/pydantic.py,sha256=n-MD0gC-oRtHSTUDD5IV2dP-xIk-zjcDgHfgjqMgiqM,1161
11
- meshagent/tools/storage.py,sha256=Y79G__Mp4swJ3tnm-zXtD-SqDeKU9kqHEVoUQH_QedA,7212
11
+ meshagent/tools/script.py,sha256=uHrJynzM0SwUHM1qXIjt-UhZLG4AQtFw-yyLv4lxGGw,10589
12
+ meshagent/tools/storage.py,sha256=NVpi9CZKSZUh8PTxxCdJhJy7Gzmdp55-zo2yHYGod_E,23340
12
13
  meshagent/tools/strict_schema.py,sha256=IytdAANa6lsfrsg5FsJuqYrxH9D_fayl-Lc9EwgLJSM,6277
13
14
  meshagent/tools/tool.py,sha256=9OAlbfaHqfgJnCDBSW-8PS0Z1K1KjWGD3JBUyiHOxAk,3131
14
15
  meshagent/tools/toolkit.py,sha256=rCCkpQBoSkmmhjnRGA4jx0QP-ds6WTJ0PkQVnf1Ls7s,3843
15
16
  meshagent/tools/uuid.py,sha256=mzRwDmXy39U5lHhd9wqV4r-ZdS8jPfDTTs4UfW4KHJQ,1342
16
- meshagent/tools/version.py,sha256=DxtMZD542lg_xb6icrE2d5JOY8oUi-v34i2Ar63ddvs,23
17
+ meshagent/tools/version.py,sha256=FVT3zgMnGxhctio1D7Bj2hvIqrqQQ-a9tvDQYKSSekk,23
17
18
  meshagent/tools/web_toolkit.py,sha256=IoOYjOBmcbQsqWT14xYg02jjWpWmGOkDSxt2U-LQoaA,1258
18
- meshagent_tools-0.24.0.dist-info/licenses/LICENSE,sha256=eTt0SPW-sVNdkZe9PS_S8WfCIyLjRXRl7sUBWdlteFg,10254
19
- meshagent_tools-0.24.0.dist-info/METADATA,sha256=D8WWKfMis7eEiRprZ2WZTCMyAlSiArWxlD00-VXpGaQ,2878
20
- meshagent_tools-0.24.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
21
- meshagent_tools-0.24.0.dist-info/top_level.txt,sha256=GlcXnHtRP6m7zlG3Df04M35OsHtNXy_DY09oFwWrH74,10
22
- meshagent_tools-0.24.0.dist-info/RECORD,,
19
+ meshagent_tools-0.24.3.dist-info/licenses/LICENSE,sha256=eTt0SPW-sVNdkZe9PS_S8WfCIyLjRXRl7sUBWdlteFg,10254
20
+ meshagent_tools-0.24.3.dist-info/METADATA,sha256=lBDQ02Y_Xaty4w6N018An8Rwg7OQHTSYajbalKekVUo,2878
21
+ meshagent_tools-0.24.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
22
+ meshagent_tools-0.24.3.dist-info/top_level.txt,sha256=GlcXnHtRP6m7zlG3Df04M35OsHtNXy_DY09oFwWrH74,10
23
+ meshagent_tools-0.24.3.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5