zettcode-tools 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ **/.venv/
2
+ **/__pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .smoke/
8
+ .coverage
9
+ **/.pytest_cache/
10
+ **/.ruff_cache/
11
+ .zett-tool-output/
12
+ .env
13
+ .DS_Store
14
+
15
+ # viztracer writes its trace here by default.
16
+ /result.json
17
+
18
+ # The docs site: dependencies, build output, and VitePress' cache.
19
+ node_modules/
20
+ docs/.vitepress/dist/
21
+ docs/.vitepress/cache/
22
+ docs/.vitepress/.temp/
23
+
24
+ # The TypeScript client is built from its sources; only dist/ is published.
25
+ clients/typescript/dist/
26
+
27
+ # The gateway's page is built from clients/gateway into the package when a distribution is made (`make gateway-ui`);
28
+ # the build output is not kept in the repository. pyproject's `artifacts` puts it in the wheel all the same.
29
+ packages/zettcode-gateway/src/zettcode/gateway/static/
30
+
31
+ # The palette gallery is generated by `make palettes`: every widget painted in
32
+ # every palette, written by docs/scripts/palette_gallery.py. Regenerate it
33
+ # rather than committing a 2 MB copy that goes stale the moment a colour moves.
34
+ docs/internal/palettes.html
35
+
36
+ # The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
37
+ packages/order.txt
38
+
39
+ # The desktop and phone apps build the gateway's page into their own folders, and electron-builder writes its
40
+ # installers under release/.
41
+ clients/desktop/renderer/
42
+ clients/desktop/release/
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.5
2
+ Name: zettcode-tools
3
+ Version: 0.4.1
4
+ Summary: The coding tools of ZettCode: read, search, write, edit and the shell, as a part of the agent that lives apart from it
5
+ Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
+ Author: Chang-LeHung
7
+ License-Expression: MIT
8
+ Keywords: agent,tools,zettcode
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: pydantic<3,>=2
19
+ Requires-Dist: zett-agent<0.3,>=0.2.10
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "zettcode-tools"
3
+ description = "The coding tools of ZettCode: read, search, write, edit and the shell, as a part of the agent that lives apart from it"
4
+ requires-python = ">=3.10"
5
+ dynamic = ["version"]
6
+ license = "MIT"
7
+ authors = [{name = "Chang-LeHung"}]
8
+ keywords = ["zettcode", "tools", "agent"]
9
+ classifiers = [
10
+ "Development Status :: 5 - Production/Stable",
11
+ "Programming Language :: Python :: 3.10",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Programming Language :: Python :: 3.15",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ # The tool contract, the message types and the @tool builder.
21
+ "zett-agent>=0.2.10,<0.3",
22
+ # A tool's parameters are described with the schema library the framework uses.
23
+ "pydantic>=2,<3",
24
+ ]
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/Chang-LeHung/zettcode"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.version]
34
+ # One version for the whole repository: the engine ships the file.
35
+ path = "../zettcode-engine/src/zettcode/version.py"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/zettcode"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = ["/src", "/pyproject.toml"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ asyncio_mode = "auto"
@@ -0,0 +1,7 @@
1
+ """The coding tools the agent offers, written here: read, search, write, edit and the shell.
2
+
3
+ ``files`` holds the six file tools and ``shell`` the seventh; ``workspace``, ``output`` and ``images`` are what they are
4
+ built from; ``boundary`` is the contract the shell tool has with the permission level. Nothing here is re-exported,
5
+ so importing the package costs nothing: ``boundary`` is imported by the permission package at start-up and has to stay
6
+ free of the schema library the tools themselves need.
7
+ """
@@ -0,0 +1,56 @@
1
+ """What the shell tool asks of the permission level, and what it asks of a sandbox.
2
+
3
+ Both are abstract base classes, and the permission package subclasses them: the tools sit below it in the import
4
+ order, so the contract has to live down here, and a subclass that forgets a method fails when it is built instead of
5
+ when a command first runs. Nothing in this module imports the framework or a model library, so the permission
6
+ package can import it at start-up for free.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from pathlib import Path
13
+
14
+
15
+ class Sandboxer(ABC):
16
+ """A sandbox that rewrites a shell command so it runs inside it."""
17
+
18
+ @property
19
+ @abstractmethod
20
+ def available(self) -> bool:
21
+ """Return whether this machine can sandbox a command at all."""
22
+
23
+ @abstractmethod
24
+ def wrap(self, command: str, cwd: Path | None = None) -> str:
25
+ """Return ``command`` as a shell line that runs sandboxed, in ``cwd`` or in the working directory.
26
+
27
+ The directory is not a detail: the policy a sandbox writes allows one directory, and a Linux sandbox
28
+ moves the process there itself, so a wrapper that is given the working directory runs the command where
29
+ it is not meant to run.
30
+
31
+ Raises:
32
+ RuntimeError: The machine has no sandbox; check :attr:`available` first.
33
+ """
34
+
35
+ @abstractmethod
36
+ def hint(self) -> str:
37
+ """Return the note that goes after a failed sandboxed command, naming the boundary it hit."""
38
+
39
+
40
+ class Boundary(ABC):
41
+ """The decision the shell tool defers to at every command: run it sandboxed, or as it was given.
42
+
43
+ ``ShellPolicy`` is the implementation. The decision is read at each call and not when the tool is built, so
44
+ ``/permission`` takes effect on the next command of a request that is already running.
45
+ """
46
+
47
+ @abstractmethod
48
+ def sandbox_now(self) -> Sandboxer | None:
49
+ """Return the sandbox the next command has to run in, or ``None`` to run it as given.
50
+
51
+ One answer, not a flag and a sandbox read separately: a level changed between the two reads could otherwise
52
+ hand back a flag for one and a sandbox for the other.
53
+ """
54
+
55
+
56
+ __all__ = ["Boundary", "Sandboxer"]
@@ -0,0 +1,279 @@
1
+ """The file tools: read, search, write and edit.
2
+
3
+ Each tool is a typed function whose signature is its input schema and whose docstring is what the model is told:
4
+ the first paragraph is the description, ``Args`` the parameters, ``Snippet`` the call shapes, ``Guidelines`` the rules
5
+ that go in the prompt. ``zett_agent.tools.base.tool`` turns one into an :class:`~zett_agent.tools.base.AgentTool`;
6
+ the work is done here, in this repository, and by nothing else.
7
+
8
+ There are two readers and no more. ``read`` returns UTF-8 text as a line range and a PNG, JPEG, GIF or WebP file as an
9
+ image, so one tool covers both. ``read_file`` is the same reader without the pictures, for a model that cannot take
10
+ an image; the capability filter above swaps one for the other.
11
+ There is no ``view_image``, because ``read`` already shows a picture, and no ``delete_file``: removing a file is a
12
+ shell command and goes through the shell's permission like any other, with no second path around it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Annotated
20
+
21
+ from pydantic import Field
22
+ from zett_agent.messages import ImageBytesSource, ImageContent, TextContent
23
+ from zett_agent.tools.base import tool
24
+
25
+ from .images import MAX_IMAGE_BYTES, SIGNATURE_BYTES, image_media_type
26
+ from .output import MAX_MATCH_BYTES, MAX_OUTPUT_BYTES, utf8_prefix
27
+ from .workspace import (
28
+ MAX_FILE_BYTES,
29
+ Located,
30
+ Pattern,
31
+ apply_edit,
32
+ read_lines,
33
+ read_text,
34
+ write_text,
35
+ )
36
+
37
+ # The parameter types, named once so that the tools that share one agree on its limits and its schema.
38
+ FilePath = Annotated[str, Field(min_length=1)]
39
+ Expression = Annotated[str, Field(min_length=1)]
40
+ Offset = Annotated[int, Field(ge=1)]
41
+ Limit = Annotated[int, Field(ge=1, le=2_000)]
42
+ MaxResults = Annotated[int, Field(ge=1, le=5_000)]
43
+ OldString = Annotated[str, Field(min_length=1)]
44
+
45
+ #: What a reader hands back: text, or a text line and the picture it describes.
46
+ ReadResult = str | list[TextContent | ImageContent]
47
+
48
+
49
+ def _image_type_of(path: Path) -> str | None:
50
+ """Return the media type of a local image file, or ``None`` when it is not a PNG, JPEG, GIF or WebP."""
51
+ with path.open("rb") as source:
52
+ return image_media_type(source.read(SIGNATURE_BYTES))
53
+
54
+
55
+ @tool
56
+ def read(file_path: FilePath, offset: Offset = 1, limit: Limit = 200) -> ReadResult:
57
+ """Read a local file: UTF-8 text as a line range, or a PNG, JPEG, GIF, or WebP image as an image.
58
+
59
+ Args:
60
+ file_path: Absolute path or a path relative to the current working directory.
61
+ offset: One-based line number to start reading from; ignored for images.
62
+ limit: Maximum number of lines to return; ignored for images.
63
+
64
+ Returns:
65
+ The requested line range, or the image and a one-line description of it; a trailing hint says how to
66
+ continue when more lines remain.
67
+
68
+ Snippet:
69
+ read(file_path="docs/diagram.png")
70
+
71
+ Guidelines:
72
+ - Use for any local file when you do not know whether it is text or an image.
73
+ - A text file comes back as a line range; use line ranges for large files.
74
+ - A trailing hint says how to go on: the next offset when more lines remain, or the shell when one line is
75
+ longer than the output limit.
76
+ - An image comes back as an image with a one-line description; the range arguments do not apply to it.
77
+ - PDF and SVG files are not supported.
78
+ """
79
+ located = Located.of(file_path)
80
+ target = located.require_file()
81
+ media_type = _image_type_of(target)
82
+ if media_type is None:
83
+ return read_lines(located, offset, limit)
84
+ size = target.stat().st_size
85
+ if size > MAX_IMAGE_BYTES:
86
+ raise ValueError(f"Image exceeds the {MAX_IMAGE_BYTES}-byte limit: {file_path}")
87
+ return [
88
+ TextContent(text=f"Image {target.name}: {media_type}, {size} bytes"),
89
+ ImageContent(source=ImageBytesSource(data=target.read_bytes(), media_type=media_type), alt_text=target.name),
90
+ ]
91
+
92
+
93
+ @tool
94
+ def read_file(file_path: FilePath, offset: Offset = 1, limit: Limit = 200) -> str:
95
+ """Read a line range from a UTF-8 text file.
96
+
97
+ Args:
98
+ file_path: Absolute path or a path relative to the current working directory.
99
+ offset: One-based line number to start reading from.
100
+ limit: Maximum number of lines to return.
101
+
102
+ Returns:
103
+ The requested line range; a trailing hint says how to continue when more lines remain.
104
+
105
+ Snippet:
106
+ read_file(file_path="src/app.py", offset=1, limit=200)
107
+
108
+ Guidelines:
109
+ - Use line ranges for large files.
110
+ - Inspect the current content before editing a file.
111
+ """
112
+ located = Located.of(file_path)
113
+ located.require_file()
114
+ return read_lines(located, offset, limit)
115
+
116
+
117
+ @tool
118
+ def glob(pattern: Expression, max_results: MaxResults = 200) -> str:
119
+ """Find paths matching a relative or absolute glob pattern.
120
+
121
+ Args:
122
+ pattern: Glob pattern such as **/*.py, ../tests/*.py, or /work/src/**/*.py.
123
+ max_results: Maximum number of sorted paths to return.
124
+
125
+ Returns:
126
+ Matching paths, one per line and sorted, empty when nothing matches, with a trailing note when the result was
127
+ truncated.
128
+
129
+ Snippet:
130
+ glob(pattern="src/**/*.py", max_results=200)
131
+
132
+ Guidelines:
133
+ - Use glob to discover files before reading or searching them.
134
+ - Narrow the pattern when the result is truncated.
135
+ """
136
+ spec = Pattern.parse(pattern)
137
+ paths: list[str] = []
138
+ used = 0
139
+ truncated = False
140
+ for match in spec.matches():
141
+ rendered = spec.render(match)
142
+ size = len(rendered.encode("utf-8"))
143
+ if len(paths) == max_results or used + size > MAX_OUTPUT_BYTES:
144
+ truncated = True
145
+ break
146
+ paths.append(rendered)
147
+ used += size
148
+ result = "\n".join(paths)
149
+ if truncated:
150
+ result += "\n... [truncated; narrow the pattern or increase max_results]"
151
+ return result
152
+
153
+
154
+ @tool
155
+ def grep(
156
+ pattern: Expression,
157
+ file_pattern: Expression = "**/*",
158
+ case_sensitive: bool = True,
159
+ max_results: MaxResults = 200,
160
+ ) -> str:
161
+ """Search UTF-8 text files selected by a relative or absolute glob pattern.
162
+
163
+ Args:
164
+ pattern: Python regular expression to search for on each line.
165
+ file_pattern: Relative or absolute glob selecting files to search.
166
+ case_sensitive: Whether letter case must match exactly.
167
+ max_results: Maximum number of matching lines to return.
168
+
169
+ Returns:
170
+ Matching lines as ``path:line: text``, one per line, with a trailing note when the result was truncated.
171
+
172
+ Snippet:
173
+ grep(pattern="class\\s+Agent", file_pattern="src/**/*.py", max_results=200)
174
+
175
+ Guidelines:
176
+ - Use a narrow file_pattern to avoid scanning unrelated files.
177
+ - Escape regular-expression characters when searching for literal text.
178
+ - Use the read tool for surrounding context.
179
+ """
180
+ spec = Pattern.parse(file_pattern)
181
+ try:
182
+ expression = re.compile(pattern, 0 if case_sensitive else re.IGNORECASE)
183
+ except re.error as error:
184
+ raise ValueError(f"Invalid regular expression: {error}") from error
185
+ lines: list[str] = []
186
+ used = 0
187
+ truncated = False
188
+ for candidate in spec.matches():
189
+ if not candidate.is_file() or candidate.stat().st_size > MAX_FILE_BYTES:
190
+ continue
191
+ try:
192
+ content = candidate.read_text(encoding="utf-8")
193
+ except UnicodeDecodeError:
194
+ continue
195
+ shown = spec.render(candidate)
196
+ for number, line in enumerate(content.splitlines(), start=1):
197
+ found = expression.search(line)
198
+ if found is None:
199
+ continue
200
+ if len(lines) == max_results:
201
+ truncated = True
202
+ break
203
+ first = max(0, found.start() - 100) if len(line.encode("utf-8")) > MAX_MATCH_BYTES else 0
204
+ rendered = f"{shown}:{number}: {utf8_prefix(line[first:], MAX_MATCH_BYTES)}"
205
+ size = len(rendered.encode("utf-8"))
206
+ if used + size > MAX_OUTPUT_BYTES:
207
+ truncated = True
208
+ break
209
+ lines.append(rendered)
210
+ used += size
211
+ if truncated:
212
+ break
213
+ result = "\n".join(lines)
214
+ if truncated:
215
+ result += "\n... [truncated; narrow the search or increase max_results]"
216
+ return result
217
+
218
+
219
+ @tool
220
+ def write_file(file_path: FilePath, content: str, overwrite: bool = True) -> str:
221
+ """Atomically write a UTF-8 text file.
222
+
223
+ Args:
224
+ file_path: Absolute path or a path relative to the current working directory.
225
+ content: Complete UTF-8 text to write.
226
+ overwrite: Whether an existing file may be replaced.
227
+
228
+ Returns:
229
+ One line saying whether the file was created or replaced, and its path.
230
+
231
+ Snippet:
232
+ write_file(file_path="notes/plan.md", content="# Plan\\n")
233
+
234
+ Guidelines:
235
+ - Use for new files or intentional full-file replacement.
236
+ - Prefer edit for a small change to an existing file.
237
+ """
238
+ located = Located.of(file_path)
239
+ exists = located.path.exists()
240
+ if exists and not located.path.is_file():
241
+ raise ValueError(f"Path is not a file: {file_path}")
242
+ if exists and not overwrite:
243
+ raise ValueError(f"File already exists: {file_path}")
244
+ write_text(located.path, content)
245
+ return f"{'Wrote' if exists else 'Created'} {located.shown}"
246
+
247
+
248
+ @tool
249
+ def edit(file_path: FilePath, old_string: OldString, new_string: str, replace_all: bool = False) -> str:
250
+ """Edit an existing UTF-8 file by replacing exact text; always use this tool to modify a file.
251
+
252
+ Args:
253
+ file_path: Absolute path or a path relative to the current working directory.
254
+ old_string: The exact text to replace, copied from the file as it is now.
255
+ new_string: The text to put in its place; an empty string deletes the match.
256
+ replace_all: Replace every occurrence of old_string instead of insisting it appears once.
257
+
258
+ Returns:
259
+ One line saying how many occurrences were replaced, and in which file.
260
+
261
+ Snippet:
262
+ edit(file_path="notes/plan.md", old_string="Draft", new_string="Final")
263
+ edit(file_path="notes/plan.md", old_string="Total", new_string="Sum", replace_all=True)
264
+
265
+ Guidelines:
266
+ - Always use this tool when you need to modify an existing file, instead of sed, awk, perl -i, python -c, tee, or shell redirection. Reserve those for bulk or mechanical rewrites this tool cannot express, such as renaming a symbol across many files.
267
+ - One change a call: call edit again for the next change, and nothing is written when the old text does not match.
268
+ - Avoid sed, cat, and similar shell commands for reading or changing files unless it is necessary; inspect files with the read tool and change them with this tool.
269
+ - Read the target lines first and copy old_string exactly: same indentation, quotes, and line breaks.
270
+ - Make old_string unique by including a neighbouring line or two; set replace_all only when every occurrence must change.
271
+ - When an edit fails, read the file again and retry with corrected old_string rather than switching to a shell command.
272
+ """
273
+ located = Located.of(file_path)
274
+ updated, replacements = apply_edit(read_text(located.require_file()), old_string, new_string, replace_all)
275
+ write_text(located.path, updated)
276
+ return f"Replaced {replacements} occurrence{'s' if replacements != 1 else ''} in {located.shown}"
277
+
278
+
279
+ __all__ = ["edit", "glob", "grep", "read", "read_file", "write_file"]
@@ -0,0 +1,32 @@
1
+ """Recognising the image files ``read`` hands to a model: by their first bytes, never by their name."""
2
+
3
+ from __future__ import annotations
4
+
5
+ #: The largest image file ``read`` returns.
6
+ MAX_IMAGE_BYTES = 10_000_000
7
+
8
+ #: Bytes needed to recognise every signature below.
9
+ SIGNATURE_BYTES = 12
10
+
11
+
12
+ def image_media_type(header: bytes) -> str | None:
13
+ """Return the media type of a PNG, JPEG, GIF or WebP from the first bytes of a file, or ``None`` for anything else.
14
+
15
+ A file's name says what its author meant; its first bytes say what it is, and a model rejects a picture
16
+ announced as the wrong type. A file that is not one of the four is text as far as ``read`` is concerned.
17
+
18
+ Args:
19
+ header: At least :data:`SIGNATURE_BYTES` bytes from the start of the file (fewer for a short file).
20
+ """
21
+ if header.startswith(b"\x89PNG\r\n\x1a\n"):
22
+ return "image/png"
23
+ if header.startswith(b"\xff\xd8\xff"):
24
+ return "image/jpeg"
25
+ if header.startswith((b"GIF87a", b"GIF89a")):
26
+ return "image/gif"
27
+ if header.startswith(b"RIFF") and header[8:12] == b"WEBP":
28
+ return "image/webp"
29
+ return None
30
+
31
+
32
+ __all__ = ["MAX_IMAGE_BYTES", "SIGNATURE_BYTES", "image_media_type"]
@@ -0,0 +1,81 @@
1
+ """Bounded UTF-8 previews: a long text is cut to a budget, and the full output stays in a file for later reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import NamedTuple
7
+
8
+ #: The most bytes, lines and bytes-per-matching-line any one tool result may carry.
9
+ MAX_OUTPUT_BYTES = 50 * 1024
10
+ MAX_OUTPUT_LINES = 2000
11
+ MAX_MATCH_BYTES = 2000
12
+
13
+ #: What stands where the middle of a long output was left out.
14
+ OMISSION = "\n... [middle omitted; read the output file for full content] ...\n"
15
+
16
+
17
+ class Preview(NamedTuple):
18
+ """A bounded view of one output stream.
19
+
20
+ Attributes:
21
+ text: What fits the budget.
22
+ truncated: Whether the stream was longer than that, so the full text has to be kept.
23
+ """
24
+
25
+ text: str
26
+ truncated: bool
27
+
28
+
29
+ def utf8_prefix(text: str, budget: int) -> str:
30
+ """Return the longest prefix of ``text`` whose UTF-8 encoding fits in ``budget`` bytes, never half a character."""
31
+ return text.encode("utf-8")[:budget].decode("utf-8", errors="ignore")
32
+
33
+
34
+ def stream_preview(path: Path, marker: str = OMISSION) -> Preview:
35
+ """Return a bounded preview of a stream written to ``path``: its start and its end, with the middle left out.
36
+
37
+ A quarter of the byte budget goes to the start (what a command printed while it started up) and the rest to
38
+ the end (its final results). Only bounded windows are read from disk, so a gigabyte of output costs what fifty
39
+ kilobytes does, and a partial UTF-8 character at either edge of a window is dropped. The line limit applies to
40
+ the text returned, the marker included. The file is never changed, which is what lets the model read what was
41
+ omitted.
42
+
43
+ Args:
44
+ path: The stream that was written.
45
+ marker: What stands between the two windows. A caller that knows where the whole text is kept puts that in
46
+ the marker; it is written once, where the cut is, so a copy of the default marker inside the text cannot
47
+ be mistaken for the cut.
48
+ """
49
+ with path.open("rb") as source:
50
+ data = source.read(MAX_OUTPUT_BYTES + 1)
51
+ text = data.decode("utf-8", errors="replace")
52
+ if (
53
+ len(data) <= MAX_OUTPUT_BYTES
54
+ and len(text.encode("utf-8")) <= MAX_OUTPUT_BYTES
55
+ and len(text.splitlines()) <= MAX_OUTPUT_LINES
56
+ ):
57
+ return Preview(text, False)
58
+ budget = max(2, MAX_OUTPUT_BYTES - len(marker.encode("utf-8")))
59
+ head_budget = budget // 4
60
+ tail_budget = budget - head_budget
61
+ head = data[:head_budget].decode("utf-8", errors="ignore")
62
+ source.seek(max(0, path.stat().st_size - tail_budget))
63
+ tail = source.read(tail_budget).decode("utf-8", errors="ignore")
64
+ # What the marker takes is held back so the line limit covers the text that comes back: a caller's marker is a
65
+ # sentence or two, and the floor keeps a long one from eating the whole preview.
66
+ lines = max(2, MAX_OUTPUT_LINES - max(1, marker.count("\n")) - 1)
67
+ head_lines = max(1, lines // 4)
68
+ head = "".join(head.splitlines(keepends=True)[:head_lines])
69
+ tail = "".join(tail.splitlines(keepends=True)[-(lines - head_lines) :])
70
+ return Preview(head + marker + tail, True)
71
+
72
+
73
+ __all__ = [
74
+ "MAX_MATCH_BYTES",
75
+ "MAX_OUTPUT_BYTES",
76
+ "MAX_OUTPUT_LINES",
77
+ "OMISSION",
78
+ "Preview",
79
+ "stream_preview",
80
+ "utf8_prefix",
81
+ ]
File without changes
@@ -0,0 +1,185 @@
1
+ """The shell tool: run one command in a directory it names, bounded in time and in what comes back.
2
+
3
+ The command's two streams go to files and not into memory, so the volume of output costs nothing, and what is
4
+ returned is a preview of each (see :func:`~zettcode.tools.output.stream_preview`). When a preview had to cut
5
+ something, the files are kept and the result ends with a note naming them, which the model can read; otherwise they
6
+ are removed. A command that fails or runs out of time is reported as an error that carries its output.
7
+
8
+ At the ``safe`` permission level the command is rewritten to run inside the sandbox (see
9
+ :class:`~zettcode.tools.boundary.Boundary`).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import os
16
+ import signal
17
+ import tempfile
18
+ from pathlib import Path
19
+ from typing import Annotated
20
+
21
+ from pydantic import Field
22
+ from zett_agent.tools.base import AgentTool, tool
23
+
24
+ from .boundary import Boundary
25
+ from .output import Preview, stream_preview
26
+ from .workspace import Located
27
+
28
+ #: Where a command's kept output goes, inside the working directory. The part that moves it out of the reader's
29
+ #: project (`zettcode-tool-output`) names the same folder, and a test holds the two equal.
30
+ OUTPUT_ROOT = ".zett-tool-output"
31
+
32
+ #: The runtime a command is given unless the model asks for another, and the most it may ask for.
33
+ DEFAULT_TIMEOUT_SECONDS = 60
34
+ MAX_TIMEOUT_SECONDS = 600
35
+
36
+ Command = Annotated[str, Field(min_length=1)]
37
+ WorkDir = Annotated[str, Field(min_length=1)]
38
+ TimeoutSeconds = Annotated[int, Field(ge=1, le=MAX_TIMEOUT_SECONDS)]
39
+
40
+
41
+ class CommandFailed(RuntimeError):
42
+ """A command that exited with a code other than 0, or ran out of time. The message carries what it printed."""
43
+
44
+
45
+ def _compose(stdout: Preview, stderr: Preview, kept: tuple[Path, Path] | None, directory: Path) -> str:
46
+ """Join the two previews into the text a result carries, with the note that names the kept files if there are any."""
47
+ sections: list[str] = []
48
+ if stdout.text:
49
+ sections.append(stdout.text)
50
+ if stderr.text:
51
+ sections.append(f"[stderr]\n{stderr.text}")
52
+ composed = "\n".join(sections)
53
+ if kept is not None:
54
+ first, second = (path.relative_to(directory).as_posix() for path in kept)
55
+ composed += f"\n... [output truncated; full streams kept at {first} and {second}]"
56
+ return composed
57
+
58
+
59
+ async def _stop(process: asyncio.subprocess.Process) -> None:
60
+ """Kill a command and every process it started, and wait for it to be gone."""
61
+ try:
62
+ if os.name == "posix":
63
+ os.killpg(process.pid, signal.SIGKILL)
64
+ else:
65
+ process.kill()
66
+ except ProcessLookupError:
67
+ pass
68
+ await process.wait()
69
+
70
+
71
+ async def run_command(command: str, timeout_seconds: int, directory: Path | None = None) -> str:
72
+ """Run ``command`` in ``directory``, or in the working directory, and return what it printed.
73
+
74
+ Raises:
75
+ ValueError: The command is blank.
76
+ CommandFailed: It exited with a code other than 0, or did not finish in ``timeout_seconds``; the message
77
+ carries its output either way.
78
+ """
79
+ if not command.strip():
80
+ raise ValueError("Shell command cannot be blank")
81
+ root = Located.of(OUTPUT_ROOT)
82
+ # No await between making the root and making the folder in it: the extension that tidies an empty root away
83
+ # runs on this loop, so it can never see the one without the other.
84
+ root.path.mkdir(parents=True, exist_ok=True)
85
+ folder = Path(tempfile.mkdtemp(prefix="shell-", dir=root.path))
86
+ stdout_file, stderr_file = folder / "stdout.txt", folder / "stderr.txt"
87
+ timed_out = False
88
+ # File descriptors rather than pipes keep memory bounded however much is printed. The two streams stay
89
+ # separate: how they interleaved is not kept.
90
+ with stdout_file.open("wb") as stdout_target, stderr_file.open("wb") as stderr_target:
91
+ process = await asyncio.create_subprocess_shell(
92
+ command,
93
+ cwd=directory if directory is not None else root.directory,
94
+ stdin=asyncio.subprocess.DEVNULL,
95
+ stdout=stdout_target,
96
+ stderr=stderr_target,
97
+ start_new_session=os.name == "posix",
98
+ )
99
+ try:
100
+ await asyncio.wait_for(process.wait(), timeout_seconds)
101
+ # ``asyncio.TimeoutError`` is the builtin from 3.11 on and a class of its own before.
102
+ except (TimeoutError, asyncio.TimeoutError):
103
+ await _stop(process)
104
+ timed_out = True
105
+ except asyncio.CancelledError:
106
+ await _stop(process)
107
+ raise
108
+ stdout, stderr = stream_preview(stdout_file), stream_preview(stderr_file)
109
+ truncated = stdout.truncated or stderr.truncated
110
+ composed = _compose(stdout, stderr, (stdout_file, stderr_file) if truncated else None, root.directory)
111
+ if not truncated:
112
+ stdout_file.unlink()
113
+ stderr_file.unlink()
114
+ folder.rmdir()
115
+ if timed_out:
116
+ plural = "" if timeout_seconds == 1 else "s"
117
+ raise CommandFailed(f"Command timed out after {timeout_seconds} second{plural}\n{composed}")
118
+ code = process.returncode if process.returncode is not None else -1
119
+ if code != 0:
120
+ raise CommandFailed(f"Command exited with code {code}\n{composed}")
121
+ return composed
122
+
123
+
124
+ def shell_tool(boundary: Boundary | None = None) -> AgentTool:
125
+ """Return the ``bash`` tool.
126
+
127
+ Args:
128
+ boundary: What decides, at each command, whether it runs inside a sandbox. ``None`` runs every command
129
+ as it was given. A command that fails inside a sandbox has a note appended that names the boundary,
130
+ because "Operation not permitted" alone sends the model round the same wall again.
131
+ """
132
+
133
+ @tool
134
+ async def bash(
135
+ command: Command, work_dir: WorkDir | None = None, timeout_seconds: TimeoutSeconds = DEFAULT_TIMEOUT_SECONDS
136
+ ) -> str:
137
+ """Run a shell command with captured output, in the workspace or in ``work_dir``.
138
+
139
+ Args:
140
+ command: Shell command to execute.
141
+ work_dir: Directory to run in: absolute, a `~` path, or relative to the workspace. Omit it to run in the workspace.
142
+ timeout_seconds: Maximum command runtime in seconds; the default is 60.
143
+
144
+ Returns:
145
+ The command's captured output, with startup and final results kept within the byte and line limits; a
146
+ truncation hint names the retained files.
147
+
148
+ Snippet:
149
+ bash(command="git status --short", timeout_seconds=60)
150
+ bash(command="npm test", work_dir="clients/vscode")
151
+
152
+ Guidelines:
153
+ - Use for builds, tests, git, and searching the code: rg or grep for text, find or ls for paths. Prefer the read, write_file and edit tools for reading and changing a file.
154
+ - Set work_dir instead of cd: every call starts a new shell, so a cd lasts only that one command.
155
+ - Search with a narrow pattern and a path, and pipe through head when the result may be long.
156
+ - Use only for bounded commands in a trusted working directory.
157
+ - A non-zero exit code or a timeout is reported as an error carrying the output.
158
+ - Output previews keep startup and final results within byte and line limits.
159
+ - When output is truncated, read the retained files named in the hint.
160
+ """
161
+ if not command.strip():
162
+ raise ValueError("Shell command cannot be blank")
163
+ located = None if work_dir is None else Located.of(work_dir)
164
+ if located is not None and not located.path.is_dir():
165
+ raise ValueError(f"Not a directory: {work_dir}")
166
+ directory = located.path if located is not None else None
167
+ sandbox = boundary.sandbox_now() if boundary is not None else None
168
+ if sandbox is None:
169
+ return await run_command(command, timeout_seconds, directory)
170
+ try:
171
+ return await run_command(sandbox.wrap(command, directory), timeout_seconds, directory)
172
+ except CommandFailed as error:
173
+ raise CommandFailed(f"{error}\n{sandbox.hint()}") from error
174
+
175
+ return bash
176
+
177
+
178
+ __all__ = [
179
+ "DEFAULT_TIMEOUT_SECONDS",
180
+ "MAX_TIMEOUT_SECONDS",
181
+ "OUTPUT_ROOT",
182
+ "CommandFailed",
183
+ "run_command",
184
+ "shell_tool",
185
+ ]
@@ -0,0 +1,271 @@
1
+ """The file system as the tools see it: paths taken from the working directory, text read in ranges, written whole."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import TextIO
10
+
11
+ from .output import MAX_OUTPUT_BYTES, utf8_prefix
12
+
13
+ #: The largest text file a tool reads whole, writes, or searches.
14
+ MAX_FILE_BYTES = 2 * 1024 * 1024
15
+
16
+ #: How much of a line is read at a time, so one enormous line cannot use the memory of a whole file.
17
+ FRAGMENT_CHARACTERS = 4096
18
+
19
+
20
+ # -- where a path points ---------------------------------------------------------------------------------------
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Located:
25
+ """A path a tool was asked about, resolved against the directory the process is in.
26
+
27
+ Attributes:
28
+ given: The path as the model wrote it, which is what an error message quotes back.
29
+ directory: The working directory, resolved.
30
+ path: Where ``given`` points: absolute paths and ``~`` are taken as they are, anything else is relative to
31
+ ``directory``; symbolic links are followed.
32
+ """
33
+
34
+ given: str
35
+ directory: Path
36
+ path: Path
37
+
38
+ @classmethod
39
+ def of(cls, given: str) -> Located:
40
+ """Resolve ``given`` against the working directory."""
41
+ directory = Path.cwd().resolve()
42
+ candidate = Path(given).expanduser()
43
+ target = candidate.resolve() if candidate.is_absolute() else (directory / candidate).resolve()
44
+ return cls(given, directory, target)
45
+
46
+ @property
47
+ def shown(self) -> str:
48
+ """Return the path as a result should name it: relative to the working directory for a relative ``given``."""
49
+ if Path(self.given).expanduser().is_absolute():
50
+ return self.path.as_posix()
51
+ return Path(os.path.relpath(self.path, self.directory)).as_posix()
52
+
53
+ def require_file(self) -> Path:
54
+ """Return the path, which has to be an existing regular file.
55
+
56
+ Raises:
57
+ ValueError: With the path as given, when there is no such file.
58
+ """
59
+ if not self.path.is_file():
60
+ raise ValueError(f"File does not exist: {self.given}")
61
+ return self.path
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class Pattern:
66
+ """A glob pattern, split into the directory to search from and the expression to match below it.
67
+
68
+ Attributes:
69
+ root: Where the search starts: the filesystem root for an absolute pattern, else the working directory.
70
+ expression: What is matched below ``root``, as ``Path.glob`` takes it.
71
+ absolute: Whether matches are reported as absolute paths (the pattern was one) or relative to ``directory``.
72
+ directory: The working directory, resolved.
73
+ """
74
+
75
+ root: Path
76
+ expression: str
77
+ absolute: bool
78
+ directory: Path
79
+
80
+ @classmethod
81
+ def parse(cls, pattern: str) -> Pattern:
82
+ """Split ``pattern`` the way ``glob`` and ``grep`` take it."""
83
+ directory = Path.cwd().resolve()
84
+ candidate = Path(pattern).expanduser()
85
+ if candidate.is_absolute():
86
+ root = Path(candidate.anchor)
87
+ return cls(root, candidate.relative_to(root).as_posix(), True, directory)
88
+ return cls(directory, candidate.as_posix(), False, directory)
89
+
90
+ def matches(self) -> list[Path]:
91
+ """Return everything the pattern matches, in sorted order."""
92
+ return sorted(self.root.glob(self.expression))
93
+
94
+ def render(self, match: Path) -> str:
95
+ """Return a match in the style the pattern was written in."""
96
+ if self.absolute:
97
+ return match.absolute().as_posix()
98
+ return Path(os.path.relpath(match.absolute(), self.directory)).as_posix()
99
+
100
+
101
+ # -- reading and writing text ------------------------------------------------------------------------------------
102
+
103
+
104
+ def read_text(path: Path) -> str:
105
+ """Return a whole text file.
106
+
107
+ Raises:
108
+ ValueError: When the file is over :data:`MAX_FILE_BYTES`.
109
+ UnicodeDecodeError: When it is not UTF-8.
110
+ """
111
+ if path.stat().st_size > MAX_FILE_BYTES:
112
+ raise ValueError(f"File exceeds the {MAX_FILE_BYTES}-byte limit")
113
+ return path.read_text(encoding="utf-8")
114
+
115
+
116
+ def write_text(path: Path, content: str) -> None:
117
+ """Replace a file with ``content`` atomically: it is written beside the file and renamed over it.
118
+
119
+ A reader of the file sees the old text or the new text, never half of it, and a failed write leaves the old
120
+ text and no stray file behind. Missing parent directories are made.
121
+
122
+ Raises:
123
+ ValueError: When the text is over :data:`MAX_FILE_BYTES`.
124
+ """
125
+ encoded = content.encode("utf-8")
126
+ if len(encoded) > MAX_FILE_BYTES:
127
+ raise ValueError(f"Content exceeds the {MAX_FILE_BYTES}-byte limit")
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ temporary: Path | None = None
130
+ try:
131
+ with tempfile.NamedTemporaryFile(mode="wb", dir=path.parent, delete=False) as handle:
132
+ handle.write(encoded)
133
+ temporary = Path(handle.name)
134
+ os.replace(temporary, path)
135
+ finally:
136
+ if temporary is not None:
137
+ temporary.unlink(missing_ok=True)
138
+
139
+
140
+ def read_lines(located: Located, offset: int, limit: int) -> str:
141
+ """Return ``limit`` lines of a UTF-8 file from line ``offset``, with a note on how to continue when more remains.
142
+
143
+ The result is held to :data:`MAX_OUTPUT_BYTES`. A line longer than that is cut, and the note says so: the
144
+ reader has no column to resume from, because one 2 MB line is not what the tool is for — the shell reads it.
145
+ A cut inside an ordinary line is not that: the budget was used up before it, and the note then names the line
146
+ to start again from. The file is read a fragment at a time and never whole.
147
+
148
+ Args:
149
+ located: The file; it has to exist.
150
+ offset: One-based first line.
151
+ limit: Most lines to return.
152
+
153
+ Raises:
154
+ ValueError: When the file is not UTF-8 text.
155
+ """
156
+ selected: list[str] = []
157
+ number, used, line_start = 1, 0, 0
158
+ next_offset: int | None = None
159
+ cut_at: int | None = None
160
+ oversized = False
161
+ try:
162
+ with located.path.open(encoding="utf-8") as source:
163
+ while fragment := source.readline(FRAGMENT_CHARACTERS):
164
+ if number >= offset + limit:
165
+ next_offset = number
166
+ break
167
+ if number >= offset:
168
+ part = utf8_prefix(fragment, MAX_OUTPUT_BYTES - used)
169
+ selected.append(part)
170
+ used += len(part.encode("utf-8"))
171
+ if len(part) < len(fragment):
172
+ # Whether this line could fill a call of its own decides what the note says, and only
173
+ # what is left of the line knows that. Reading on is bounded by one budget.
174
+ cut_at = number
175
+ oversized = _line_longer_than_budget(
176
+ source, used - line_start, fragment[len(part) :], MAX_OUTPUT_BYTES
177
+ )
178
+ break
179
+ if fragment.endswith("\n"):
180
+ number, line_start = number + 1, used
181
+ except UnicodeDecodeError as error:
182
+ raise ValueError(f"File is not UTF-8 text: {located.given}") from error
183
+ content = "".join(selected)
184
+ if cut_at is not None and oversized:
185
+ content += (
186
+ f"\n... [line truncated; line {cut_at} is longer than the {MAX_OUTPUT_BYTES // 1024} KB output limit "
187
+ "and was cut here; read it with the shell]"
188
+ )
189
+ elif cut_at is not None:
190
+ content += f"\n... [output truncated; continue with offset={cut_at}]"
191
+ elif next_offset is not None:
192
+ content += f"\n... [more lines; continue with offset={next_offset}]"
193
+ return content
194
+
195
+
196
+ def _line_longer_than_budget(source: TextIO, seen: int, tail: str, budget: int) -> bool:
197
+ """Return whether the line continues for more than ``budget`` bytes in all.
198
+
199
+ Args:
200
+ source: The file, positioned just after the fragment the cut happened in.
201
+ seen: How many bytes of this line are already in the result.
202
+ tail: The rest of the fragment the cut happened in, which is not in the result yet.
203
+ budget: What one result may carry.
204
+
205
+ The line is read on to its end, but never past ``budget`` bytes: past that point the answer is known, and
206
+ the rest of a runaway line is what the shell is for.
207
+ """
208
+ fragment = tail
209
+ while True:
210
+ seen += len(fragment.encode("utf-8"))
211
+ if seen > budget:
212
+ return True
213
+ if not fragment or fragment.endswith("\n"):
214
+ return False
215
+ fragment = source.readline(FRAGMENT_CHARACTERS)
216
+
217
+
218
+ # -- editing ------------------------------------------------------------------------------------------------------
219
+
220
+
221
+ def apply_edit(content: str, old_string: str, new_string: str, replace_all: bool = False) -> tuple[str, int]:
222
+ """Replace ``old_string`` in ``content`` once, or everywhere when ``replace_all`` says so.
223
+
224
+ By default ``old_string`` must appear exactly once in the text, so include the surrounding characters that
225
+ make it unique; ``new_string`` may be empty, which deletes the match.
226
+
227
+ Returns:
228
+ The new text and how many occurrences were replaced.
229
+
230
+ Raises:
231
+ ValueError: Saying what to do, when the old text is not found or is not unique. Nothing has been written
232
+ by then, since the caller writes once, after the replacement has passed.
233
+ """
234
+ matches = content.count(old_string)
235
+ if matches == 0:
236
+ raise ValueError(f"Text to replace was not found: {_excerpt(old_string)}. {_miss_hint(content, old_string)}")
237
+ if matches > 1 and not replace_all:
238
+ raise ValueError(
239
+ f"Text to replace is not unique; found {matches} matches "
240
+ f"(include more surrounding lines, or set replace_all to change every one): {_excerpt(old_string)}"
241
+ )
242
+ updated = content.replace(old_string, new_string, -1 if replace_all else 1)
243
+ return updated, matches if replace_all else 1
244
+
245
+
246
+ def _miss_hint(content: str, old_string: str) -> str:
247
+ """Say why an exact match failed, so the model fixes ``old_string`` instead of leaving the tool."""
248
+ if " ".join(content.split()).count(" ".join(old_string.split())) > 0:
249
+ return (
250
+ "A match exists that differs only in whitespace or indentation; "
251
+ "read the file again and copy the lines exactly. Nothing was written."
252
+ )
253
+ return "Read the file again and copy old_string from its current content. Nothing was written."
254
+
255
+
256
+ def _excerpt(value: str, *, limit: int = 60) -> str:
257
+ """Shorten one matched snippet for an error message."""
258
+ collapsed = " ".join(value.split())
259
+ return repr(f"{collapsed[:limit]}\u2026" if len(collapsed) > limit else collapsed)
260
+
261
+
262
+ __all__ = [
263
+ "FRAGMENT_CHARACTERS",
264
+ "MAX_FILE_BYTES",
265
+ "Located",
266
+ "Pattern",
267
+ "apply_edit",
268
+ "read_lines",
269
+ "read_text",
270
+ "write_text",
271
+ ]