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,819 @@
1
+ """Application runtime that composes AgentClient with coding extensions.
2
+
3
+ The runtime is built in two steps. :meth:`ZettCodeEngine.preview` assembles
4
+ everything that needs no provider — settings, the session store, this
5
+ application's own extensions — so the shell can paint its first frame;
6
+ :meth:`ZettCodeEngine.start` imports the provider SDK, builds the client, and
7
+ is what a turn or a command awaits before it reaches the model. That split is
8
+ worth a few hundred milliseconds of startup, and the modules that cost them
9
+ stay out of the import graph until they are needed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import os
16
+ import platform
17
+ import sys
18
+ from collections.abc import Callable, Mapping
19
+ from dataclasses import dataclass, field, replace
20
+ from datetime import datetime
21
+ from typing import TYPE_CHECKING, TypeAlias
22
+
23
+ from zett_agent.agent import AgentRunConfig
24
+ from zett_agent.client import AgentClient
25
+ from zett_agent.dispatcher import AgentEventDispatcher
26
+ from zett_agent.extensions.shell_approval import ShellApprovalExtension
27
+ from zett_agent.extensions.todo import TodoWriteExtension
28
+ from zett_agent.ids import new_uuid7
29
+ from zett_agent.model import ReasoningEffort
30
+
31
+ from zettcode.config import (
32
+ DEFAULT_ANTHROPIC_BASE_URL,
33
+ KEYLESS_TOKEN,
34
+ NO_MODEL_REFUSAL,
35
+ ModelConfig,
36
+ ZettCodeConfig,
37
+ model_index,
38
+ )
39
+ from zettcode.engine.plugins import AgentBuild, Plugins, load_plugins
40
+ from zettcode.engine.plugins.services import (
41
+ APPROVAL,
42
+ COMPACTION,
43
+ COMPACTION_COUNTER,
44
+ CONTEXT,
45
+ HARNESS,
46
+ MESSENGER,
47
+ MODELS,
48
+ PERSISTENCE,
49
+ POLICY,
50
+ SELECTED_MODEL,
51
+ SIDES,
52
+ TODOS,
53
+ USAGE,
54
+ )
55
+
56
+ from .dialogue.side import SideQuestions
57
+ from .extensions.compaction import COUNTER_WAIT, CompactionCounter, OnDemandCompaction
58
+ from .extensions.context import ContextExtension, Tokenizer
59
+ from .extensions.harness_switch import HarnessSwitch
60
+ from .extensions.usage import UsageExtension
61
+ from .permission import Permission, ShellPolicy, parse_permission
62
+ from .permission.sandbox import ShellSandbox
63
+ from .pipeline import ImportPipeline
64
+ from .storage import NoLocks, SessionGuard, SessionLocked, SessionLocks, SessionStore
65
+
66
+ if TYPE_CHECKING: # pragma: no cover - annotations only; the imports are the cost
67
+ from zett_agent.providers.anthropic import AnthropicProvider
68
+ from zett_agent.providers.openai import OpenAIProvider
69
+
70
+ #: The adapters this runtime may own. Each is an ``AgentModel``; naming both
71
+ #: is what also says they can be closed, which the runtime does at shutdown.
72
+ Adapter: TypeAlias = OpenAIProvider | AnthropicProvider
73
+
74
+ #: Loads the plugins for a runtime from the configuration and what the runtime hands them by name.
75
+ PluginLoader: TypeAlias = Callable[[ZettCodeConfig, Mapping[str, object]], Plugins]
76
+
77
+
78
+ #: Modules imported ahead of the code that needs them, in this order. The model
79
+ #: SDKs are the cost — hundreds of milliseconds each, even with their bytecode
80
+ #: cached — and the zett-agent module is what :meth:`ZettCodeEngine._build`
81
+ #: would otherwise import on the event loop. A name added here moves its import
82
+ #: off the first turn and onto the pipeline's thread.
83
+ WARM_MODULES = ("zett_agent.client", "zettcode.engine.extensions.files")
84
+
85
+ #: What each model SDK needs imported before its first request, in order. ``openai`` alone is not
86
+ #: enough: its resource tree (``openai.resources``) is imported the first time a client touches
87
+ #: one, and that is five hundred modules — 430 ms measured — on whichever thread made the first
88
+ #: request, which is the event loop. ``anthropic`` has no such tree to speak of (15 ms).
89
+ OPENAI_WARM_MODULES = ("openai", "openai.resources")
90
+ ANTHROPIC_WARM_MODULES = ("anthropic",)
91
+
92
+
93
+ #: Modules a request needs only under the configuration that asks for them: each
94
+ #: of these pulls its own machinery, and a run that has no use for one should not
95
+ #: pay for it. The names are the ones :meth:`ZettCodeEngine._build` imports.
96
+ WARM_MODULES_WHEN = (
97
+ ("subagent_enabled", "zett_agent.extensions.subagent"),
98
+ ("mcp_enabled", "zett_agent.extensions.mcp"),
99
+ )
100
+
101
+
102
+ def _load_engine_plugins(config: ZettCodeConfig, shared: Mapping[str, object]) -> Plugins:
103
+ """Load the installed plugins that need only the engine, and the switches that turn them off.
104
+
105
+ The engine ships no built-in plugin of its own: composing the agent (the tools, the store, the permission, the
106
+ compaction, the MCP client) is :mod:`zettcode.sdk.builtins`, which passes its own loader here. A runtime built
107
+ this way still honours ``[plugins]`` for the distributions and single files a reader installed.
108
+ """
109
+ return load_plugins(config, builtins=(), shared=shared)
110
+
111
+
112
+ def build_system_prompt(config: ZettCodeConfig, *, now: datetime | None = None) -> str:
113
+ """Build request instructions with an explicit workspace boundary.
114
+
115
+ The prompt is the head of every request, so whatever it contains is part of
116
+ the provider's cache key: the date is deliberately the only time-derived
117
+ value, and only the day of it. A clock reading with seconds would make the
118
+ head unique to each launch and drop the cache for the whole conversation.
119
+
120
+ Args:
121
+ config: Settings the runtime was built from.
122
+ now: Moment to describe, injectable for tests; defaults to the local
123
+ current time.
124
+ """
125
+ moment = now or datetime.now().astimezone()
126
+ return f"""You are ZettCode, a focused coding agent working with the user in one local workspace.
127
+
128
+ Inspect relevant files before editing. Make focused changes, preserve unrelated work,
129
+ and run checks proportional to risk. Explain blockers concretely. Do not perform
130
+ destructive actions unless the user explicitly requests them.
131
+
132
+ Changing files: use the file tools, and only them. To edit a file that exists, call
133
+ edit directly: read the lines first, copy old_string exactly, and make one change a
134
+ call — call edit again for the next one. Use write_file for a new file or a deliberate
135
+ whole-file rewrite.
136
+ Never change a file's contents through the shell: no sed, awk, perl -i,
137
+ python -c, a script you write to do the editing, cat or echo with a redirection, a heredoc,
138
+ or tee. There is no delete tool: remove a file with rm in the shell, like any command.
139
+ The shell is for running things (tests, builds, git, searches, removing files), not for editing; the
140
+ file tools show the user exactly what changed and the shell commands do not. If
141
+ edit fails, read the file again and retry it with corrected old_string; do not fall
142
+ back to a shell command.
143
+
144
+ Reading files: call read tool, and do not shell out to cat, sed, head, tail, nl, awk or
145
+ python -c to look at a file. read takes file_path, offset and limit (at most 2000
146
+ lines a call), so a long file is read in the parts you need rather than whole; the
147
+ reply says where to continue when more remains. Those shell tools are for what read
148
+ cannot do — a binary, a file past its limits, a transformation no line range
149
+ expresses — and only then; say what read could not do. grep and glob are for finding
150
+ what to read — the matching lines — not for printing a file. A bash call starts in
151
+ the workspace and every call is a new shell, so give it work_dir instead of cd'ing.
152
+
153
+ Runtime environment:
154
+ - Workspace: {config.workspace}
155
+ - Today: {moment.date().isoformat()}
156
+ - Operating system: {platform.platform()}
157
+ - Architecture: {platform.machine() or "unknown"}
158
+ - Python: {platform.python_implementation()} {platform.python_version()}
159
+ - Executable: {sys.executable}
160
+ - Shell: {os.getenv("SHELL", "unknown")}
161
+
162
+ Treat the workspace as the current working directory. Prefer relative paths in tool
163
+ calls. Verify mutable facts with tools instead of assuming this startup snapshot is current."""
164
+
165
+
166
+ def _build_provider(selected: ModelConfig) -> Adapter:
167
+ """Build the adapter one model entry asks for, importing only its SDK.
168
+
169
+ The import lives in this function rather than at module level because it is
170
+ the cost the first frame may not pay; the runtime's import pipeline warms the
171
+ same module on its own thread before this runs.
172
+
173
+ A model with no key is called with :data:`KEYLESS_TOKEN`, because the adapters refuse to be built without one and a
174
+ local server ignores what it is sent. The temperature is the adapter's own argument on an OpenAI-compatible
175
+ endpoint; Anthropic's adapter has none, so it goes in the body of the request, where the API reads it.
176
+ """
177
+ token = selected.token or KEYLESS_TOKEN
178
+ if selected.anthropic:
179
+ from zett_agent.providers.anthropic import AnthropicProvider
180
+
181
+ extra: dict[str, object] = {}
182
+ if selected.temperature is not None:
183
+ extra["extra_body"] = {"temperature": selected.temperature}
184
+ return AnthropicProvider(
185
+ selected.model,
186
+ token,
187
+ base_url=selected.base_url or DEFAULT_ANTHROPIC_BASE_URL,
188
+ **extra, # type: ignore[arg-type]
189
+ )
190
+ from zett_agent.providers.openai import OpenAIProvider
191
+
192
+ options: dict[str, object] = {}
193
+ if selected.temperature is not None:
194
+ options["temperature"] = selected.temperature
195
+ return OpenAIProvider(
196
+ selected.model,
197
+ token,
198
+ base_url=selected.base_url,
199
+ response=selected.responses_api,
200
+ **options, # type: ignore[arg-type]
201
+ )
202
+
203
+
204
+ @dataclass(slots=True)
205
+ class ZettCodeEngine:
206
+ """Own the model, the persistence and approval extensions, the client, and the active session.
207
+
208
+ Before :meth:`start` runs, ``client`` and ``model`` are ``None``: everything
209
+ that does not need a provider is already here, which is what lets the shell
210
+ paint. :attr:`started` and :attr:`provider` are the readers that insist the
211
+ runtime is ready.
212
+ """
213
+
214
+ config: ZettCodeConfig
215
+ persistence: SessionStore
216
+ #: The permission level, shared with the approval memory and the deny extension.
217
+ policy: ShellPolicy
218
+ #: The level a run starts at, and what a session that never chose one goes back to when it is opened again.
219
+ default_permission: Permission
220
+ tokenizer: Tokenizer
221
+ effort: ReasoningEffort
222
+ session_id: str
223
+ #: The model the next request goes to, or ``None`` on a machine with no model configured yet. The shell
224
+ #: paints and runs every command with ``None``; only a request insists on one, and says where to add it.
225
+ active_model: ModelConfig | None
226
+ #: What the compaction counts the context with; loaded on a thread of its own so that the
227
+ #: first turn does not load it on the event loop. See :class:`CompactionCounter`.
228
+ compaction_counter: CompactionCounter
229
+ plugins: Plugins = field(default_factory=Plugins)
230
+ #: The lock on the session this process has open: only the process that holds it writes to the session.
231
+ #: A runtime that was not given one (a test's) shares nothing, so every claim is granted.
232
+ session_locks: SessionGuard = field(default_factory=NoLocks)
233
+ #: The line to the other sessions, as the SDK builds it; the runtime only carries it, so the engine does not
234
+ #: depend on the broker. ``None`` registers no tool and joins no broker.
235
+ messenger: object | None = None
236
+ event_dispatcher: AgentEventDispatcher | None = None
237
+ client: AgentClient | None = None
238
+ model: Adapter | None = None
239
+ _starting: asyncio.Task[ZettCodeEngine] | None = field(default=None, repr=False)
240
+ _counter: asyncio.Task[None] | None = field(default=None, repr=False)
241
+ #: The sweep for empty session folders a killed process left behind, run once at the start on a thread.
242
+ _tidying: asyncio.Task[int] | None = field(default=None, repr=False)
243
+ _models: dict[ModelConfig, Adapter] = field(default_factory=dict)
244
+ #: Each session's own self-compaction choice as last read or written; see :meth:`self_compaction_choice`.
245
+ _self_choices: dict[str, bool | None] = field(default_factory=dict, repr=False)
246
+ #: Each session's own permission level as last read or written; see :meth:`permission_choice`.
247
+ _permissions: dict[str, str | None] = field(default_factory=dict, repr=False)
248
+ #: Whether a level was chosen for this process rather than for a session (``--full``, a client's
249
+ #: ``permission=``): while it is set, opening a session does not put that session's level back.
250
+ _permission_override: bool = field(default=False, repr=False)
251
+ #: The warm-up imports, running on a daemon thread, and the loop-side event
252
+ #: that releases whoever is waiting for them.
253
+ _imports: ImportPipeline | None = field(default=None, repr=False)
254
+ _ready: asyncio.Event | None = field(default=None, repr=False)
255
+
256
+ @classmethod
257
+ def preview(
258
+ cls, config: ZettCodeConfig, load: PluginLoader | None = None, *, messenger: object | None = None
259
+ ) -> ZettCodeEngine:
260
+ """Build the session side of the runtime, without a provider.
261
+
262
+ Everything here is cheap: paths, a session identity, and the
263
+ extensions this application owns. The provider SDK, the MCP client,
264
+ and the tokenizer's vocabulary stay unimported until :meth:`start`.
265
+
266
+ Args:
267
+ config: Settings for the workspace and the session.
268
+ load: How the plugins are loaded, given the configuration and what the runtime hands them. A front
269
+ end that has plugins of its own passes its loader; the default loads the installed distributions
270
+ and no built-in plugin.
271
+ messenger: The line to the other sessions, which the caller builds
272
+ (:meth:`zettcode.sdk.messenger.Messenger.create`); the runtime hands it to the plugins by name and
273
+ keeps it for whoever asks. ``None`` switches messaging off for this runtime.
274
+ """
275
+ os.chdir(config.workspace)
276
+ # A machine that has never been set up has no model to start on; the shell opens anyway and says so (see
277
+ # the invariant S23), and `use_model` sizes this runtime the moment `/config` writes the first entry.
278
+ selected = config.models[0] if config.models else None
279
+ effort = config.reasoning_effort if selected is None else selected.accepted_effort(config.reasoning_effort)
280
+ # One policy, read by everything that decides whether a command is asked
281
+ # about: the memory lets `safe` and `auto` through, the deny extension
282
+ # refuses for `deny`, and the shell changes it from `/permission`.
283
+ policy = ShellPolicy.from_mode(
284
+ config.shell_approval, config.workspace, ShellSandbox(config.workspace, network=config.shell_network)
285
+ )
286
+ counter = CompactionCounter()
287
+ persistence = SessionStore(config.store, config.workspace)
288
+ models: dict[ModelConfig, Adapter] = {}
289
+ harness = HarnessSwitch(on=config.jit_harness)
290
+ # What the built-in agent plugins build on, handed over by name: they own the extensions, the
291
+ # runtime owns this state and goes on using it. See `zettcode.engine.plugins.services`.
292
+ shared: dict[str, object] = {
293
+ POLICY: policy,
294
+ HARNESS: harness,
295
+ PERSISTENCE: persistence,
296
+ MODELS: models,
297
+ COMPACTION_COUNTER: counter,
298
+ }
299
+ if selected is not None:
300
+ # A plugin that sizes something for the model asks for this one; a machine with no model provides
301
+ # nothing under the name, so the same plugins build their defaults and are sized on the first switch.
302
+ shared[SELECTED_MODEL] = selected
303
+ if messenger is not None:
304
+ shared[MESSENGER] = messenger
305
+ runtime = cls(
306
+ config=config,
307
+ persistence=persistence,
308
+ session_locks=SessionLocks(persistence.session_dir),
309
+ policy=policy,
310
+ default_permission=policy.level,
311
+ tokenizer=Tokenizer(),
312
+ effort=effort,
313
+ session_id=new_uuid7(),
314
+ active_model=selected,
315
+ compaction_counter=counter,
316
+ plugins=(load or _load_engine_plugins)(config, shared),
317
+ messenger=messenger,
318
+ _models=models,
319
+ )
320
+ # The session a launch starts in is a new one, so this always succeeds; a launch that resumes
321
+ # another moves the lock when it installs it.
322
+ runtime.session_locks.claim(runtime.session_id)
323
+ # Asked for every request, so a choice made in the middle of a conversation applies to its next one.
324
+ runtime.compaction.self_compact = runtime.self_compacting
325
+ return runtime
326
+
327
+ def start(self) -> asyncio.Task[ZettCodeEngine]:
328
+ """Build the provider and the client once, whoever asks first.
329
+
330
+ The task is kept, so a background warm-up and the first turn cannot
331
+ build two clients, and every later caller gets the same answer. What the
332
+ build needs imported starts here as well, on a thread nobody waits for:
333
+ a launch that ends before its first turn ends without paying for them.
334
+
335
+ The token counter starts loading beside them, on the daemon thread its own
336
+ deadline allows, so the vocabulary is there by the time a reader asks for
337
+ `/context` instead of being fetched on that keystroke. Nothing waits for
338
+ it here either: the load belongs to this runtime rather than to whoever
339
+ needed it first, and :meth:`aclose` releases the wait.
340
+
341
+ Raises:
342
+ ValueError: No model is configured. Everything built here is built from one, and a build that failed
343
+ is never retried (the task is kept), so a machine that has not been set up is refused before a
344
+ task that could not finish is left behind. ``/config`` is what changes it.
345
+ """
346
+ if self.active_model is None:
347
+ raise ValueError(NO_MODEL_REFUSAL)
348
+ if self._starting is None:
349
+ self._begin_imports()
350
+ self.compaction_counter.start()
351
+ self._starting = asyncio.ensure_future(self._build())
352
+ self._counter = asyncio.ensure_future(self.tokenizer.prepare())
353
+ # Folders that held no conversation and that no process has open: what a launch that was killed
354
+ # leaves. A thread, because it is a listing and a lock probe per folder and nothing waits for it.
355
+ self._tidying = asyncio.ensure_future(asyncio.to_thread(self.persistence.prune_empty))
356
+ return self._starting
357
+
358
+ def warm_modules(self) -> tuple[str, ...]:
359
+ """Return the modules to import ahead of the first turn, in order.
360
+
361
+ The shell calls :meth:`start` once the first frame is painted, so what
362
+ this list costs is paid while the reader reads that screen. A module
363
+ belongs here when a turn needs it on the way to its first request and
364
+ the first frame does not: the model SDKs, the zett-agent client, and the
365
+ extensions the configuration turns on.
366
+ """
367
+ config = self.config
368
+ # The SDKs of every configured model, not only the active one: `/model` can select an
369
+ # entry that speaks the other API, and its adapter imports its SDK when it is built. An
370
+ # entry that is not configured cannot be selected, so its SDK is left alone — a run that
371
+ # only talks to OpenAI-compatible endpoints does not import `anthropic` on a thread that
372
+ # competes with the interface for the interpreter.
373
+ sdks: list[str] = []
374
+ if any(not model.anthropic for model in config.models):
375
+ sdks.extend(OPENAI_WARM_MODULES)
376
+ if any(model.anthropic for model in config.models):
377
+ sdks.extend(ANTHROPIC_WARM_MODULES)
378
+ optional = tuple(module for setting, module in WARM_MODULES_WHEN if getattr(config, setting, False))
379
+ return (*sdks, *WARM_MODULES, *optional)
380
+
381
+ def _begin_imports(self) -> None:
382
+ """Start the import pipeline, and the loop-side signal that releases its waiters.
383
+
384
+ The signal is an :class:`asyncio.Event` set from the pipeline's thread,
385
+ so the build waits for the imports without occupying a thread of its
386
+ own: the default executor's threads are joined while the interpreter
387
+ shuts down, which is what would keep a launch that ends early alive for
388
+ the whole import.
389
+ """
390
+ loop = asyncio.get_running_loop()
391
+ self._ready = asyncio.Event()
392
+
393
+ def finished() -> None:
394
+ """Release the build, from whichever thread the pipeline reports on."""
395
+ loop.call_soon_threadsafe(self._imports_done)
396
+
397
+ self._imports = ImportPipeline(self.warm_modules())
398
+ self._imports.start(on_finish=finished)
399
+
400
+ def _imports_done(self) -> None:
401
+ """Release whoever waits on the imports; runs on the event loop."""
402
+ if self._ready is not None:
403
+ self._ready.set()
404
+
405
+ @property
406
+ def _waiting_for_imports(self) -> bool:
407
+ """Return whether a build is still parked on the import pipeline."""
408
+ return self._ready is not None and not self._ready.is_set()
409
+
410
+ async def _build(self) -> ZettCodeEngine:
411
+ """Wait for the imports, then hand the client its extensions.
412
+
413
+ The imports block for a few hundred milliseconds — a model SDK alone is
414
+ most of it on a cold cache — and they are already running on the
415
+ pipeline's thread, so waiting here usually finds them finished and costs
416
+ nothing. The event loop keeps painting through it, and the provider
417
+ built at the end of the wait finds its SDK cached instead of importing
418
+ it here; everything the loop owns is still built on the loop.
419
+ """
420
+ if self._ready is not None:
421
+ await self._ready.wait()
422
+ # The compaction's encoding, loaded on its own thread since `start`: what the first turn
423
+ # would otherwise load on this loop, with nothing painted and no key read until it was done.
424
+ # Waiting for it here is only a wait — the loop is free — and it is bounded, so a first run
425
+ # with no network goes on with an estimate instead of for as long as a download takes.
426
+ await self.compaction_counter.wait(COUNTER_WAIT)
427
+
428
+ from zett_agent.client import create_agent
429
+
430
+ selected = self.active_model
431
+ if selected is None:
432
+ # Nothing to build a provider for. The shell refuses a request before it gets this far; anything
433
+ # else that asked anyway is told the one thing that changes it.
434
+ raise ValueError(NO_MODEL_REFUSAL)
435
+ model = self._provider(selected)
436
+ # Every extension the agent runs is a plugin's: the built-in ones (the coding tools, the session
437
+ # store, the shell approval, the compaction ... see `zettcode.sdk.builtins`) in the order
438
+ # they are listed there, then any a third-party plugin brings, and last the one host that drives
439
+ # the plugins' hooks, which places itself at the earliest priority any plugin asked for.
440
+ extensions = [*self.plugins.agent_extensions(AgentBuild(model=model)), *self.plugins.extensions]
441
+ client = await create_agent(
442
+ model,
443
+ config=AgentRunConfig(session_id=self.session_id),
444
+ system_prompt=build_system_prompt(self.config),
445
+ extensions=extensions,
446
+ reasoning_effort=self.config.reasoning_effort,
447
+ parallel_tool_call=self.config.parallel_tool_call,
448
+ max_iterations=self.config.max_iterations,
449
+ )
450
+ client.event_dispatcher = self.event_dispatcher
451
+ self.model = model
452
+ self.client = client
453
+ return self
454
+
455
+ def _provider(self, selected: ModelConfig) -> Adapter:
456
+ """Return the provider for one configured model, building it if needed.
457
+
458
+ Which adapter the entry gets is the entry's own choice: ``anthropic``
459
+ speaks the Messages API through its own SDK, and every other entry
460
+ speaks an OpenAI-compatible one, over chat completions or, when
461
+ ``responses_api`` is set, the Responses API.
462
+ """
463
+ provider = self._models.get(selected)
464
+ if provider is None:
465
+ provider = _build_provider(selected)
466
+ self._models[selected] = provider
467
+ return provider
468
+
469
+ @property
470
+ def started(self) -> AgentClient:
471
+ """Return the client, insisting that :meth:`start` has been awaited."""
472
+ if self.client is None:
473
+ raise RuntimeError("Runtime has not started; await start() first")
474
+ return self.client
475
+
476
+ @property
477
+ def provider(self) -> Adapter:
478
+ """Return the provider for the active model, insisting the runtime started."""
479
+ if self.model is None:
480
+ raise RuntimeError("Runtime has not started; await start() first")
481
+ return self.model
482
+
483
+ @property
484
+ def required_model(self) -> ModelConfig:
485
+ """Return the model the next request goes to, refusing on a machine that has none.
486
+
487
+ The readers that only make sense with a model — a turn, a title, an export's tag — say so here, in one
488
+ place, with the one instruction that changes it. A shell that paints instead asks
489
+ :attr:`active_model` and draws its own state for ``None``.
490
+
491
+ Raises:
492
+ ValueError: No model is configured.
493
+ """
494
+ if self.active_model is None:
495
+ raise ValueError(NO_MODEL_REFUSAL)
496
+ return self.active_model
497
+
498
+ def set_event_dispatcher(self, dispatcher: AgentEventDispatcher | None) -> None:
499
+ """Send streamed events to ``dispatcher``, now or as soon as there is a client."""
500
+ self.event_dispatcher = dispatcher
501
+ if self.client is not None:
502
+ self.client.event_dispatcher = dispatcher
503
+
504
+ @classmethod
505
+ async def create(
506
+ cls, config: ZettCodeConfig, load: PluginLoader | None = None, *, messenger: object | None = None
507
+ ) -> ZettCodeEngine:
508
+ """Build and start a runtime, for callers with nothing to paint."""
509
+ runtime = cls.preview(config, load, messenger=messenger)
510
+ await runtime.start()
511
+ return runtime
512
+
513
+ @property
514
+ def todos(self) -> TodoWriteExtension | None:
515
+ """Return the extension that keeps the model's plan, or ``None`` when no plugin provides one."""
516
+ return self.plugins.find(TODOS, TodoWriteExtension)
517
+
518
+ @property
519
+ def approval(self) -> ShellApprovalExtension:
520
+ """Return the shell approval extension, whose memory the shell reads and resets."""
521
+ return self.plugins.service(APPROVAL, ShellApprovalExtension)
522
+
523
+ @property
524
+ def usage(self) -> UsageExtension:
525
+ """Return the token accounting the status line and ``/usage`` read."""
526
+ return self.plugins.service(USAGE, UsageExtension)
527
+
528
+ @property
529
+ def context(self) -> ContextExtension:
530
+ """Return the recorder of what each session's last request was made of."""
531
+ return self.plugins.service(CONTEXT, ContextExtension)
532
+
533
+ @property
534
+ def sides(self) -> SideQuestions:
535
+ """Return the read-only boundary a side question runs behind; the shell marks the run it starts."""
536
+ return self.plugins.service(SIDES, SideQuestions)
537
+
538
+ @property
539
+ def compaction(self) -> OnDemandCompaction:
540
+ """Return the compaction policy, which a model switch resizes."""
541
+ return self.plugins.service(COMPACTION, OnDemandCompaction)
542
+
543
+ @property
544
+ def harness(self) -> HarnessSwitch:
545
+ """Return the switch of the just-in-time harness, which the next request reads."""
546
+ return self.plugins.service(HARNESS, HarnessSwitch)
547
+
548
+ def use_harness(self, on: bool) -> bool:
549
+ """Turn the just-in-time harness on or off for later requests, and return whether it is on.
550
+
551
+ Like the permission level this is per run and not written to the configuration: ``[harness] jit`` is the
552
+ default a run starts with. It takes effect on the next request, because a request mounts the harness's
553
+ tools when it starts.
554
+ """
555
+ self.harness.on = bool(on)
556
+ return self.harness.on
557
+
558
+ @property
559
+ def permission(self) -> Permission:
560
+ """Return how much of the shell may run without asking, right now."""
561
+ return self.policy.level
562
+
563
+ def use_permission(self, name: str | Permission, *, remember: bool = True) -> Permission:
564
+ """Set how much of the shell may run without asking, and remember it for this session.
565
+
566
+ The level is written to the session's own metadata, so opening the session again puts it back in force
567
+ instead of asking all over again; a session set back to the level a run starts at forgets the choice and
568
+ follows the default. A new session begins at the default, so one job's decision does not leak into the
569
+ next. It takes effect on the next command, including one in the request that is running now.
570
+
571
+ Args:
572
+ name: A level, or its name; case and surrounding space do not matter.
573
+ remember: Whether the level belongs to the session. ``False`` is for a decision that covers this run
574
+ only, such as "approve every command" from the approval panel.
575
+
576
+ Raises:
577
+ ValueError: The name is not one of the four levels.
578
+ SessionLocked: Another process has the session open, and the level is part of the session.
579
+ OSError: The level could not be written to the session; nothing changed, so a caller that reported
580
+ success would be reporting a setting that is not on disk.
581
+ """
582
+ selected = name if isinstance(name, Permission) else parse_permission(name)
583
+ if remember:
584
+ # The level is stored in the session, so it is a write, and only the process holding the session may
585
+ # write to it. The other settings that are the session's own ask the same question first.
586
+ if self.session_locked:
587
+ raise SessionLocked()
588
+ self.persistence.set_permission(self.session_id, selected.value, default=self.default_permission.value)
589
+ self._permissions[self.session_id] = selected.value
590
+ # The level belongs to the session again, so opening another one puts that one's level back.
591
+ self._permission_override = False
592
+ else:
593
+ # A level chosen for this process wins over what a session remembers, for as long as it runs.
594
+ self._permission_override = True
595
+ self.policy.level = selected
596
+ return selected
597
+
598
+ def permission_choice(self, session_id: str) -> str | None:
599
+ """Return the level one session last ran at, ``None`` when it never chose one.
600
+
601
+ Read from the session's file once and remembered: opening a session asks for it on every start, and the
602
+ only writer is the process holding the session's lock.
603
+ """
604
+ if session_id not in self._permissions:
605
+ self._permissions[session_id] = self.persistence.session_permission(session_id)
606
+ return self._permissions[session_id]
607
+
608
+ def _adopt_permission(self) -> None:
609
+ """Put the active session's own level in force, or the one a run starts at when it has none."""
610
+ if self._permission_override:
611
+ return
612
+ stored = self.permission_choice(self.session_id)
613
+ try:
614
+ self.policy.level = self.default_permission if stored is None else Permission(stored)
615
+ except ValueError:
616
+ # A level a newer version wrote, or a damaged file: the default is the honest answer.
617
+ self.policy.level = self.default_permission
618
+
619
+ def approve_all_shell_commands(self) -> None:
620
+ """Stop asking for shell approval for the rest of this process."""
621
+ self.use_permission(Permission.FULL, remember=False)
622
+
623
+ def self_compaction_choice(self, session_id: str) -> bool | None:
624
+ """Return the choice one session made about self-compaction, ``None`` when it follows the default.
625
+
626
+ Read from the session's file once and remembered: the status line asks on every repaint, and only
627
+ the process that holds the session's lock writes it, so what was read stays true until this
628
+ runtime changes it.
629
+ """
630
+ if session_id not in self._self_choices:
631
+ self._self_choices[session_id] = self.persistence.session_self_compact(session_id)
632
+ return self._self_choices[session_id]
633
+
634
+ def choose_self_compaction(self, session_id: str, choice: bool | None) -> None:
635
+ """Save one session's choice about self-compaction, and remember it only once it is saved.
636
+
637
+ Raises:
638
+ OSError: The choice could not be written; the remembered one is unchanged.
639
+ """
640
+ self.persistence.set_self_compact(session_id, choice, default=self.config.self_compaction)
641
+ self._self_choices[session_id] = choice
642
+
643
+ def self_compacting(self, session_id: str) -> bool:
644
+ """Return whether the model may request compaction in one session.
645
+
646
+ The session's own choice when it made one, else the configuration's ``[compaction] self``.
647
+ """
648
+ choice = self.self_compaction_choice(session_id)
649
+ return self.config.self_compaction if choice is None else choice
650
+
651
+ def use_effort(self, name: str | ReasoningEffort) -> ReasoningEffort:
652
+ """Select how much reasoning the model may spend on later requests.
653
+
654
+ A level the active model does not take is refused, naming the ones it does: a reader who typed
655
+ ``/effort ultra`` should hear that this model stops at ``high``, not get ``high`` unannounced.
656
+ (A level that is *inherited* — the one in force when the model changes — is moved, by
657
+ :meth:`use_model`, because nobody asked for it.)
658
+
659
+ Args:
660
+ name: One of zett-agent's levels, by value (``"high"``) or as the
661
+ enum itself; case and surrounding space are ignored.
662
+
663
+ Returns:
664
+ The level now in force.
665
+
666
+ Raises:
667
+ ValueError: The name is not a level, or the active model does not take it, or no model is configured.
668
+ """
669
+ if isinstance(name, ReasoningEffort):
670
+ level = name
671
+ else:
672
+ try:
673
+ level = ReasoningEffort(name.strip().lower())
674
+ except ValueError:
675
+ levels = ", ".join(known.value for known in ReasoningEffort)
676
+ raise ValueError(f"Unknown reasoning effort: {name}. Try one of: {levels}") from None
677
+ active = self.active_model
678
+ if active is None:
679
+ raise ValueError(NO_MODEL_REFUSAL)
680
+ accepted = active.efforts
681
+ if level not in accepted:
682
+ levels = ", ".join(known.value for known in accepted)
683
+ raise ValueError(f"{active.shown_name} does not take {level.value}. Try one of: {levels}")
684
+ self.effort = level
685
+ return level
686
+
687
+ def find_model(self, name: str) -> ModelConfig | None:
688
+ """Return the configured model a recorded name stands for, if exactly one does.
689
+
690
+ The name a session records is the one the picker showed. It is looked for first as that, and only when no
691
+ display name answers, as a model id — ``/model`` takes a name the same way, so a session and a reader
692
+ always mean the same model by one word. A name that two entries answer to is not a choice this can make
693
+ for the reader, and is reported as not found: the configuration is no longer the one the session was saved
694
+ with, and guessing would move the model under the reader without saying so.
695
+ """
696
+ try:
697
+ index = model_index(self.config.models, name)
698
+ except ValueError:
699
+ return None
700
+ return None if index is None else self.config.models[index]
701
+
702
+ def use_model(self, name: str | ModelConfig) -> ModelConfig:
703
+ """Select a configured model for subsequent requests without changing sessions."""
704
+ if isinstance(name, ModelConfig):
705
+ # The very entry first, so two entries that hold the same settings stay two choices; an
706
+ # equal one is accepted too, because a model read back from the config file is a new
707
+ # object holding what the session's entry holds, and is the same model to a reader.
708
+ chosen = next((entry for entry in self.config.models if entry is name), None) or next(
709
+ (entry for entry in self.config.models if entry == name), None
710
+ )
711
+ if chosen is None:
712
+ raise ValueError("Unknown model selection")
713
+ else:
714
+ index = model_index(self.config.models, name)
715
+ if index is None:
716
+ raise ValueError(f"Unknown model: {name}")
717
+ chosen = self.config.models[index]
718
+ self.active_model = chosen
719
+ # A preview runtime has no provider yet; the model it names is what
720
+ # :meth:`_build` starts from.
721
+ if self.client is not None:
722
+ self.model = self._provider(chosen)
723
+ # Context size is a property of the model, so switching models moves
724
+ # the point at which a request is compacted with it.
725
+ self.compaction.max_tokens = chosen.compaction_max_tokens
726
+ self.compaction.keep_recent_tokens = chosen.compaction_keep_tokens
727
+ # The level in force follows the model to the nearest one it takes, so a request is never sent
728
+ # with a level its endpoint would reject.
729
+ self.effort = chosen.accepted_effort(self.effort)
730
+ return chosen
731
+
732
+ def replace_models(self, models: tuple[ModelConfig, ...]) -> ModelConfig:
733
+ """Adopt a new list of models in this running session, keeping the one in use if it is still there.
734
+
735
+ This is what ``/config`` calls after it has written the file: the entries a reader added or
736
+ removed take effect without a restart, and the model a conversation is using is not
737
+ changed under it unless it was the one removed. An entry is the same entry when it is equal
738
+ by value — what is read back from the file is a new object holding the same settings — and
739
+ the one in use is looked for that way. When it is not there — it was removed, or it was the
740
+ entry that was edited — the entry now at its old place takes over: the edited one stays in
741
+ use, and a removed one is replaced by its neighbour rather than by whichever came first.
742
+
743
+ Args:
744
+ models: The configured models, in file order; must not be empty. Adding the first one to a
745
+ machine that had none is how ``/config`` sets this program up: that entry becomes the
746
+ model in use, because there is nothing it would otherwise be added beside.
747
+
748
+ Returns:
749
+ The model in use afterwards, which is the previous one unless it was removed.
750
+ """
751
+ if not models:
752
+ raise ValueError("At least one model must stay configured")
753
+ previous = self.active_model
754
+ old = self.config.models
755
+ place = old.index(previous) if previous is not None and previous in old else 0
756
+ self.config = replace(self.config, models=models)
757
+ # The adapters already built stay in the cache even for an entry that is gone: the cache is
758
+ # what shutdown walks to close each client, so dropping one here would leak its connection.
759
+ chosen = next((entry for entry in models if entry == previous), models[min(place, len(models) - 1)])
760
+ return self.use_model(chosen)
761
+
762
+ def new_session(self) -> str:
763
+ """Switch future requests to a fresh session identity, and lock it."""
764
+ self.session_id = new_uuid7()
765
+ self.session_locks.claim(self.session_id)
766
+ self._adopt_permission()
767
+ return self.session_id
768
+
769
+ def use_session(self, session_id: str) -> None:
770
+ """Select a persisted or new explicit session for the next request, and try to lock it.
771
+
772
+ Whether the lock was had is :attr:`session_locked`: a session another process has open is still
773
+ selected, so the reader can read it, and is not written to.
774
+ """
775
+ if not session_id.strip():
776
+ raise ValueError("Session ID cannot be empty")
777
+ self.session_id = session_id.strip()
778
+ self.session_locks.claim(self.session_id)
779
+ self._adopt_permission()
780
+
781
+ @property
782
+ def session_locked(self) -> bool:
783
+ """Return whether the active session is held by another process, so that this one only reads it."""
784
+ return not self.session_locks.holds(self.session_id)
785
+
786
+ def retake_session(self) -> bool:
787
+ """Try again to lock the active session, and say whether this process holds it now."""
788
+ return self.session_locks.claim(self.session_id)
789
+
790
+ async def aclose(self) -> None:
791
+ """Release all resources owned by this runtime.
792
+
793
+ A build that is still parked on its imports is cancelled rather than
794
+ awaited: those modules are for a turn that will not happen now, and the
795
+ thread importing them is a daemon this process does not wait for either.
796
+ A build that is past them is awaited, because it is already creating the
797
+ client this method has to close.
798
+ """
799
+ if self._waiting_for_imports and self._starting is not None:
800
+ self._starting.cancel()
801
+ if self._imports is not None:
802
+ # Whether or not a build is waiting, the imports have no reader left.
803
+ self._imports.stop()
804
+ if self._starting is not None:
805
+ await asyncio.gather(self._starting, return_exceptions=True)
806
+ counter, self._counter = self._counter, None
807
+ if counter is not None:
808
+ # A counter load still running is this shell leaving before it
809
+ # answered: cancelling the wait leaves the load on its daemon thread,
810
+ # which this process does not wait for either.
811
+ counter.cancel()
812
+ await asyncio.gather(counter, return_exceptions=True)
813
+ tidying, self._tidying = self._tidying, None
814
+ if tidying is not None:
815
+ await asyncio.gather(tidying, return_exceptions=True)
816
+ for model in self._models.values():
817
+ await model.aclose()
818
+ await self.persistence.close()
819
+ self.session_locks.release()