mm-agenttoolkit 0.1.0__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,55 @@
1
+ from .skills import (
2
+ LoadedSkill,
3
+ Skill,
4
+ SkillChanges,
5
+ Skills,
6
+ parse_skill,
7
+ )
8
+ from .tools import (
9
+ ActionResult,
10
+ Inject,
11
+ SkillRefreshMiddleware,
12
+ Tool,
13
+ ToolAvailability,
14
+ ToolCall,
15
+ ToolContext,
16
+ ToolDescription,
17
+ ToolEffect,
18
+ ToolMetadata,
19
+ ToolMiddleware,
20
+ ToolPredicate,
21
+ Tools,
22
+ ToolSchema,
23
+ ToolSchemaFormat,
24
+ build_schema,
25
+ description_from_context,
26
+ provided,
27
+ requires,
28
+ )
29
+
30
+ __all__ = [
31
+ "ActionResult",
32
+ "Inject",
33
+ "LoadedSkill",
34
+ "Skill",
35
+ "SkillChanges",
36
+ "SkillRefreshMiddleware",
37
+ "Skills",
38
+ "Tool",
39
+ "ToolAvailability",
40
+ "ToolCall",
41
+ "ToolContext",
42
+ "ToolDescription",
43
+ "ToolEffect",
44
+ "ToolMetadata",
45
+ "ToolMiddleware",
46
+ "ToolPredicate",
47
+ "ToolSchema",
48
+ "ToolSchemaFormat",
49
+ "Tools",
50
+ "build_schema",
51
+ "description_from_context",
52
+ "parse_skill",
53
+ "provided",
54
+ "requires",
55
+ ]
@@ -0,0 +1,59 @@
1
+ from .fs import (
2
+ EditError,
3
+ Entry,
4
+ FileTooLargeError,
5
+ LocalWorkspace,
6
+ PathOutsideWorkspaceError,
7
+ Workspace,
8
+ WorkspaceError,
9
+ )
10
+ from .shell import (
11
+ BindMount,
12
+ BubblewrapSandbox,
13
+ DockerSandbox,
14
+ Sandbox,
15
+ SandboxError,
16
+ SandboxExecutionError,
17
+ SandboxLimits,
18
+ SandboxPolicy,
19
+ SandboxResult,
20
+ SandboxUnavailableError,
21
+ UnsafeLocalSandbox,
22
+ )
23
+ from .todo import (
24
+ InMemoryTodoList,
25
+ Todo,
26
+ TodoError,
27
+ TodoList,
28
+ TodoNotFoundError,
29
+ TodoStateError,
30
+ TodoStatus,
31
+ )
32
+
33
+ __all__ = [
34
+ "BindMount",
35
+ "BubblewrapSandbox",
36
+ "DockerSandbox",
37
+ "EditError",
38
+ "Entry",
39
+ "FileTooLargeError",
40
+ "InMemoryTodoList",
41
+ "LocalWorkspace",
42
+ "PathOutsideWorkspaceError",
43
+ "Sandbox",
44
+ "SandboxError",
45
+ "SandboxExecutionError",
46
+ "SandboxLimits",
47
+ "SandboxPolicy",
48
+ "SandboxResult",
49
+ "SandboxUnavailableError",
50
+ "Todo",
51
+ "TodoError",
52
+ "TodoList",
53
+ "TodoNotFoundError",
54
+ "TodoStateError",
55
+ "TodoStatus",
56
+ "UnsafeLocalSandbox",
57
+ "Workspace",
58
+ "WorkspaceError",
59
+ ]
@@ -0,0 +1,21 @@
1
+ from .workspace import (
2
+ EditError,
3
+ Entry,
4
+ FileTooLargeError,
5
+ GrepMatch,
6
+ LocalWorkspace,
7
+ PathOutsideWorkspaceError,
8
+ Workspace,
9
+ WorkspaceError,
10
+ )
11
+
12
+ __all__ = [
13
+ "EditError",
14
+ "Entry",
15
+ "FileTooLargeError",
16
+ "GrepMatch",
17
+ "LocalWorkspace",
18
+ "PathOutsideWorkspaceError",
19
+ "Workspace",
20
+ "WorkspaceError",
21
+ ]
@@ -0,0 +1,482 @@
1
+ import asyncio
2
+ import os
3
+ import re
4
+ import stat
5
+ import tempfile
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from pathlib import Path, PurePath, PurePosixPath
9
+ from typing import Protocol, runtime_checkable
10
+
11
+
12
+ class WorkspaceError(Exception):
13
+ pass
14
+
15
+
16
+ class PathOutsideWorkspaceError(WorkspaceError, ValueError):
17
+ pass
18
+
19
+
20
+ class FileTooLargeError(WorkspaceError):
21
+ pass
22
+
23
+
24
+ class EditError(WorkspaceError, ValueError):
25
+ pass
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Entry:
30
+ path: str
31
+ is_dir: bool
32
+ is_symlink: bool
33
+ size: int | None = None
34
+ modified: float | None = None
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class GrepMatch:
39
+ path: str
40
+ line_number: int
41
+ line: str
42
+ context_before: tuple[str, ...] = ()
43
+ context_after: tuple[str, ...] = ()
44
+
45
+
46
+ @runtime_checkable
47
+ class Workspace(Protocol):
48
+ @property
49
+ def root(self) -> Path: ...
50
+
51
+ async def read_file(
52
+ self,
53
+ path: str | os.PathLike[str],
54
+ *,
55
+ encoding: str | None = None,
56
+ ) -> str: ...
57
+
58
+ async def write_file(
59
+ self,
60
+ path: str | os.PathLike[str],
61
+ content: str,
62
+ *,
63
+ encoding: str | None = None,
64
+ create_parents: bool = True,
65
+ ) -> None: ...
66
+
67
+ async def edit_file(
68
+ self,
69
+ path: str | os.PathLike[str],
70
+ old: str,
71
+ new: str,
72
+ *,
73
+ replace_all: bool = False,
74
+ encoding: str | None = None,
75
+ ) -> int: ...
76
+
77
+ async def glob(self, pattern: str) -> Sequence[Entry]: ...
78
+
79
+ async def grep(
80
+ self,
81
+ pattern: str,
82
+ *,
83
+ glob: str | None = None,
84
+ case_sensitive: bool = True,
85
+ context_lines: int = 0,
86
+ max_matches: int | None = None,
87
+ ) -> Sequence[GrepMatch]: ...
88
+
89
+ async def list_dir(
90
+ self,
91
+ path: str | os.PathLike[str] = ".",
92
+ *,
93
+ recursive: bool = False,
94
+ limit: int | None = None,
95
+ ) -> Sequence[Entry]: ...
96
+
97
+ async def stat(
98
+ self,
99
+ path: str | os.PathLike[str],
100
+ ) -> Entry | None: ...
101
+
102
+
103
+ class LocalWorkspace:
104
+ def __init__(
105
+ self,
106
+ root: str | os.PathLike[str],
107
+ *,
108
+ mounts: Mapping[str | PurePosixPath, str | os.PathLike[str]] | None = None,
109
+ encoding: str = "utf-8",
110
+ max_file_bytes: int | None = 10 * 1024 * 1024,
111
+ ) -> None:
112
+ if max_file_bytes is not None and max_file_bytes < 1:
113
+ raise ValueError("max_file_bytes must be positive or None")
114
+
115
+ root_path = Path(root).expanduser()
116
+ if root_path.exists() and not root_path.is_dir():
117
+ raise NotADirectoryError(root_path)
118
+ root_path.mkdir(parents=True, exist_ok=True)
119
+
120
+ self._root = root_path.resolve()
121
+ self._mounts = _normalize_mounts(mounts)
122
+ self._encoding = encoding
123
+ self._max_file_bytes = max_file_bytes
124
+
125
+ @property
126
+ def root(self) -> Path:
127
+ return self._root
128
+
129
+ async def read_file(
130
+ self,
131
+ path: str | os.PathLike[str],
132
+ *,
133
+ encoding: str | None = None,
134
+ ) -> str:
135
+ return await asyncio.to_thread(self._read_file, path, encoding)
136
+
137
+ def _read_file(
138
+ self,
139
+ path: str | os.PathLike[str],
140
+ encoding: str | None,
141
+ ) -> str:
142
+ target = self._resolve(path)
143
+ if not target.is_file():
144
+ raise FileNotFoundError(target)
145
+ self._check_size(target.stat().st_size)
146
+ return target.read_text(encoding=encoding or self._encoding)
147
+
148
+ async def write_file(
149
+ self,
150
+ path: str | os.PathLike[str],
151
+ content: str,
152
+ *,
153
+ encoding: str | None = None,
154
+ create_parents: bool = True,
155
+ ) -> None:
156
+ await asyncio.to_thread(
157
+ self._write_file,
158
+ path,
159
+ content,
160
+ encoding,
161
+ create_parents,
162
+ )
163
+
164
+ def _write_file(
165
+ self,
166
+ path: str | os.PathLike[str],
167
+ content: str,
168
+ encoding: str | None,
169
+ create_parents: bool,
170
+ ) -> None:
171
+ target = self._resolve(path)
172
+ if target.exists() and not target.is_file():
173
+ raise IsADirectoryError(target)
174
+
175
+ data = content.encode(encoding or self._encoding)
176
+ self._check_size(len(data))
177
+ if create_parents:
178
+ target.parent.mkdir(parents=True, exist_ok=True)
179
+ elif not target.parent.is_dir():
180
+ raise FileNotFoundError(target.parent)
181
+
182
+ self._assert_inside(target.parent.resolve(), self._base_of(target))
183
+ _atomic_write(target, data)
184
+
185
+ async def edit_file(
186
+ self,
187
+ path: str | os.PathLike[str],
188
+ old: str,
189
+ new: str,
190
+ *,
191
+ replace_all: bool = False,
192
+ encoding: str | None = None,
193
+ ) -> int:
194
+ return await asyncio.to_thread(
195
+ self._edit_file,
196
+ path,
197
+ old,
198
+ new,
199
+ replace_all,
200
+ encoding,
201
+ )
202
+
203
+ def _edit_file(
204
+ self,
205
+ path: str | os.PathLike[str],
206
+ old: str,
207
+ new: str,
208
+ replace_all: bool,
209
+ encoding: str | None,
210
+ ) -> int:
211
+ if not old:
212
+ raise EditError("old text must not be empty")
213
+
214
+ selected_encoding = encoding or self._encoding
215
+ content = self._read_file(path, selected_encoding)
216
+ occurrences = content.count(old)
217
+ if occurrences == 0:
218
+ raise EditError("old text was not found")
219
+ if occurrences > 1 and not replace_all:
220
+ raise EditError(
221
+ f"old text occurs {occurrences} times; use replace_all=True"
222
+ )
223
+
224
+ count = occurrences if replace_all else 1
225
+ updated = content.replace(old, new, -1 if replace_all else 1)
226
+ self._write_file(path, updated, selected_encoding, True)
227
+ return count
228
+
229
+ async def glob(self, pattern: str) -> Sequence[Entry]:
230
+ return await asyncio.to_thread(self._glob, pattern)
231
+
232
+ def _glob(self, pattern: str) -> list[Entry]:
233
+ _validate_pattern(pattern)
234
+ matches: list[Entry] = []
235
+ for candidate in self._root.glob(pattern):
236
+ try:
237
+ matches.append(self._entry(candidate))
238
+ except OSError:
239
+ continue
240
+ return sorted(matches, key=lambda entry: entry.path)
241
+
242
+ async def grep(
243
+ self,
244
+ pattern: str,
245
+ *,
246
+ glob: str | None = None,
247
+ case_sensitive: bool = True,
248
+ context_lines: int = 0,
249
+ max_matches: int | None = None,
250
+ ) -> Sequence[GrepMatch]:
251
+ return await asyncio.to_thread(
252
+ self._grep,
253
+ pattern,
254
+ glob,
255
+ case_sensitive,
256
+ context_lines,
257
+ max_matches,
258
+ )
259
+
260
+ def _grep(
261
+ self,
262
+ pattern: str,
263
+ glob: str | None,
264
+ case_sensitive: bool,
265
+ context_lines: int,
266
+ max_matches: int | None,
267
+ ) -> list[GrepMatch]:
268
+ if context_lines < 0:
269
+ raise ValueError("context_lines must be non-negative")
270
+ if max_matches is not None and max_matches < 0:
271
+ raise ValueError("max_matches must be non-negative or None")
272
+ try:
273
+ regex = re.compile(pattern, 0 if case_sensitive else re.IGNORECASE)
274
+ except re.error as error:
275
+ raise ValueError(f"invalid regex pattern: {pattern}") from error
276
+
277
+ candidates = (
278
+ self._glob(glob) if glob is not None else self._list_dir(".", True, None)
279
+ )
280
+ matches: list[GrepMatch] = []
281
+ for entry in candidates:
282
+ if entry.is_dir:
283
+ continue
284
+ if max_matches is not None and len(matches) >= max_matches:
285
+ break
286
+ try:
287
+ lines = (
288
+ self._resolve(entry.path)
289
+ .read_text(encoding=self._encoding)
290
+ .splitlines()
291
+ )
292
+ except (UnicodeDecodeError, OSError):
293
+ continue
294
+ for index, line in enumerate(lines):
295
+ if not regex.search(line):
296
+ continue
297
+ matches.append(
298
+ GrepMatch(
299
+ path=entry.path,
300
+ line_number=index + 1,
301
+ line=line,
302
+ context_before=tuple(
303
+ lines[max(0, index - context_lines) : index]
304
+ ),
305
+ context_after=tuple(
306
+ lines[index + 1 : index + 1 + context_lines]
307
+ ),
308
+ )
309
+ )
310
+ if max_matches is not None and len(matches) >= max_matches:
311
+ break
312
+ return matches
313
+
314
+ async def list_dir(
315
+ self,
316
+ path: str | os.PathLike[str] = ".",
317
+ *,
318
+ recursive: bool = False,
319
+ limit: int | None = None,
320
+ ) -> Sequence[Entry]:
321
+ return await asyncio.to_thread(self._list_dir, path, recursive, limit)
322
+
323
+ def _list_dir(
324
+ self,
325
+ path: str | os.PathLike[str],
326
+ recursive: bool,
327
+ limit: int | None,
328
+ ) -> list[Entry]:
329
+ if limit is not None and limit < 0:
330
+ raise ValueError("limit must be non-negative or None")
331
+ directory = self._resolve(path)
332
+ if not directory.is_dir():
333
+ raise NotADirectoryError(directory)
334
+
335
+ discovered = directory.rglob("*") if recursive else directory.iterdir()
336
+ candidates = sorted(discovered, key=self._display)
337
+ entries: list[Entry] = []
338
+ for candidate in candidates:
339
+ if limit is not None and len(entries) >= limit:
340
+ break
341
+ try:
342
+ entries.append(self._entry(candidate))
343
+ except OSError:
344
+ continue
345
+ return sorted(entries, key=lambda entry: entry.path)
346
+
347
+ async def stat(
348
+ self,
349
+ path: str | os.PathLike[str],
350
+ ) -> Entry | None:
351
+ return await asyncio.to_thread(self._stat, path)
352
+
353
+ def _stat(self, path: str | os.PathLike[str]) -> Entry | None:
354
+ target = self._resolve_entry(path)
355
+ try:
356
+ return self._entry(target)
357
+ except FileNotFoundError:
358
+ return None
359
+
360
+ def _entry(self, path: Path) -> Entry:
361
+ metadata = path.lstat()
362
+ is_dir = path.is_dir()
363
+ return Entry(
364
+ path=self._display(path),
365
+ is_dir=is_dir,
366
+ is_symlink=path.is_symlink(),
367
+ size=None if is_dir else metadata.st_size,
368
+ modified=metadata.st_mtime,
369
+ )
370
+
371
+ def _resolve(self, path: str | os.PathLike[str]) -> Path:
372
+ target, base = self._locate(path)
373
+ target = target.resolve(strict=False)
374
+ self._assert_inside(target, base)
375
+ return target
376
+
377
+ def _resolve_entry(self, path: str | os.PathLike[str]) -> Path:
378
+ target, base = self._locate(path)
379
+ target = Path(os.path.abspath(target))
380
+ self._assert_inside(target, base)
381
+ self._assert_inside(target.parent.resolve(strict=False), base)
382
+ return target
383
+
384
+ def _locate(self, path: str | os.PathLike[str]) -> tuple[Path, Path]:
385
+ """Map a requested path to a host path plus the root it may not escape.
386
+
387
+ Mount prefixes are matched textually because a POSIX-absolute prefix
388
+ such as ``/skills`` is drive-relative on Windows, so ``Path`` would
389
+ silently reinterpret it against the current drive.
390
+ """
391
+ text = os.fspath(path).replace("\\", "/")
392
+ for prefix, mount in self._mounts:
393
+ if text == prefix:
394
+ return mount, mount
395
+ if text.startswith(f"{prefix}/"):
396
+ return mount / text[len(prefix) + 1 :], mount
397
+
398
+ requested = Path(path)
399
+ target = requested if requested.is_absolute() else self._root / requested
400
+ return target, self._root
401
+
402
+ def _assert_inside(self, path: Path, base: Path | None = None) -> None:
403
+ base = self._root if base is None else base
404
+ try:
405
+ path.relative_to(base)
406
+ except ValueError as error:
407
+ raise PathOutsideWorkspaceError(
408
+ f"path is outside workspace: {path}"
409
+ ) from error
410
+
411
+ def _base_of(self, path: Path) -> Path:
412
+ for _, mount in self._mounts:
413
+ if path == mount or mount in path.parents:
414
+ return mount
415
+ return self._root
416
+
417
+ def _display(self, path: Path) -> str:
418
+ base = self._base_of(path)
419
+ relative = path.relative_to(base).as_posix()
420
+ if base == self._root:
421
+ return relative if relative != "." else "."
422
+ prefix = next(prefix for prefix, mount in self._mounts if mount == base)
423
+ return prefix if relative == "." else f"{prefix}/{relative}"
424
+
425
+ def _check_size(self, size: int) -> None:
426
+ if self._max_file_bytes is not None and size > self._max_file_bytes:
427
+ raise FileTooLargeError(
428
+ f"file is {size} bytes; limit is {self._max_file_bytes} bytes"
429
+ )
430
+
431
+
432
+ def _normalize_mounts(
433
+ mounts: Mapping[str | PurePosixPath, str | os.PathLike[str]] | None,
434
+ ) -> tuple[tuple[str, Path], ...]:
435
+ if not mounts:
436
+ return ()
437
+
438
+ normalized: list[tuple[str, Path]] = []
439
+ for prefix, directory in mounts.items():
440
+ text = str(prefix).replace("\\", "/").rstrip("/")
441
+ if not text.startswith("/"):
442
+ raise ValueError(f"mount prefix must be absolute: {prefix}")
443
+
444
+ target = Path(directory).expanduser()
445
+ if target.exists() and not target.is_dir():
446
+ raise NotADirectoryError(target)
447
+ target.mkdir(parents=True, exist_ok=True)
448
+ normalized.append((text, target.resolve()))
449
+
450
+ # Longest first so nested mounts win over their parent prefix.
451
+ return tuple(sorted(normalized, key=lambda mount: len(mount[0]), reverse=True))
452
+
453
+
454
+ def _validate_pattern(pattern: str) -> None:
455
+ if not pattern:
456
+ raise ValueError("glob pattern must not be empty")
457
+ parsed = PurePath(pattern)
458
+ if parsed.is_absolute() or ".." in parsed.parts:
459
+ raise PathOutsideWorkspaceError(
460
+ f"glob pattern must stay inside workspace: {pattern}"
461
+ )
462
+
463
+
464
+ def _atomic_write(target: Path, data: bytes) -> None:
465
+ existing_mode = stat.S_IMODE(target.stat().st_mode) if target.exists() else None
466
+ descriptor, temporary_name = tempfile.mkstemp(
467
+ prefix=f".{target.name}.",
468
+ suffix=".tmp",
469
+ dir=target.parent,
470
+ )
471
+ temporary = Path(temporary_name)
472
+ try:
473
+ with os.fdopen(descriptor, "wb") as handle:
474
+ handle.write(data)
475
+ handle.flush()
476
+ os.fsync(handle.fileno())
477
+ if existing_mode is not None:
478
+ temporary.chmod(existing_mode)
479
+ os.replace(temporary, target)
480
+ except BaseException:
481
+ temporary.unlink(missing_ok=True)
482
+ raise
@@ -0,0 +1,30 @@
1
+ from .backends import (
2
+ BindMount,
3
+ BubblewrapSandbox,
4
+ DockerNetworkMode,
5
+ DockerSandbox,
6
+ UnsafeLocalSandbox,
7
+ )
8
+ from .policy import SandboxLimits, SandboxPolicy
9
+ from .sandbox import (
10
+ Sandbox,
11
+ SandboxError,
12
+ SandboxExecutionError,
13
+ SandboxResult,
14
+ SandboxUnavailableError,
15
+ )
16
+
17
+ __all__ = [
18
+ "BindMount",
19
+ "BubblewrapSandbox",
20
+ "DockerNetworkMode",
21
+ "DockerSandbox",
22
+ "Sandbox",
23
+ "SandboxError",
24
+ "SandboxExecutionError",
25
+ "SandboxLimits",
26
+ "SandboxPolicy",
27
+ "SandboxResult",
28
+ "SandboxUnavailableError",
29
+ "UnsafeLocalSandbox",
30
+ ]
@@ -0,0 +1,11 @@
1
+ from .bubblewrap import BubblewrapSandbox
2
+ from .docker import BindMount, DockerNetworkMode, DockerSandbox
3
+ from .subprocess import UnsafeLocalSandbox
4
+
5
+ __all__ = [
6
+ "BindMount",
7
+ "BubblewrapSandbox",
8
+ "DockerNetworkMode",
9
+ "DockerSandbox",
10
+ "UnsafeLocalSandbox",
11
+ ]