zettcode-engine 0.3.4__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.
Files changed (90) hide show
  1. zettcode/_compat.py +35 -0
  2. zettcode/budget.py +24 -0
  3. zettcode/config.py +1088 -0
  4. zettcode/config_edit.py +457 -0
  5. zettcode/engine/__init__.py +39 -0
  6. zettcode/engine/agent/__init__.py +81 -0
  7. zettcode/engine/agent/base.py +60 -0
  8. zettcode/engine/agent/context.py +166 -0
  9. zettcode/engine/agent/exports.py +73 -0
  10. zettcode/engine/agent/interaction.py +54 -0
  11. zettcode/engine/agent/sessions.py +218 -0
  12. zettcode/engine/agent/settings.py +269 -0
  13. zettcode/engine/agent/turns.py +308 -0
  14. zettcode/engine/core.py +819 -0
  15. zettcode/engine/dialogue/__init__.py +6 -0
  16. zettcode/engine/dialogue/ask.py +113 -0
  17. zettcode/engine/dialogue/side.py +197 -0
  18. zettcode/engine/dialogue/title.py +80 -0
  19. zettcode/engine/export/__init__.py +421 -0
  20. zettcode/engine/export/trace/script.js +18 -0
  21. zettcode/engine/export/trace/style.css +136 -0
  22. zettcode/engine/export/trace/template.html +31 -0
  23. zettcode/engine/extensions/__init__.py +8 -0
  24. zettcode/engine/extensions/capabilities.py +68 -0
  25. zettcode/engine/extensions/compaction.py +259 -0
  26. zettcode/engine/extensions/context.py +521 -0
  27. zettcode/engine/extensions/files.py +101 -0
  28. zettcode/engine/extensions/guidance.py +17 -0
  29. zettcode/engine/extensions/harness.py +208 -0
  30. zettcode/engine/extensions/harness_switch.py +18 -0
  31. zettcode/engine/extensions/mcp_notice.py +49 -0
  32. zettcode/engine/extensions/relay.py +93 -0
  33. zettcode/engine/extensions/subagents.py +391 -0
  34. zettcode/engine/extensions/tool_output.py +153 -0
  35. zettcode/engine/extensions/truncation.py +156 -0
  36. zettcode/engine/extensions/usage.py +304 -0
  37. zettcode/engine/harness_paths.py +74 -0
  38. zettcode/engine/permission/__init__.py +197 -0
  39. zettcode/engine/permission/approval.py +66 -0
  40. zettcode/engine/permission/readonly.py +435 -0
  41. zettcode/engine/permission/sandbox.py +140 -0
  42. zettcode/engine/pipeline.py +191 -0
  43. zettcode/engine/plugins/__init__.py +72 -0
  44. zettcode/engine/plugins/commands.py +152 -0
  45. zettcode/engine/plugins/context.py +251 -0
  46. zettcode/engine/plugins/extension.py +183 -0
  47. zettcode/engine/plugins/groups.py +74 -0
  48. zettcode/engine/plugins/loader.py +367 -0
  49. zettcode/engine/plugins/mixins.py +156 -0
  50. zettcode/engine/plugins/plugin.py +190 -0
  51. zettcode/engine/plugins/services.py +68 -0
  52. zettcode/engine/plugins/settings.py +117 -0
  53. zettcode/engine/plugins/state.py +64 -0
  54. zettcode/engine/plugins/switches.py +553 -0
  55. zettcode/engine/py.typed +0 -0
  56. zettcode/engine/server_tools.py +179 -0
  57. zettcode/engine/storage/__init__.py +51 -0
  58. zettcode/engine/storage/layout.py +161 -0
  59. zettcode/engine/storage/ledger.py +659 -0
  60. zettcode/engine/storage/locks.py +182 -0
  61. zettcode/engine/storage/metadata.py +278 -0
  62. zettcode/engine/storage/records.py +373 -0
  63. zettcode/engine/storage/store.py +1056 -0
  64. zettcode/engine/tools/__init__.py +7 -0
  65. zettcode/engine/tools/boundary.py +56 -0
  66. zettcode/engine/tools/files.py +262 -0
  67. zettcode/engine/tools/images.py +32 -0
  68. zettcode/engine/tools/output.py +81 -0
  69. zettcode/engine/tools/shell.py +181 -0
  70. zettcode/engine/tools/workspace.py +271 -0
  71. zettcode/error_text.py +217 -0
  72. zettcode/filelock.py +125 -0
  73. zettcode/logging_setup.py +196 -0
  74. zettcode/mention.py +124 -0
  75. zettcode/modes.py +18 -0
  76. zettcode/paths.py +113 -0
  77. zettcode/plugin_tui.py +133 -0
  78. zettcode/processes.py +126 -0
  79. zettcode/prompt.py +94 -0
  80. zettcode/providers.py +215 -0
  81. zettcode/py.typed +0 -0
  82. zettcode/registry.py +63 -0
  83. zettcode/relay.py +267 -0
  84. zettcode/statusline_parts.py +84 -0
  85. zettcode/version.py +11 -0
  86. zettcode/wire.py +30 -0
  87. zettcode/wording.py +11 -0
  88. zettcode_engine-0.3.4.dist-info/METADATA +23 -0
  89. zettcode_engine-0.3.4.dist-info/RECORD +90 -0
  90. zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
@@ -0,0 +1,391 @@
1
+ """The subagent profiles ZettCode exposes through the ``task`` tool.
2
+
3
+ zett-agent runs a subagent in-process: :class:`SubAgentDefinition` names the
4
+ model and the extensions that register the child's tools, and the ``task`` tool
5
+ creates one child per call with its own session linked to the caller. ZettCode
6
+ supplies its own definitions so a child gets this workspace's tools and this
7
+ application's JSONL session store, and so each profile's tool boundary is a
8
+ decision made here rather than a zett-agent default.
9
+
10
+ The profiles are deliberately asymmetric. Exploration cannot write anything;
11
+ reasoning has no tools at all; coding may edit files, and when the parent has a shell it has the parent's own
12
+ tools, the shell included, under the parent's permission level (a command that needs approval is put to the
13
+ person through the parent, see :mod:`.relay`). No profile carries a ``task`` tool of its own, so a child cannot
14
+ delegate to another child.
15
+
16
+ A child is given the same project instructions the parent has (the workspace's ``AGENTS.md``): one
17
+ that edits files and does not know the project's rules is the child most in need of them.
18
+
19
+ Everything zett-agent contributes is imported inside the builders rather
20
+ than at module scope. The subagent extension imports its SQLite session store,
21
+ and that imports SQLAlchemy; a launch that never runs a child must not pay for a
22
+ database before the first frame is on screen.
23
+
24
+ A child runs on the model the *parent's request* runs on, not the one the runtime was built with: ``/model``
25
+ and resuming a session change the parent's model, and a profile that kept the build-time adapter would
26
+ quietly answer on another provider. Its reasoning effort is the profile's default unless the parent model
27
+ asks for another through the ``task`` tool's ``reasoning_effort``; either way it is moved to the nearest
28
+ level the model takes, because an endpoint that rejects a level it does not know fails the whole task.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Callable
34
+ from contextvars import ContextVar
35
+ from copy import deepcopy
36
+ from functools import cache
37
+ from pathlib import Path
38
+ from typing import TYPE_CHECKING, Any
39
+
40
+ from zettcode.config import DEFAULT_SUBAGENT_MAX_ITERATIONS
41
+
42
+ from ..storage import SessionStore
43
+ from .capabilities import ModelCapabilities
44
+
45
+ if TYPE_CHECKING: # pragma: no cover - annotations only; the imports are the cost
46
+ from zett_agent.agent import Agent, AgentRunContext
47
+ from zett_agent.extensions.base import AgentExtension
48
+ from zett_agent.extensions.subagent import SubAgentDefinition, SubAgentExtension
49
+ from zett_agent.model import AgentModel
50
+ from zett_agent.tools.base import AgentTool
51
+
52
+ #: The effort the parent model asked for on the ``task`` call being run. A tool call is one task, so this
53
+ #: is per call even when several run in parallel; ``None`` outside one, or when it asked for nothing.
54
+ _REQUESTED_EFFORT: ContextVar[str | None] = ContextVar("zettcode_subagent_effort", default=None)
55
+
56
+ #: What every prompt starts from. A child's context is its own: it sees nothing the parent has read or
57
+ #: said, and the parent sees nothing it does — only the last message it writes comes back — so each
58
+ #: prompt says so, and says what that last message should be.
59
+ _ISOLATION = (
60
+ "You work for a parent agent that cannot see anything you read, think or do: the final message you "
61
+ "write is the only thing it receives, so it has to stand on its own. You do not talk to the user."
62
+ )
63
+
64
+ EXPLORE_PROMPT = (
65
+ "You are ZettCode's exploration subagent. "
66
+ + _ISOLATION
67
+ + "\n\nTools: read, glob, grep. You cannot change files or run commands."
68
+ "\n\nHow to work:\n"
69
+ "- Start broad and narrow quickly: glob for the shape of the project, grep for symbols and strings, then "
70
+ "read only the parts that matter (use line ranges for a large file).\n"
71
+ "- Make independent searches in the same step instead of one after another.\n"
72
+ "- Follow a lead until it is confirmed or ruled out. When the question is about every place something "
73
+ "happens, a first match is not an answer.\n"
74
+ "- Trust the files, not memory: report paths and line numbers you actually saw.\n"
75
+ "\nReport the answer first, then the evidence as `path:line - what is there`, then what you could not "
76
+ "determine and where you would look next. As short as the question allows: no preamble, no account of "
77
+ "your search."
78
+ )
79
+
80
+ REASONING_PROMPT = (
81
+ "You are ZettCode's reasoning subagent. "
82
+ + _ISOLATION
83
+ + "\n\nYou have no tools. Reason only from what the task contains, and when something you would need is "
84
+ "not in it, say so instead of assuming it."
85
+ "\n\nHow to work: restate the actual question; list the options, including the boring one; compare them "
86
+ "against the constraints you were given; look for the failure case of the option you prefer; check any "
87
+ "number or claim you rely on."
88
+ "\n\nReport the recommendation first, with the reason in a few sentences; then its risks or costs; then "
89
+ "what is still open and what would settle it. Do not restate the task."
90
+ )
91
+
92
+ CODING_PROMPT = (
93
+ "You are ZettCode's coding subagent. " + _ISOLATION + "\n\nTools: read, glob, grep, write_file, edit. You cannot "
94
+ "run commands, not even tests or a formatter: a command needs the user's approval and you have no way to "
95
+ "ask for it. It also has no file-deletion tool: deleting is a command too. The parent runs them after it "
96
+ "reads your report."
97
+ "\n\nHow to work:\n"
98
+ "- Read before you edit: the file, and enough of its neighbours to match their conventions (naming, "
99
+ "imports, error handling, tests). Follow the project instructions you were given.\n"
100
+ "- Make the smallest change that does what was asked. Do not refactor, rename, reformat or fix what you "
101
+ "were not asked to touch; mention it instead.\n"
102
+ "- Prefer edit with a short, unique old_string to rewriting a whole file, and read the result "
103
+ "back after a change that is not trivial.\n"
104
+ "- When the request is ambiguous, or the code contradicts it, do the part that is clear and say exactly "
105
+ "what you did not do and why. Never guess at a requirement.\n"
106
+ "\nReport what you changed (each path, and the substance of the change), anything you left alone on "
107
+ "purpose, and the exact commands the parent should run to check it."
108
+ )
109
+
110
+ #: The coding child of an agent that has a shell: it is given the parent's own tools and permission level.
111
+ CODING_SHELL_PROMPT = (
112
+ "You are ZettCode's coding subagent. "
113
+ + _ISOLATION
114
+ + "\n\nTools: read, write_file, edit and bash, and whatever else the user has set up for the parent (skills, "
115
+ "MCP servers). You cannot start another subagent, and you cannot put a question to the user: what you need to "
116
+ "know has to be in the task, and anything else is for the parent to ask."
117
+ "\n\nbash runs under the user's permission level, exactly as it does for the parent: a command that level "
118
+ "does not allow unasked is put to the user for approval, and at `deny` it is refused. Search with rg, grep "
119
+ "and find, and read a file with read (in line ranges for a long one) rather than with cat, sed or head. "
120
+ "There is no file-deletion tool: remove a file with rm, which follows the same rule."
121
+ "\n\nHow to work:\n"
122
+ "- Read before you edit: the file, and enough of its neighbours to match their conventions (naming, "
123
+ "imports, error handling, tests). Follow the project instructions you were given.\n"
124
+ "- Make the smallest change that does what was asked. Do not refactor, rename, reformat or fix what you "
125
+ "were not asked to touch; mention it instead.\n"
126
+ "- Prefer edit with a short, unique old_string to rewriting a whole file, and read the result "
127
+ "back after a change that is not trivial.\n"
128
+ "- Run the tests or the formatter when the task calls for it, and report what they printed. When a command "
129
+ "was refused or declined, say so instead of guessing at its result.\n"
130
+ "- When the request is ambiguous, or the code contradicts it, do the part that is clear and say exactly "
131
+ "what you did not do and why. Never guess at a requirement.\n"
132
+ "\nReport what you changed (each path, and the substance of the change), anything you left alone on "
133
+ "purpose, what you ran and what it showed, and any command you could not run."
134
+ )
135
+
136
+ #: What the parent model reads when it decides whether to delegate. A child sees none of the parent's
137
+ #: conversation, which is the one thing a parent forgets, so each says what its prompt has to carry.
138
+ EXPLORE_DESCRIPTION = (
139
+ "search the workspace read-only (read, glob, grep, images) for a question that needs several searches, and "
140
+ "get back findings with paths and line numbers. It sees none of this conversation: put every name, path "
141
+ "and constraint it needs in the prompt, and say what shape of answer you want."
142
+ )
143
+ REASONING_DESCRIPTION = (
144
+ "weigh tradeoffs, plans and ambiguous decisions with no tools and no access to the workspace. It can read "
145
+ "nothing: put the facts in the prompt."
146
+ )
147
+ CODING_DESCRIPTION = (
148
+ "make a bounded change to files in the workspace and report exactly what it changed. It cannot run "
149
+ "commands, so you run the tests and linters after reading its report. It sees none of this conversation: "
150
+ "name the files and the exact change in the prompt."
151
+ )
152
+ CODING_SHELL_DESCRIPTION = (
153
+ "make a bounded change to files in the workspace, run what the change needs (tests, a formatter), and "
154
+ "report exactly what it changed and what it ran. It works under the user's permission level as you do, so "
155
+ "a command may be put to the user for approval. It sees none of this conversation: name the files and the "
156
+ "exact change in the prompt."
157
+ )
158
+
159
+ #: The profiles that are given the parent's own extensions (its tools and its permission level), and not only
160
+ #: what a profile lists. The others are deliberately small: one only reads, one has no tools at all.
161
+ INHERITING = frozenset({"coding"})
162
+
163
+
164
+ def build_subagents(
165
+ *,
166
+ model: AgentModel,
167
+ persistence: SessionStore,
168
+ capabilities: ModelCapabilities,
169
+ workspace: Path | None = None,
170
+ max_iterations: int = DEFAULT_SUBAGENT_MAX_ITERATIONS,
171
+ shell: bool = False,
172
+ ) -> tuple[SubAgentDefinition, ...]:
173
+ """Return the profiles the ``task`` tool offers, one per tool boundary.
174
+
175
+ Args:
176
+ model: Provider the children run on; the caller owns its resources.
177
+ persistence: Session store that writes each child's own log, linked to
178
+ the calling session through ``parent_session_id``.
179
+ capabilities: Parent's capability filter, so a text-only model is given the text reader inside a child
180
+ too.
181
+ workspace: Directory whose ``AGENTS.md`` files every child is given, as the parent has
182
+ them; ``None`` gives a child no project instructions.
183
+ max_iterations: Model calls one child may make for one task.
184
+ shell: Whether the ``coding`` child is given the parent's own tools, shell included, when it is created
185
+ (see :data:`INHERITING`). Without it the child edits files and nothing else, and says so in its prompt.
186
+ """
187
+ from zett_agent.extensions.agents_md import AgentsMdExtension
188
+ from zett_agent.extensions.subagent import SubAgentDefinition
189
+ from zett_agent.model import ReasoningEffort
190
+
191
+ from .files import FileTools
192
+ from .guidance import tool_guidance
193
+
194
+ def instructions() -> tuple[AgentExtension, ...]:
195
+ """A fresh project-instructions extension per child: it reads the files on every request."""
196
+ return () if workspace is None else (AgentsMdExtension(workspace),)
197
+
198
+ return (
199
+ SubAgentDefinition(
200
+ name="explore",
201
+ description=EXPLORE_DESCRIPTION,
202
+ system_prompt=EXPLORE_PROMPT,
203
+ model=model,
204
+ extensions=(
205
+ persistence,
206
+ FileTools(read_only=True),
207
+ capabilities,
208
+ *instructions(),
209
+ tool_guidance(),
210
+ ),
211
+ reasoning_effort=ReasoningEffort.LOW,
212
+ max_iterations=max_iterations,
213
+ ),
214
+ SubAgentDefinition(
215
+ name="reasoning",
216
+ description=REASONING_DESCRIPTION,
217
+ system_prompt=REASONING_PROMPT,
218
+ model=model,
219
+ extensions=(persistence, capabilities, *instructions(), tool_guidance()),
220
+ reasoning_effort=ReasoningEffort.HIGH,
221
+ max_iterations=max_iterations,
222
+ ),
223
+ SubAgentDefinition(
224
+ name="coding",
225
+ description=CODING_SHELL_DESCRIPTION if shell else CODING_DESCRIPTION,
226
+ system_prompt=CODING_SHELL_PROMPT if shell else CODING_PROMPT,
227
+ model=model,
228
+ extensions=(
229
+ persistence,
230
+ # With a shell the file tools come from the parent's own bundle, which has the shell in it.
231
+ *(() if shell else (FileTools(read_only=False),)),
232
+ capabilities,
233
+ *instructions(),
234
+ tool_guidance(),
235
+ ),
236
+ reasoning_effort=ReasoningEffort.MEDIUM,
237
+ max_iterations=max_iterations,
238
+ ),
239
+ )
240
+
241
+
242
+ @cache
243
+ def _extension_class() -> Callable[..., SubAgentExtension]:
244
+ """Return the ``task`` extension that runs a child on its parent's model and lets the parent pick the effort.
245
+
246
+ Built on first use for the reason the rest of this module imports late: the framework's module is
247
+ what pulls in the database layer.
248
+ """
249
+ from zett_agent.agent import AgentRunConfig
250
+ from zett_agent.extensions.subagent import SubAgentExtension
251
+ from zett_agent.model import ReasoningEffort
252
+ from zett_agent.tools.base import AgentTool as Tool
253
+
254
+ from .relay import RelayingAgent, relay_to
255
+
256
+ class ParentModelSubAgents(SubAgentExtension):
257
+ """The framework's ``task`` tool, with the child on the parent's model and an optional effort."""
258
+
259
+ def __init__(
260
+ self,
261
+ subagents: Any,
262
+ *,
263
+ capabilities: ModelCapabilities,
264
+ shares: Callable[[AgentExtension], bool] | None = None,
265
+ ) -> None:
266
+ super().__init__(subagents)
267
+ self._capabilities = capabilities
268
+ self._shares = shares
269
+
270
+ def _levels(self, context: AgentRunContext) -> tuple[ReasoningEffort, ...]:
271
+ """Return the levels the parent's model takes: every one for a model the runtime has no entry for."""
272
+ config = self._capabilities.config_of(context.model)
273
+ return config.efforts if config is not None else tuple(ReasoningEffort)
274
+
275
+ def _inherited(self, definition: SubAgentDefinition, context: AgentRunContext) -> tuple[AgentExtension, ...]:
276
+ """Return the parent's own extensions that a child of this profile is given as well.
277
+
278
+ The parent's extension instances are shared, not copied: they are written to serve several sessions at
279
+ once, and sharing is what puts the child under the same permission level, the same approval memory and
280
+ the same skills and servers as the parent.
281
+ """
282
+ if self._shares is None or definition.name not in INHERITING:
283
+ return ()
284
+ return tuple(extension for extension in context.extensions if self._shares(extension))
285
+
286
+ async def _create_child(
287
+ self, definition: SubAgentDefinition, child_session_id: str, context: AgentRunContext
288
+ ) -> Agent:
289
+ """Create the child on the model of the request that delegated, at a level that model takes."""
290
+ parent = context.model or definition.model
291
+ asked = _REQUESTED_EFFORT.get()
292
+ effort = ReasoningEffort(asked) if asked else definition.reasoning_effort
293
+ config = self._capabilities.config_of(parent)
294
+ if config is not None:
295
+ effort = config.accepted_effort(effort)
296
+ child = await RelayingAgent.create(
297
+ parent,
298
+ config=AgentRunConfig(session_id=child_session_id, parent_session_id=context.session_id),
299
+ system_prompt=definition.system_prompt,
300
+ tools=definition.tools,
301
+ extensions=(*definition.extensions, *self._inherited(definition, context)),
302
+ reasoning_effort=effort,
303
+ max_iterations=definition.max_iterations,
304
+ )
305
+ child.relay = relay_to(context)
306
+ return child
307
+
308
+ def _build_tool(self, context: AgentRunContext, subagents: Any) -> AgentTool:
309
+ """Add an optional ``reasoning_effort`` to the framework's tool, listing what this model takes."""
310
+ base = super()._build_tool(context, subagents)
311
+ levels = self._levels(context)
312
+ names = [level.value for level in levels]
313
+ parameters = deepcopy(base.parameters)
314
+ parameters["properties"]["reasoning_effort"] = {
315
+ "type": "string",
316
+ "enum": names,
317
+ "description": (
318
+ "How hard the subagent thinks. Leave it out for the profile's default "
319
+ "(explore low, coding medium, reasoning high)."
320
+ ),
321
+ }
322
+ inner = base.handler
323
+
324
+ async def handler(**arguments: Any) -> Any:
325
+ asked = arguments.pop("reasoning_effort", None)
326
+ if asked is not None and asked not in names:
327
+ raise ValueError(f"Unknown reasoning_effort {asked!r}; this model takes: {', '.join(names)}")
328
+ token = _REQUESTED_EFFORT.set(asked)
329
+ try:
330
+ return await inner(**arguments)
331
+ finally:
332
+ _REQUESTED_EFFORT.reset(token)
333
+
334
+ return Tool(
335
+ name=base.name,
336
+ description=base.description,
337
+ parameters=parameters,
338
+ handler=handler,
339
+ guidelines=(
340
+ *base.guidelines,
341
+ f"Set reasoning_effort only when a task is harder or easier than its profile assumes; "
342
+ f"the levels you can ask for are {', '.join(names)}.",
343
+ ),
344
+ snippet=base.snippet,
345
+ execution_mode=base.execution_mode,
346
+ )
347
+
348
+ return ParentModelSubAgents
349
+
350
+
351
+ def subagent_extension(
352
+ *,
353
+ model: AgentModel,
354
+ persistence: SessionStore,
355
+ capabilities: ModelCapabilities,
356
+ workspace: Path | None = None,
357
+ max_iterations: int = DEFAULT_SUBAGENT_MAX_ITERATIONS,
358
+ shares: Callable[[AgentExtension], bool] | None = None,
359
+ ) -> AgentExtension:
360
+ """Return the ``task`` tool with ZettCode's profiles and their tool boundaries.
361
+
362
+ zett-agent's extension supplies the registration and child-session
363
+ machinery; the policy it is configured with is :func:`build_subagents`:
364
+ which profiles exist and what each child may touch.
365
+
366
+ Args:
367
+ model: Provider a child runs on when the request that delegated names none; otherwise the
368
+ children use the parent request's own.
369
+ persistence: Session store that writes each child's own log, linked to
370
+ the calling session through ``parent_session_id``.
371
+ capabilities: Parent's capability filter, so a text-only model is given the text reader inside a child
372
+ too, and the lookup of which levels a model takes.
373
+ workspace: Directory whose ``AGENTS.md`` files every child is given; ``None`` for none.
374
+ max_iterations: Model calls one child may make for one task.
375
+ shares: Says, of each extension the parent runs, whether the ``coding`` child is given it too. That is what
376
+ gives the child the parent's tools and puts its shell under the parent's permission level; the caller,
377
+ which knows what every extension is, decides (the ``task`` tool itself and the harness are not shared).
378
+ ``None`` shares nothing, and the child then edits files and has no shell.
379
+ """
380
+ return _extension_class()(
381
+ build_subagents(
382
+ model=model,
383
+ persistence=persistence,
384
+ capabilities=capabilities,
385
+ workspace=workspace,
386
+ max_iterations=max_iterations,
387
+ shell=shares is not None,
388
+ ),
389
+ capabilities=capabilities,
390
+ shares=shares,
391
+ )
@@ -0,0 +1,153 @@
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 ..storage import 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
+ #: Where, inside a session's folder, the moved output is kept.
45
+ KEPT_AS = "tool-output"
46
+
47
+ #: The note the tool ends its result with when a preview was cut short. It is matched at the very
48
+ #: end of the text and nowhere else: a command can print anything it likes, and a line that merely
49
+ #: looks like this one, earlier in the output, is the command's own and must not be acted on.
50
+ _NOTE = re.compile(r"full streams kept at (?P<out>\S+) and (?P<err>\S+)\]\s*\Z")
51
+
52
+
53
+ class ToolOutputExtension(AgentExtension):
54
+ """Move a shell command's kept output out of the workspace and into the session's folder."""
55
+
56
+ def __init__(self, store: SessionStore) -> None:
57
+ """Keep the store whose session folders the output moves into."""
58
+ self._store = store
59
+
60
+ async def after_tool(
61
+ self,
62
+ context: AgentRunContext,
63
+ call: ToolCall,
64
+ result: ToolMessage,
65
+ error: Exception | None,
66
+ ) -> None:
67
+ """Rewrite a truncated shell result so its files are in the session's folder.
68
+
69
+ Both outcomes carry the note — a command that failed has its output kept the same way —
70
+ so the result is handled whether or not ``error`` is set. Nothing here may fail a tool
71
+ call: whatever goes wrong, the result is left as the tool wrote it, and the files stay
72
+ where the tool put them, which still works.
73
+ """
74
+ if call.name != SHELL_TOOL:
75
+ return
76
+ try:
77
+ await self._move_kept_output(context, result)
78
+ finally:
79
+ self._tidy()
80
+
81
+ async def _move_kept_output(self, context: AgentRunContext, result: ToolMessage) -> None:
82
+ """Move the output the result's note names, and rewrite the note to say where it went."""
83
+ if not isinstance(result.content, str):
84
+ return
85
+ note = _NOTE.search(result.content)
86
+ session_id = context.config.session_id
87
+ if note is None or not isinstance(session_id, str):
88
+ return
89
+ moved = await asyncio.to_thread(self._relocate, note.group("out"), note.group("err"), session_id)
90
+ if moved is None:
91
+ return
92
+ text, out, err = result.content, *moved
93
+ result.content = (
94
+ text[: note.start("out")] + out + text[note.end("out") : note.start("err")] + err + text[note.end("err") :]
95
+ )
96
+
97
+ @staticmethod
98
+ def _tidy() -> None:
99
+ """Remove the tool's output root when nothing is in it, so the project is as it was found.
100
+
101
+ The tool makes the root on *every* call, before it knows whether the output will need
102
+ keeping, and takes only its own folder away again: a command that printed a line leaves an
103
+ empty ``.zett-tool-output`` behind, which is the folder a reader sees in their project.
104
+
105
+ This is done here, on the event loop, and not with the move on a thread, on purpose. A
106
+ command about to run makes the root and then, on the very next line and with nothing
107
+ awaited between, makes its folder in it; two commands on the loop cannot interleave
108
+ there, and a removal from a thread could land between the two and fail a command that
109
+ did nothing wrong. A root with anything in it — a neighbour's running command — is not
110
+ empty, and the removal fails, which is the right answer.
111
+ """
112
+ try:
113
+ (Path.cwd().resolve() / OUTPUT_ROOT).rmdir()
114
+ except OSError:
115
+ pass
116
+
117
+ def _relocate(self, out: str, err: str, session_id: str) -> tuple[str, str] | None:
118
+ """Move the folder the two paths name, and return where its two files now are.
119
+
120
+ Only a folder the tool itself would have made is moved: a direct child of the tool's
121
+ output root, named like its own, holding exactly the two streams it names. Anything
122
+ else — a path a command printed, a folder that is not there — is left alone.
123
+
124
+ Returns:
125
+ The new paths of the standard output and error files, or ``None`` when nothing was
126
+ moved.
127
+ """
128
+ first, second = PurePosixPath(out), PurePosixPath(err)
129
+ if (
130
+ len(first.parts) != 3
131
+ or first.parts[0] != OUTPUT_ROOT
132
+ or not first.parts[1].startswith("shell-")
133
+ or first.parts[:2] != second.parts[:2]
134
+ or first.name != "stdout.txt"
135
+ or second.name != "stderr.txt"
136
+ or len(second.parts) != 3
137
+ ):
138
+ return None
139
+ source = Path.cwd().resolve() / OUTPUT_ROOT / first.parts[1]
140
+ try:
141
+ if not (source / "stdout.txt").is_file() or not (source / "stderr.txt").is_file():
142
+ return None
143
+ kept = self._store.session_dir(session_id) / KEPT_AS
144
+ kept.mkdir(parents=True, exist_ok=True)
145
+ target = kept / source.name
146
+ suffix = 1
147
+ while target.exists():
148
+ suffix += 1
149
+ target = kept / f"{source.name}-{suffix}"
150
+ shutil.move(str(source), str(target))
151
+ except (OSError, ValueError):
152
+ return None
153
+ return str(target / "stdout.txt"), str(target / "stderr.txt")