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,367 @@
|
|
|
1
|
+
"""Discover installed plugins through Python entry points and prepare them.
|
|
2
|
+
|
|
3
|
+
A distribution advertises a plugin by publishing a class (or a zero-argument
|
|
4
|
+
factory returning one) under the ``zettcode.plugins`` group::
|
|
5
|
+
|
|
6
|
+
[project.entry-points."zettcode.plugins"]
|
|
7
|
+
my-plugin = "my_package:MyPlugin"
|
|
8
|
+
|
|
9
|
+
A single ``.py`` file in :attr:`ZettCodeConfig.plugins_dir` (``~/.zettcode/plugins`` by default) is the other way in:
|
|
10
|
+
every class the file defines that derives from :class:`Plugin` (``TuiPlugin`` included) is loaded, under the file's
|
|
11
|
+
stem as its origin. Nothing else registers a plugin: installing the distribution or dropping the file in is the
|
|
12
|
+
opt-in, and ``[plugins] disable`` in the config is the way back out. A plugin that cannot be
|
|
13
|
+
imported, instantiated, or activated is reported in
|
|
14
|
+
:attr:`~zettcode.engine.plugins.plugin.Plugins.failures`; one broken plugin must not take
|
|
15
|
+
the whole application with it.
|
|
16
|
+
|
|
17
|
+
The accepted plugins are bundled into one host extension (see
|
|
18
|
+
:mod:`zettcode.engine.plugins.extension`). Identities must be unique — a plugin's declared ``name``, else its
|
|
19
|
+
entry-point name — and so must the names of what they register, because a later plugin that collides is skipped
|
|
20
|
+
and reported rather than silently shadowed.
|
|
21
|
+
|
|
22
|
+
:class:`Loader` is the engine's: it merges what the agent uses. The terminal's loader subclasses it to merge what
|
|
23
|
+
only a terminal has (see :class:`zettcode.app.plugins.loader.TerminalLoader`).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import importlib.util
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import TYPE_CHECKING, Any
|
|
33
|
+
|
|
34
|
+
from zettcode.config import read_disabled_plugins
|
|
35
|
+
from zettcode.providers import BUILTIN_PROVIDERS
|
|
36
|
+
|
|
37
|
+
from .context import PluginContext
|
|
38
|
+
from .extension import HOST_NAME, PluginExtension
|
|
39
|
+
from .plugin import Plugin, Plugins, PluginSetting
|
|
40
|
+
from .state import Contribution
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
43
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
44
|
+
from importlib.metadata import EntryPoint
|
|
45
|
+
|
|
46
|
+
from zettcode.config import ZettCodeConfig
|
|
47
|
+
from zettcode.mention import MentionProvider
|
|
48
|
+
from zettcode.providers import Provider
|
|
49
|
+
|
|
50
|
+
#: Entry-point group a distribution publishes its plugin under.
|
|
51
|
+
PLUGIN_ENTRY_POINT_GROUP = "zettcode.plugins"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def installed_entry_points() -> tuple[EntryPoint, ...]:
|
|
55
|
+
"""Return every plugin entry point installed in this environment.
|
|
56
|
+
|
|
57
|
+
The import is deferred to the call so the plugin package itself stays cheap
|
|
58
|
+
to import, and results are sorted by entry-point name so the load order is the same on every run.
|
|
59
|
+
"""
|
|
60
|
+
from importlib.metadata import entry_points
|
|
61
|
+
|
|
62
|
+
return tuple(sorted(entry_points(group=PLUGIN_ENTRY_POINT_GROUP), key=lambda point: point.name))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def installed_files(directory: Path) -> tuple[Path, ...]:
|
|
66
|
+
"""Return the single-file plugins in ``directory``, sorted by name; a folder that is not there has none."""
|
|
67
|
+
try:
|
|
68
|
+
found = sorted(directory.glob("*.py"))
|
|
69
|
+
except OSError:
|
|
70
|
+
return ()
|
|
71
|
+
return tuple(path for path in found if path.is_file() and not path.name.startswith(("_", ".")))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _file_plugins(path: Path) -> tuple[list[Plugin[Any]], list[tuple[str, BaseException]]]:
|
|
75
|
+
"""Import a single-file plugin and instantiate every Plugin subclass it defines itself.
|
|
76
|
+
|
|
77
|
+
The module name carries a digest of the resolved path, so two folders that hold the same stem in one process are
|
|
78
|
+
two modules rather than one stale entry. A constructor that raises fails only its own class; the file's other
|
|
79
|
+
classes still load.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The plugins, and the ``(origin:class, error)`` of every class that could not be constructed.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
BaseException: The import itself failed; the caller reports it as the file's failure.
|
|
86
|
+
"""
|
|
87
|
+
digest = hashlib.sha1(str(path.resolve()).encode()).hexdigest()[:8]
|
|
88
|
+
name = f"_zettcode_plugin_{path.stem}_{digest}"
|
|
89
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
90
|
+
if spec is None or spec.loader is None: # pragma: no cover - a .py file always has a loader
|
|
91
|
+
raise ImportError(f"{path.name} cannot be loaded as a module")
|
|
92
|
+
module = importlib.util.module_from_spec(spec)
|
|
93
|
+
sys.modules[name] = module
|
|
94
|
+
try:
|
|
95
|
+
spec.loader.exec_module(module)
|
|
96
|
+
except BaseException:
|
|
97
|
+
sys.modules.pop(name, None)
|
|
98
|
+
raise
|
|
99
|
+
found = [
|
|
100
|
+
value
|
|
101
|
+
for value in vars(module).values()
|
|
102
|
+
if isinstance(value, type)
|
|
103
|
+
and value is not Plugin
|
|
104
|
+
and issubclass(value, Plugin)
|
|
105
|
+
and value.__module__ == module.__name__
|
|
106
|
+
]
|
|
107
|
+
if not found:
|
|
108
|
+
raise ValueError(f"{path.name} defines no class deriving from Plugin or TuiPlugin")
|
|
109
|
+
plugins: list[Plugin[Any]] = []
|
|
110
|
+
failures: list[tuple[str, BaseException]] = []
|
|
111
|
+
for value in sorted(found, key=lambda cls: cls.__name__):
|
|
112
|
+
try:
|
|
113
|
+
plugins.append(value())
|
|
114
|
+
except KeyboardInterrupt:
|
|
115
|
+
raise
|
|
116
|
+
except BaseException as error:
|
|
117
|
+
failures.append((value.__name__, error))
|
|
118
|
+
return plugins, failures
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_plugins(
|
|
122
|
+
config: ZettCodeConfig,
|
|
123
|
+
*,
|
|
124
|
+
entry_points: Iterable[EntryPoint] | None = None,
|
|
125
|
+
builtins: Sequence[Plugin[Any]],
|
|
126
|
+
shared: Mapping[str, object] | None = None,
|
|
127
|
+
) -> Plugins:
|
|
128
|
+
"""Load the builtin plugins and every enabled distribution into one bundle.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
config: Settings the plugins are activated with, and the source of the
|
|
132
|
+
``[plugins]`` enable/disable switches.
|
|
133
|
+
entry_points: Entry points to load; ``None`` reads the installed ones.
|
|
134
|
+
Tests pass their own, so discovery can be exercised without
|
|
135
|
+
installing a distribution.
|
|
136
|
+
builtins: Plugins compiled into the repository, which the engine does not ship: the caller passes
|
|
137
|
+
:data:`zettcode.sdk.builtins.AGENT_PLUGINS` for the program's agent, and ``()`` to load only what a
|
|
138
|
+
reader installed.
|
|
139
|
+
shared: Objects the runtime hands over by name before any plugin activates (see
|
|
140
|
+
:mod:`zettcode.engine.plugins.services`); a plugin reads them with ``container.service``, and they
|
|
141
|
+
stay findable on the returned bundle. A plugin cannot provide a name that is taken here.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The host extension, services, and load failures the plugins produced.
|
|
145
|
+
"""
|
|
146
|
+
return run(Loader(config, shared), config, builtins=builtins, entry_points=entry_points)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def run(
|
|
150
|
+
loader: Loader,
|
|
151
|
+
config: ZettCodeConfig,
|
|
152
|
+
*,
|
|
153
|
+
builtins: Sequence[Plugin[Any]],
|
|
154
|
+
entry_points: Iterable[EntryPoint] | None,
|
|
155
|
+
) -> Plugins:
|
|
156
|
+
"""Feed the builtins, the enabled distributions and the single-file plugins to ``loader``, and return what it bundled.
|
|
157
|
+
|
|
158
|
+
The builtins go first, so a plugin that registers under one of their names replaces it in place.
|
|
159
|
+
Builtins always load, while ``[plugins] enabled`` only gates the distributions and the files.
|
|
160
|
+
"""
|
|
161
|
+
for plugin in builtins:
|
|
162
|
+
loader.accept(plugin, origin=plugin.name or type(plugin).__name__, builtin=True)
|
|
163
|
+
loader.builtins_loaded()
|
|
164
|
+
if config.plugins_enabled:
|
|
165
|
+
points = tuple(entry_points) if entry_points is not None else installed_entry_points()
|
|
166
|
+
# The configuration file's own list and the switch file's, merged for the one caller that loads. The switch
|
|
167
|
+
# module cannot be imported here (it imports this one), so the union is written out; `off_names` is the same
|
|
168
|
+
# rule for the front ends.
|
|
169
|
+
disabled = set(config.disabled_plugins) | set(read_disabled_plugins(config.plugins_file))
|
|
170
|
+
for point in points:
|
|
171
|
+
if point.name in disabled:
|
|
172
|
+
continue
|
|
173
|
+
try:
|
|
174
|
+
plugin = _instantiate(point)
|
|
175
|
+
except KeyboardInterrupt:
|
|
176
|
+
raise
|
|
177
|
+
except BaseException as error:
|
|
178
|
+
loader.fail(point.name, error)
|
|
179
|
+
continue
|
|
180
|
+
loader.accept(plugin, origin=point.name)
|
|
181
|
+
for path in installed_files(config.plugins_dir):
|
|
182
|
+
origin = path.stem
|
|
183
|
+
if origin in disabled:
|
|
184
|
+
continue
|
|
185
|
+
try:
|
|
186
|
+
plugins, failures = _file_plugins(path)
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
raise
|
|
189
|
+
except BaseException as error:
|
|
190
|
+
loader.fail(origin, error)
|
|
191
|
+
continue
|
|
192
|
+
for class_name, failure in failures:
|
|
193
|
+
loader.fail(origin, failure, label=class_name)
|
|
194
|
+
for plugin in plugins:
|
|
195
|
+
loader.accept(plugin, origin=origin, label=type(plugin).__name__)
|
|
196
|
+
return loader.bundle()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Loader:
|
|
200
|
+
"""Accumulate accepted plugins into the one bundle the application gets."""
|
|
201
|
+
|
|
202
|
+
def __init__(self, config: ZettCodeConfig, shared: Mapping[str, object] | None = None) -> None:
|
|
203
|
+
self.config = config
|
|
204
|
+
self.plugins: list[Plugin[Any]] = []
|
|
205
|
+
self.mentions: list[MentionProvider] = []
|
|
206
|
+
self.providers: list[Provider] = []
|
|
207
|
+
self.contributed: list[Contribution] = []
|
|
208
|
+
self.services: dict[str, object] = dict(shared or {})
|
|
209
|
+
self.plain_commands: list[object] = []
|
|
210
|
+
#: One ``(origin, why)`` per plugin that could not be loaded; the messages a front end shows are built from
|
|
211
|
+
#: these, so a list can put a header on its own row.
|
|
212
|
+
self.failed: list[tuple[str, str]] = []
|
|
213
|
+
#: The origins of the compiled-in plugins, in load order, so a bundle can say which rows are the program's.
|
|
214
|
+
self.builtins: list[str] = []
|
|
215
|
+
self._identities: set[str] = set()
|
|
216
|
+
self._extension_names: set[str] = {HOST_NAME}
|
|
217
|
+
self._mention_kinds: set[str] = set()
|
|
218
|
+
self._provider_names: set[str] = {provider.name for provider in BUILTIN_PROVIDERS}
|
|
219
|
+
|
|
220
|
+
# -- what a subclass changes --
|
|
221
|
+
|
|
222
|
+
def new_context(self, name: str = "") -> PluginContext:
|
|
223
|
+
"""Return the context one plugin is activated with, named so its ``[plugins.<name>]`` settings are read."""
|
|
224
|
+
return PluginContext(self.config, self.services, name=name)
|
|
225
|
+
|
|
226
|
+
def activated(self, plugin: Plugin[Any], context: PluginContext) -> None:
|
|
227
|
+
"""Run after a plugin's ``activate`` succeeded and before its contribution is checked; may raise."""
|
|
228
|
+
|
|
229
|
+
def clash(self, context: PluginContext) -> str | None:
|
|
230
|
+
"""Return why this plugin's contribution cannot be merged, or ``None`` when it can."""
|
|
231
|
+
# A mention token has no prefix naming its provider, so two providers
|
|
232
|
+
# with the same kind would make resolution ambiguous. Reject the plugin.
|
|
233
|
+
mention_clash = next(
|
|
234
|
+
(provider.kind for provider in context.mentions if provider.kind in self._mention_kinds),
|
|
235
|
+
None,
|
|
236
|
+
)
|
|
237
|
+
if mention_clash is not None:
|
|
238
|
+
return f"mention kind {mention_clash} is already registered"
|
|
239
|
+
# A name is what `/config add` is typed with, so two hosts sharing one cannot both be
|
|
240
|
+
# reached; the one that was there first, a built-in or an earlier plugin, keeps it.
|
|
241
|
+
provider_clash = next(
|
|
242
|
+
(provider.name for provider in context.providers if provider.name in self._provider_names),
|
|
243
|
+
None,
|
|
244
|
+
)
|
|
245
|
+
if provider_clash is not None:
|
|
246
|
+
return f"provider {provider_clash} is already registered"
|
|
247
|
+
# The agent refuses two extensions with one name, and that would fail the first turn rather than
|
|
248
|
+
# the load, so the clash is caught here; the name of the host itself counts.
|
|
249
|
+
extension_clash = next(
|
|
250
|
+
(extension.name for extension in context.extensions if extension.name in self._extension_names),
|
|
251
|
+
None,
|
|
252
|
+
)
|
|
253
|
+
if extension_clash is not None:
|
|
254
|
+
return f"extension {extension_clash} is already registered"
|
|
255
|
+
# A service is looked up by its name, so a second provider would be unreachable behind the first.
|
|
256
|
+
service_clash = next((name for name in context.services if name in self.services), None)
|
|
257
|
+
if service_clash is not None:
|
|
258
|
+
return f"service {service_clash} is already provided"
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
def merge(self, context: PluginContext) -> None:
|
|
262
|
+
"""Take what an accepted plugin registered."""
|
|
263
|
+
self._extension_names.update(extension.name for extension in context.extensions)
|
|
264
|
+
self.contributed.extend(context.extensions)
|
|
265
|
+
self.services.update(context.services)
|
|
266
|
+
self._provider_names.update(provider.name for provider in context.providers)
|
|
267
|
+
self.providers.extend(context.providers)
|
|
268
|
+
self._mention_kinds.update(provider.kind for provider in context.mentions)
|
|
269
|
+
self.mentions.extend(context.mentions)
|
|
270
|
+
self.plain_commands.extend(context.commands.commands)
|
|
271
|
+
|
|
272
|
+
def builtins_loaded(self) -> None:
|
|
273
|
+
"""Called once the builtin plugins have all been offered, before any distribution."""
|
|
274
|
+
|
|
275
|
+
def bundle(self) -> Plugins:
|
|
276
|
+
"""Return everything the accepted plugins contributed."""
|
|
277
|
+
host = PluginExtension(self.plugins) if self.plugins else None
|
|
278
|
+
return Plugins(
|
|
279
|
+
host=host,
|
|
280
|
+
mentions=tuple(self.mentions),
|
|
281
|
+
providers=tuple(self.providers),
|
|
282
|
+
contributed=tuple(self.contributed),
|
|
283
|
+
services=dict(self.services),
|
|
284
|
+
plain_commands=tuple(self.plain_commands),
|
|
285
|
+
failed=tuple(self.failed),
|
|
286
|
+
builtins=tuple(self.builtins),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# -- the same for every front end --
|
|
290
|
+
|
|
291
|
+
def fail(self, origin: str, error: BaseException | str, *, label: str | None = None) -> None:
|
|
292
|
+
"""Record one plugin that could not be prepared, naming the class when there is one."""
|
|
293
|
+
reason = _reason(error)
|
|
294
|
+
self.failed.append((origin, f"{label}: {reason}" if label else reason))
|
|
295
|
+
|
|
296
|
+
def accept(self, plugin: Plugin[Any], *, origin: str, builtin: bool = False, label: str | None = None) -> None:
|
|
297
|
+
"""Activate one plugin and merge what it registered, or report why not.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
plugin: The plugin to activate.
|
|
301
|
+
origin: The name of the distribution (or of the compiled-in plugin) it came from.
|
|
302
|
+
builtin: Whether it is compiled into the program, which is what makes it impossible to switch off.
|
|
303
|
+
label: The class name, when this plugin came from a file that can hold several; a failure says which.
|
|
304
|
+
"""
|
|
305
|
+
name = plugin.name or origin or type(plugin).__name__
|
|
306
|
+
if builtin and origin not in self.builtins:
|
|
307
|
+
# Recorded before it is activated: a compiled-in plugin is the program's own whether or not it loaded,
|
|
308
|
+
# and the list of them is what a switch is refused against.
|
|
309
|
+
self.builtins.append(origin)
|
|
310
|
+
if plugin.setting is not None and not _is_bool_setting(self.config, plugin.setting):
|
|
311
|
+
# A switch that names a setting the configuration does not have, or one that is not a boolean, would be a
|
|
312
|
+
# row nobody could move or a truthy default in a panel. It is a plugin bug, and the load says so.
|
|
313
|
+
self.fail(
|
|
314
|
+
origin,
|
|
315
|
+
f"setting {plugin.setting.path} names no boolean setting of the configuration",
|
|
316
|
+
label=label,
|
|
317
|
+
)
|
|
318
|
+
return
|
|
319
|
+
if name in self._identities:
|
|
320
|
+
self.fail(origin, f"duplicate plugin name {name!r}", label=label)
|
|
321
|
+
return
|
|
322
|
+
if not plugin.name:
|
|
323
|
+
plugin.name = name
|
|
324
|
+
if not plugin.origin:
|
|
325
|
+
plugin.origin = origin or name
|
|
326
|
+
container = self.new_context(name)
|
|
327
|
+
try:
|
|
328
|
+
plugin.activate(container)
|
|
329
|
+
self.activated(plugin, container)
|
|
330
|
+
except KeyboardInterrupt:
|
|
331
|
+
raise
|
|
332
|
+
except BaseException as error:
|
|
333
|
+
self.fail(origin, error, label=label)
|
|
334
|
+
return
|
|
335
|
+
clash = self.clash(container)
|
|
336
|
+
if clash is not None:
|
|
337
|
+
self.fail(origin, clash, label=label)
|
|
338
|
+
return
|
|
339
|
+
self._identities.add(name)
|
|
340
|
+
self.merge(container)
|
|
341
|
+
self.plugins.append(plugin)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _is_bool_setting(config: ZettCodeConfig, setting: PluginSetting) -> bool:
|
|
345
|
+
"""Return whether the configuration has the field a plugin's setting names, and holds a boolean there."""
|
|
346
|
+
return isinstance(getattr(config, setting.field, None), bool)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _instantiate(point: EntryPoint) -> Plugin[Any]:
|
|
350
|
+
"""Resolve one entry point to a plugin: a class, a factory, or an instance."""
|
|
351
|
+
target = point.load()
|
|
352
|
+
if isinstance(target, Plugin):
|
|
353
|
+
return target
|
|
354
|
+
if isinstance(target, type) and issubclass(target, Plugin):
|
|
355
|
+
return target()
|
|
356
|
+
if callable(target):
|
|
357
|
+
produced = target()
|
|
358
|
+
if isinstance(produced, Plugin):
|
|
359
|
+
return produced
|
|
360
|
+
raise TypeError(f"factory returned {type(produced).__name__}, not a Plugin")
|
|
361
|
+
raise TypeError(f"{type(target).__name__} is not a Plugin, a Plugin subclass, or a factory")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _reason(error: BaseException | str) -> str:
|
|
365
|
+
"""Return the text a load failure should show, never an empty message."""
|
|
366
|
+
text = str(error).strip()
|
|
367
|
+
return text or (type(error).__name__ if isinstance(error, BaseException) else "failed")
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The hook groups a plugin can implement, split by the stage they observe.
|
|
2
|
+
|
|
3
|
+
Mirrors ``zett-agent``'s extension mixins one for one, so an author who knows
|
|
4
|
+
the extension docs already knows where a hook belongs. :class:`PluginAgentMixin`
|
|
5
|
+
bundles the agent flow. Every hook defaults to doing nothing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from contextlib import aclosing
|
|
11
|
+
from typing import TYPE_CHECKING, cast
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only, so the imports stay lazy
|
|
14
|
+
from collections.abc import AsyncGenerator, AsyncIterator
|
|
15
|
+
|
|
16
|
+
from zett_agent.agent import AgentRunConfig, AgentRunContext
|
|
17
|
+
from zett_agent.extensions.base import ModelRequestNext, ToolCallNext
|
|
18
|
+
from zett_agent.extensions.events import ExtensionEvent
|
|
19
|
+
from zett_agent.extensions.external import ExternalEvent
|
|
20
|
+
from zett_agent.messages import AssistantMessage, ToolCall, ToolMessage
|
|
21
|
+
from zett_agent.model import ModelEvent, ModelRequest, ModelResponse
|
|
22
|
+
from zett_agent.tools.base import ToolResult
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PluginSetupMixin:
|
|
26
|
+
"""Setup hooks: request-scoped tools and conversation context.
|
|
27
|
+
|
|
28
|
+
Runs before any input is converted, so a plugin can register tools or
|
|
29
|
+
restore state that later hooks already see.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
async def on_tool(self, context: AgentRunContext) -> None:
|
|
33
|
+
"""Register local or provider-hosted tools before context restoration."""
|
|
34
|
+
|
|
35
|
+
async def on_state(self, context: AgentRunContext) -> None:
|
|
36
|
+
"""Restore history and initialize state before any input conversion."""
|
|
37
|
+
|
|
38
|
+
async def on_message(self, context: AgentRunContext) -> None:
|
|
39
|
+
"""Transform the pending user message after state restoration."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PluginRunMixin:
|
|
43
|
+
"""Hooks around one complete request and its terminal outcome."""
|
|
44
|
+
|
|
45
|
+
async def before_run(self, context: AgentRunContext) -> None:
|
|
46
|
+
"""Run after the transformed user input is appended and published."""
|
|
47
|
+
|
|
48
|
+
async def after_run(self, context: AgentRunContext, result: AssistantMessage) -> None:
|
|
49
|
+
"""Run after a successful final answer is appended."""
|
|
50
|
+
|
|
51
|
+
async def on_success(self, context: AgentRunContext, result: AssistantMessage) -> None:
|
|
52
|
+
"""Run once after a request completes successfully."""
|
|
53
|
+
|
|
54
|
+
async def on_error(self, context: AgentRunContext, error: Exception) -> None:
|
|
55
|
+
"""Run before an agent error is propagated to the caller."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PluginTurnMixin:
|
|
59
|
+
"""Hooks around one model/tool turn inside a request."""
|
|
60
|
+
|
|
61
|
+
async def before_turn(self, context: AgentRunContext) -> None:
|
|
62
|
+
"""Run before this turn's model call, after request assembly."""
|
|
63
|
+
|
|
64
|
+
async def after_turn(self, context: AgentRunContext, result: AssistantMessage) -> None:
|
|
65
|
+
"""Run once the turn's assistant message and tool results are settled."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PluginModelMixin:
|
|
69
|
+
"""Hooks immediately before and after each primary model invocation."""
|
|
70
|
+
|
|
71
|
+
async def before_model(self, context: AgentRunContext, request: ModelRequest) -> None:
|
|
72
|
+
"""Inspect the request before preprocessing a primary model call."""
|
|
73
|
+
|
|
74
|
+
async def after_model(self, context: AgentRunContext, response: ModelResponse) -> None:
|
|
75
|
+
"""Run after the complete assistant message is appended."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PluginToolMixin:
|
|
79
|
+
"""Hooks immediately before and after each requested tool invocation."""
|
|
80
|
+
|
|
81
|
+
async def before_tool(self, context: AgentRunContext, call: ToolCall) -> None:
|
|
82
|
+
"""Run before one requested tool is invoked."""
|
|
83
|
+
|
|
84
|
+
async def after_tool(
|
|
85
|
+
self,
|
|
86
|
+
context: AgentRunContext,
|
|
87
|
+
call: ToolCall,
|
|
88
|
+
result: ToolMessage,
|
|
89
|
+
error: Exception | None,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Inspect or modify a tool result before it is appended."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PluginMaintenanceMixin:
|
|
95
|
+
"""Hooks for work a caller asks for outside a model turn."""
|
|
96
|
+
|
|
97
|
+
async def on_compact(self, context: AgentRunContext) -> None:
|
|
98
|
+
"""Do the plugin's part of a compaction a caller asked for."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PluginEventMixin:
|
|
102
|
+
"""Hooks for internal notifications and external input."""
|
|
103
|
+
|
|
104
|
+
def accept(self, config: AgentRunConfig | None, event: ExternalEvent) -> bool:
|
|
105
|
+
"""Handle one external event and report whether it was accepted."""
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
async def on_event(self, context: AgentRunContext, event: ExtensionEvent) -> None:
|
|
109
|
+
"""Process a published notification; inspect its concrete type with ``match``."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class PluginMiddlewareMixin:
|
|
113
|
+
"""Middleware that wraps the provider stream and the tool handlers."""
|
|
114
|
+
|
|
115
|
+
async def on_model_request(
|
|
116
|
+
self,
|
|
117
|
+
context: AgentRunContext,
|
|
118
|
+
request: ModelRequest,
|
|
119
|
+
call_next: ModelRequestNext,
|
|
120
|
+
) -> AsyncIterator[ModelEvent]:
|
|
121
|
+
"""Wrap one provider request and its complete streamed response."""
|
|
122
|
+
# ``call_next`` widens to AsyncIterator, which aclosing cannot type-check;
|
|
123
|
+
# the provider stream is the async generator behind it.
|
|
124
|
+
stream = cast("AsyncGenerator[ModelEvent]", call_next(request))
|
|
125
|
+
async with aclosing(stream) as events:
|
|
126
|
+
async for event in events:
|
|
127
|
+
yield event
|
|
128
|
+
|
|
129
|
+
async def on_tool_call(
|
|
130
|
+
self,
|
|
131
|
+
context: AgentRunContext,
|
|
132
|
+
call: ToolCall,
|
|
133
|
+
call_next: ToolCallNext,
|
|
134
|
+
) -> ToolResult:
|
|
135
|
+
"""Wrap one registered local tool handler and return its result."""
|
|
136
|
+
return await call_next()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class PluginAgentMixin(
|
|
140
|
+
PluginSetupMixin,
|
|
141
|
+
PluginRunMixin,
|
|
142
|
+
PluginTurnMixin,
|
|
143
|
+
PluginModelMixin,
|
|
144
|
+
PluginToolMixin,
|
|
145
|
+
PluginMaintenanceMixin,
|
|
146
|
+
PluginEventMixin,
|
|
147
|
+
PluginMiddlewareMixin,
|
|
148
|
+
):
|
|
149
|
+
"""The agent-flow group: every hook the model/tool lifecycle offers.
|
|
150
|
+
|
|
151
|
+
It bundles the finer groups below it — setup, run, turn, model, tool,
|
|
152
|
+
maintenance, events, and middleware — so ``PluginAgentMixin`` names the
|
|
153
|
+
whole surface in one base and an import can choose the granularity it
|
|
154
|
+
wants: the umbrella for "this plugin drives the agent", one group for "this
|
|
155
|
+
plugin only wants the tool stage".
|
|
156
|
+
"""
|