zettcode-truncation 0.4.2__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,20 @@
1
+ Metadata-Version: 2.5
2
+ Name: zettcode-truncation
3
+ Version: 0.4.2
4
+ Summary: The over-long tool result ZettCode keeps in 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,truncation,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
20
+ Requires-Dist: zettcode-tools
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "zettcode-truncation"
3
+ description = "The over-long tool result ZettCode keeps in 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", "truncation", "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 result is kept in, and the budget and preview the tools own.
21
+ "zettcode-engine",
22
+ "zettcode-tools",
23
+ # The extension, its hooks and the messages it rewrites.
24
+ "zett-agent>=0.2.10,<0.3",
25
+ ]
26
+
27
+ # A part of the program, not a plugin: the `[tool.zettcode] plugin = true` marker is what the plugin release line looks for. It is
28
+ # activated with the agent's other parts and nothing turns it off: without it a result from a server, a child or a
29
+ # plugin can fill the session's context on every later request.
30
+ [project.entry-points."zettcode.plugin"]
31
+ long-output = "zettcode.truncation.plugin:LongOutput"
32
+
33
+ [project.urls]
34
+ Repository = "https://github.com/Chang-LeHung/zettcode"
35
+
36
+ [build-system]
37
+ requires = ["hatchling"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.version]
41
+ # One version for the whole repository: the engine ships the file.
42
+ path = "../zettcode-engine/src/zettcode/version.py"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/zettcode"]
46
+
47
+ [tool.hatch.build.targets.sdist]
48
+ include = ["/src", "/pyproject.toml"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ asyncio_mode = "auto"
@@ -0,0 +1,12 @@
1
+ """A result too long to send, kept whole in the session's folder: the plugin that hands this 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.truncation.extension`) reads the store the runtime provides under ``persistence``; it
5
+ runs before the store's own extension, so what the store appends is the preview and not the megabyte.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .plugin import LongOutput
11
+
12
+ __all__ = ["LongOutput"]
@@ -0,0 +1,155 @@
1
+ """Keep a tool result that is too long to send in a file, and send its start and its end instead.
2
+
3
+ The tools in this repository bound what they return (``zettcode/tools/output.py``: 50 KB and 2000 lines, with a note
4
+ saying how to go on). Nothing bounds a tool that comes from somewhere else — an MCP server's answer, a subagent's
5
+ report, a plugin's own tool — and one of those can arrive carrying a megabyte: it is paid for in the model's context
6
+ on every later request of the session, and once it is cut nothing in it can be read again.
7
+
8
+ So after a call, and before its result is stored or sent anywhere, a result over the budget is written whole into the
9
+ session's folder beside the shell's kept streams (``tool-output/``), and what replaces it is its start and its end
10
+ around a note naming that file. The model reads the rest with ``read``, by absolute path, which is the same way it
11
+ reads what a long command printed. The file goes away with the session it belongs to, and the reader's project never
12
+ sees it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import re
19
+ from pathlib import Path
20
+
21
+ from zett_agent.agent import AgentRunContext
22
+ from zett_agent.extensions.base import AgentExtension
23
+ from zett_agent.messages import ToolCall, ToolMessage
24
+
25
+ from zettcode.engine.storage import KEPT_OUTPUT, SessionStore
26
+ from zettcode.tools.output import MAX_OUTPUT_BYTES, MAX_OUTPUT_LINES, stream_preview
27
+
28
+ #: The tool that keeps its own output: it names the streams it kept, and that note is the whole point of its
29
+ #: result, so its preview is never taken apart here.
30
+ OWN_TRUNCATION = "bash"
31
+
32
+ #: What a result may carry and still be sent whole. It is a little over what our own tools keep themselves to, with
33
+ #: room for the note each of them ends with, so a result they bounded is left exactly as they wrote it; anything
34
+ #: larger came from a tool that does not bound itself.
35
+ MAX_RESULT_BYTES = MAX_OUTPUT_BYTES + 4 * 1024
36
+ MAX_RESULT_LINES = MAX_OUTPUT_LINES + 100
37
+
38
+ #: Every character ``str.splitlines()`` treats as a line boundary. A guard that counted only ``\n`` would call a
39
+ #: CR-separated result short, which is how a tool passing on a Windows program's output is written.
40
+ LINE_BREAKS = "\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029"
41
+
42
+ #: What a kept file may be named after, from the tool and the call. A name from the model is not a path: no `/`, no
43
+ #: `..`, and nothing a shell or a filesystem reads as special.
44
+ _UNSAFE = re.compile(r"[^A-Za-z0-9_-]+")
45
+
46
+ #: The longest stem a kept file gets, before the suffix that tells two of them apart.
47
+ MAX_STEM = 80
48
+
49
+
50
+ class LongOutputExtension(AgentExtension):
51
+ """Keep an over-long tool result in the session's folder, and send its start and its end."""
52
+
53
+ def __init__(self, store: SessionStore) -> None:
54
+ """Keep the store whose session folders the output is written 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
+ """Replace an over-long result with a preview of it and a note naming the file that holds it.
65
+
66
+ Both outcomes are handled, since a failed call's message can be long too. Nothing here may fail a tool
67
+ call: whatever goes wrong — no session yet, a folder that cannot be written — the result is left as the
68
+ tool wrote it, and the model still has it.
69
+ """
70
+ if call.name == OWN_TRUNCATION or not isinstance(result.content, str):
71
+ return
72
+ session_id = context.config.session_id
73
+ if not isinstance(session_id, str) or lightly_under_budget(result.content):
74
+ return
75
+ try:
76
+ kept = await asyncio.to_thread(self._keep, result.content, call, session_id)
77
+ except (OSError, ValueError):
78
+ return
79
+ if kept is not None:
80
+ result.content = kept
81
+
82
+ def _keep(self, text: str, call: ToolCall, session_id: str) -> str | None:
83
+ """Write ``text`` whole into the session's folder and return what stands in its place.
84
+
85
+ Returns ``None`` when the result is already small enough: the encode that decides it costs what the text
86
+ costs, so it is paid here, off the event loop, and not twice.
87
+ """
88
+ if not over_budget(text):
89
+ return None
90
+ path = self._write(text, call, session_id)
91
+ marker = f"\n... [middle omitted; the whole of it is at {path} — read that file with the read tool] ...\n"
92
+ preview = stream_preview(path, marker=marker)
93
+ if not preview.truncated:
94
+ # The budget here is a little over the preview's, so this cannot happen while the two constants
95
+ # hold; a file left behind with the text sent whole would be worse than no file at all.
96
+ path.unlink(missing_ok=True)
97
+ return None
98
+ return preview.text
99
+
100
+ def _write(self, text: str, call: ToolCall, session_id: str) -> Path:
101
+ """Write the result to a new file in the session's ``tool-output`` folder, and return its path."""
102
+ kept = self._store.session_dir(session_id) / KEPT_OUTPUT
103
+ kept.mkdir(parents=True, exist_ok=True)
104
+ stem = _stem(call)
105
+ suffix = 1
106
+ while True:
107
+ target = kept / (f"{stem}.txt" if suffix == 1 else f"{stem}-{suffix}.txt")
108
+ # The name is claimed by creating it, not by asking whether it is free: two calls of one name can
109
+ # reach the folder at the same time, and `exists()` then a write would let them share a file.
110
+ try:
111
+ # `newline=""` because the preview reads these bytes back: text mode would write `\r\n` on
112
+ # Windows, so the model would be shown a file that says something its `read` does not.
113
+ with target.open("x", encoding="utf-8", newline="") as handle:
114
+ handle.write(text)
115
+ except FileExistsError:
116
+ suffix += 1
117
+ continue
118
+ return target
119
+
120
+
121
+ def over_budget(text: str) -> bool:
122
+ """Return whether a result is longer than a tool may send: either more bytes or more lines than the budget."""
123
+ return len(text.encode("utf-8")) > MAX_RESULT_BYTES or len(text.splitlines()) > MAX_RESULT_LINES
124
+
125
+
126
+ def lightly_under_budget(text: str) -> bool:
127
+ """Return whether ``text`` is certainly within the budget, without encoding it.
128
+
129
+ A character is at most four bytes in UTF-8, so a result of fewer than a quarter of the byte budget in
130
+ characters, with fewer line boundaries than the line budget, cannot be over it. The boundaries are the ones
131
+ ``str.splitlines()`` counts, not just ``\n``: a result written with carriage returns only is as many lines as
132
+ one with newlines, and a guard that missed them would send it whole.
133
+
134
+ This is the cheap answer read before the work goes to a thread: the encode that decides the rest costs what
135
+ the text costs, which is the reason it is off the loop.
136
+ """
137
+ boundaries = sum(text.count(break_) for break_ in LINE_BREAKS)
138
+ return len(text) * 4 <= MAX_RESULT_BYTES and boundaries + 1 <= MAX_RESULT_LINES
139
+
140
+
141
+ def _stem(call: ToolCall) -> str:
142
+ """Return the name a kept file is given: the tool and the call, with anything a path could not hold removed."""
143
+ cleaned = _UNSAFE.sub("-", f"{call.name}-{call.id}").strip("-.")
144
+ return cleaned[:MAX_STEM].strip("-.") or "output"
145
+
146
+
147
+ __all__ = [
148
+ "LINE_BREAKS",
149
+ "MAX_RESULT_BYTES",
150
+ "MAX_RESULT_LINES",
151
+ "OWN_TRUNCATION",
152
+ "LongOutputExtension",
153
+ "lightly_under_budget",
154
+ "over_budget",
155
+ ]
@@ -0,0 +1,27 @@
1
+ """The plugin that keeps an over-long tool result in 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 LongOutputExtension
11
+
12
+
13
+ class LongOutput(Plugin[PluginContext]):
14
+ """Keeps a result over a tool's budget in a file, and sends its start and its end instead.
15
+
16
+ The tools in this repository bound what they return themselves; this is the backstop for the ones that come
17
+ from elsewhere — an MCP server, a subagent, a plugin — so one long answer cannot fill the context of every
18
+ later request in the session. It states the priority nearly everything shares, and it is handed over ahead of
19
+ the session store because it has to see the result first.
20
+ """
21
+
22
+ name = "long-output"
23
+ description = "Keeps over-long tool results in the session folder"
24
+
25
+ def activate(self, ctx: PluginContext) -> None:
26
+ """Register the extension over the session store."""
27
+ ctx.agent.register_extension(LongOutputExtension(ctx.service(PERSISTENCE, SessionStore)))