zettcode-tool-output 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-tool-output
3
+ Version: 0.4.1
4
+ Summary: The kept shell output of ZettCode, moved into the session's folder, 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,tool-output,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: zett-agent<0.3,>=0.2.10
19
+ Requires-Dist: zettcode-engine
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "zettcode-tool-output"
3
+ description = "The kept shell output of ZettCode, moved into the session's folder, 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", "tool-output", "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 plugin base classes, the session store the extension moves the output into, and the name it is handed under.
21
+ "zettcode-engine",
22
+ # The extension, its hooks and the messages it rewrites.
23
+ "zett-agent>=0.2.10,<0.3",
24
+ ]
25
+
26
+ # A part of the program, not a plugin: the `[tool.zettcode] plugin = true` marker is what the plugin release line looks for. It is
27
+ # activated with the agent's other parts and nothing turns it off: without it a command's output would be left in the
28
+ # reader's project.
29
+ [project.entry-points."zettcode.plugin"]
30
+ tool-output = "zettcode.tool_output.plugin:ToolOutput"
31
+
32
+ [project.urls]
33
+ Repository = "https://github.com/Chang-LeHung/zettcode"
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.version]
40
+ # One version for the whole repository: the engine ships the file.
41
+ path = "../zettcode-engine/src/zettcode/version.py"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/zettcode"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = ["/src", "/pyproject.toml"]
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
51
+ asyncio_mode = "auto"
@@ -0,0 +1,12 @@
1
+ """A shell command's kept output, moved into the session's folder: the plugin that hands it to the agent.
2
+
3
+ A part of the program that lives in a distribution of its own, found through the ``zettcode.plugin`` entry point.
4
+ The extension (:mod:`zettcode.tool_output.extension`) reads the store the runtime provides under ``persistence``, so
5
+ nothing here builds one; it acts on the ``bash`` tool's result after the tool has run and before it is stored.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .plugin import ToolOutput
11
+
12
+ __all__ = ["ToolOutput"]
@@ -0,0 +1,150 @@
1
+ """Keep what a shell command printed out of the reader's workspace.
2
+
3
+ The ``bash`` tool keeps a command's full output when the preview it returns is cut
4
+ short, so the model can read the rest. It keeps it in a ``.zett-tool-output`` folder *inside the
5
+ directory it runs in* — the reader's own project — and names the files in the text it returns::
6
+
7
+ ... [output truncated; full streams kept at .zett-tool-output/shell-ab12cd/stdout.txt and
8
+ .zett-tool-output/shell-ab12cd/stderr.txt]
9
+
10
+ That puts files the reader never asked for among their source, where they turn up in a status
11
+ listing, a search, a backup, or a commit. The folder cannot simply be made a link to somewhere
12
+ else, because the tool resolves it and then asks for the result relative to the working
13
+ directory, which a link out of it breaks the moment output is truncated.
14
+
15
+ What is done here is to deal with the result: after the tool has run and before
16
+ its result is stored or sent anywhere, the folder it named is moved into the session's own folder
17
+ under the store — ``~/.zettcode/sessions/<workspace>/<session>/tool-output/`` — and the paths in
18
+ the text are rewritten to where the files now are. The model reads them with ``read_file`` by
19
+ absolute path, which that tool accepts, so nothing about how it uses them changes; the reader's
20
+ project is left as it was found; and the output goes away with the session it belongs to.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import re
27
+ import shutil
28
+ from pathlib import Path, PurePosixPath
29
+
30
+ from zett_agent.agent import AgentRunContext
31
+ from zett_agent.extensions.base import AgentExtension
32
+ from zett_agent.messages import ToolCall, ToolMessage
33
+
34
+ from zettcode.engine.storage import KEPT_OUTPUT, SessionStore
35
+
36
+ #: The tool whose output is moved.
37
+ SHELL_TOOL = "bash"
38
+
39
+ #: The folder the tool makes inside the directory it runs in. It is written out here, and not imported from the tool,
40
+ #: because the tool's module brings the schema library with it and this one is loaded at start-up; a test holds the two
41
+ #: equal.
42
+ OUTPUT_ROOT = ".zett-tool-output"
43
+
44
+ #: The note the tool ends its result with when a preview was cut short. It is matched at the very
45
+ #: end of the text and nowhere else: a command can print anything it likes, and a line that merely
46
+ #: looks like this one, earlier in the output, is the command's own and must not be acted on.
47
+ _NOTE = re.compile(r"full streams kept at (?P<out>\S+) and (?P<err>\S+)\]\s*\Z")
48
+
49
+
50
+ class ToolOutputExtension(AgentExtension):
51
+ """Move a shell command's kept output out of the workspace and into the session's folder."""
52
+
53
+ def __init__(self, store: SessionStore) -> None:
54
+ """Keep the store whose session folders the output moves into."""
55
+ self._store = store
56
+
57
+ async def after_tool(
58
+ self,
59
+ context: AgentRunContext,
60
+ call: ToolCall,
61
+ result: ToolMessage,
62
+ error: Exception | None,
63
+ ) -> None:
64
+ """Rewrite a truncated shell result so its files are in the session's folder.
65
+
66
+ Both outcomes carry the note — a command that failed has its output kept the same way —
67
+ so the result is handled whether or not ``error`` is set. Nothing here may fail a tool
68
+ call: whatever goes wrong, the result is left as the tool wrote it, and the files stay
69
+ where the tool put them, which still works.
70
+ """
71
+ if call.name != SHELL_TOOL:
72
+ return
73
+ try:
74
+ await self._move_kept_output(context, result)
75
+ finally:
76
+ self._tidy()
77
+
78
+ async def _move_kept_output(self, context: AgentRunContext, result: ToolMessage) -> None:
79
+ """Move the output the result's note names, and rewrite the note to say where it went."""
80
+ if not isinstance(result.content, str):
81
+ return
82
+ note = _NOTE.search(result.content)
83
+ session_id = context.config.session_id
84
+ if note is None or not isinstance(session_id, str):
85
+ return
86
+ moved = await asyncio.to_thread(self._relocate, note.group("out"), note.group("err"), session_id)
87
+ if moved is None:
88
+ return
89
+ text, out, err = result.content, *moved
90
+ result.content = (
91
+ text[: note.start("out")] + out + text[note.end("out") : note.start("err")] + err + text[note.end("err") :]
92
+ )
93
+
94
+ @staticmethod
95
+ def _tidy() -> None:
96
+ """Remove the tool's output root when nothing is in it, so the project is as it was found.
97
+
98
+ The tool makes the root on *every* call, before it knows whether the output will need
99
+ keeping, and takes only its own folder away again: a command that printed a line leaves an
100
+ empty ``.zett-tool-output`` behind, which is the folder a reader sees in their project.
101
+
102
+ This is done here, on the event loop, and not with the move on a thread, on purpose. A
103
+ command about to run makes the root and then, on the very next line and with nothing
104
+ awaited between, makes its folder in it; two commands on the loop cannot interleave
105
+ there, and a removal from a thread could land between the two and fail a command that
106
+ did nothing wrong. A root with anything in it — a neighbour's running command — is not
107
+ empty, and the removal fails, which is the right answer.
108
+ """
109
+ try:
110
+ (Path.cwd().resolve() / OUTPUT_ROOT).rmdir()
111
+ except OSError:
112
+ pass
113
+
114
+ def _relocate(self, out: str, err: str, session_id: str) -> tuple[str, str] | None:
115
+ """Move the folder the two paths name, and return where its two files now are.
116
+
117
+ Only a folder the tool itself would have made is moved: a direct child of the tool's
118
+ output root, named like its own, holding exactly the two streams it names. Anything
119
+ else — a path a command printed, a folder that is not there — is left alone.
120
+
121
+ Returns:
122
+ The new paths of the standard output and error files, or ``None`` when nothing was
123
+ moved.
124
+ """
125
+ first, second = PurePosixPath(out), PurePosixPath(err)
126
+ if (
127
+ len(first.parts) != 3
128
+ or first.parts[0] != OUTPUT_ROOT
129
+ or not first.parts[1].startswith("shell-")
130
+ or first.parts[:2] != second.parts[:2]
131
+ or first.name != "stdout.txt"
132
+ or second.name != "stderr.txt"
133
+ or len(second.parts) != 3
134
+ ):
135
+ return None
136
+ source = Path.cwd().resolve() / OUTPUT_ROOT / first.parts[1]
137
+ try:
138
+ if not (source / "stdout.txt").is_file() or not (source / "stderr.txt").is_file():
139
+ return None
140
+ kept = self._store.session_dir(session_id) / KEPT_OUTPUT
141
+ kept.mkdir(parents=True, exist_ok=True)
142
+ target = kept / source.name
143
+ suffix = 1
144
+ while target.exists():
145
+ suffix += 1
146
+ target = kept / f"{source.name}-{suffix}"
147
+ shutil.move(str(source), str(target))
148
+ except (OSError, ValueError):
149
+ return None
150
+ return str(target / "stdout.txt"), str(target / "stderr.txt")
@@ -0,0 +1,25 @@
1
+ """The plugin that moves a shell command's kept output into the session's folder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from zettcode.engine.plugins.context import PluginContext
6
+ from zettcode.engine.plugins.plugin import Plugin
7
+ from zettcode.engine.plugins.services import PERSISTENCE
8
+ from zettcode.engine.storage import SessionStore
9
+
10
+ from .extension import ToolOutputExtension
11
+
12
+
13
+ class ToolOutput(Plugin[PluginContext]):
14
+ """Moves a long tool result into the session's folder before it is stored or sent anywhere.
15
+
16
+ The extension acts on what the ``bash`` tool wrote, so it registers at the same priority as the coding bundle and
17
+ is handed over after it: a run keeps the order the extensions registered in wherever their priorities agree.
18
+ """
19
+
20
+ name = "tool-output"
21
+ description = "Keeps long tool output in the session folder"
22
+
23
+ def activate(self, ctx: PluginContext) -> None:
24
+ """Register the extension over the session store."""
25
+ ctx.agent.register_extension(ToolOutputExtension(ctx.service(PERSISTENCE, SessionStore)))