zettcode-engine 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. zettcode/_compat.py +35 -0
  2. zettcode/budget.py +24 -0
  3. zettcode/config.py +1088 -0
  4. zettcode/config_edit.py +457 -0
  5. zettcode/engine/__init__.py +39 -0
  6. zettcode/engine/agent/__init__.py +81 -0
  7. zettcode/engine/agent/base.py +60 -0
  8. zettcode/engine/agent/context.py +166 -0
  9. zettcode/engine/agent/exports.py +73 -0
  10. zettcode/engine/agent/interaction.py +54 -0
  11. zettcode/engine/agent/sessions.py +218 -0
  12. zettcode/engine/agent/settings.py +269 -0
  13. zettcode/engine/agent/turns.py +308 -0
  14. zettcode/engine/core.py +819 -0
  15. zettcode/engine/dialogue/__init__.py +6 -0
  16. zettcode/engine/dialogue/ask.py +113 -0
  17. zettcode/engine/dialogue/side.py +197 -0
  18. zettcode/engine/dialogue/title.py +80 -0
  19. zettcode/engine/export/__init__.py +421 -0
  20. zettcode/engine/export/trace/script.js +18 -0
  21. zettcode/engine/export/trace/style.css +136 -0
  22. zettcode/engine/export/trace/template.html +31 -0
  23. zettcode/engine/extensions/__init__.py +8 -0
  24. zettcode/engine/extensions/capabilities.py +68 -0
  25. zettcode/engine/extensions/compaction.py +259 -0
  26. zettcode/engine/extensions/context.py +521 -0
  27. zettcode/engine/extensions/files.py +101 -0
  28. zettcode/engine/extensions/guidance.py +17 -0
  29. zettcode/engine/extensions/harness.py +208 -0
  30. zettcode/engine/extensions/harness_switch.py +18 -0
  31. zettcode/engine/extensions/mcp_notice.py +49 -0
  32. zettcode/engine/extensions/relay.py +93 -0
  33. zettcode/engine/extensions/subagents.py +391 -0
  34. zettcode/engine/extensions/tool_output.py +153 -0
  35. zettcode/engine/extensions/truncation.py +156 -0
  36. zettcode/engine/extensions/usage.py +304 -0
  37. zettcode/engine/harness_paths.py +74 -0
  38. zettcode/engine/permission/__init__.py +197 -0
  39. zettcode/engine/permission/approval.py +66 -0
  40. zettcode/engine/permission/readonly.py +435 -0
  41. zettcode/engine/permission/sandbox.py +140 -0
  42. zettcode/engine/pipeline.py +191 -0
  43. zettcode/engine/plugins/__init__.py +72 -0
  44. zettcode/engine/plugins/commands.py +152 -0
  45. zettcode/engine/plugins/context.py +251 -0
  46. zettcode/engine/plugins/extension.py +183 -0
  47. zettcode/engine/plugins/groups.py +74 -0
  48. zettcode/engine/plugins/loader.py +367 -0
  49. zettcode/engine/plugins/mixins.py +156 -0
  50. zettcode/engine/plugins/plugin.py +190 -0
  51. zettcode/engine/plugins/services.py +68 -0
  52. zettcode/engine/plugins/settings.py +117 -0
  53. zettcode/engine/plugins/state.py +64 -0
  54. zettcode/engine/plugins/switches.py +553 -0
  55. zettcode/engine/py.typed +0 -0
  56. zettcode/engine/server_tools.py +179 -0
  57. zettcode/engine/storage/__init__.py +51 -0
  58. zettcode/engine/storage/layout.py +161 -0
  59. zettcode/engine/storage/ledger.py +659 -0
  60. zettcode/engine/storage/locks.py +182 -0
  61. zettcode/engine/storage/metadata.py +278 -0
  62. zettcode/engine/storage/records.py +373 -0
  63. zettcode/engine/storage/store.py +1056 -0
  64. zettcode/engine/tools/__init__.py +7 -0
  65. zettcode/engine/tools/boundary.py +56 -0
  66. zettcode/engine/tools/files.py +262 -0
  67. zettcode/engine/tools/images.py +32 -0
  68. zettcode/engine/tools/output.py +81 -0
  69. zettcode/engine/tools/shell.py +181 -0
  70. zettcode/engine/tools/workspace.py +271 -0
  71. zettcode/error_text.py +217 -0
  72. zettcode/filelock.py +125 -0
  73. zettcode/logging_setup.py +196 -0
  74. zettcode/mention.py +124 -0
  75. zettcode/modes.py +18 -0
  76. zettcode/paths.py +113 -0
  77. zettcode/plugin_tui.py +133 -0
  78. zettcode/processes.py +126 -0
  79. zettcode/prompt.py +94 -0
  80. zettcode/providers.py +215 -0
  81. zettcode/py.typed +0 -0
  82. zettcode/registry.py +63 -0
  83. zettcode/relay.py +267 -0
  84. zettcode/statusline_parts.py +84 -0
  85. zettcode/version.py +11 -0
  86. zettcode/wire.py +30 -0
  87. zettcode/wording.py +11 -0
  88. zettcode_engine-0.3.4.dist-info/METADATA +23 -0
  89. zettcode_engine-0.3.4.dist-info/RECORD +90 -0
  90. zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
@@ -0,0 +1,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
+ """