zettcode-engine 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zettcode/_compat.py +35 -0
- zettcode/budget.py +24 -0
- zettcode/config.py +1088 -0
- zettcode/config_edit.py +457 -0
- zettcode/engine/__init__.py +39 -0
- zettcode/engine/agent/__init__.py +81 -0
- zettcode/engine/agent/base.py +60 -0
- zettcode/engine/agent/context.py +166 -0
- zettcode/engine/agent/exports.py +73 -0
- zettcode/engine/agent/interaction.py +54 -0
- zettcode/engine/agent/sessions.py +218 -0
- zettcode/engine/agent/settings.py +269 -0
- zettcode/engine/agent/turns.py +308 -0
- zettcode/engine/core.py +819 -0
- zettcode/engine/dialogue/__init__.py +6 -0
- zettcode/engine/dialogue/ask.py +113 -0
- zettcode/engine/dialogue/side.py +197 -0
- zettcode/engine/dialogue/title.py +80 -0
- zettcode/engine/export/__init__.py +421 -0
- zettcode/engine/export/trace/script.js +18 -0
- zettcode/engine/export/trace/style.css +136 -0
- zettcode/engine/export/trace/template.html +31 -0
- zettcode/engine/extensions/__init__.py +8 -0
- zettcode/engine/extensions/capabilities.py +68 -0
- zettcode/engine/extensions/compaction.py +259 -0
- zettcode/engine/extensions/context.py +521 -0
- zettcode/engine/extensions/files.py +101 -0
- zettcode/engine/extensions/guidance.py +17 -0
- zettcode/engine/extensions/harness.py +208 -0
- zettcode/engine/extensions/harness_switch.py +18 -0
- zettcode/engine/extensions/mcp_notice.py +49 -0
- zettcode/engine/extensions/relay.py +93 -0
- zettcode/engine/extensions/subagents.py +391 -0
- zettcode/engine/extensions/tool_output.py +153 -0
- zettcode/engine/extensions/truncation.py +156 -0
- zettcode/engine/extensions/usage.py +304 -0
- zettcode/engine/harness_paths.py +74 -0
- zettcode/engine/permission/__init__.py +197 -0
- zettcode/engine/permission/approval.py +66 -0
- zettcode/engine/permission/readonly.py +435 -0
- zettcode/engine/permission/sandbox.py +140 -0
- zettcode/engine/pipeline.py +191 -0
- zettcode/engine/plugins/__init__.py +72 -0
- zettcode/engine/plugins/commands.py +152 -0
- zettcode/engine/plugins/context.py +251 -0
- zettcode/engine/plugins/extension.py +183 -0
- zettcode/engine/plugins/groups.py +74 -0
- zettcode/engine/plugins/loader.py +367 -0
- zettcode/engine/plugins/mixins.py +156 -0
- zettcode/engine/plugins/plugin.py +190 -0
- zettcode/engine/plugins/services.py +68 -0
- zettcode/engine/plugins/settings.py +117 -0
- zettcode/engine/plugins/state.py +64 -0
- zettcode/engine/plugins/switches.py +553 -0
- zettcode/engine/py.typed +0 -0
- zettcode/engine/server_tools.py +179 -0
- zettcode/engine/storage/__init__.py +51 -0
- zettcode/engine/storage/layout.py +161 -0
- zettcode/engine/storage/ledger.py +659 -0
- zettcode/engine/storage/locks.py +182 -0
- zettcode/engine/storage/metadata.py +278 -0
- zettcode/engine/storage/records.py +373 -0
- zettcode/engine/storage/store.py +1056 -0
- zettcode/engine/tools/__init__.py +7 -0
- zettcode/engine/tools/boundary.py +56 -0
- zettcode/engine/tools/files.py +262 -0
- zettcode/engine/tools/images.py +32 -0
- zettcode/engine/tools/output.py +81 -0
- zettcode/engine/tools/shell.py +181 -0
- zettcode/engine/tools/workspace.py +271 -0
- zettcode/error_text.py +217 -0
- zettcode/filelock.py +125 -0
- zettcode/logging_setup.py +196 -0
- zettcode/mention.py +124 -0
- zettcode/modes.py +18 -0
- zettcode/paths.py +113 -0
- zettcode/plugin_tui.py +133 -0
- zettcode/processes.py +126 -0
- zettcode/prompt.py +94 -0
- zettcode/providers.py +215 -0
- zettcode/py.typed +0 -0
- zettcode/registry.py +63 -0
- zettcode/relay.py +267 -0
- zettcode/statusline_parts.py +84 -0
- zettcode/version.py +11 -0
- zettcode/wire.py +30 -0
- zettcode/wording.py +11 -0
- zettcode_engine-0.3.4.dist-info/METADATA +23 -0
- zettcode_engine-0.3.4.dist-info/RECORD +90 -0
- zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""What the reader can change about how the agent runs: model, reasoning effort, shell permission."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from zettcode.config import NO_MODEL_REFUSAL, ModelConfig, _read_models
|
|
10
|
+
from zettcode.config_edit import (
|
|
11
|
+
ConfigEditError,
|
|
12
|
+
add_model,
|
|
13
|
+
file_entries,
|
|
14
|
+
remove_model,
|
|
15
|
+
replace_model,
|
|
16
|
+
set_self_compaction,
|
|
17
|
+
write_text_atomically,
|
|
18
|
+
)
|
|
19
|
+
from zettcode.engine.permission import PERMISSION_LEVELS, Permission
|
|
20
|
+
from zettcode.paths import config_path
|
|
21
|
+
|
|
22
|
+
from .base import AgentBase
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SettingsMixin(AgentBase):
|
|
26
|
+
"""Read and change the model, the reasoning effort, and how much of the shell may run unasked."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def models(self) -> tuple[ModelConfig, ...]:
|
|
30
|
+
"""Return the models available for selection."""
|
|
31
|
+
return self.runtime.config.models
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def active_model(self) -> ModelConfig | None:
|
|
35
|
+
"""Return the model selected for the next turn, or ``None`` when this machine has none configured."""
|
|
36
|
+
return self.runtime.active_model
|
|
37
|
+
|
|
38
|
+
def find_model(self, name: str) -> ModelConfig | None:
|
|
39
|
+
"""Return the configured model a recorded name stands for, if exactly one does."""
|
|
40
|
+
return self.runtime.find_model(name)
|
|
41
|
+
|
|
42
|
+
def use_model(self, name: str | ModelConfig) -> ModelConfig:
|
|
43
|
+
"""Select a configured model for subsequent turns."""
|
|
44
|
+
return self.runtime.use_model(name)
|
|
45
|
+
|
|
46
|
+
# -- the models in the config file --------------------------------------------------
|
|
47
|
+
@property
|
|
48
|
+
def config_file(self) -> Path:
|
|
49
|
+
"""Return the file the models are read from and written to."""
|
|
50
|
+
return config_path()
|
|
51
|
+
|
|
52
|
+
def _read_config_file(self, *, allow_missing: bool = False) -> str:
|
|
53
|
+
"""Return the config file's text, or raise :class:`ConfigEditError` saying why not.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
allow_missing: Answer an empty file when there is none. Only what sets a machine up asks for that —
|
|
57
|
+
the first model, and the list that shows there is nothing to list — because a fresh machine has
|
|
58
|
+
no config file at all. Every other edit treats a missing file as the reader's file that vanished
|
|
59
|
+
and says so, rather than writing a new one that would hold none of what theirs did.
|
|
60
|
+
"""
|
|
61
|
+
path = self.config_file
|
|
62
|
+
try:
|
|
63
|
+
return path.read_text(encoding="utf-8")
|
|
64
|
+
except FileNotFoundError as error:
|
|
65
|
+
if allow_missing:
|
|
66
|
+
return ""
|
|
67
|
+
raise ConfigEditError(f"{path} does not exist") from error
|
|
68
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
69
|
+
raise ConfigEditError(f"{path} cannot be read: {error}") from error
|
|
70
|
+
|
|
71
|
+
def file_models(self) -> tuple[tuple[ModelConfig, Mapping[str, object]], ...]:
|
|
72
|
+
"""Return each model in the file with the keys the file writes for it.
|
|
73
|
+
|
|
74
|
+
The file is the source of truth for editing, not the models the session started with: a
|
|
75
|
+
reader may have changed it by hand since. The keys are what is *written* — a token left to
|
|
76
|
+
an environment variable is absent — which is what a rewrite has to start from.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ConfigEditError: When the file cannot be read, is not valid, or lays its models out in
|
|
80
|
+
a way the editor cannot safely change.
|
|
81
|
+
"""
|
|
82
|
+
text = self._read_config_file(allow_missing=not self.models)
|
|
83
|
+
path = self.config_file
|
|
84
|
+
entries = file_entries(text, where=path)
|
|
85
|
+
try:
|
|
86
|
+
# A file that lists none is a list with nothing in it, not a refusal: `/config` is how the first
|
|
87
|
+
# one is written, so the panel it opens must be able to show an empty file.
|
|
88
|
+
models = _read_models(entries, path, required=False)
|
|
89
|
+
except ValueError as error:
|
|
90
|
+
raise ConfigEditError(str(error)) from error
|
|
91
|
+
return tuple(zip(models, entries, strict=True))
|
|
92
|
+
|
|
93
|
+
def _commit_models(self, text: str) -> ModelConfig:
|
|
94
|
+
"""Write the edited text, then make the running session use what it now says.
|
|
95
|
+
|
|
96
|
+
The file is written first and the session is changed second, so a write that fails leaves
|
|
97
|
+
the session as it was, and what the session holds is always something the file holds too.
|
|
98
|
+
"""
|
|
99
|
+
path = self.config_file
|
|
100
|
+
try:
|
|
101
|
+
write_text_atomically(path, text)
|
|
102
|
+
except OSError as error:
|
|
103
|
+
raise ConfigEditError(f"{path} could not be written: {error}") from error
|
|
104
|
+
models = _read_models(file_entries(text, where=path), path)
|
|
105
|
+
return self.runtime.replace_models(models)
|
|
106
|
+
|
|
107
|
+
def add_configured_model(self, entry: Mapping[str, object]) -> ModelConfig:
|
|
108
|
+
"""Add one ``[[models]]`` entry to the file and to this session; return the new model.
|
|
109
|
+
|
|
110
|
+
The model in use does not change: adding is not choosing. The new one appears in ``/model``. The exception
|
|
111
|
+
is the first model a machine has — there is nothing to add it beside — so that one becomes the model in
|
|
112
|
+
use, and it is chosen here rather than by the file's order: another process may have written an entry
|
|
113
|
+
since this session started, and ``/config`` announces the entry it just wrote, not that one.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
entry: The keys of the new model as the file writes them.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
ConfigEditError: When the entry is not a model that can be called, or the file cannot
|
|
120
|
+
be edited. Nothing is written then.
|
|
121
|
+
"""
|
|
122
|
+
text = add_model(self._read_config_file(allow_missing=not self.models), entry, where=self.config_file)
|
|
123
|
+
first = self.runtime.active_model is None
|
|
124
|
+
self._commit_models(text)
|
|
125
|
+
model = self.runtime.config.models[-1]
|
|
126
|
+
return self.runtime.use_model(model) if first else model
|
|
127
|
+
|
|
128
|
+
def update_configured_model(self, index: int, entry: Mapping[str, object]) -> ModelConfig:
|
|
129
|
+
"""Rewrite model number ``index`` (from zero) of the file as ``entry``; return the result."""
|
|
130
|
+
text = replace_model(self._read_config_file(), index, entry, where=self.config_file)
|
|
131
|
+
self._commit_models(text)
|
|
132
|
+
return self.runtime.config.models[index]
|
|
133
|
+
|
|
134
|
+
def change_compact_percent(self, percent: float) -> tuple[ModelConfig, str | None]:
|
|
135
|
+
"""Compact the model in use at ``percent`` of its window from the next request, and write it to the config file.
|
|
136
|
+
|
|
137
|
+
The running session is changed whatever becomes of the file, since the reader asked for it now: a file
|
|
138
|
+
that cannot be edited (no file, a layout the editor refuses) leaves the change for this run only, and the
|
|
139
|
+
reason is returned beside the model so it can be said.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
percent: Share of the window, more than 0 and at most 100.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The model now in use, and why the file was not changed (``None`` when it was).
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ValueError: ``percent`` is not between 0 and 100, or no model is configured to compact.
|
|
149
|
+
"""
|
|
150
|
+
if not 0 < percent <= 100:
|
|
151
|
+
raise ValueError("The compression share must be more than 0 and at most 100.")
|
|
152
|
+
if self.runtime.active_model is None:
|
|
153
|
+
raise ValueError(NO_MODEL_REFUSAL)
|
|
154
|
+
active = self.runtime.required_model
|
|
155
|
+
value = int(percent) if float(percent).is_integer() else percent
|
|
156
|
+
try:
|
|
157
|
+
for index, (model, entry) in enumerate(self.file_models()):
|
|
158
|
+
if model == active:
|
|
159
|
+
return self.update_configured_model(index, {**entry, "compact_percent": value}), None
|
|
160
|
+
reason = "the model in use is not in the config file"
|
|
161
|
+
except ConfigEditError as error:
|
|
162
|
+
reason = str(error)
|
|
163
|
+
edited = replace(active, compact_percent=float(percent))
|
|
164
|
+
models = tuple(edited if model is active else model for model in self.models)
|
|
165
|
+
return self.runtime.replace_models(models), reason
|
|
166
|
+
|
|
167
|
+
def remove_configured_model(self, index: int) -> ModelConfig:
|
|
168
|
+
"""Remove model number ``index`` (from zero) from the file and this session.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The model in use afterwards: the one before unless it was the one removed.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
ConfigEditError: When it is the only model, or the file cannot be edited.
|
|
175
|
+
"""
|
|
176
|
+
text = remove_model(self._read_config_file(), index, where=self.config_file)
|
|
177
|
+
return self._commit_models(text)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def self_compaction(self) -> bool:
|
|
181
|
+
"""Return whether the model may request compaction in this session right now."""
|
|
182
|
+
return self.runtime.self_compacting(self.session_id)
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def self_compaction_choice(self) -> bool | None:
|
|
186
|
+
"""Return this session's own choice, or ``None`` when it follows the default."""
|
|
187
|
+
return self.runtime.self_compaction_choice(self.session_id)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def self_compaction_default(self) -> bool:
|
|
191
|
+
"""Return what a session that has made no choice does: ``[compaction] self`` in the config."""
|
|
192
|
+
return self.runtime.config.self_compaction
|
|
193
|
+
|
|
194
|
+
def use_self_compaction(self, choice: bool | None) -> None:
|
|
195
|
+
"""Set this session's own choice, or give it back with ``None`` so it follows the default.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
SessionLocked: Another process has the session open; the choice is part of the session.
|
|
199
|
+
ConfigEditError: The choice could not be saved. Nothing is reported as saved then.
|
|
200
|
+
"""
|
|
201
|
+
self.require_session()
|
|
202
|
+
try:
|
|
203
|
+
self.runtime.choose_self_compaction(self.session_id, choice)
|
|
204
|
+
except OSError as error:
|
|
205
|
+
raise ConfigEditError(f"Self-compaction setting was not saved: {error}") from error
|
|
206
|
+
|
|
207
|
+
def remember_self_compaction(self, enabled: bool) -> Path:
|
|
208
|
+
"""Write ``[compaction] self = enabled`` into the config file: the default for every session.
|
|
209
|
+
|
|
210
|
+
The file is written first and the running configuration changed second, so a write that fails
|
|
211
|
+
leaves both as they were. A session with a choice of its own keeps it; one without follows this at
|
|
212
|
+
once. A config file that does not exist is not created for it, as for the theme.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
The file written.
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ConfigEditError: When there is no config file, it cannot be read or edited safely, or it cannot
|
|
219
|
+
be written.
|
|
220
|
+
"""
|
|
221
|
+
text = set_self_compaction(self._read_config_file(), enabled, where=self.config_file)
|
|
222
|
+
try:
|
|
223
|
+
write_text_atomically(self.config_file, text)
|
|
224
|
+
except OSError as error:
|
|
225
|
+
raise ConfigEditError(f"{self.config_file} could not be written: {error}") from error
|
|
226
|
+
self.runtime.config = replace(self.runtime.config, self_compaction=enabled)
|
|
227
|
+
return self.config_file
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def effort(self) -> str:
|
|
231
|
+
"""Return the reasoning effort the next request will ask for."""
|
|
232
|
+
return self.runtime.effort.value
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def efforts(self) -> tuple[str, ...]:
|
|
236
|
+
"""Return the reasoning levels the active model takes, cheapest first; none when no model is configured."""
|
|
237
|
+
model = self.runtime.active_model
|
|
238
|
+
return () if model is None else tuple(level.value for level in model.efforts)
|
|
239
|
+
|
|
240
|
+
def use_effort(self, name: str) -> str:
|
|
241
|
+
"""Select a reasoning level for later requests and return its value."""
|
|
242
|
+
return self.runtime.use_effort(name).value
|
|
243
|
+
|
|
244
|
+
def approve_all_shell_commands(self) -> None:
|
|
245
|
+
"""Approve every shell command for the rest of this run, without asking."""
|
|
246
|
+
self.runtime.approve_all_shell_commands()
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def permission(self) -> Permission:
|
|
250
|
+
"""Return how much of the shell may run without asking."""
|
|
251
|
+
return self.runtime.permission
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def permissions(self) -> tuple[str, ...]:
|
|
255
|
+
"""Return the permission levels, strictest first."""
|
|
256
|
+
return tuple(level.value for level in PERMISSION_LEVELS)
|
|
257
|
+
|
|
258
|
+
def use_permission(self, name: str | Permission, *, remember: bool = True) -> Permission:
|
|
259
|
+
"""Set how much of the shell may run without asking, and by default remember it for this session."""
|
|
260
|
+
return self.runtime.use_permission(name, remember=remember)
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def harness(self) -> bool:
|
|
264
|
+
"""Return whether the just-in-time harness is on for the next request."""
|
|
265
|
+
return self.runtime.harness.on
|
|
266
|
+
|
|
267
|
+
def use_harness(self, on: bool) -> bool:
|
|
268
|
+
"""Turn the just-in-time harness on or off for later requests, for the rest of this run."""
|
|
269
|
+
return self.runtime.use_harness(on)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Running a turn: building the message, streaming the agent's events, and steering one in flight."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Sequence
|
|
6
|
+
from contextlib import aclosing
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from zett_agent.agent import AgentRunConfig
|
|
11
|
+
from zett_agent.dispatcher import AgentEventDispatcher
|
|
12
|
+
from zett_agent.events import AgentEvent
|
|
13
|
+
from zett_agent.exceptions import AgentResumeError
|
|
14
|
+
from zett_agent.extensions.external import ExternalEvent
|
|
15
|
+
from zett_agent.extensions.steering import STEERING_MESSAGE_EVENT_NAME
|
|
16
|
+
from zett_agent.messages import ImageBytesSource, ImageContent, TextContent, UserMessage
|
|
17
|
+
from zett_agent.model import CacheMode
|
|
18
|
+
from zett_agent.resume import ResumePoint
|
|
19
|
+
|
|
20
|
+
from zettcode.engine.dialogue.side import ORIGIN, READER, SIDE, WATCHED, question, watched_by
|
|
21
|
+
from zettcode.prompt import MentionPart, PromptPart, carries_image, model_text_part, plain_text
|
|
22
|
+
from zettcode.relay import Relayed
|
|
23
|
+
|
|
24
|
+
from .base import AgentBase
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cache_mode_of(value: CacheMode | str | None, *, side: bool) -> CacheMode:
|
|
28
|
+
"""Return the cache mode one run carries, checking a word the caller wrote.
|
|
29
|
+
|
|
30
|
+
A turn of the conversation may extend the provider's cached prefix: the next turn reads it, which is what
|
|
31
|
+
caching is for. A side question is not replayed, so its own content is not worth writing into the cache; it
|
|
32
|
+
starts at ``READ``, which still reuses the conversation's warm prefix and marks only the stable parts.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: ``value`` is not one of ``off``, ``read`` and ``read_write``.
|
|
36
|
+
"""
|
|
37
|
+
if value is None:
|
|
38
|
+
return CacheMode.READ if side else CacheMode.READ_WRITE
|
|
39
|
+
try:
|
|
40
|
+
return CacheMode(value)
|
|
41
|
+
except ValueError:
|
|
42
|
+
raise ValueError(f"cache must be off, read or read_write, not {value!r}") from None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TurnsMixin(AgentBase):
|
|
46
|
+
"""Turn the reader's parts into a request, stream what comes back, and steer it."""
|
|
47
|
+
|
|
48
|
+
def set_event_dispatcher(self, dispatcher: AgentEventDispatcher) -> None:
|
|
49
|
+
"""Send streamed agent events to the application's transcript projector."""
|
|
50
|
+
self.runtime.set_event_dispatcher(dispatcher)
|
|
51
|
+
|
|
52
|
+
async def stream(
|
|
53
|
+
self,
|
|
54
|
+
parts: Sequence[PromptPart],
|
|
55
|
+
*,
|
|
56
|
+
hint: str | None = None,
|
|
57
|
+
side: bool = False,
|
|
58
|
+
origin: str = READER,
|
|
59
|
+
watched: bool | None = None,
|
|
60
|
+
relayed: Relayed | None = None,
|
|
61
|
+
cache: CacheMode | str | None = None,
|
|
62
|
+
) -> AsyncIterator[AgentEvent]:
|
|
63
|
+
"""Run one turn with the selected session, model, and reasoning effort.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
parts: The user turn in the order it was written: text runs and the
|
|
67
|
+
images the composer interleaved with them. A turn without
|
|
68
|
+
images is simply one text part.
|
|
69
|
+
hint: Text appended after the turn: the instructions a ``@``
|
|
70
|
+
reference contributes, then anything the caller added (the SDK
|
|
71
|
+
merges them). The text the user typed then rides along as the
|
|
72
|
+
message's ``prompt`` attribute, so the stored message is what
|
|
73
|
+
was typed and a restored session shows it.
|
|
74
|
+
side: Ask without letting the exchange join the conversation; see
|
|
75
|
+
:mod:`zettcode.engine.dialogue.side`.
|
|
76
|
+
relayed: The turn is a message another session sent. It is what the model reads
|
|
77
|
+
(with where it came from), and ``parts`` and ``hint`` are not used.
|
|
78
|
+
cache: How this request may use the provider's prompt cache: ``"read_write"`` (the default) extends
|
|
79
|
+
it, ``"read"`` reuses a warm prefix without marking this request's new content for caching, and
|
|
80
|
+
``"off"`` sends no cache directive at all. A side question starts at ``"read"`` unless told
|
|
81
|
+
otherwise, because its answer is never replayed.
|
|
82
|
+
origin: Who asked a side question — ``"reader"`` for the reader's ``/btw``, or the name of the plugin
|
|
83
|
+
asking for itself. It is stored with the exchange, and only a reader's own is restored into a transcript
|
|
84
|
+
by default.
|
|
85
|
+
watched: Whether a reopened transcript restores this exchange, overriding what ``origin`` implies. It does
|
|
86
|
+
not make a front end draw it now. Ignored when ``side`` is false.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
SessionLocked: Another process has the session open. Checked before anything else, and a side
|
|
90
|
+
question is no exception: the store records it even though it never replays it.
|
|
91
|
+
"""
|
|
92
|
+
self.require_session()
|
|
93
|
+
await self.runtime.start()
|
|
94
|
+
client = self.runtime.started
|
|
95
|
+
used = self.runtime.required_model.shown_name
|
|
96
|
+
session_id = self.session_id
|
|
97
|
+
try:
|
|
98
|
+
# Closed here, not left to the garbage collector: a consumer that stops early (a `break`, `aclose()`)
|
|
99
|
+
# throws GeneratorExit in at the `yield`, and the library's stream must unwind then, or the agent is
|
|
100
|
+
# left in its "generating" phase and refuses the next request.
|
|
101
|
+
async with aclosing(
|
|
102
|
+
cast(
|
|
103
|
+
"AsyncGenerator[AgentEvent]",
|
|
104
|
+
client.stream(
|
|
105
|
+
self.request(parts, hint=hint, side=side, origin=origin, watched=watched)
|
|
106
|
+
if relayed is None
|
|
107
|
+
else relayed.message(),
|
|
108
|
+
config=AgentRunConfig(
|
|
109
|
+
session_id=session_id,
|
|
110
|
+
cache_mode=cache_mode_of(cache, side=side),
|
|
111
|
+
),
|
|
112
|
+
model=self.runtime.provider,
|
|
113
|
+
reasoning_effort=self.runtime.effort,
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
) as events:
|
|
117
|
+
async for event in events:
|
|
118
|
+
yield event
|
|
119
|
+
finally:
|
|
120
|
+
if not side:
|
|
121
|
+
self._remember_model(session_id, used)
|
|
122
|
+
|
|
123
|
+
async def resume_point(self) -> ResumePoint | None:
|
|
124
|
+
"""Report where the active session was interrupted, or ``None`` when there is nothing to pick up.
|
|
125
|
+
|
|
126
|
+
Nothing is written and no model is called. A session another process has open is refused, as a turn is.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
SessionLocked: Another process has the session open.
|
|
130
|
+
"""
|
|
131
|
+
self.require_session()
|
|
132
|
+
await self.runtime.start()
|
|
133
|
+
return await self.runtime.started.resume_point(
|
|
134
|
+
config=AgentRunConfig(session_id=self.session_id), model=self.runtime.provider
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def resume_blocker(self) -> str | None:
|
|
138
|
+
"""Say why the active session cannot be picked up, in the library's own words; ``None`` when it can.
|
|
139
|
+
|
|
140
|
+
:meth:`resume_point` answers only that there is none. The reason is one the reader can act on — a finished
|
|
141
|
+
request, a refusal that would be declined again, a context window that is full — and the library says it when
|
|
142
|
+
a resume is asked for, so it is asked for here and stopped before it starts.
|
|
143
|
+
"""
|
|
144
|
+
self.require_session()
|
|
145
|
+
await self.runtime.start()
|
|
146
|
+
stream = self.runtime.started.stream_resume(
|
|
147
|
+
config=AgentRunConfig(session_id=self.session_id), model=self.runtime.provider
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
async for _event in stream:
|
|
151
|
+
return None # it can be resumed after all; the first event is as far as this looks
|
|
152
|
+
except AgentResumeError as error:
|
|
153
|
+
return str(error)
|
|
154
|
+
finally:
|
|
155
|
+
await stream.aclose() # type: ignore[attr-defined]
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
async def stream_resume(self) -> AsyncIterator[AgentEvent]:
|
|
159
|
+
"""Pick the interrupted request up where it stopped, with the model and effort the next turn would use.
|
|
160
|
+
|
|
161
|
+
The library takes none of these from the session, so the same ones a turn would carry are given.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
SessionLocked: Another process has the session open.
|
|
165
|
+
AgentResumeError: There is nothing to pick up; :meth:`resume_point` says whether there is.
|
|
166
|
+
"""
|
|
167
|
+
self.require_session()
|
|
168
|
+
await self.runtime.start()
|
|
169
|
+
client = self.runtime.started
|
|
170
|
+
used = self.runtime.required_model.shown_name
|
|
171
|
+
session_id = self.session_id
|
|
172
|
+
try:
|
|
173
|
+
async with aclosing(
|
|
174
|
+
cast(
|
|
175
|
+
"AsyncGenerator[AgentEvent]",
|
|
176
|
+
client.stream_resume(
|
|
177
|
+
config=AgentRunConfig(session_id=session_id),
|
|
178
|
+
model=self.runtime.provider,
|
|
179
|
+
reasoning_effort=self.runtime.effort,
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
) as events:
|
|
183
|
+
async for event in events:
|
|
184
|
+
yield event
|
|
185
|
+
finally:
|
|
186
|
+
self._remember_model(session_id, used)
|
|
187
|
+
|
|
188
|
+
def _remember_model(self, session_id: str, model: str) -> None:
|
|
189
|
+
"""Record the model a turn used in its session, so resuming the session starts on it.
|
|
190
|
+
|
|
191
|
+
Done when the turn ends, however it ends — a stopped or failed turn used the model too —
|
|
192
|
+
and not before it, because a session's folder does not exist until its first message is
|
|
193
|
+
written. A side question is not part of the conversation and records nothing. Metadata is
|
|
194
|
+
never worth failing a conversation for, so a store that cannot be written or read here is
|
|
195
|
+
passed over; the next turn tries again.
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
self.runtime.persistence.set_model(session_id, model)
|
|
199
|
+
except (OSError, ValueError):
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def request(
|
|
204
|
+
parts: Sequence[PromptPart],
|
|
205
|
+
*,
|
|
206
|
+
hint: str | None = None,
|
|
207
|
+
side: bool = False,
|
|
208
|
+
origin: str = READER,
|
|
209
|
+
watched: bool | None = None,
|
|
210
|
+
) -> str | UserMessage:
|
|
211
|
+
"""Return the user turn those ordered parts make up.
|
|
212
|
+
|
|
213
|
+
Text and images keep the order they were written in inside one
|
|
214
|
+
``UserMessage``, so a picture is read where its writer placed it rather
|
|
215
|
+
than after the whole prompt. A turn with no image stays a plain string.
|
|
216
|
+
``hint`` is appended last — as a trailing part when an image is in the
|
|
217
|
+
turn — and the typed text then rides along as the message's ``prompt``
|
|
218
|
+
attribute, so storage and a restored session keep it.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
parts: The turn in the order it was written.
|
|
222
|
+
hint: Instructions the model reads after the turn: what a ``@``
|
|
223
|
+
reference contributed, then what the caller added.
|
|
224
|
+
side: Mark the turn as a side question, which the store records but
|
|
225
|
+
never replays.
|
|
226
|
+
origin: Who asked it: ``"reader"``, or the name of the plugin asking for itself. The store keeps it beside
|
|
227
|
+
the side mark.
|
|
228
|
+
watched: Whether a reopened transcript restores it, overriding what ``origin`` implies. Left out, a
|
|
229
|
+
reader's question is restored and a plugin's is not; it does not make a front end draw it now.
|
|
230
|
+
"""
|
|
231
|
+
message = TurnsMixin.turn(parts, hint=hint)
|
|
232
|
+
if not side:
|
|
233
|
+
return message
|
|
234
|
+
if isinstance(message, UserMessage):
|
|
235
|
+
return replace(
|
|
236
|
+
message,
|
|
237
|
+
attributes={
|
|
238
|
+
**message.attributes,
|
|
239
|
+
SIDE: True,
|
|
240
|
+
ORIGIN: origin,
|
|
241
|
+
WATCHED: watched_by(origin, watched),
|
|
242
|
+
},
|
|
243
|
+
include_in_messages=False,
|
|
244
|
+
)
|
|
245
|
+
return question(message, origin=origin, watched=watched)
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def turn(parts: Sequence[PromptPart], *, hint: str | None = None) -> str | UserMessage:
|
|
249
|
+
"""Return the message those parts make up, before any side marking."""
|
|
250
|
+
if not carries_image(parts):
|
|
251
|
+
text = plain_text(parts)
|
|
252
|
+
if hint is None:
|
|
253
|
+
return text
|
|
254
|
+
said = "".join(model_text_part(part) for part in parts)
|
|
255
|
+
return UserMessage(content=f"{said}\n\n{hint}", attributes={"prompt": text})
|
|
256
|
+
content: list[TextContent | ImageContent] = []
|
|
257
|
+
for part in parts:
|
|
258
|
+
if isinstance(part, (str, MentionPart)):
|
|
259
|
+
text = model_text_part(part)
|
|
260
|
+
if text:
|
|
261
|
+
content.append(TextContent(text))
|
|
262
|
+
continue
|
|
263
|
+
data, media_type = part
|
|
264
|
+
content.append(ImageContent(source=ImageBytesSource(data=data, media_type=media_type)))
|
|
265
|
+
if hint is not None:
|
|
266
|
+
content.append(TextContent(hint))
|
|
267
|
+
message = UserMessage(content=content)
|
|
268
|
+
if hint is not None:
|
|
269
|
+
message.attributes["prompt"] = plain_text(parts)
|
|
270
|
+
return message
|
|
271
|
+
|
|
272
|
+
def tasks(self) -> tuple[tuple[str, str], ...]:
|
|
273
|
+
"""Return the current session's plan as display-ready status/content pairs."""
|
|
274
|
+
todos = self.runtime.todos
|
|
275
|
+
progress = todos.todos(self.session_id) if todos is not None else None
|
|
276
|
+
return tuple((item.status.value, item.content) for item in progress.todos) if progress else ()
|
|
277
|
+
|
|
278
|
+
def steer(self, message: str | UserMessage) -> bool:
|
|
279
|
+
"""Queue an urgent user message for the request that is running.
|
|
280
|
+
|
|
281
|
+
The agent adopts it at the next model or tool boundary and drops the
|
|
282
|
+
rest of the current task, so the shell offers this only while a turn is
|
|
283
|
+
in flight. Returns whether a running request accepted the message, which
|
|
284
|
+
is what tells the shell it may show the message as queued.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
message: What the user typed, as text, or as a message when it carries
|
|
288
|
+
pictures (see :meth:`turn`): zett-agent's steering inbox takes either.
|
|
289
|
+
An empty one is refused without asking the agent, and a request that
|
|
290
|
+
is not running refuses it here.
|
|
291
|
+
"""
|
|
292
|
+
if isinstance(message, str):
|
|
293
|
+
if not message.strip():
|
|
294
|
+
return False
|
|
295
|
+
payload: dict[str, object] = {"content": message}
|
|
296
|
+
else:
|
|
297
|
+
if not message.text.strip() and not message.parts:
|
|
298
|
+
return False
|
|
299
|
+
payload = {"message": message}
|
|
300
|
+
try:
|
|
301
|
+
agent = self.runtime.started.agent
|
|
302
|
+
except RuntimeError:
|
|
303
|
+
return False
|
|
304
|
+
accepted = agent.emit_external_event(
|
|
305
|
+
ExternalEvent(name=STEERING_MESSAGE_EVENT_NAME, payload=payload),
|
|
306
|
+
config=AgentRunConfig(session_id=self.session_id),
|
|
307
|
+
)
|
|
308
|
+
return bool(accepted)
|