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
zettcode/config_edit.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Edit the ``[[models]]`` entries of the config file without disturbing the rest of it.
|
|
2
|
+
|
|
3
|
+
The file is the reader's: it has comments they wrote, tables this code has never heard of, and a
|
|
4
|
+
layout they chose. Rewriting it from parsed data would throw all of that away, so the edits here are
|
|
5
|
+
made on the *text* — one ``[[models]]`` block is added, replaced or removed — and everything outside
|
|
6
|
+
that block is left byte for byte as it was.
|
|
7
|
+
|
|
8
|
+
Because text editing is only safe when the text is what it looks like, every edit is checked before
|
|
9
|
+
it is written: the result must parse, must hold exactly the models the edit meant it to, and every
|
|
10
|
+
table other than ``models`` must be equal to what it was. A file that spells its models some other
|
|
11
|
+
way (an inline array, say) is refused rather than guessed at, and a refused or failed edit writes
|
|
12
|
+
nothing.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import tempfile
|
|
21
|
+
from collections.abc import Mapping, Sequence
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from zettcode._compat import tomllib
|
|
26
|
+
from zettcode.config import MODEL_KEYS, ModelConfig, _read_models
|
|
27
|
+
|
|
28
|
+
#: A line that opens a table or an array of tables; the first thing on it, so a ``[`` inside a value
|
|
29
|
+
#: or a comment is not one.
|
|
30
|
+
_HEADER = re.compile(r"^\s*\[")
|
|
31
|
+
#: The line that opens one model.
|
|
32
|
+
_MODEL_HEADER = re.compile(r"^\s*\[\[\s*models\s*\]\]\s*(#.*)?$")
|
|
33
|
+
|
|
34
|
+
#: The order keys are written in, so a new entry reads the way the documentation shows one.
|
|
35
|
+
_KEY_ORDER = (
|
|
36
|
+
"display_model",
|
|
37
|
+
"model",
|
|
38
|
+
"base_url",
|
|
39
|
+
"token",
|
|
40
|
+
"chat",
|
|
41
|
+
"anthropic",
|
|
42
|
+
"responses_api",
|
|
43
|
+
"multimodal",
|
|
44
|
+
"temperature",
|
|
45
|
+
"web_search",
|
|
46
|
+
"web_search_name",
|
|
47
|
+
"context_window",
|
|
48
|
+
"compact_percent",
|
|
49
|
+
"reasoning_efforts",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ConfigEditError(ValueError):
|
|
54
|
+
"""The file could not be edited safely; its message says why and nothing was written."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def toml_string(value: str) -> str:
|
|
58
|
+
"""Return ``value`` as a TOML basic string, escaping what a basic string cannot hold."""
|
|
59
|
+
out = ['"']
|
|
60
|
+
for character in value:
|
|
61
|
+
code = ord(character)
|
|
62
|
+
if character == '"':
|
|
63
|
+
out.append('\\"')
|
|
64
|
+
elif character == "\\":
|
|
65
|
+
out.append("\\\\")
|
|
66
|
+
elif character == "\n":
|
|
67
|
+
out.append("\\n")
|
|
68
|
+
elif character == "\t":
|
|
69
|
+
out.append("\\t")
|
|
70
|
+
elif code < 0x20 or code == 0x7F:
|
|
71
|
+
out.append(f"\\u{code:04x}")
|
|
72
|
+
else:
|
|
73
|
+
out.append(character)
|
|
74
|
+
out.append('"')
|
|
75
|
+
return "".join(out)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _value(value: object) -> str:
|
|
79
|
+
"""Render one setting's value."""
|
|
80
|
+
if isinstance(value, bool):
|
|
81
|
+
return "true" if value else "false"
|
|
82
|
+
if isinstance(value, str):
|
|
83
|
+
return toml_string(value)
|
|
84
|
+
if isinstance(value, (int, float)):
|
|
85
|
+
return repr(value)
|
|
86
|
+
if isinstance(value, (list, tuple)) and all(isinstance(item, str) for item in value):
|
|
87
|
+
return "[" + ", ".join(toml_string(item) for item in value) + "]"
|
|
88
|
+
raise ConfigEditError(f"cannot write a value of type {type(value).__name__}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render_entry(entry: Mapping[str, object]) -> str:
|
|
92
|
+
"""Return one ``[[models]]`` block for ``entry``, keys in the documented order.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
entry: The keys of one model as they are written in the file.
|
|
96
|
+
"""
|
|
97
|
+
unknown = sorted(set(entry) - MODEL_KEYS)
|
|
98
|
+
if unknown:
|
|
99
|
+
raise ConfigEditError(f"unknown model keys: {', '.join(unknown)}")
|
|
100
|
+
lines = ["[[models]]"]
|
|
101
|
+
for key in _KEY_ORDER:
|
|
102
|
+
if key in entry:
|
|
103
|
+
lines.append(f"{key} = {_value(entry[key])}")
|
|
104
|
+
return "\n".join(lines) + "\n"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def entry_from_model(model: ModelConfig, *, token: str | None = None) -> dict[str, object]:
|
|
108
|
+
"""Return the file keys for ``model``, leaving out whatever is the default.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
model: A validated model.
|
|
112
|
+
token: What to write for the token; ``None`` writes the one the model holds.
|
|
113
|
+
"""
|
|
114
|
+
from zettcode.config import DEFAULT_COMPACT_PERCENT, DEFAULT_CONTEXT_WINDOW
|
|
115
|
+
|
|
116
|
+
written = model.token if token is None else token
|
|
117
|
+
entry: dict[str, object] = {"model": model.model}
|
|
118
|
+
if written:
|
|
119
|
+
entry["token"] = written
|
|
120
|
+
if model.display_model:
|
|
121
|
+
entry["display_model"] = model.display_model
|
|
122
|
+
if model.base_url:
|
|
123
|
+
entry["base_url"] = model.base_url
|
|
124
|
+
if model.anthropic:
|
|
125
|
+
entry["anthropic"] = True
|
|
126
|
+
if model.responses_api:
|
|
127
|
+
entry["responses_api"] = True
|
|
128
|
+
if model.chat:
|
|
129
|
+
entry["chat"] = True
|
|
130
|
+
if model.multimodal:
|
|
131
|
+
entry["multimodal"] = True
|
|
132
|
+
if model.temperature is not None:
|
|
133
|
+
entry["temperature"] = model.temperature
|
|
134
|
+
if model.web_search:
|
|
135
|
+
entry["web_search"] = True
|
|
136
|
+
if model.web_search_name:
|
|
137
|
+
entry["web_search_name"] = model.web_search_name
|
|
138
|
+
if model.context_window != DEFAULT_CONTEXT_WINDOW:
|
|
139
|
+
entry["context_window"] = model.context_window
|
|
140
|
+
if model.compact_percent != DEFAULT_COMPACT_PERCENT:
|
|
141
|
+
entry["compact_percent"] = model.compact_percent
|
|
142
|
+
if model.reasoning_efforts:
|
|
143
|
+
entry["reasoning_efforts"] = [level.value for level in model.reasoning_efforts]
|
|
144
|
+
return entry
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _blocks(lines: list[str]) -> list[tuple[int, int]]:
|
|
148
|
+
"""Return the ``(start, end)`` line range of every ``[[models]]`` block, in order.
|
|
149
|
+
|
|
150
|
+
A block runs to the next table header, less the blank and comment lines at its end: those are
|
|
151
|
+
the introduction to whatever follows, and belong to it.
|
|
152
|
+
"""
|
|
153
|
+
blocks: list[tuple[int, int]] = []
|
|
154
|
+
for index, line in enumerate(lines):
|
|
155
|
+
if not _MODEL_HEADER.match(line):
|
|
156
|
+
continue
|
|
157
|
+
end = len(lines)
|
|
158
|
+
for later in range(index + 1, len(lines)):
|
|
159
|
+
if _HEADER.match(lines[later]):
|
|
160
|
+
end = later
|
|
161
|
+
break
|
|
162
|
+
while end > index + 1 and (not lines[end - 1].strip() or lines[end - 1].lstrip().startswith("#")):
|
|
163
|
+
end -= 1
|
|
164
|
+
blocks.append((index, end))
|
|
165
|
+
return blocks
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parse(text: str, where: Path) -> dict[str, Any]:
|
|
169
|
+
try:
|
|
170
|
+
return tomllib.loads(text)
|
|
171
|
+
except tomllib.TOMLDecodeError as error:
|
|
172
|
+
raise ConfigEditError(f"{where} is not valid TOML: {error}") from error
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _guarded(text: str, where: Path) -> tuple[list[str], list[tuple[int, int]], dict[str, Any]]:
|
|
176
|
+
"""Return the file's lines, its model blocks and its data, or refuse a layout this cannot edit.
|
|
177
|
+
|
|
178
|
+
A file with no ``[[models]]`` table at all is an empty list of models rather than a layout to refuse: that is
|
|
179
|
+
what a fresh machine has, and ``/config`` is how the first entry is written into it.
|
|
180
|
+
"""
|
|
181
|
+
data = _parse(text, where)
|
|
182
|
+
declared = data.get("models") is not None
|
|
183
|
+
models = data.get("models")
|
|
184
|
+
if models is None:
|
|
185
|
+
models = []
|
|
186
|
+
data = {**data, "models": models}
|
|
187
|
+
if not isinstance(models, list) or not all(isinstance(entry, dict) for entry in models):
|
|
188
|
+
raise ConfigEditError(f"{where} has no [[models]] entries to edit")
|
|
189
|
+
lines = text.split("\n")
|
|
190
|
+
blocks = _blocks(lines)
|
|
191
|
+
if len(blocks) != len(models):
|
|
192
|
+
raise ConfigEditError(
|
|
193
|
+
f"{where} writes its models in a way /config cannot edit safely "
|
|
194
|
+
f"({len(models)} models, {len(blocks)} [[models]] blocks); edit the file by hand"
|
|
195
|
+
)
|
|
196
|
+
if declared and not blocks:
|
|
197
|
+
# `models = []`, or the key spelled some other way: this writes `[[models]]` blocks, and a second spelling
|
|
198
|
+
# of the same key cannot be added beside it — `models = []` plus one block is not TOML at all.
|
|
199
|
+
raise ConfigEditError(f"{where} writes 'models' as a value, which /config cannot edit; edit the file by hand")
|
|
200
|
+
return lines, blocks, data
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _checked(new_text: str, before: dict[str, Any], expected: list[dict[str, Any]], where: Path) -> None:
|
|
204
|
+
"""Refuse a result that does not hold what the edit meant it to, before anything is written."""
|
|
205
|
+
after = _parse(new_text, where)
|
|
206
|
+
if after.get("models") != expected:
|
|
207
|
+
raise ConfigEditError("the edit did not produce the models it meant to; nothing was written")
|
|
208
|
+
others_before = {key: value for key, value in before.items() if key != "models"}
|
|
209
|
+
others_after = {key: value for key, value in after.items() if key != "models"}
|
|
210
|
+
if others_before != others_after:
|
|
211
|
+
raise ConfigEditError("the edit would have changed something other than the models; nothing was written")
|
|
212
|
+
models = after.get("models")
|
|
213
|
+
try:
|
|
214
|
+
_read_models(models, where)
|
|
215
|
+
except ValueError as error:
|
|
216
|
+
raise ConfigEditError(str(error)) from error
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def add_model(text: str, entry: Mapping[str, object], *, where: Path) -> str:
|
|
220
|
+
"""Return ``text`` with one more model after the last, which is where a reader looks for it.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
text: The config file as it is.
|
|
224
|
+
entry: The new model's keys.
|
|
225
|
+
where: The file's path, for messages.
|
|
226
|
+
"""
|
|
227
|
+
lines, blocks, data = _guarded(text, where)
|
|
228
|
+
block = render_entry(entry).rstrip("\n").split("\n")
|
|
229
|
+
expected = [*data["models"], dict(_parse(render_entry(entry), where)["models"][0])]
|
|
230
|
+
if blocks:
|
|
231
|
+
at = blocks[-1][1]
|
|
232
|
+
result = "\n".join([*lines[:at], "", *block, *lines[at:]])
|
|
233
|
+
else:
|
|
234
|
+
# Nothing to insert after: the entry goes at the end, set apart from whatever the file holds by one blank
|
|
235
|
+
# line, and a file that holds nothing gets no leading blank line.
|
|
236
|
+
head = text.rstrip()
|
|
237
|
+
body = "\n".join(block)
|
|
238
|
+
result = f"{head}\n\n{body}\n" if head else f"{body}\n"
|
|
239
|
+
_checked(result, data, expected, where)
|
|
240
|
+
return result
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def replace_model(text: str, index: int, entry: Mapping[str, object], *, where: Path) -> str:
|
|
244
|
+
"""Return ``text`` with model number ``index`` (from zero) rewritten as ``entry``.
|
|
245
|
+
|
|
246
|
+
Comments inside that one block are not kept; those above it and everywhere else are.
|
|
247
|
+
"""
|
|
248
|
+
lines, blocks, data = _guarded(text, where)
|
|
249
|
+
if not 0 <= index < len(blocks):
|
|
250
|
+
raise ConfigEditError(f"there is no model number {index + 1}")
|
|
251
|
+
start, end = blocks[index]
|
|
252
|
+
new = [*lines[:start], *render_entry(entry).rstrip("\n").split("\n"), *lines[end:]]
|
|
253
|
+
expected = list(data["models"])
|
|
254
|
+
expected[index] = dict(_parse(render_entry(entry), where)["models"][0])
|
|
255
|
+
result = "\n".join(new)
|
|
256
|
+
_checked(result, data, expected, where)
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def remove_model(text: str, index: int, *, where: Path) -> str:
|
|
261
|
+
"""Return ``text`` without model number ``index`` (from zero). The last one cannot be removed."""
|
|
262
|
+
lines, blocks, data = _guarded(text, where)
|
|
263
|
+
if not 0 <= index < len(blocks):
|
|
264
|
+
raise ConfigEditError(f"there is no model number {index + 1}")
|
|
265
|
+
if len(blocks) == 1:
|
|
266
|
+
raise ConfigEditError("that is the only model; add another before removing it")
|
|
267
|
+
start, end = blocks[index]
|
|
268
|
+
# The blank line that set the block apart goes with it, so removing leaves no gap behind.
|
|
269
|
+
if start > 0 and not lines[start - 1].strip():
|
|
270
|
+
start -= 1
|
|
271
|
+
new = [*lines[:start], *lines[end:]]
|
|
272
|
+
expected = list(data["models"])
|
|
273
|
+
del expected[index]
|
|
274
|
+
result = "\n".join(new)
|
|
275
|
+
_checked(result, data, expected, where)
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _opened_by_subtables(lines: Sequence[str], table: str, key: str, value: object) -> bool:
|
|
280
|
+
"""Return whether the file opens ``table`` only through sub-tables of it, with none of them called ``key``.
|
|
281
|
+
|
|
282
|
+
A file that writes ``[plugins.recap]`` has a ``plugins`` table the moment it parses, although no line ever says
|
|
283
|
+
``[plugins]``; TOML lets a parent be declared after its children, so the key can still be written under a header
|
|
284
|
+
appended at the end. That holds only when the file does not assign the table itself, inline (``plugins = {...}``)
|
|
285
|
+
or through dotted keys (``plugins.disable = []``): a table assigned like that cannot be declared a second time.
|
|
286
|
+
A sub-table already called ``key`` (``[plugins.disable]``) cannot be written at all: the key and the table would
|
|
287
|
+
be one name declared twice. One case is refused although TOML would take it, because nobody has needed it and the
|
|
288
|
+
check after the edit is what keeps a wider rule honest: an entry of the table that is not a plain sub-table (an
|
|
289
|
+
array of tables, ``[[plugins.notes]]``). Anything refused is left for the reader to edit by hand.
|
|
290
|
+
|
|
291
|
+
This looks at lines, not at TOML, so it knows the spellings it was written for: a bare or quoted first segment.
|
|
292
|
+
A spelling it cannot see makes the appended header invalid, which the edit's own check catches and reports as a
|
|
293
|
+
file it cannot edit, so a miss costs a refusal and never a wrong file.
|
|
294
|
+
"""
|
|
295
|
+
first = rf"(?:{re.escape(table)}|\"{re.escape(table)}\"|'{re.escape(table)}')"
|
|
296
|
+
assigned = re.compile(rf"^\s*{first}\s*[.=]")
|
|
297
|
+
if any(assigned.match(item) for item in lines):
|
|
298
|
+
return False
|
|
299
|
+
if not isinstance(value, dict) or not value or key in value:
|
|
300
|
+
return False
|
|
301
|
+
return all(isinstance(item, dict) for item in value.values())
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _set_key(text: str, table: str, key: str, literal: str, value: object, *, where: Path, command: str) -> str:
|
|
305
|
+
"""Return ``text`` with ``[table] key`` set to ``value``, touching nothing else.
|
|
306
|
+
|
|
307
|
+
The key's line is replaced where the file already has one, added under an existing header, or the
|
|
308
|
+
table is appended at the end. The result is checked before it is returned: it must parse and differ
|
|
309
|
+
from the original in nothing but this key. A file that spells the table some other way (an inline
|
|
310
|
+
table, say) is refused rather than guessed at; a table it opens only through a sub-table of it, as
|
|
311
|
+
``[plugins.recap]`` opens ``plugins``, gets a header of its own appended (see
|
|
312
|
+
:func:`_opened_by_subtables`).
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
table: The table's name (``"ui"``).
|
|
316
|
+
key: The key in it (``"theme"``).
|
|
317
|
+
literal: ``value`` as TOML spells it (``'"nord"'``, ``"true"``).
|
|
318
|
+
value: What the key must read back as.
|
|
319
|
+
where: The file, for messages.
|
|
320
|
+
command: What the reader ran, for the message when the file cannot be edited safely.
|
|
321
|
+
"""
|
|
322
|
+
header_pattern = re.compile(rf"^\s*\[\s*{re.escape(table)}\s*\]\s*(#.*)?$")
|
|
323
|
+
key_pattern = re.compile(rf"^\s*{re.escape(key)}\s*=")
|
|
324
|
+
before = _parse(text, where)
|
|
325
|
+
lines = text.split("\n")
|
|
326
|
+
line = f"{key} = {literal}"
|
|
327
|
+
header = next((index for index, item in enumerate(lines) if header_pattern.match(item)), None)
|
|
328
|
+
if header is None:
|
|
329
|
+
if table in before and not _opened_by_subtables(lines, table, key, before[table]):
|
|
330
|
+
raise ConfigEditError(
|
|
331
|
+
f"{where} writes [{table}] in a way {command} cannot edit safely; edit the file by hand"
|
|
332
|
+
)
|
|
333
|
+
while lines and not lines[-1].strip():
|
|
334
|
+
lines.pop()
|
|
335
|
+
new = [*lines, "", f"[{table}]", line, ""]
|
|
336
|
+
else:
|
|
337
|
+
end = next((i for i in range(header + 1, len(lines)) if _HEADER.match(lines[i])), len(lines))
|
|
338
|
+
existing = next((i for i in range(header + 1, end) if key_pattern.match(lines[i])), None)
|
|
339
|
+
if existing is not None:
|
|
340
|
+
new = [*lines[:existing], line, *lines[existing + 1 :]]
|
|
341
|
+
else:
|
|
342
|
+
new = [*lines[: header + 1], line, *lines[header + 1 :]]
|
|
343
|
+
result = "\n".join(new)
|
|
344
|
+
try:
|
|
345
|
+
after = _parse(result, where)
|
|
346
|
+
except ConfigEditError as error:
|
|
347
|
+
# ``text`` parsed above, so it is this edit that made the file unreadable, not the reader: a table spelled in a
|
|
348
|
+
# way the line patterns above do not see (a quoted ``"plugins"``, say) is one the edit cannot make room in.
|
|
349
|
+
raise ConfigEditError(
|
|
350
|
+
f"{where} writes [{table}] in a way {command} cannot edit safely; edit the file by hand"
|
|
351
|
+
) from error
|
|
352
|
+
table_before = {name: item for name, item in before.get(table, {}).items() if name != key}
|
|
353
|
+
table_after = {name: item for name, item in after.get(table, {}).items() if name != key}
|
|
354
|
+
others_before = {name: item for name, item in before.items() if name != table}
|
|
355
|
+
others_after = {name: item for name, item in after.items() if name != table}
|
|
356
|
+
if after.get(table, {}).get(key) != value or table_before != table_after or others_before != others_after:
|
|
357
|
+
raise ConfigEditError(f"the edit would have changed something other than {table}.{key}; nothing was written")
|
|
358
|
+
return result
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def set_theme(text: str, name: str, *, where: Path) -> str:
|
|
362
|
+
"""Return ``text`` with ``[ui] theme`` set to ``name``, touching nothing else.
|
|
363
|
+
|
|
364
|
+
Like the model edits it is checked before it is returned: the result must parse and differ from the
|
|
365
|
+
original in nothing but ``ui.theme``.
|
|
366
|
+
"""
|
|
367
|
+
return _set_key(text, "ui", "theme", toml_string(name), name, where=where, command="/theme")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def set_pet(text: str, name: str, *, where: Path) -> str:
|
|
371
|
+
"""Return ``text`` with ``[ui] pet`` set to ``name``, touching nothing else."""
|
|
372
|
+
return _set_key(text, "ui", "pet", toml_string(name), name, where=where, command="/pet")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def set_pet_position(text: str, across: float, down: float, *, where: Path) -> str:
|
|
376
|
+
"""Return ``text`` with ``[ui] pet_position`` set to where the pet was left, touching nothing else.
|
|
377
|
+
|
|
378
|
+
The shares are rounded to three places: a pet moves a cell at a time, and a number such as
|
|
379
|
+
``0.8571428571428571`` in a file the reader reads and edits is noise.
|
|
380
|
+
"""
|
|
381
|
+
shares = [round(min(1.0, max(0.0, across)), 3), round(min(1.0, max(0.0, down)), 3)]
|
|
382
|
+
literal = "[" + ", ".join(repr(share) for share in shares) + "]"
|
|
383
|
+
return _set_key(text, "ui", "pet_position", literal, shares, where=where, command="the pet")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def set_self_compaction(text: str, enabled: bool, *, where: Path) -> str:
|
|
387
|
+
"""Return ``text`` with ``[compaction] self`` set to ``enabled``, touching nothing else."""
|
|
388
|
+
return _set_key(
|
|
389
|
+
text, "compaction", "self", "true" if enabled else "false", enabled, where=where, command="/plugins"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def set_string_list(text: str, table: str, key: str, values: Sequence[str], *, where: Path, command: str) -> str:
|
|
394
|
+
"""Return ``text`` with ``[table] key`` set to an array of strings, touching nothing else.
|
|
395
|
+
|
|
396
|
+
The array is written on one line, in the order given; the result is checked the way every other key edit is.
|
|
397
|
+
"""
|
|
398
|
+
literal = "[" + ", ".join(toml_string(value) for value in values) + "]"
|
|
399
|
+
return _set_key(text, table, key, literal, list(values), where=where, command=command)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def set_boolean(text: str, table: str, key: str, enabled: bool, *, where: Path, command: str) -> str:
|
|
403
|
+
"""Return ``text`` with ``[table] key`` set to a boolean, touching nothing else.
|
|
404
|
+
|
|
405
|
+
One key of one table, written and checked the way every other single-key edit is: a file that cannot take it is
|
|
406
|
+
refused whole rather than half-written.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
text: The configuration as it is now.
|
|
410
|
+
table: The table's name (``"skills"``).
|
|
411
|
+
key: The key in it (``"enabled"``).
|
|
412
|
+
enabled: What it should read back as.
|
|
413
|
+
where: The file, for messages.
|
|
414
|
+
command: What the reader pressed, for the message when the file cannot be edited safely.
|
|
415
|
+
"""
|
|
416
|
+
return _set_key(text, table, key, "true" if enabled else "false", enabled, where=where, command=command)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def set_statusline(text: str, shown: Mapping[str, bool], *, where: Path) -> str:
|
|
420
|
+
"""Return ``text`` with each named part of ``[statusline]`` set to shown or hidden, touching nothing else.
|
|
421
|
+
|
|
422
|
+
A part is a key of the table (``ctx = false`` hides it, ``ctx = true`` shows it again). Each key is
|
|
423
|
+
written and checked on its own, as the other single-key edits are, so a file that cannot take one is
|
|
424
|
+
refused whole rather than half-written.
|
|
425
|
+
"""
|
|
426
|
+
for name, visible in shown.items():
|
|
427
|
+
text = _set_key(
|
|
428
|
+
text, "statusline", name, "true" if visible else "false", visible, where=where, command="/statusline"
|
|
429
|
+
)
|
|
430
|
+
return text
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def file_entries(text: str, *, where: Path) -> list[dict[str, Any]]:
|
|
434
|
+
"""Return the models as the file writes them (a token left to the environment stays absent); none is a list."""
|
|
435
|
+
return list(_guarded(text, where)[2].get("models") or [])
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def write_text_atomically(path: Path, text: str) -> None:
|
|
439
|
+
"""Replace ``path`` with ``text`` so a crash leaves the old file or the new one, never half.
|
|
440
|
+
|
|
441
|
+
The new file takes the old one's permissions: the file holds tokens, and a reader who made it
|
|
442
|
+
private has not asked for that to change by adding a model.
|
|
443
|
+
"""
|
|
444
|
+
path = path.expanduser()
|
|
445
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
446
|
+
mode = path.stat().st_mode & 0o777 if path.exists() else 0o600
|
|
447
|
+
handle, name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
448
|
+
try:
|
|
449
|
+
with os.fdopen(handle, "w", encoding="utf-8", newline="") as file:
|
|
450
|
+
file.write(text)
|
|
451
|
+
with contextlib.suppress(OSError): # not every file system keeps modes
|
|
452
|
+
os.chmod(name, mode)
|
|
453
|
+
os.replace(name, path)
|
|
454
|
+
except BaseException:
|
|
455
|
+
with contextlib.suppress(OSError):
|
|
456
|
+
os.unlink(name)
|
|
457
|
+
raise
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The engine: the one agent core that the terminal and the SDK both drive.
|
|
2
|
+
|
|
3
|
+
Most programs want :mod:`zettcode.sdk`, which is the documented, stable surface. The engine is here for the ones that
|
|
4
|
+
need the core itself, and it stays small on purpose; see ``docs/internal/engine.md`` and
|
|
5
|
+
``docs/internal/namespace-split.md``. It imports nothing else of ours: the plugin set it runs, the line to the other
|
|
6
|
+
sessions and the MCP client are the SDK's, passed in by whoever builds the runtime. Names load on first use, so
|
|
7
|
+
importing one submodule does not build the rest.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
#: Where each public name lives; read by :func:`__getattr__` on first use.
|
|
16
|
+
_EXPORTS = {
|
|
17
|
+
"ZettCodeEngine": ".core",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING: # pragma: no cover - for type checkers, not the runtime
|
|
22
|
+
from .core import ZettCodeEngine
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def __getattr__(name: str) -> object:
|
|
26
|
+
"""Import the module a public name lives in, the first time it is asked for."""
|
|
27
|
+
try:
|
|
28
|
+
module = _EXPORTS[name]
|
|
29
|
+
except KeyError:
|
|
30
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
|
31
|
+
return getattr(importlib.import_module(module, __name__), name)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def __dir__() -> list[str]:
|
|
35
|
+
"""Return the names this package publishes."""
|
|
36
|
+
return sorted(__all__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = ["ZettCodeEngine"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The agent: one class, assembled from the parts that make it up.
|
|
2
|
+
|
|
3
|
+
The class used to be one 750-line body doing eight unrelated jobs. It is divided by what each part is responsible for:
|
|
4
|
+
|
|
5
|
+
============== ===========================================================================
|
|
6
|
+
``base`` :class:`AgentBase` — the engine, the workspace, the active session id
|
|
7
|
+
``settings`` :class:`SettingsMixin` — model, reasoning effort, shell permission
|
|
8
|
+
``context`` :class:`ContextMixin` — the request the next turn carries; compaction
|
|
9
|
+
``sessions`` :class:`SessionsMixin` — switch, resume, fork, list, and title sessions
|
|
10
|
+
``turns`` :class:`TurnsMixin` — build a request, stream its events, steer it
|
|
11
|
+
``interaction`` :class:`InteractionMixin` — answer an approval request or a question
|
|
12
|
+
``exports`` :class:`ExportsMixin` — write a session out as a page
|
|
13
|
+
``prompt`` the parts of a user turn: text, ``@`` references, images (no class)
|
|
14
|
+
============== ===========================================================================
|
|
15
|
+
|
|
16
|
+
A mixin inherits the ones whose methods it really calls, and :class:`AgentBase` below all of them, so each is
|
|
17
|
+
type-checked against what it relies on; nothing is declared twice. This class is the composition and the
|
|
18
|
+
lifecycle — building the engine, and closing it. It knows nothing of the terminal: the terminal's commands and
|
|
19
|
+
preferences are a subclass of its own (``zettcode.app.agent.agent.TerminalAgent``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import TypeVar
|
|
25
|
+
|
|
26
|
+
from zettcode.config import ZettCodeConfig
|
|
27
|
+
from zettcode.engine.core import PluginLoader, ZettCodeEngine
|
|
28
|
+
|
|
29
|
+
from .context import ContextMixin
|
|
30
|
+
from .exports import ExportsMixin
|
|
31
|
+
from .interaction import InteractionMixin
|
|
32
|
+
from .sessions import SessionsMixin
|
|
33
|
+
from .settings import SettingsMixin
|
|
34
|
+
from .turns import TurnsMixin
|
|
35
|
+
|
|
36
|
+
__all__ = ["ZettCodeAgent"]
|
|
37
|
+
|
|
38
|
+
AgentT = TypeVar("AgentT", bound="ZettCodeAgent")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ZettCodeAgent(
|
|
42
|
+
InteractionMixin,
|
|
43
|
+
TurnsMixin,
|
|
44
|
+
ExportsMixin,
|
|
45
|
+
SessionsMixin,
|
|
46
|
+
SettingsMixin,
|
|
47
|
+
ContextMixin,
|
|
48
|
+
):
|
|
49
|
+
"""Expose agent operations without leaking engine resources to the front end.
|
|
50
|
+
|
|
51
|
+
The engine assembles the vendor client and extensions; this class is the entry point for turns, session and
|
|
52
|
+
model selection, progress, and responses to approval requests.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, runtime: ZettCodeEngine) -> None:
|
|
56
|
+
self.runtime = runtime
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
async def create(cls: type[AgentT], config: ZettCodeConfig, load: PluginLoader) -> AgentT:
|
|
60
|
+
"""Build the engine with the plugin set the caller chose, and wrap it in the agent.
|
|
61
|
+
|
|
62
|
+
The loader is required: the engine ships no built-in plugin, so a runtime built without one has no tools, no
|
|
63
|
+
session store and no approval — and the front ends that know which agent they want are the SDK
|
|
64
|
+
(:func:`zettcode.sdk.builtins.load_agent_plugins`) and the terminal
|
|
65
|
+
(:func:`zettcode.app.plugins.loader.terminal_plugins`).
|
|
66
|
+
"""
|
|
67
|
+
return cls(await ZettCodeEngine.create(config, load))
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def preview(cls: type[AgentT], config: ZettCodeConfig, load: PluginLoader) -> AgentT:
|
|
71
|
+
"""Wrap an engine that still has to start, so a front end can paint first.
|
|
72
|
+
|
|
73
|
+
The engine here holds the session and the extensions the loader's plugins contribute; the provider SDK and
|
|
74
|
+
the client are built by the first :meth:`runtime.start`, which a turn or a command awaits. Everything a front
|
|
75
|
+
end reads per frame — the model, the effort, the session, the usage — is answered from that preview.
|
|
76
|
+
"""
|
|
77
|
+
return cls(ZettCodeEngine.preview(config, load))
|
|
78
|
+
|
|
79
|
+
async def aclose(self) -> None:
|
|
80
|
+
"""Release the underlying engine resources."""
|
|
81
|
+
await self.runtime.aclose()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""What every part of the agent shares: the runtime it speaks to, and who and where it is.
|
|
2
|
+
|
|
3
|
+
The mixins in this package are split by what they are responsible for, and each one
|
|
4
|
+
inherits this class only to be type-checked against what the others rely on. The two
|
|
5
|
+
properties here are read by nearly all of them, which is why they live below all of them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from zettcode.engine.core import ZettCodeEngine
|
|
13
|
+
from zettcode.engine.storage import SessionLocked
|
|
14
|
+
from zettcode.mention import MentionProvider
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AgentBase:
|
|
18
|
+
"""The runtime, the workspace it works in, and the session the next turn belongs to."""
|
|
19
|
+
|
|
20
|
+
#: The runtime this agent speaks to; the one thing the application agent holds.
|
|
21
|
+
runtime: ZettCodeEngine
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def workspace(self) -> Path:
|
|
25
|
+
"""Return the workspace displayed in the header."""
|
|
26
|
+
return self.runtime.config.workspace
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def session_id(self) -> str:
|
|
30
|
+
"""Return the session used for the next turn."""
|
|
31
|
+
return self.runtime.session_id
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def plugin_mentions(self) -> tuple[MentionProvider, ...]:
|
|
35
|
+
"""Return the ``@`` resource providers the installed plugins registered."""
|
|
36
|
+
return self.runtime.plugins.mentions
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def session_locked(self) -> bool:
|
|
40
|
+
"""Return whether another process has the active session open, so that this one may only read it.
|
|
41
|
+
|
|
42
|
+
Every session is locked by the process that has it open (see :mod:`..storage.locks`). A session this
|
|
43
|
+
process started is always its own; one it resumed may be somebody else's, and then it is shown and
|
|
44
|
+
nothing is written to it: no turn, no title, no compaction.
|
|
45
|
+
"""
|
|
46
|
+
return self.runtime.session_locked
|
|
47
|
+
|
|
48
|
+
def retake_session(self) -> bool:
|
|
49
|
+
"""Try again to lock the active session; return whether this process holds it now."""
|
|
50
|
+
return self.runtime.retake_session()
|
|
51
|
+
|
|
52
|
+
def require_session(self) -> None:
|
|
53
|
+
"""Refuse to go on when the active session is another process's.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
SessionLocked: The lock is not held. A ``ValueError``, which the shell shows as a line in the
|
|
57
|
+
conversation.
|
|
58
|
+
"""
|
|
59
|
+
if self.session_locked:
|
|
60
|
+
raise SessionLocked()
|