zettcode-engine 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zettcode/_compat.py +35 -0
- zettcode/budget.py +24 -0
- zettcode/config.py +1088 -0
- zettcode/config_edit.py +457 -0
- zettcode/engine/__init__.py +39 -0
- zettcode/engine/agent/__init__.py +81 -0
- zettcode/engine/agent/base.py +60 -0
- zettcode/engine/agent/context.py +166 -0
- zettcode/engine/agent/exports.py +73 -0
- zettcode/engine/agent/interaction.py +54 -0
- zettcode/engine/agent/sessions.py +218 -0
- zettcode/engine/agent/settings.py +269 -0
- zettcode/engine/agent/turns.py +308 -0
- zettcode/engine/core.py +819 -0
- zettcode/engine/dialogue/__init__.py +6 -0
- zettcode/engine/dialogue/ask.py +113 -0
- zettcode/engine/dialogue/side.py +197 -0
- zettcode/engine/dialogue/title.py +80 -0
- zettcode/engine/export/__init__.py +421 -0
- zettcode/engine/export/trace/script.js +18 -0
- zettcode/engine/export/trace/style.css +136 -0
- zettcode/engine/export/trace/template.html +31 -0
- zettcode/engine/extensions/__init__.py +8 -0
- zettcode/engine/extensions/capabilities.py +68 -0
- zettcode/engine/extensions/compaction.py +259 -0
- zettcode/engine/extensions/context.py +521 -0
- zettcode/engine/extensions/files.py +101 -0
- zettcode/engine/extensions/guidance.py +17 -0
- zettcode/engine/extensions/harness.py +208 -0
- zettcode/engine/extensions/harness_switch.py +18 -0
- zettcode/engine/extensions/mcp_notice.py +49 -0
- zettcode/engine/extensions/relay.py +93 -0
- zettcode/engine/extensions/subagents.py +391 -0
- zettcode/engine/extensions/tool_output.py +153 -0
- zettcode/engine/extensions/truncation.py +156 -0
- zettcode/engine/extensions/usage.py +304 -0
- zettcode/engine/harness_paths.py +74 -0
- zettcode/engine/permission/__init__.py +197 -0
- zettcode/engine/permission/approval.py +66 -0
- zettcode/engine/permission/readonly.py +435 -0
- zettcode/engine/permission/sandbox.py +140 -0
- zettcode/engine/pipeline.py +191 -0
- zettcode/engine/plugins/__init__.py +72 -0
- zettcode/engine/plugins/commands.py +152 -0
- zettcode/engine/plugins/context.py +251 -0
- zettcode/engine/plugins/extension.py +183 -0
- zettcode/engine/plugins/groups.py +74 -0
- zettcode/engine/plugins/loader.py +367 -0
- zettcode/engine/plugins/mixins.py +156 -0
- zettcode/engine/plugins/plugin.py +190 -0
- zettcode/engine/plugins/services.py +68 -0
- zettcode/engine/plugins/settings.py +117 -0
- zettcode/engine/plugins/state.py +64 -0
- zettcode/engine/plugins/switches.py +553 -0
- zettcode/engine/py.typed +0 -0
- zettcode/engine/server_tools.py +179 -0
- zettcode/engine/storage/__init__.py +51 -0
- zettcode/engine/storage/layout.py +161 -0
- zettcode/engine/storage/ledger.py +659 -0
- zettcode/engine/storage/locks.py +182 -0
- zettcode/engine/storage/metadata.py +278 -0
- zettcode/engine/storage/records.py +373 -0
- zettcode/engine/storage/store.py +1056 -0
- zettcode/engine/tools/__init__.py +7 -0
- zettcode/engine/tools/boundary.py +56 -0
- zettcode/engine/tools/files.py +262 -0
- zettcode/engine/tools/images.py +32 -0
- zettcode/engine/tools/output.py +81 -0
- zettcode/engine/tools/shell.py +181 -0
- zettcode/engine/tools/workspace.py +271 -0
- zettcode/error_text.py +217 -0
- zettcode/filelock.py +125 -0
- zettcode/logging_setup.py +196 -0
- zettcode/mention.py +124 -0
- zettcode/modes.py +18 -0
- zettcode/paths.py +113 -0
- zettcode/plugin_tui.py +133 -0
- zettcode/processes.py +126 -0
- zettcode/prompt.py +94 -0
- zettcode/providers.py +215 -0
- zettcode/py.typed +0 -0
- zettcode/registry.py +63 -0
- zettcode/relay.py +267 -0
- zettcode/statusline_parts.py +84 -0
- zettcode/version.py +11 -0
- zettcode/wire.py +30 -0
- zettcode/wording.py +11 -0
- zettcode_engine-0.3.4.dist-info/METADATA +23 -0
- zettcode_engine-0.3.4.dist-info/RECORD +90 -0
- zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
zettcode/engine/core.py
ADDED
|
@@ -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()
|