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,140 @@
|
|
|
1
|
+
"""The operating-system sandbox a shell command runs in at the ``safe`` level.
|
|
2
|
+
|
|
3
|
+
``safe`` is the middle rung between asking about everything and running everything: a command runs without a
|
|
4
|
+
question, and what it can do is bounded by the kernel and not by anybody's reading of the command line. The
|
|
5
|
+
boundary is the one Codex draws for its workspace-write mode:
|
|
6
|
+
|
|
7
|
+
* it may write the workspace, and the system temporary directory (``/tmp`` and ``$TMPDIR``), and nothing else;
|
|
8
|
+
* it may read the rest of the machine, except the places credentials live (``~/.ssh``, ``~/.aws`` and the like);
|
|
9
|
+
* it has no network, unless the configuration says ``[shell] network = true``;
|
|
10
|
+
* the files that run code later outside it (``.git/hooks``, ``.git/config``, shell start-up files) stay read-only
|
|
11
|
+
even inside the workspace.
|
|
12
|
+
|
|
13
|
+
The enforcement is ``zett-agent``'s: Seatbelt (``sandbox-exec``) on macOS and bubblewrap on Linux. Windows has
|
|
14
|
+
none, and neither has a Linux machine without ``bwrap``; there :attr:`ShellSandbox.available` is false and the
|
|
15
|
+
policy falls back to what ``safe`` was before the sandbox existed (read-only commands run, the rest ask), so the
|
|
16
|
+
word never promises a boundary the machine cannot draw.
|
|
17
|
+
|
|
18
|
+
The sandbox does not run the command. It rewrites it into one that starts under the sandbox's launcher, which the
|
|
19
|
+
shell tool then runs the way it runs any command, so the output handling, the timeout and the process-group kill
|
|
20
|
+
stay the framework's.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import shlex
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from zett_agent.sandbox import NetworkMode, SandboxPolicy, SandboxUnavailableError, create_sandbox
|
|
31
|
+
from zett_agent.sandbox.base import Sandbox
|
|
32
|
+
|
|
33
|
+
from ..tools.boundary import Sandboxer
|
|
34
|
+
|
|
35
|
+
__all__ = ["ShellSandbox", "SANDBOX_HINT", "temporary_roots"]
|
|
36
|
+
|
|
37
|
+
#: Appended to the failure of a command that ran sandboxed, so the model learns the boundary instead of
|
|
38
|
+
#: retrying the same call: a denied write and a missing network look like a bare "Operation not permitted".
|
|
39
|
+
SANDBOX_HINT = (
|
|
40
|
+
"[sandbox] This command ran in the `safe` sandbox: it can write only the workspace and the temporary "
|
|
41
|
+
"directory{network}. If it failed for that reason, tell the user which command needs more and let them "
|
|
42
|
+
"choose /permission full."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def temporary_roots() -> tuple[Path, ...]:
|
|
47
|
+
"""Return the temporary directories a sandboxed command may write: ``/tmp`` and the process's own.
|
|
48
|
+
|
|
49
|
+
On macOS these are different places (``/tmp`` is ``/private/tmp``, and ``$TMPDIR`` is under
|
|
50
|
+
``/var/folders``); on Linux they usually coincide. Only directories that exist are returned.
|
|
51
|
+
"""
|
|
52
|
+
candidates = [Path("/tmp"), Path(tempfile.gettempdir())]
|
|
53
|
+
found: list[Path] = []
|
|
54
|
+
for path in candidates:
|
|
55
|
+
if path.is_dir() and path not in found:
|
|
56
|
+
found.append(path)
|
|
57
|
+
return tuple(found)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ShellSandbox(Sandboxer):
|
|
61
|
+
"""The sandbox for one workspace: whether this machine has one, and a command rewritten to run in it."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, workspace: Path, *, network: bool = False) -> None:
|
|
64
|
+
"""Describe the boundary; nothing is probed until :attr:`available` is first read.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
workspace: The directory commands run in, which they may write.
|
|
68
|
+
network: Whether a sandboxed command may use the network.
|
|
69
|
+
"""
|
|
70
|
+
self.workspace = workspace
|
|
71
|
+
self.network = network
|
|
72
|
+
self._sandbox: Sandbox | None = None
|
|
73
|
+
self._reason: str | None = None
|
|
74
|
+
self._probed = False
|
|
75
|
+
|
|
76
|
+
def _probe(self) -> None:
|
|
77
|
+
"""Build the sandbox once and remember why not when the machine cannot."""
|
|
78
|
+
if self._probed:
|
|
79
|
+
return
|
|
80
|
+
self._probed = True
|
|
81
|
+
if not (sys.platform == "darwin" or sys.platform.startswith("linux")):
|
|
82
|
+
self._reason = "this platform has no sandbox; commands at `safe` are limited to read-only ones"
|
|
83
|
+
return
|
|
84
|
+
policy = SandboxPolicy.workspace_write(
|
|
85
|
+
self.workspace,
|
|
86
|
+
*temporary_roots(),
|
|
87
|
+
network=NetworkMode.FULL if self.network else NetworkMode.NONE,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
self._sandbox = create_sandbox(policy, require_isolation=True)
|
|
91
|
+
except SandboxUnavailableError as error:
|
|
92
|
+
self._reason = str(error)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def available(self) -> bool:
|
|
96
|
+
"""Return whether this machine can sandbox a command."""
|
|
97
|
+
self._probe()
|
|
98
|
+
return self._sandbox is not None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def boundary(self) -> Sandbox | None:
|
|
102
|
+
"""Return the sandbox itself, or ``None`` when this machine has none.
|
|
103
|
+
|
|
104
|
+
For a caller that runs code *inside* one rather than rewriting a command into it: the harness starts a
|
|
105
|
+
child process for the model's own tools, and at `safe` that child runs in this boundary.
|
|
106
|
+
"""
|
|
107
|
+
self._probe()
|
|
108
|
+
return self._sandbox
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def unavailable_reason(self) -> str | None:
|
|
112
|
+
"""Return why there is no sandbox, or ``None`` when there is one."""
|
|
113
|
+
self._probe()
|
|
114
|
+
return self._reason
|
|
115
|
+
|
|
116
|
+
def wrap(self, command: str, cwd: Path | None = None) -> str:
|
|
117
|
+
"""Return ``command`` as a shell line that runs it inside the sandbox, in ``cwd``.
|
|
118
|
+
|
|
119
|
+
The environment the sandbox allows (no API keys or tokens) is spelled out with ``env -i``, because the
|
|
120
|
+
shell tool starts the line with the host's own environment.
|
|
121
|
+
|
|
122
|
+
``cwd`` is where the command is meant to run — the tool's ``work_dir``, or the workspace. A Linux sandbox
|
|
123
|
+
puts ``--chdir`` in the argv it returns, and that wins over the ``cwd=`` of the spawn, so passing the
|
|
124
|
+
workspace for a command that runs somewhere else would run it in the wrong directory without a word. A
|
|
125
|
+
directory outside the workspace is outside the policy the sandbox writes, and the command will fail there
|
|
126
|
+
rather than be let out of it.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
RuntimeError: The machine has no sandbox; check :attr:`available` first.
|
|
130
|
+
"""
|
|
131
|
+
self._probe()
|
|
132
|
+
if self._sandbox is None:
|
|
133
|
+
raise RuntimeError(self._reason or "no sandbox")
|
|
134
|
+
wrapped = self._sandbox.wrap(command, cwd=cwd if cwd is not None else Path.cwd())
|
|
135
|
+
assignments = [f"{name}={value}" for name, value in wrapped.env.items()]
|
|
136
|
+
return shlex.join(["env", "-i", *assignments, *wrapped.argv])
|
|
137
|
+
|
|
138
|
+
def hint(self) -> str:
|
|
139
|
+
"""Return the note that goes after a failed sandboxed command."""
|
|
140
|
+
return SANDBOX_HINT.format(network="" if self.network else " and has no network")
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Work that must not hold the process, on threads nobody joins.
|
|
2
|
+
|
|
3
|
+
A launch is often abandoned before its first turn — read the screen, quit — and
|
|
4
|
+
what it was warming must not hold that exit open. What decides that is the kind
|
|
5
|
+
of thread the work runs on: the default executor's threads are joined while the
|
|
6
|
+
interpreter shuts down, so anything still in flight delays the process by its
|
|
7
|
+
whole duration — about a second for the model SDKs, seconds for a download —
|
|
8
|
+
while a daemon thread is left behind and the same exit takes milliseconds.
|
|
9
|
+
Neither leaves anything on stderr on the versions this project runs.
|
|
10
|
+
|
|
11
|
+
Two shapes of it live here: :class:`ImportPipeline`, for the modules a launch
|
|
12
|
+
wants before the first turn, and :class:`DaemonCall`, for one callable whose
|
|
13
|
+
answer may arrive after whoever asked for it has gone. Nothing is wasted either
|
|
14
|
+
way: what finished is a cache hit for the work that comes later, and what did not
|
|
15
|
+
is what that work would have paid anyway, done while the reader is reading the
|
|
16
|
+
first screen — a pipeline filling behind an instruction.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import importlib
|
|
23
|
+
import threading
|
|
24
|
+
from collections.abc import Callable, Sequence
|
|
25
|
+
from typing import Generic, TypeVar
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ImportPipeline:
|
|
29
|
+
"""Import a list of modules, in order, on a daemon thread.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
names: Dotted module names, imported in this order. A name that cannot be
|
|
33
|
+
imported is recorded in :attr:`failures` and the list carries on: a
|
|
34
|
+
module that is missing here is reported where it is actually used,
|
|
35
|
+
which is where a reader can act on it.
|
|
36
|
+
|
|
37
|
+
Note:
|
|
38
|
+
:meth:`start` returns without waiting for anything, and nothing else
|
|
39
|
+
waits for the thread either — a process that ends mid-list ends at once.
|
|
40
|
+
:meth:`wait` is for the code that needs the modules now, on the thread
|
|
41
|
+
that needs them.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, names: Sequence[str]) -> None:
|
|
45
|
+
"""Hold the list; nothing starts until :meth:`start`."""
|
|
46
|
+
self._names = tuple(names)
|
|
47
|
+
self._finished = threading.Event()
|
|
48
|
+
self._stopped = threading.Event()
|
|
49
|
+
self._on_finish: Callable[[], None] | None = None
|
|
50
|
+
self._thread: threading.Thread | None = None
|
|
51
|
+
#: Modules that could not be imported, and why, keyed by module name.
|
|
52
|
+
self.failures: dict[str, BaseException] = {}
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def names(self) -> tuple[str, ...]:
|
|
56
|
+
"""Return the modules this pipeline imports, in order."""
|
|
57
|
+
return self._names
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def done(self) -> bool:
|
|
61
|
+
"""Return whether the list was worked through, or given up on."""
|
|
62
|
+
return self._finished.is_set()
|
|
63
|
+
|
|
64
|
+
def start(self, *, on_finish: Callable[[], None] | None = None) -> None:
|
|
65
|
+
"""Begin importing, and return without waiting for any of it.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
on_finish: Called on the pipeline's thread once the list is done or
|
|
69
|
+
given up on, so a caller can release whoever is waiting. It runs
|
|
70
|
+
off the event loop and must not touch it directly; a loop that
|
|
71
|
+
has closed in the meantime is not an error.
|
|
72
|
+
"""
|
|
73
|
+
if self._thread is not None:
|
|
74
|
+
return
|
|
75
|
+
self._on_finish = on_finish
|
|
76
|
+
self._thread = threading.Thread(target=self._run, name="zettcode-imports", daemon=True)
|
|
77
|
+
self._thread.start()
|
|
78
|
+
|
|
79
|
+
def wait(self, timeout: float | None = None) -> bool:
|
|
80
|
+
"""Block until the list is done, and report whether it finished in time."""
|
|
81
|
+
return self._finished.wait(timeout)
|
|
82
|
+
|
|
83
|
+
def stop(self) -> None:
|
|
84
|
+
"""Ask the thread to give up at the next module boundary, and release waiters.
|
|
85
|
+
|
|
86
|
+
Called when the work is no longer wanted — the shell is going away and
|
|
87
|
+
the imports were for a turn that will not happen. The module being
|
|
88
|
+
imported is not interrupted, so the thread is never cut in half by this;
|
|
89
|
+
it notices between one module and the next.
|
|
90
|
+
"""
|
|
91
|
+
self._stopped.set()
|
|
92
|
+
self._finished.set()
|
|
93
|
+
self._notify()
|
|
94
|
+
|
|
95
|
+
def _run(self) -> None:
|
|
96
|
+
try:
|
|
97
|
+
for name in self._names:
|
|
98
|
+
if self._stopped.is_set():
|
|
99
|
+
break
|
|
100
|
+
try:
|
|
101
|
+
importlib.import_module(name)
|
|
102
|
+
except Exception as error:
|
|
103
|
+
# A failure here has no reader: the module is reported by
|
|
104
|
+
# whatever needed it, which can say what it was doing.
|
|
105
|
+
self.failures[name] = error
|
|
106
|
+
finally:
|
|
107
|
+
self._finished.set()
|
|
108
|
+
self._notify()
|
|
109
|
+
|
|
110
|
+
def _notify(self) -> None:
|
|
111
|
+
"""Tell the caller the list is over, from the pipeline's own thread."""
|
|
112
|
+
if self._on_finish is None:
|
|
113
|
+
return
|
|
114
|
+
try:
|
|
115
|
+
self._on_finish()
|
|
116
|
+
except RuntimeError:
|
|
117
|
+
# The loop this was reporting to closed while the imports ran: the
|
|
118
|
+
# shell that asked for them has already gone, and nobody is waiting.
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
#: The value a callable hands back, of whatever type it is.
|
|
123
|
+
T = TypeVar("T")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class DaemonCall(Generic[T]):
|
|
127
|
+
"""Run one callable on a daemon thread, and wait for it without owning it.
|
|
128
|
+
|
|
129
|
+
``asyncio.to_thread`` does the same work, but its thread belongs to the
|
|
130
|
+
interpreter's default executor, which is joined while the process shuts down:
|
|
131
|
+
a call still running when the shell leaves delays the exit by its whole
|
|
132
|
+
duration. Here the thread is a daemon and the signal is an event, so
|
|
133
|
+
abandoning the call costs nothing — the answer is simply dropped, or picked
|
|
134
|
+
up later by :meth:`peek` if it arrived after all.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
action: Called on the thread; its return value is what :meth:`wait`
|
|
138
|
+
answers.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, action: Callable[[], T]) -> None:
|
|
142
|
+
"""Hold the callable; nothing runs until :meth:`start`."""
|
|
143
|
+
self._action = action
|
|
144
|
+
self._thread: threading.Thread | None = None
|
|
145
|
+
self._done: asyncio.Event | None = None
|
|
146
|
+
self._result: T | None = None
|
|
147
|
+
self._error: BaseException | None = None
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def started(self) -> bool:
|
|
151
|
+
"""Return whether the call is on a thread."""
|
|
152
|
+
return self._thread is not None
|
|
153
|
+
|
|
154
|
+
def start(self) -> None:
|
|
155
|
+
"""Begin the call and return at once, with the work on another thread."""
|
|
156
|
+
if self._thread is not None:
|
|
157
|
+
return
|
|
158
|
+
loop = asyncio.get_running_loop()
|
|
159
|
+
self._done = asyncio.Event()
|
|
160
|
+
self._thread = threading.Thread(target=self._run, args=(loop, self._done), name="zettcode-call", daemon=True)
|
|
161
|
+
self._thread.start()
|
|
162
|
+
|
|
163
|
+
def _run(self, loop: asyncio.AbstractEventLoop, done: asyncio.Event) -> None:
|
|
164
|
+
"""Do the work, then release whoever waits, from this thread."""
|
|
165
|
+
try:
|
|
166
|
+
self._result = self._action()
|
|
167
|
+
except BaseException as error: # noqa: BLE001 - an answer of None covers every failure
|
|
168
|
+
self._error = error
|
|
169
|
+
finally:
|
|
170
|
+
try:
|
|
171
|
+
loop.call_soon_threadsafe(done.set)
|
|
172
|
+
except RuntimeError:
|
|
173
|
+
# The loop this was reporting to has closed: the shell that asked
|
|
174
|
+
# for the call has already gone, and nobody is waiting.
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
async def wait(self, timeout: float | None = None) -> T | None:
|
|
178
|
+
"""Wait for the answer, and return ``None`` if it failed or came too late."""
|
|
179
|
+
if self._done is None:
|
|
180
|
+
raise RuntimeError("DaemonCall.start() has not been called")
|
|
181
|
+
try:
|
|
182
|
+
await asyncio.wait_for(self._done.wait(), timeout)
|
|
183
|
+
except asyncio.TimeoutError:
|
|
184
|
+
return None
|
|
185
|
+
return None if self._error is not None else self._result
|
|
186
|
+
|
|
187
|
+
def peek(self) -> T | None:
|
|
188
|
+
"""Return the answer if the call has finished, without waiting for it."""
|
|
189
|
+
if self._done is None or not self._done.is_set() or self._error is not None:
|
|
190
|
+
return None
|
|
191
|
+
return self._result
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The engine's plugin support: the base class, the context, the loader, and the switches.
|
|
2
|
+
|
|
3
|
+
A plugin needs only this package::
|
|
4
|
+
|
|
5
|
+
from zettcode.engine.plugins import Plugin, PluginContext
|
|
6
|
+
|
|
7
|
+
class Audit(Plugin):
|
|
8
|
+
name = "audit"
|
|
9
|
+
|
|
10
|
+
def activate(self, ctx: PluginContext) -> None:
|
|
11
|
+
ctx.agent.register_extension(AuditExtension())
|
|
12
|
+
|
|
13
|
+
What a plugin can register follows from the groups on ``ctx``: :attr:`PluginContext.agent` in every process, and
|
|
14
|
+
:attr:`PluginContext.tui` where the terminal is loading. A plugin that fills a header or status row by overriding a
|
|
15
|
+
hook is a :class:`zettcode.app.plugins.TuiPlugin`, which is this class plus that mixin. The plugins ZettCode composes
|
|
16
|
+
the agent from are not here: they are :data:`zettcode.sdk.builtins.AGENT_PLUGINS`, because they need the broker, the
|
|
17
|
+
config file and the MCP client, which the engine does not carry.
|
|
18
|
+
|
|
19
|
+
The pieces live in focused modules — :mod:`~zettcode.engine.plugins.mixins` for the hook groups,
|
|
20
|
+
:mod:`~zettcode.engine.plugins.context` for activation, :mod:`~zettcode.engine.plugins.plugin` for the base class, and
|
|
21
|
+
:mod:`~zettcode.engine.plugins.loader` for discovery — and are re-exported here as one surface.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from .context import PluginContext
|
|
27
|
+
from .extension import PluginExtension
|
|
28
|
+
from .loader import PLUGIN_ENTRY_POINT_GROUP, Loader, installed_entry_points, load_plugins
|
|
29
|
+
from .mixins import (
|
|
30
|
+
PluginAgentMixin,
|
|
31
|
+
PluginEventMixin,
|
|
32
|
+
PluginMaintenanceMixin,
|
|
33
|
+
PluginMiddlewareMixin,
|
|
34
|
+
PluginModelMixin,
|
|
35
|
+
PluginRunMixin,
|
|
36
|
+
PluginSetupMixin,
|
|
37
|
+
PluginToolMixin,
|
|
38
|
+
PluginTurnMixin,
|
|
39
|
+
)
|
|
40
|
+
from .plugin import Plugin, Plugins, PluginSetting
|
|
41
|
+
from .state import AgentBuild, Contribution, ExtensionFactory
|
|
42
|
+
from .switches import PluginEntry, applied, catalog, off_names, set_enabled, set_many
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"PLUGIN_ENTRY_POINT_GROUP",
|
|
46
|
+
"AgentBuild",
|
|
47
|
+
"Plugin",
|
|
48
|
+
"PluginContext",
|
|
49
|
+
"Contribution",
|
|
50
|
+
"ExtensionFactory",
|
|
51
|
+
"Loader",
|
|
52
|
+
"PluginAgentMixin",
|
|
53
|
+
"PluginEventMixin",
|
|
54
|
+
"PluginExtension",
|
|
55
|
+
"PluginMaintenanceMixin",
|
|
56
|
+
"PluginMiddlewareMixin",
|
|
57
|
+
"PluginModelMixin",
|
|
58
|
+
"PluginRunMixin",
|
|
59
|
+
"PluginSetupMixin",
|
|
60
|
+
"PluginToolMixin",
|
|
61
|
+
"PluginTurnMixin",
|
|
62
|
+
"Plugins",
|
|
63
|
+
"PluginEntry",
|
|
64
|
+
"PluginSetting",
|
|
65
|
+
"applied",
|
|
66
|
+
"catalog",
|
|
67
|
+
"installed_entry_points",
|
|
68
|
+
"load_plugins",
|
|
69
|
+
"off_names",
|
|
70
|
+
"set_enabled",
|
|
71
|
+
"set_many",
|
|
72
|
+
]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""The commands a plugin may offer that say nothing about a front end.
|
|
2
|
+
|
|
3
|
+
A plugin registers a *plain* command on :attr:`~zettcode.engine.plugins.context.PluginContext.commands` when the
|
|
4
|
+
result is text: a paragraph to show and a one-line remark, or a request to start a turn. No widget, no page, no question put to a person — those
|
|
5
|
+
belong to the terminal, which registers its own kind of command on ``ctx.tui`` (see
|
|
6
|
+
:class:`zettcode.app.plugins.groups.TuiGroups`). Keeping the two apart is what lets a command be shown by a front end
|
|
7
|
+
that has no terminal: the handler cannot reach for a shell it will not have.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TypeAlias
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CommandGroups",
|
|
18
|
+
"PlainCommand",
|
|
19
|
+
"PlainCommandContext",
|
|
20
|
+
"PlainCommandResult",
|
|
21
|
+
"PlainHandler",
|
|
22
|
+
"TextCommandResult",
|
|
23
|
+
"TurnCommandResult",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class PlainCommandContext:
|
|
29
|
+
"""What a plain command's handler is handed.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
argument: The words typed after the command's name.
|
|
33
|
+
level: The shell level the session is running at (``deny``, ``ask``, ``safe`` or ``full``), filled by the
|
|
34
|
+
front end that runs the command. A command that decides what to ask the model says it in these terms
|
|
35
|
+
rather than reaching for a shell it may not have.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
argument: str = ""
|
|
39
|
+
level: str = "ask"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class TextCommandResult:
|
|
44
|
+
"""Words: Markdown for the conversation, an optional document, and a one-line remark.
|
|
45
|
+
|
|
46
|
+
``message`` is what the conversation keeps, as before. ``markdown`` is a document for the front end to show on its
|
|
47
|
+
own — the terminal opens it as a page, the editor as a preview — for an answer too long to sit between two turns
|
|
48
|
+
or one a reader means to read rather than scroll past. It is a new field and not a new result type, so a front end
|
|
49
|
+
that predates it still runs the command and still shows ``message``.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
message: Markdown added to the conversation.
|
|
53
|
+
notification: A one-line remark, as a toast.
|
|
54
|
+
markdown: A Markdown document shown by the front end in its own view; empty when there is none.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
message: str = ""
|
|
58
|
+
notification: str = ""
|
|
59
|
+
markdown: str = ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class TurnCommandResult:
|
|
64
|
+
"""A request to start rather than an answer: the text the model reads, and what the reader sees for it.
|
|
65
|
+
|
|
66
|
+
A command that is a preset for a turn — `/code-review` is the first — says so by returning this. The front end
|
|
67
|
+
that ran the command starts the turn with ``prompt``; ``shown`` is the short form the conversation keeps, which is
|
|
68
|
+
what the reader typed.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
prompt: What the model reads.
|
|
72
|
+
shown: What the conversation keeps, when it is not ``prompt``.
|
|
73
|
+
notification: A one-line remark, as a toast.
|
|
74
|
+
cache: How the turn may use the provider's prompt cache: ``"read"`` for a one-off whose answer nobody will
|
|
75
|
+
follow, ``"read_write"`` to extend the cached prefix, ``"off"`` for no directive at all. Empty leaves the
|
|
76
|
+
front end's own default, which is ``"read_write"`` for a turn.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
prompt: str
|
|
80
|
+
shown: str = ""
|
|
81
|
+
notification: str = ""
|
|
82
|
+
cache: str = ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
#: What a plain command's handler answers with. A failure is not a third case: it raises ``ValueError``, which every
|
|
86
|
+
#: front end already reports as the command's error line.
|
|
87
|
+
PlainCommandResult: TypeAlias = TextCommandResult | TurnCommandResult
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
#: A plain command's handler: sync or async, and it never needs a UI.
|
|
91
|
+
PlainHandler: TypeAlias = Callable[[PlainCommandContext], Awaitable[PlainCommandResult] | PlainCommandResult]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class PlainCommand:
|
|
96
|
+
"""One registered plain command: the name typed, the summary shown, and what runs it.
|
|
97
|
+
|
|
98
|
+
``when_busy`` is what a front end does with the command while a request is in flight (``"refuse"``,
|
|
99
|
+
``"queue"`` or ``"now"``), and ``subcommands`` are the words it takes first, for a menu that offers them. Both are
|
|
100
|
+
the shell's vocabulary rather than a second one: a command that a process without a screen runs later should not
|
|
101
|
+
need a different spelling.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
name: str
|
|
105
|
+
summary: str
|
|
106
|
+
handler: PlainHandler
|
|
107
|
+
when_busy: str = "refuse"
|
|
108
|
+
subcommands: tuple[object, ...] = ()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CommandGroups:
|
|
112
|
+
"""The plain commands one plugin offers, kept in registration order and unique by name."""
|
|
113
|
+
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
self._commands: dict[str, PlainCommand] = {}
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def commands(self) -> tuple[PlainCommand, ...]:
|
|
119
|
+
"""Return the commands registered so far, in registration order."""
|
|
120
|
+
return tuple(self._commands.values())
|
|
121
|
+
|
|
122
|
+
def register(
|
|
123
|
+
self,
|
|
124
|
+
name: str,
|
|
125
|
+
summary: str,
|
|
126
|
+
handler: PlainHandler,
|
|
127
|
+
*,
|
|
128
|
+
when_busy: str = "refuse",
|
|
129
|
+
subcommands: tuple[object, ...] = (),
|
|
130
|
+
) -> PlainCommand:
|
|
131
|
+
"""Add one command that any front end can run.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
name: The way it is typed, with its leading slash (``"/usage"``).
|
|
135
|
+
summary: One line for the command list.
|
|
136
|
+
handler: Runs with a :class:`PlainCommandContext` and returns a :class:`PlainCommandResult`.
|
|
137
|
+
when_busy: What a front end does when it is typed while a request is in flight.
|
|
138
|
+
subcommands: The words it takes first, offered by a menu that has one.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: When the name or the summary is blank, or the name is already taken.
|
|
142
|
+
"""
|
|
143
|
+
command = name.strip()
|
|
144
|
+
if not command or not command.startswith("/"):
|
|
145
|
+
raise ValueError(f"A command is typed with a leading slash, not {name!r}")
|
|
146
|
+
if not summary.strip():
|
|
147
|
+
raise ValueError(f"Command {command} needs a summary")
|
|
148
|
+
if command in self._commands:
|
|
149
|
+
raise ValueError(f"Command already registered: {command}")
|
|
150
|
+
registered = PlainCommand(command, summary.strip(), handler, when_busy, tuple(subcommands))
|
|
151
|
+
self._commands[command] = registered
|
|
152
|
+
return registered
|