zettcode-tool-output 0.4.1__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.
- zettcode/tool_output/__init__.py +12 -0
- zettcode/tool_output/extension.py +150 -0
- zettcode/tool_output/plugin.py +25 -0
- zettcode/tool_output/py.typed +0 -0
- zettcode_tool_output-0.4.1.dist-info/METADATA +19 -0
- zettcode_tool_output-0.4.1.dist-info/RECORD +8 -0
- zettcode_tool_output-0.4.1.dist-info/WHEEL +4 -0
- zettcode_tool_output-0.4.1.dist-info/entry_points.txt +2 -0
|
@@ -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)))
|
|
File without changes
|
|
@@ -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,8 @@
|
|
|
1
|
+
zettcode/tool_output/__init__.py,sha256=s9HHlPVeYAF95hmF4ergSkIXmab2DDwYo1tZe5g32Rc,547
|
|
2
|
+
zettcode/tool_output/extension.py,sha256=KMopW49QN7sOIfLWPTc0kAd7SIDBSwHsghEz1qRRieQ,7121
|
|
3
|
+
zettcode/tool_output/plugin.py,sha256=vhhl1_6OHNe8wgN3RVvqq2NZ9jyXqN_lekAzbkPEgEQ,1062
|
|
4
|
+
zettcode/tool_output/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
zettcode_tool_output-0.4.1.dist-info/METADATA,sha256=YRgNijsbaBpIt8aGr6ADcXyl1ZCRSsFsYKcktRSommw,821
|
|
6
|
+
zettcode_tool_output-0.4.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
zettcode_tool_output-0.4.1.dist-info/entry_points.txt,sha256=YUY_F_LCmgSii2mIcDlom7yEY7wj8Uvxeh1crLcHH4Q,71
|
|
8
|
+
zettcode_tool_output-0.4.1.dist-info/RECORD,,
|