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.
- zettcode/_compat.py +35 -0
- zettcode/budget.py +24 -0
- zettcode/config.py +1088 -0
- zettcode/config_edit.py +457 -0
- zettcode/engine/__init__.py +39 -0
- zettcode/engine/agent/__init__.py +81 -0
- zettcode/engine/agent/base.py +60 -0
- zettcode/engine/agent/context.py +166 -0
- zettcode/engine/agent/exports.py +73 -0
- zettcode/engine/agent/interaction.py +54 -0
- zettcode/engine/agent/sessions.py +218 -0
- zettcode/engine/agent/settings.py +269 -0
- zettcode/engine/agent/turns.py +308 -0
- zettcode/engine/core.py +819 -0
- zettcode/engine/dialogue/__init__.py +6 -0
- zettcode/engine/dialogue/ask.py +113 -0
- zettcode/engine/dialogue/side.py +197 -0
- zettcode/engine/dialogue/title.py +80 -0
- zettcode/engine/export/__init__.py +421 -0
- zettcode/engine/export/trace/script.js +18 -0
- zettcode/engine/export/trace/style.css +136 -0
- zettcode/engine/export/trace/template.html +31 -0
- zettcode/engine/extensions/__init__.py +8 -0
- zettcode/engine/extensions/capabilities.py +68 -0
- zettcode/engine/extensions/compaction.py +259 -0
- zettcode/engine/extensions/context.py +521 -0
- zettcode/engine/extensions/files.py +101 -0
- zettcode/engine/extensions/guidance.py +17 -0
- zettcode/engine/extensions/harness.py +208 -0
- zettcode/engine/extensions/harness_switch.py +18 -0
- zettcode/engine/extensions/mcp_notice.py +49 -0
- zettcode/engine/extensions/relay.py +93 -0
- zettcode/engine/extensions/subagents.py +391 -0
- zettcode/engine/extensions/tool_output.py +153 -0
- zettcode/engine/extensions/truncation.py +156 -0
- zettcode/engine/extensions/usage.py +304 -0
- zettcode/engine/harness_paths.py +74 -0
- zettcode/engine/permission/__init__.py +197 -0
- zettcode/engine/permission/approval.py +66 -0
- zettcode/engine/permission/readonly.py +435 -0
- zettcode/engine/permission/sandbox.py +140 -0
- zettcode/engine/pipeline.py +191 -0
- zettcode/engine/plugins/__init__.py +72 -0
- zettcode/engine/plugins/commands.py +152 -0
- zettcode/engine/plugins/context.py +251 -0
- zettcode/engine/plugins/extension.py +183 -0
- zettcode/engine/plugins/groups.py +74 -0
- zettcode/engine/plugins/loader.py +367 -0
- zettcode/engine/plugins/mixins.py +156 -0
- zettcode/engine/plugins/plugin.py +190 -0
- zettcode/engine/plugins/services.py +68 -0
- zettcode/engine/plugins/settings.py +117 -0
- zettcode/engine/plugins/state.py +64 -0
- zettcode/engine/plugins/switches.py +553 -0
- zettcode/engine/py.typed +0 -0
- zettcode/engine/server_tools.py +179 -0
- zettcode/engine/storage/__init__.py +51 -0
- zettcode/engine/storage/layout.py +161 -0
- zettcode/engine/storage/ledger.py +659 -0
- zettcode/engine/storage/locks.py +182 -0
- zettcode/engine/storage/metadata.py +278 -0
- zettcode/engine/storage/records.py +373 -0
- zettcode/engine/storage/store.py +1056 -0
- zettcode/engine/tools/__init__.py +7 -0
- zettcode/engine/tools/boundary.py +56 -0
- zettcode/engine/tools/files.py +262 -0
- zettcode/engine/tools/images.py +32 -0
- zettcode/engine/tools/output.py +81 -0
- zettcode/engine/tools/shell.py +181 -0
- zettcode/engine/tools/workspace.py +271 -0
- zettcode/error_text.py +217 -0
- zettcode/filelock.py +125 -0
- zettcode/logging_setup.py +196 -0
- zettcode/mention.py +124 -0
- zettcode/modes.py +18 -0
- zettcode/paths.py +113 -0
- zettcode/plugin_tui.py +133 -0
- zettcode/processes.py +126 -0
- zettcode/prompt.py +94 -0
- zettcode/providers.py +215 -0
- zettcode/py.typed +0 -0
- zettcode/registry.py +63 -0
- zettcode/relay.py +267 -0
- zettcode/statusline_parts.py +84 -0
- zettcode/version.py +11 -0
- zettcode/wire.py +30 -0
- zettcode/wording.py +11 -0
- zettcode_engine-0.3.4.dist-info/METADATA +23 -0
- zettcode_engine-0.3.4.dist-info/RECORD +90 -0
- 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")
|