zettcode-sdk 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/daemon/__init__.py +17 -0
- zettcode/daemon/__main__.py +10 -0
- zettcode/daemon/cli.py +693 -0
- zettcode/daemon/companions.py +63 -0
- zettcode/daemon/process.py +559 -0
- zettcode/daemon/run.py +678 -0
- zettcode/daemon/state.py +356 -0
- zettcode/doctor.py +167 -0
- zettcode/mentions.py +320 -0
- zettcode/sdk/__init__.py +195 -0
- zettcode/sdk/builtins.py +608 -0
- zettcode/sdk/client.py +1810 -0
- zettcode/sdk/commands.py +9 -0
- zettcode/sdk/daemons.py +244 -0
- zettcode/sdk/errors.py +132 -0
- zettcode/sdk/events.py +434 -0
- zettcode/sdk/extensions/__init__.py +6 -0
- zettcode/sdk/extensions/mcp.py +276 -0
- zettcode/sdk/extensions/messaging.py +281 -0
- zettcode/sdk/handlers.py +126 -0
- zettcode/sdk/messenger.py +299 -0
- zettcode/sdk/models.py +156 -0
- zettcode/sdk/py.typed +0 -0
- zettcode/sdk/session.py +1212 -0
- zettcode/sdk/translation.py +384 -0
- zettcode/sdk/types.py +907 -0
- zettcode/table.py +95 -0
- zettcode_sdk-0.3.4.dist-info/METADATA +21 -0
- zettcode_sdk-0.3.4.dist-info/RECORD +30 -0
- zettcode_sdk-0.3.4.dist-info/WHEEL +4 -0
zettcode/sdk/client.py
ADDED
|
@@ -0,0 +1,1810 @@
|
|
|
1
|
+
"""The entry point: a client for one workspace, its settings, and the sessions in it.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
import zettcode.sdk as zc
|
|
6
|
+
|
|
7
|
+
async with zc.Client("~/project", model="gpt-5", permission="safe") as client:
|
|
8
|
+
result = await client.prompt("find the failing test and fix it", on_approval=zc.approve_commands("pytest"))
|
|
9
|
+
print(result.text, result.usage.total_tokens)
|
|
10
|
+
|
|
11
|
+
A client is the terminal application without the terminal: the same agent, tools, plugins, MCP servers, skills,
|
|
12
|
+
project instructions, session store and permission levels, driven from code. It reads the same config file unless
|
|
13
|
+
it is given a configuration of its own (see :class:`Client`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import dataclasses
|
|
20
|
+
import difflib
|
|
21
|
+
import functools
|
|
22
|
+
import os
|
|
23
|
+
import weakref
|
|
24
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
25
|
+
from dataclasses import dataclass, replace
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from types import TracebackType
|
|
28
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
29
|
+
|
|
30
|
+
from zettcode.modes import MODE_FOLLOWUP, MODE_STEER
|
|
31
|
+
|
|
32
|
+
from .errors import (
|
|
33
|
+
ClientClosedError,
|
|
34
|
+
ConfigurationError,
|
|
35
|
+
SendError,
|
|
36
|
+
SessionBusyError,
|
|
37
|
+
SessionLockedError,
|
|
38
|
+
SessionNotFoundError,
|
|
39
|
+
WorkspaceConflictError,
|
|
40
|
+
)
|
|
41
|
+
from .events import AnyEvent, ReasoningDelta, TextDelta, ToolFinished, ToolStarted
|
|
42
|
+
from .handlers import ApprovalHandler, QuestionHandler
|
|
43
|
+
from .session import EventCallback, Prompt, RunStream, Session, messages_of
|
|
44
|
+
from .types import (
|
|
45
|
+
CacheMode,
|
|
46
|
+
Delivered,
|
|
47
|
+
Image,
|
|
48
|
+
IncomingMessage,
|
|
49
|
+
MentionInfo,
|
|
50
|
+
Message,
|
|
51
|
+
ModelCheck,
|
|
52
|
+
ModelInfo,
|
|
53
|
+
Peer,
|
|
54
|
+
PluginEntry,
|
|
55
|
+
Receipt,
|
|
56
|
+
RunResult,
|
|
57
|
+
SendLimits,
|
|
58
|
+
SessionInfo,
|
|
59
|
+
SessionStatsInfo,
|
|
60
|
+
StoredSession,
|
|
61
|
+
StreamEvent,
|
|
62
|
+
Usage,
|
|
63
|
+
WorkspaceUsage,
|
|
64
|
+
brief_of,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
68
|
+
from zett_agent.extensions.shell_approval import ShellApprovalMode
|
|
69
|
+
from zett_agent.model import ReasoningEffort
|
|
70
|
+
|
|
71
|
+
from zettcode.broker.models import Receipt as BrokerReceipt
|
|
72
|
+
from zettcode.config import ModelConfig, ZettCodeConfig
|
|
73
|
+
from zettcode.engine.agent import ZettCodeAgent
|
|
74
|
+
from zettcode.engine.plugins.commands import PlainCommand
|
|
75
|
+
from zettcode.mentions import MentionRegistry
|
|
76
|
+
|
|
77
|
+
from .messenger import Messenger
|
|
78
|
+
|
|
79
|
+
__all__ = ["Client", "PluginReport", "SETTINGS", "Sessions", "prompt", "prompt_sync"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _Missing:
|
|
83
|
+
"""The default of a setting that was not given.
|
|
84
|
+
|
|
85
|
+
``None`` cannot be it: several settings take ``None`` as a value (``theme = "none"`` is the file's way of
|
|
86
|
+
saying it, and ``mcp_config = None`` means there is no file), so a setting that was left out has to be told
|
|
87
|
+
apart from one that was set to nothing.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str: # pragma: no cover - debugging only
|
|
91
|
+
return "UNSET"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_UNSET: Any = _Missing()
|
|
95
|
+
|
|
96
|
+
#: The configuration fields :class:`Client` takes by name, laid over whatever it read. `tests/test_sdk_config.py`
|
|
97
|
+
#: holds the signature to this list, so a field added to the configuration is either taken here or fails a test.
|
|
98
|
+
SETTINGS: tuple[str, ...] = (
|
|
99
|
+
"store",
|
|
100
|
+
"theme_file",
|
|
101
|
+
"theme",
|
|
102
|
+
"themes_dir",
|
|
103
|
+
"pet",
|
|
104
|
+
"pet_position",
|
|
105
|
+
"reasoning_effort",
|
|
106
|
+
"shell_approval",
|
|
107
|
+
"reduced_motion",
|
|
108
|
+
"parallel_tool_call",
|
|
109
|
+
"max_iterations",
|
|
110
|
+
"transcript_max_entries",
|
|
111
|
+
"mouse_scroll_speed",
|
|
112
|
+
"tips",
|
|
113
|
+
"agents_md_enabled",
|
|
114
|
+
"ask_user_enabled",
|
|
115
|
+
"skills_enabled",
|
|
116
|
+
"skill_roots",
|
|
117
|
+
"skills_scope",
|
|
118
|
+
"mcp_enabled",
|
|
119
|
+
"mcp_config",
|
|
120
|
+
"mcp_scope",
|
|
121
|
+
"self_compaction",
|
|
122
|
+
"statusline_hidden",
|
|
123
|
+
"plugins_enabled",
|
|
124
|
+
"disabled_plugins",
|
|
125
|
+
"plugin_settings",
|
|
126
|
+
"plugins_file",
|
|
127
|
+
"plugins_dir",
|
|
128
|
+
"subagent_enabled",
|
|
129
|
+
"subagent_max_iterations",
|
|
130
|
+
"subagent_messaging",
|
|
131
|
+
"todowrite_enabled",
|
|
132
|
+
"jit_harness",
|
|
133
|
+
"shell_network",
|
|
134
|
+
"sessions_enabled",
|
|
135
|
+
"update_enabled",
|
|
136
|
+
"update_file",
|
|
137
|
+
"harness_dir",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
#: What a client switches off unless it is told otherwise. The terminal application joins the broker that lets
|
|
141
|
+
#: sessions message each other and checks for a release in the background; a program embedding the agent should
|
|
142
|
+
#: not do either of those behind its back.
|
|
143
|
+
EMBEDDED_DEFAULTS: dict[str, object] = {"sessions_enabled": False, "update_enabled": False}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@functools.cache
|
|
147
|
+
def _settings() -> frozenset[str]:
|
|
148
|
+
"""Return the names of the settings a client accepts. Read from the config class on first use, which imports it."""
|
|
149
|
+
from zettcode.config import ZettCodeConfig
|
|
150
|
+
|
|
151
|
+
return frozenset(field.name for field in dataclasses.fields(ZettCodeConfig))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@functools.cache
|
|
155
|
+
def _path_settings() -> tuple[frozenset[str], frozenset[str]]:
|
|
156
|
+
"""Return the settings that hold a path, and the ones that hold several, so a string can stand for either."""
|
|
157
|
+
import typing
|
|
158
|
+
|
|
159
|
+
from zettcode.config import ZettCodeConfig
|
|
160
|
+
|
|
161
|
+
single: set[str] = set()
|
|
162
|
+
several: set[str] = set()
|
|
163
|
+
for name, hint in typing.get_type_hints(ZettCodeConfig).items():
|
|
164
|
+
text = str(hint)
|
|
165
|
+
if "Path" not in text:
|
|
166
|
+
continue
|
|
167
|
+
(several if text.startswith("tuple") else single).add(name)
|
|
168
|
+
return frozenset(single), frozenset(several)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@functools.cache
|
|
172
|
+
def _enum_settings() -> dict[str, type]:
|
|
173
|
+
"""Return the settings that hold one of a fixed set of values, by the enum that lists them."""
|
|
174
|
+
import enum
|
|
175
|
+
import typing
|
|
176
|
+
|
|
177
|
+
from zettcode.config import ZettCodeConfig
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
name: hint
|
|
181
|
+
for name, hint in typing.get_type_hints(ZettCodeConfig).items()
|
|
182
|
+
if isinstance(hint, type) and issubclass(hint, enum.Enum)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _coerced(settings: Mapping[str, object]) -> dict[str, object]:
|
|
187
|
+
"""Return the settings read the way a config file's strings are: paths as paths, choices as their enum.
|
|
188
|
+
|
|
189
|
+
The configuration stores whatever it is given for these, so a misspelt ``reasoning_effort`` would be accepted
|
|
190
|
+
here and fail on the first request; checking it now is what turns it into an error that lists the valid values.
|
|
191
|
+
"""
|
|
192
|
+
single, several = _path_settings()
|
|
193
|
+
choices = _enum_settings()
|
|
194
|
+
values: dict[str, object] = {}
|
|
195
|
+
for name, value in settings.items():
|
|
196
|
+
if name in single and isinstance(value, str):
|
|
197
|
+
value = Path(value).expanduser()
|
|
198
|
+
elif name in several and isinstance(value, (list, tuple)):
|
|
199
|
+
value = tuple(Path(item).expanduser() if isinstance(item, str) else item for item in value)
|
|
200
|
+
elif name in choices:
|
|
201
|
+
value = _choice(name, value, choices[name])
|
|
202
|
+
values[name] = value
|
|
203
|
+
return values
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _choice(name: str, value: object, kind: type) -> object:
|
|
207
|
+
"""Return ``value`` as a member of ``kind``, accepting a member, or its value in any case."""
|
|
208
|
+
if isinstance(value, kind):
|
|
209
|
+
return value
|
|
210
|
+
if isinstance(value, str):
|
|
211
|
+
try:
|
|
212
|
+
return kind(value.strip().lower())
|
|
213
|
+
except ValueError:
|
|
214
|
+
pass
|
|
215
|
+
allowed = ", ".join(str(member.value) for member in kind) # type: ignore[attr-defined]
|
|
216
|
+
raise ConfigurationError(f"{name} must be one of: {allowed} (got {value!r})")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
#: The clients that are open in this process, and the working directory the process had before the first one.
|
|
220
|
+
_OPEN: weakref.WeakSet[Client] = weakref.WeakSet()
|
|
221
|
+
_ORIGINAL_CWD: list[str] = []
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True, slots=True)
|
|
225
|
+
class PluginReport:
|
|
226
|
+
"""The plugins a client loaded.
|
|
227
|
+
|
|
228
|
+
Attributes:
|
|
229
|
+
loaded: Their names, in the order they take part (built-in ones first).
|
|
230
|
+
failures: One message per plugin that could not be loaded; what fails is reported, never fatal.
|
|
231
|
+
plain_commands: The commands the plugins registered for every front end — text, a Markdown document, or a
|
|
232
|
+
preset for a turn — in registration order, so a client with no terminal can offer them.
|
|
233
|
+
entries: The same plugins as a list shows them: what loaded, what the next start will leave out, and what could
|
|
234
|
+
not load, each with the origin a switch is written with. A front end with a panel reads this; `loaded`
|
|
235
|
+
and `failures` stay for the callers that only want the names.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
loaded: tuple[str, ...]
|
|
239
|
+
failures: tuple[str, ...]
|
|
240
|
+
plain_commands: tuple[PlainCommand, ...] = ()
|
|
241
|
+
entries: tuple[PluginEntry, ...] = ()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _plugin_entry(entry: Any) -> PluginEntry:
|
|
245
|
+
"""Return an engine-side plugin entry as the plain value the SDK hands out."""
|
|
246
|
+
return PluginEntry(
|
|
247
|
+
name=entry.name,
|
|
248
|
+
origin=entry.origin,
|
|
249
|
+
builtin=entry.builtin,
|
|
250
|
+
enabled=entry.enabled,
|
|
251
|
+
failure=entry.failure,
|
|
252
|
+
setting=entry.setting,
|
|
253
|
+
switchable=entry.switchable,
|
|
254
|
+
why=entry.why,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def build_config(
|
|
259
|
+
workspace: str | Path | None,
|
|
260
|
+
config: ZettCodeConfig | None,
|
|
261
|
+
config_path: str | Path | None,
|
|
262
|
+
models: Sequence[ModelConfig | Mapping[str, object]] | None,
|
|
263
|
+
settings: Mapping[str, object],
|
|
264
|
+
) -> ZettCodeConfig:
|
|
265
|
+
"""Return the configuration a client runs with.
|
|
266
|
+
|
|
267
|
+
Where it comes from, in order: ``config`` as given; else ``models`` on top of the defaults, the config file not
|
|
268
|
+
read at all; else the config file (``config_path``, ``$ZETTCODE_CONFIG`` or ``~/.zettcode/config.toml``).
|
|
269
|
+
``settings`` are then laid over it by field name.
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
ConfigurationError: The workspace is not a directory, a setting name is unknown (with a suggestion), a model
|
|
273
|
+
entry is malformed, or the file is unreadable or holds no model.
|
|
274
|
+
"""
|
|
275
|
+
if "workspace" in settings:
|
|
276
|
+
raise ConfigurationError("Pass the workspace as the first argument, not as a setting")
|
|
277
|
+
from zettcode.config import ZettCodeConfig, load_config
|
|
278
|
+
|
|
279
|
+
known = _settings()
|
|
280
|
+
unknown = sorted(name for name in settings if name not in known)
|
|
281
|
+
if unknown:
|
|
282
|
+
hints = []
|
|
283
|
+
for name in unknown:
|
|
284
|
+
close = difflib.get_close_matches(name, sorted(known), n=1)
|
|
285
|
+
hints.append(f"{name!r}" + (f" (did you mean {close[0]!r}?)" if close else ""))
|
|
286
|
+
raise ConfigurationError(f"Unknown setting: {', '.join(hints)}")
|
|
287
|
+
chosen = workspace if workspace is not None else (config.workspace if config is not None else Path.cwd())
|
|
288
|
+
try:
|
|
289
|
+
root = Path(chosen).expanduser().resolve(strict=True)
|
|
290
|
+
except (OSError, RuntimeError) as error:
|
|
291
|
+
raise ConfigurationError(f"The workspace {str(chosen)!r} does not exist") from error
|
|
292
|
+
if not root.is_dir():
|
|
293
|
+
raise ConfigurationError(f"The workspace {str(root)!r} is not a directory")
|
|
294
|
+
try:
|
|
295
|
+
if config is not None:
|
|
296
|
+
base = replace(config, workspace=root)
|
|
297
|
+
embedded: dict[str, object] = {}
|
|
298
|
+
elif models is not None:
|
|
299
|
+
base = ZettCodeConfig(workspace=root, models=_models(models))
|
|
300
|
+
embedded = dict(EMBEDDED_DEFAULTS)
|
|
301
|
+
else:
|
|
302
|
+
base = load_config(root, path=config_path)
|
|
303
|
+
embedded = dict(EMBEDDED_DEFAULTS)
|
|
304
|
+
return replace(base, **cast("dict[str, Any]", {**embedded, **_coerced(settings)}))
|
|
305
|
+
except ConfigurationError:
|
|
306
|
+
raise
|
|
307
|
+
except (ValueError, TypeError, AttributeError) as error:
|
|
308
|
+
# AttributeError is what a value of the wrong kind raises inside the configuration's own checks.
|
|
309
|
+
raise ConfigurationError(f"Invalid configuration: {error}") from error
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _models(entries: Sequence[ModelConfig | Mapping[str, object]]) -> tuple[ModelConfig, ...]:
|
|
313
|
+
"""Return the model entries as :class:`ModelConfig`, accepting plain mappings of the same fields."""
|
|
314
|
+
from zettcode.config import ModelConfig
|
|
315
|
+
|
|
316
|
+
if isinstance(entries, (str, bytes)) or not isinstance(entries, Sequence) or not entries:
|
|
317
|
+
raise ConfigurationError("models must be a non-empty list of ModelConfig or dicts")
|
|
318
|
+
built: list[ModelConfig] = []
|
|
319
|
+
for entry in entries:
|
|
320
|
+
if isinstance(entry, ModelConfig):
|
|
321
|
+
built.append(entry)
|
|
322
|
+
elif isinstance(entry, Mapping):
|
|
323
|
+
try:
|
|
324
|
+
built.append(ModelConfig(**entry)) # type: ignore[arg-type]
|
|
325
|
+
except (TypeError, ValueError) as error:
|
|
326
|
+
raise ConfigurationError(f"Invalid model entry {dict(entry)!r}: {error}") from error
|
|
327
|
+
else:
|
|
328
|
+
raise ConfigurationError(f"A model entry is a ModelConfig or a dict, not {type(entry).__name__}")
|
|
329
|
+
return tuple(built)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class Client:
|
|
333
|
+
"""The agent for one workspace.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
workspace: The directory the agent works in; ``None`` is the current one (or ``config.workspace``).
|
|
337
|
+
config: A ready configuration. Nothing is read from disk, and the embedding defaults are not applied.
|
|
338
|
+
config_path: A config file to read instead of the default one. Ignored when ``config`` or ``models`` is given.
|
|
339
|
+
models: Models to use without any config file: :class:`~zettcode.config.ModelConfig` entries or dicts of the
|
|
340
|
+
same fields (``{"model": "gpt-5", "token": "sk-..."}``). The first is the active one.
|
|
341
|
+
model: The model to start on, by the name :attr:`models` lists.
|
|
342
|
+
effort: The reasoning level to start on.
|
|
343
|
+
permission: The shell permission level: ``deny``, ``ask``, ``safe`` (every command in a sandbox) or ``full``.
|
|
344
|
+
resume: A session id to continue instead of starting a new conversation.
|
|
345
|
+
store, theme_file, theme, themes_dir, pet, pet_position, reasoning_effort, shell_approval, reduced_motion,
|
|
346
|
+
parallel_tool_call, max_iterations, transcript_max_entries, mouse_scroll_speed, tips, agents_md_enabled,
|
|
347
|
+
ask_user_enabled, skills_enabled, skill_roots, skills_scope, mcp_enabled, mcp_config, mcp_scope,
|
|
348
|
+
self_compaction, statusline_hidden, plugins_enabled, disabled_plugins, plugin_settings, plugins_file, plugins_dir, subagent_enabled,
|
|
349
|
+
subagent_max_iterations, subagent_messaging, todowrite_enabled, jit_harness, shell_network,
|
|
350
|
+
sessions_enabled, update_enabled, update_file, harness_dir: One
|
|
351
|
+
:class:`~zettcode.config.ZettCodeConfig` field each, laid over the configuration this client read. Every
|
|
352
|
+
one is named and typed here, so a misspelt setting is a ``TypeError`` from Python rather than a
|
|
353
|
+
configuration error; a value of the wrong type is still a ``ConfigurationError``. ``None`` is a value
|
|
354
|
+
for the fields that take one, and leaving a field out is the only way to keep the configured value.
|
|
355
|
+
|
|
356
|
+
The client defaults to ``sessions_enabled=False`` and ``update_enabled=False``: a program embedding the agent
|
|
357
|
+
neither joins the broker that lets terminal sessions message one another nor checks PyPI. Turning sessions on and
|
|
358
|
+
calling :meth:`join_sessions` makes this client one of the sessions the others can address.
|
|
359
|
+
|
|
360
|
+
Use it as ``async with Client(...) as client`` (or call :meth:`aclose`) so the provider connection and the session
|
|
361
|
+
lock are released. The coding tools work relative to the process's working directory, so creating a client
|
|
362
|
+
changes it to the workspace and closing the last client puts it back; clients on two different workspaces cannot
|
|
363
|
+
be open at once in one process.
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
ConfigurationError: The configuration, or a model, effort or permission chosen from it, is not usable.
|
|
367
|
+
WorkspaceConflictError: Another open client works in a different directory.
|
|
368
|
+
SessionNotFoundError: ``resume`` names a session that cannot be read.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
workspace: Path
|
|
372
|
+
|
|
373
|
+
def __init__(
|
|
374
|
+
self,
|
|
375
|
+
workspace: str | Path | None = None,
|
|
376
|
+
*,
|
|
377
|
+
config: ZettCodeConfig | None = None,
|
|
378
|
+
config_path: str | Path | None = None,
|
|
379
|
+
models: Sequence[ModelConfig | Mapping[str, object]] | None = None,
|
|
380
|
+
model: str | None = None,
|
|
381
|
+
effort: str | None = None,
|
|
382
|
+
permission: str | None = None,
|
|
383
|
+
resume: str | None = None,
|
|
384
|
+
# The configuration fields, named and typed rather than taken as any keyword: a misspelt one is then a
|
|
385
|
+
# TypeError from Python at the call, not a configuration error raised once the client is being built.
|
|
386
|
+
store: str | Path = _UNSET,
|
|
387
|
+
theme_file: str | Path | None = _UNSET,
|
|
388
|
+
theme: str | None = _UNSET,
|
|
389
|
+
themes_dir: str | Path = _UNSET,
|
|
390
|
+
pet: str | None = _UNSET,
|
|
391
|
+
pet_position: tuple[float, float] | None = _UNSET,
|
|
392
|
+
reasoning_effort: ReasoningEffort | str = _UNSET,
|
|
393
|
+
shell_approval: ShellApprovalMode | str = _UNSET,
|
|
394
|
+
reduced_motion: bool = _UNSET,
|
|
395
|
+
parallel_tool_call: bool = _UNSET,
|
|
396
|
+
max_iterations: int = _UNSET,
|
|
397
|
+
transcript_max_entries: int = _UNSET,
|
|
398
|
+
mouse_scroll_speed: float = _UNSET,
|
|
399
|
+
tips: bool = _UNSET,
|
|
400
|
+
agents_md_enabled: bool = _UNSET,
|
|
401
|
+
ask_user_enabled: bool = _UNSET,
|
|
402
|
+
skills_enabled: bool = _UNSET,
|
|
403
|
+
skill_roots: Sequence[str | Path] = _UNSET,
|
|
404
|
+
skills_scope: str = _UNSET,
|
|
405
|
+
mcp_enabled: bool = _UNSET,
|
|
406
|
+
mcp_config: str | Path | None = _UNSET,
|
|
407
|
+
mcp_scope: str = _UNSET,
|
|
408
|
+
self_compaction: bool = _UNSET,
|
|
409
|
+
statusline_hidden: Sequence[str] = _UNSET,
|
|
410
|
+
plugins_enabled: bool = _UNSET,
|
|
411
|
+
disabled_plugins: Sequence[str] = _UNSET,
|
|
412
|
+
plugin_settings: Mapping[str, Mapping[str, object]] = _UNSET,
|
|
413
|
+
plugins_file: str | Path = _UNSET,
|
|
414
|
+
plugins_dir: str | Path = _UNSET,
|
|
415
|
+
subagent_enabled: bool = _UNSET,
|
|
416
|
+
subagent_max_iterations: int = _UNSET,
|
|
417
|
+
subagent_messaging: bool = _UNSET,
|
|
418
|
+
todowrite_enabled: bool = _UNSET,
|
|
419
|
+
jit_harness: bool = _UNSET,
|
|
420
|
+
shell_network: bool = _UNSET,
|
|
421
|
+
sessions_enabled: bool = _UNSET,
|
|
422
|
+
update_enabled: bool = _UNSET,
|
|
423
|
+
update_file: str | Path = _UNSET,
|
|
424
|
+
harness_dir: str | Path = _UNSET,
|
|
425
|
+
) -> None:
|
|
426
|
+
self._closed = False
|
|
427
|
+
self._stream: RunStream | None = None
|
|
428
|
+
self._handles: dict[str, Session] = {}
|
|
429
|
+
self._switching = asyncio.Lock()
|
|
430
|
+
self._messenger: Messenger | None = None
|
|
431
|
+
given: dict[str, object] = {}
|
|
432
|
+
# A plain loop, not a comprehension: a comprehension has a scope of its own before Python 3.12 inlines it,
|
|
433
|
+
# and `locals()` inside one is then the comprehension's, not this constructor's.
|
|
434
|
+
for name in SETTINGS:
|
|
435
|
+
value = locals()[name]
|
|
436
|
+
if value is not _UNSET:
|
|
437
|
+
given[name] = value
|
|
438
|
+
resolved = build_config(workspace, config, config_path, models, given)
|
|
439
|
+
self._register(resolved.workspace)
|
|
440
|
+
# Imported here, not at the top of the module: importing the SDK costs nothing until a client is made.
|
|
441
|
+
from zettcode.engine.agent import ZettCodeAgent
|
|
442
|
+
from zettcode.engine.core import ZettCodeEngine
|
|
443
|
+
|
|
444
|
+
from .builtins import load_agent_plugins
|
|
445
|
+
from .messenger import Messenger
|
|
446
|
+
from .translation import AgentBridge
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
runtime = ZettCodeEngine.preview(resolved, load_agent_plugins, messenger=Messenger.create(resolved))
|
|
450
|
+
except BaseException:
|
|
451
|
+
self._unregister()
|
|
452
|
+
raise
|
|
453
|
+
self._agent = ZettCodeAgent(runtime)
|
|
454
|
+
self._bridge = AgentBridge(self._agent)
|
|
455
|
+
self._fresh = {runtime.session_id}
|
|
456
|
+
try:
|
|
457
|
+
if model is not None:
|
|
458
|
+
self.use_model(model)
|
|
459
|
+
if effort is not None:
|
|
460
|
+
self.use_effort(effort)
|
|
461
|
+
if permission is not None:
|
|
462
|
+
# The client's setting, not a decision about the session: a prompt that asks for `deny` must not
|
|
463
|
+
# leave the session on `deny` for the next reader of it.
|
|
464
|
+
try:
|
|
465
|
+
self._agent.runtime.use_permission(permission, remember=False)
|
|
466
|
+
except ValueError as error:
|
|
467
|
+
raise ConfigurationError(str(error)) from error
|
|
468
|
+
if resume is not None:
|
|
469
|
+
self._resume_now(resume)
|
|
470
|
+
except BaseException:
|
|
471
|
+
self._dispose()
|
|
472
|
+
raise
|
|
473
|
+
self.sessions = Sessions(self)
|
|
474
|
+
#: One reader for the workspace's ledger, so a second look at the usage reads only what changed.
|
|
475
|
+
self._ledger_reader: Any = None
|
|
476
|
+
|
|
477
|
+
# -- process-wide bookkeeping --
|
|
478
|
+
@classmethod
|
|
479
|
+
def _around(cls, agent: ZettCodeAgent) -> Client:
|
|
480
|
+
"""Return a client over an agent that a front end built itself, a subclass with parts of its own.
|
|
481
|
+
|
|
482
|
+
For the terminal, which adds slash commands and preferences to the engine's agent and then drives that agent
|
|
483
|
+
through the SDK like any other program. It is not public: a program builds a client from a configuration. The
|
|
484
|
+
front end keeps closing its agent, so the client takes no part in the process-wide bookkeeping of open
|
|
485
|
+
workspaces (the agent's engine already moved the process into its workspace).
|
|
486
|
+
"""
|
|
487
|
+
from .translation import AgentBridge
|
|
488
|
+
|
|
489
|
+
client = cls.__new__(cls)
|
|
490
|
+
client._closed = False
|
|
491
|
+
client._stream = None
|
|
492
|
+
client._handles = {}
|
|
493
|
+
client._switching = asyncio.Lock()
|
|
494
|
+
client._messenger = None
|
|
495
|
+
client.workspace = agent.runtime.config.workspace
|
|
496
|
+
client._agent = agent
|
|
497
|
+
client._bridge = AgentBridge(agent)
|
|
498
|
+
client._fresh = {agent.runtime.session_id}
|
|
499
|
+
client.sessions = Sessions(client)
|
|
500
|
+
client._ledger_reader = None
|
|
501
|
+
return client
|
|
502
|
+
|
|
503
|
+
@property
|
|
504
|
+
def _link(self) -> Messenger | None:
|
|
505
|
+
"""Return the runtime's line to the other sessions.
|
|
506
|
+
|
|
507
|
+
The engine carries it as ``object``: it does not import the broker, so whoever builds the runtime owns the
|
|
508
|
+
type. This is that boundary, in the one place that reads it.
|
|
509
|
+
"""
|
|
510
|
+
return cast("Messenger | None", self._agent.runtime.messenger)
|
|
511
|
+
|
|
512
|
+
# -- process-wide bookkeeping --
|
|
513
|
+
def _register(self, workspace: Path) -> None:
|
|
514
|
+
"""Refuse a second workspace in this process, and remember the working directory to restore."""
|
|
515
|
+
for other in list(_OPEN):
|
|
516
|
+
if not other._closed and other.workspace != workspace:
|
|
517
|
+
raise WorkspaceConflictError(
|
|
518
|
+
f"A client for {other.workspace} is open; this process's working directory is shared, "
|
|
519
|
+
f"so {workspace} must wait until it is closed"
|
|
520
|
+
)
|
|
521
|
+
self.workspace = workspace
|
|
522
|
+
if not _OPEN:
|
|
523
|
+
_ORIGINAL_CWD[:] = [os.getcwd()]
|
|
524
|
+
_OPEN.add(self)
|
|
525
|
+
|
|
526
|
+
def _unregister(self) -> None:
|
|
527
|
+
_OPEN.discard(self)
|
|
528
|
+
if not _OPEN and _ORIGINAL_CWD:
|
|
529
|
+
original = _ORIGINAL_CWD.pop()
|
|
530
|
+
try:
|
|
531
|
+
os.chdir(original)
|
|
532
|
+
except OSError:
|
|
533
|
+
pass # the directory it started in is gone; staying in the workspace is the lesser evil
|
|
534
|
+
|
|
535
|
+
def _dispose(self) -> None:
|
|
536
|
+
"""Release what a client holds without awaiting anything; the path a failed construction takes."""
|
|
537
|
+
self._closed = True
|
|
538
|
+
try:
|
|
539
|
+
self._agent.runtime.session_locks.release()
|
|
540
|
+
finally:
|
|
541
|
+
self._unregister()
|
|
542
|
+
|
|
543
|
+
# -- the life of the client --
|
|
544
|
+
async def __aenter__(self) -> Client:
|
|
545
|
+
return self
|
|
546
|
+
|
|
547
|
+
async def __aexit__(
|
|
548
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
549
|
+
) -> None:
|
|
550
|
+
await self.aclose()
|
|
551
|
+
|
|
552
|
+
@property
|
|
553
|
+
def closed(self) -> bool:
|
|
554
|
+
"""Return whether :meth:`aclose` has run."""
|
|
555
|
+
return self._closed
|
|
556
|
+
|
|
557
|
+
async def aclose(self) -> None:
|
|
558
|
+
"""Stop a run in progress and release the provider connections, the session store and the session lock.
|
|
559
|
+
|
|
560
|
+
Safe to call twice, and to call while a run is in progress: the run is closed first.
|
|
561
|
+
"""
|
|
562
|
+
if self._closed:
|
|
563
|
+
return
|
|
564
|
+
self._closed = True
|
|
565
|
+
messenger, self._messenger = self._messenger, None
|
|
566
|
+
stream = self._stream
|
|
567
|
+
try:
|
|
568
|
+
if messenger is not None:
|
|
569
|
+
await messenger.close()
|
|
570
|
+
if stream is not None:
|
|
571
|
+
await stream.aclose()
|
|
572
|
+
finally:
|
|
573
|
+
try:
|
|
574
|
+
await self._agent.runtime.aclose()
|
|
575
|
+
finally:
|
|
576
|
+
try:
|
|
577
|
+
self._agent.runtime.session_locks.release()
|
|
578
|
+
finally:
|
|
579
|
+
self._unregister()
|
|
580
|
+
|
|
581
|
+
def _check_open(self) -> None:
|
|
582
|
+
if self._closed:
|
|
583
|
+
raise ClientClosedError("This client was closed")
|
|
584
|
+
|
|
585
|
+
@property
|
|
586
|
+
def busy(self) -> bool:
|
|
587
|
+
"""Return whether a run is in progress on this client.
|
|
588
|
+
|
|
589
|
+
One run at a time is the client's contract: a turn, a side question or a resume takes it until it ends, and
|
|
590
|
+
asking for another raises :class:`SessionBusyError`. A front end that keeps a busy flag of its own wants this
|
|
591
|
+
as well, because something other than that front end can take the slot — a plugin asking the model for itself,
|
|
592
|
+
as a recap does.
|
|
593
|
+
"""
|
|
594
|
+
return self._stream is not None
|
|
595
|
+
|
|
596
|
+
def _check_free(self) -> None:
|
|
597
|
+
if self._stream is not None:
|
|
598
|
+
raise SessionBusyError(
|
|
599
|
+
"A run is already in progress on this client. Wait for it, or close it: leave the `async with "
|
|
600
|
+
"session.stream(...)` block, `await run.aclose()`, or cancel the task"
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
def _claim(self, stream: RunStream) -> None:
|
|
604
|
+
if self._stream is not None and self._stream is not stream:
|
|
605
|
+
self._check_free()
|
|
606
|
+
self._stream = stream
|
|
607
|
+
self._describe(busy=True)
|
|
608
|
+
self._stream_mark("begin")
|
|
609
|
+
|
|
610
|
+
def _release(self, stream: RunStream) -> None:
|
|
611
|
+
if self._stream is stream:
|
|
612
|
+
self._stream = None
|
|
613
|
+
self._describe(busy=False)
|
|
614
|
+
self._stream_mark("end")
|
|
615
|
+
|
|
616
|
+
# -- sessions --
|
|
617
|
+
@property
|
|
618
|
+
def config(self) -> ZettCodeConfig:
|
|
619
|
+
"""Return the configuration in force."""
|
|
620
|
+
return self._agent.runtime.config
|
|
621
|
+
|
|
622
|
+
@property
|
|
623
|
+
def session(self) -> Session:
|
|
624
|
+
"""Return the current conversation, which a new client starts empty."""
|
|
625
|
+
return self._handle(self._agent.session_id)
|
|
626
|
+
|
|
627
|
+
# -- the other sessions --
|
|
628
|
+
async def join_sessions(self, on_message: Callable[[IncomingMessage], Receipt], *, direct: bool = False) -> bool:
|
|
629
|
+
"""Join the session broker, so other ZettCode sessions can find this one and send it messages.
|
|
630
|
+
|
|
631
|
+
The terminal joins it too; a program embedding the agent does not unless it is built with
|
|
632
|
+
``sessions_enabled=True``, which is what this call needs to have anything to join.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
on_message: Called on the event loop with every message another session sends. It must not block; what
|
|
636
|
+
it returns is the answer the sender's agent is given — whether the message was taken, and whether
|
|
637
|
+
the turn it starts has begun or is waiting.
|
|
638
|
+
direct: Say that this client runs a message with ``IncomingMessage.direct`` set as its reader's own
|
|
639
|
+
prompt, and answers it with :meth:`send_back`; a chat window then talks to it as a reader would.
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
Whether this client is reachable now; ``False`` when sessions are switched off for it.
|
|
643
|
+
|
|
644
|
+
Raises:
|
|
645
|
+
ClientClosedError: The client was closed.
|
|
646
|
+
"""
|
|
647
|
+
self._check_open()
|
|
648
|
+
messenger = self._link
|
|
649
|
+
if messenger is None:
|
|
650
|
+
return False
|
|
651
|
+
self._messenger = messenger
|
|
652
|
+
messenger.direct = direct
|
|
653
|
+
messenger.receiver = lambda relayed: self._receipt(
|
|
654
|
+
on_message(
|
|
655
|
+
IncomingMessage(
|
|
656
|
+
sender_id=relayed.sender_id,
|
|
657
|
+
sender=relayed.sender,
|
|
658
|
+
text=relayed.text,
|
|
659
|
+
via=relayed.via,
|
|
660
|
+
hops=relayed.hops,
|
|
661
|
+
direct=relayed.direct,
|
|
662
|
+
mode=relayed.mode,
|
|
663
|
+
group=relayed.group,
|
|
664
|
+
images=tuple(Image(item.data, item.media_type) for item in relayed.images),
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
self._describe(busy=self._stream is not None)
|
|
669
|
+
await messenger.start()
|
|
670
|
+
return True
|
|
671
|
+
|
|
672
|
+
def relay(
|
|
673
|
+
self,
|
|
674
|
+
message: IncomingMessage,
|
|
675
|
+
*,
|
|
676
|
+
on_approval: ApprovalHandler | None = None,
|
|
677
|
+
on_question: QuestionHandler | None = None,
|
|
678
|
+
on_event: EventCallback | None = None,
|
|
679
|
+
timeout: float | None = None,
|
|
680
|
+
raw: bool = False,
|
|
681
|
+
keep_events: bool = False,
|
|
682
|
+
defer_answers: bool = False,
|
|
683
|
+
) -> RunStream:
|
|
684
|
+
"""Run one turn for a message another session sent, as the terminal does when one arrives.
|
|
685
|
+
|
|
686
|
+
The message is what the model reads, framed with where it came from; the answer belongs to this session's
|
|
687
|
+
own reader, and the sender sees nothing of it unless the model replies with ``send_message``.
|
|
688
|
+
"""
|
|
689
|
+
return self.session.relay(
|
|
690
|
+
message,
|
|
691
|
+
on_approval=on_approval,
|
|
692
|
+
on_question=on_question,
|
|
693
|
+
on_event=on_event,
|
|
694
|
+
timeout=timeout,
|
|
695
|
+
raw=raw,
|
|
696
|
+
keep_events=keep_events,
|
|
697
|
+
defer_answers=defer_answers,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
@property
|
|
701
|
+
def reachable(self) -> bool:
|
|
702
|
+
"""Return whether the session broker is connected right now: the line every send and every peer rides on."""
|
|
703
|
+
messenger = self._link
|
|
704
|
+
return messenger is not None and messenger.connected
|
|
705
|
+
|
|
706
|
+
@property
|
|
707
|
+
def handle(self) -> str:
|
|
708
|
+
"""Return the name the other sessions call this one by, or an empty string before it is registered."""
|
|
709
|
+
messenger = self._link
|
|
710
|
+
return messenger.handle if messenger is not None else ""
|
|
711
|
+
|
|
712
|
+
def peers(self) -> tuple[Peer, ...]:
|
|
713
|
+
"""Return the other sessions that are registered with the broker now, from memory (nothing is asked).
|
|
714
|
+
|
|
715
|
+
A front end that lists who can be written to repaints from :meth:`watch_peers` and reads this. It is empty
|
|
716
|
+
without a broker, and a client that never joined the sessions sees nobody.
|
|
717
|
+
"""
|
|
718
|
+
messenger = self._link
|
|
719
|
+
if messenger is None:
|
|
720
|
+
return ()
|
|
721
|
+
return tuple(
|
|
722
|
+
Peer(entry.session_id, entry.handle, entry.title, entry.workspace, entry.status, entry.kind, entry.direct)
|
|
723
|
+
for entry in messenger.peers()
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
def watch_peers(self, callback: Callable[[], None] | None) -> None:
|
|
727
|
+
"""Call ``callback`` on the event loop whenever the peers change or the broker is lost or found again.
|
|
728
|
+
|
|
729
|
+
It takes no arguments: read :meth:`peers` and :attr:`reachable` for the state. ``None`` stops the calls.
|
|
730
|
+
There is one listener; a second call replaces the first.
|
|
731
|
+
"""
|
|
732
|
+
messenger = self._link
|
|
733
|
+
if messenger is None:
|
|
734
|
+
return
|
|
735
|
+
messenger.on_change = callback
|
|
736
|
+
messenger.on_problem = None if callback is None else (lambda _said: callback())
|
|
737
|
+
|
|
738
|
+
def watch_traffic(self, callback: Callable[[dict[str, Any]], None] | None) -> None:
|
|
739
|
+
"""Call ``callback`` on the event loop with every message that passes through the broker (``None`` stops).
|
|
740
|
+
|
|
741
|
+
A line is ``{"kind": "message", "message": {...}}`` for a message that was handed over (who sent it, who
|
|
742
|
+
took it, when, and the text), or ``{"kind": "graph"}`` when who is here or which groups exist changed. This
|
|
743
|
+
is a view of the whole broker for a front end that draws who talks to whom; it is not this session's mail.
|
|
744
|
+
"""
|
|
745
|
+
messenger = self._link
|
|
746
|
+
if messenger is not None:
|
|
747
|
+
messenger.client.observe(callback)
|
|
748
|
+
|
|
749
|
+
async def traffic_graph(self) -> dict[str, Any]:
|
|
750
|
+
"""Return the broker's picture: ``sessions``, ``groups``, ``edges`` (messages per pair) and ``recent``.
|
|
751
|
+
|
|
752
|
+
Raises:
|
|
753
|
+
ConfigurationError: This client did not join the sessions.
|
|
754
|
+
SendError: The broker is not reachable.
|
|
755
|
+
"""
|
|
756
|
+
return await self._traffic("graph")
|
|
757
|
+
|
|
758
|
+
async def traffic_older(self, before: int, limit: int = 50, group: str = "") -> dict[str, Any]:
|
|
759
|
+
"""Return the stored messages before the one with id ``before``, oldest first, and whether ``more`` remain.
|
|
760
|
+
|
|
761
|
+
With ``group`` they are that group's own messages, and ``before`` of ``0`` asks for its newest.
|
|
762
|
+
"""
|
|
763
|
+
return await self._traffic("older", before, limit, group)
|
|
764
|
+
|
|
765
|
+
async def traffic_timeline(self, since: float, until: float, limit: int = 500) -> dict[str, Any]:
|
|
766
|
+
"""Return the messages the broker stored between two times (epoch seconds), oldest first.
|
|
767
|
+
|
|
768
|
+
Nothing is sent again: this is what a front end replays on screen. The answer also says ``span`` (the times of
|
|
769
|
+
the oldest and newest stored message) and ``now``.
|
|
770
|
+
"""
|
|
771
|
+
return await self._traffic("timeline", since, until, limit)
|
|
772
|
+
|
|
773
|
+
async def _traffic(self, what: str, *args: Any) -> dict[str, Any]:
|
|
774
|
+
from zettcode.broker.models import SendError as BrokerSendError
|
|
775
|
+
|
|
776
|
+
self._check_open()
|
|
777
|
+
messenger = self._messenger
|
|
778
|
+
if messenger is None:
|
|
779
|
+
raise ConfigurationError("This client has not joined the sessions; call join_sessions first")
|
|
780
|
+
try:
|
|
781
|
+
client = messenger.client
|
|
782
|
+
if what == "graph":
|
|
783
|
+
return await client.graph()
|
|
784
|
+
return await (client.older(*args) if what == "older" else client.timeline(*args))
|
|
785
|
+
except BrokerSendError as error:
|
|
786
|
+
raise SendError(error.reason, str(error)) from error
|
|
787
|
+
|
|
788
|
+
def watch(self, session_id: str) -> None:
|
|
789
|
+
"""Ask to be told, as it happens, what the turns of another session say (see :meth:`watch_streams`).
|
|
790
|
+
|
|
791
|
+
Kept while the client lives, and asked again when the broker connection is lost and found. Watching costs the
|
|
792
|
+
watched session nothing while it is idle, and a few lines a second while it works.
|
|
793
|
+
"""
|
|
794
|
+
messenger = self._link
|
|
795
|
+
if messenger is not None:
|
|
796
|
+
messenger.watch(session_id)
|
|
797
|
+
|
|
798
|
+
def unwatch(self, session_id: str) -> None:
|
|
799
|
+
"""Stop being told what a session's turns say."""
|
|
800
|
+
messenger = self._link
|
|
801
|
+
if messenger is not None:
|
|
802
|
+
messenger.unwatch(session_id)
|
|
803
|
+
|
|
804
|
+
def watch_streams(self, callback: Callable[[StreamEvent], None] | None) -> None:
|
|
805
|
+
"""Call ``callback`` on the event loop with every :class:`~zettcode.sdk.types.StreamEvent` of a watched session.
|
|
806
|
+
|
|
807
|
+
What is streamed is a view of the turn, not a record: pieces can be missed by a reader that falls behind, and
|
|
808
|
+
the stored conversation (:meth:`Sessions.history_of`) is what is true once the turn has ended. ``None`` stops the
|
|
809
|
+
calls; there is one listener.
|
|
810
|
+
"""
|
|
811
|
+
messenger = self._link
|
|
812
|
+
if messenger is None:
|
|
813
|
+
return
|
|
814
|
+
if callback is None:
|
|
815
|
+
messenger.on_stream = None
|
|
816
|
+
return
|
|
817
|
+
|
|
818
|
+
def relay(session_id: str, wire: dict[str, Any]) -> None:
|
|
819
|
+
kind = wire.get("t")
|
|
820
|
+
if not isinstance(kind, str):
|
|
821
|
+
return
|
|
822
|
+
text, call_id, name, brief, ok = (wire.get(key) for key in ("text", "id", "name", "brief", "ok"))
|
|
823
|
+
callback(
|
|
824
|
+
StreamEvent(
|
|
825
|
+
session_id,
|
|
826
|
+
kind,
|
|
827
|
+
text if isinstance(text, str) else "",
|
|
828
|
+
call_id if isinstance(call_id, str) else "",
|
|
829
|
+
name if isinstance(name, str) else "",
|
|
830
|
+
brief if isinstance(brief, str) else "",
|
|
831
|
+
ok if isinstance(ok, bool) else None,
|
|
832
|
+
)
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
messenger.on_stream = relay
|
|
836
|
+
|
|
837
|
+
def _stream_event(self, event: Any) -> None:
|
|
838
|
+
"""Say what a running turn just did to whoever watches it; nothing happens when nobody does."""
|
|
839
|
+
messenger = self._link
|
|
840
|
+
if messenger is None or not messenger.client.watched:
|
|
841
|
+
return
|
|
842
|
+
if isinstance(event, TextDelta):
|
|
843
|
+
messenger.stream("text", text=event.text)
|
|
844
|
+
elif isinstance(event, ReasoningDelta):
|
|
845
|
+
messenger.stream("thinking")
|
|
846
|
+
elif isinstance(event, ToolStarted):
|
|
847
|
+
messenger.stream("tool", id=event.call_id, name=event.name, brief=brief_of(event.arguments))
|
|
848
|
+
elif isinstance(event, ToolFinished):
|
|
849
|
+
messenger.stream("tool_end", id=event.call_id, ok=event.ok)
|
|
850
|
+
|
|
851
|
+
def _stream_mark(self, kind: str) -> None:
|
|
852
|
+
messenger = self._link
|
|
853
|
+
if messenger is not None:
|
|
854
|
+
messenger.stream(kind)
|
|
855
|
+
|
|
856
|
+
@property
|
|
857
|
+
def send_limits(self) -> SendLimits:
|
|
858
|
+
"""Return what the broker accepts from a sender, as it announced it (the defaults before it has)."""
|
|
859
|
+
messenger = self._link
|
|
860
|
+
if messenger is None:
|
|
861
|
+
return SendLimits()
|
|
862
|
+
limits = messenger.client.limits
|
|
863
|
+
return SendLimits(limits.max_text, limits.rate_limit, limits.rate_window, limits.max_hops)
|
|
864
|
+
|
|
865
|
+
async def send(
|
|
866
|
+
self, to: str, text: str, *, direct: bool = False, mode: str = MODE_STEER, images: Sequence[Image] = ()
|
|
867
|
+
) -> Delivered:
|
|
868
|
+
"""Send a message to another ZettCode session without going through this session's model.
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
to: The session's handle, or its id.
|
|
872
|
+
text: What to say; the sender's own words, so the receiving agent is told its reader typed it. It may be
|
|
873
|
+
empty when ``images`` is not.
|
|
874
|
+
direct: Ask a session that can (``Peer.direct``) to run the text as its own reader's prompt, and to send
|
|
875
|
+
its final answer back with :meth:`send_back`. For any other session the flag is ignored.
|
|
876
|
+
mode: What a busy recipient does with it: ``"steer"`` (the default) hands it to the request that is
|
|
877
|
+
running so it changes course, and ``"followup"`` waits until that request ends. An idle recipient
|
|
878
|
+
starts a turn either way.
|
|
879
|
+
images: Pictures to send with it (at most four, 4 MiB each, 8 MiB together). Only a ``direct`` message
|
|
880
|
+
may carry them: the session that takes it puts them into its own prompt, and is refused one whose
|
|
881
|
+
model does not read images.
|
|
882
|
+
|
|
883
|
+
Returns:
|
|
884
|
+
What the receiving session answered: who took it, and whether the turn started, was steered into the
|
|
885
|
+
running request, or is queued.
|
|
886
|
+
|
|
887
|
+
Raises:
|
|
888
|
+
ConfigurationError: This client did not join the sessions, so it has no line to send on.
|
|
889
|
+
SendError: Nobody was reached, and why (``reason``).
|
|
890
|
+
"""
|
|
891
|
+
from zettcode.broker.protocol import VIA_USER, ImageData
|
|
892
|
+
|
|
893
|
+
self._check_open()
|
|
894
|
+
messenger = self._messenger
|
|
895
|
+
if messenger is None:
|
|
896
|
+
raise ConfigurationError(
|
|
897
|
+
"This client has not joined the sessions; build it with sessions_enabled=True and call join_sessions"
|
|
898
|
+
)
|
|
899
|
+
from zettcode.broker.models import SendError as BrokerSendError
|
|
900
|
+
|
|
901
|
+
try:
|
|
902
|
+
delivered = await messenger.send(
|
|
903
|
+
to,
|
|
904
|
+
text,
|
|
905
|
+
via=VIA_USER,
|
|
906
|
+
direct=direct,
|
|
907
|
+
mode=mode,
|
|
908
|
+
images=[ImageData(item.media_type, bytes(item.data)) for item in images],
|
|
909
|
+
)
|
|
910
|
+
except BrokerSendError as error:
|
|
911
|
+
raise SendError(error.reason, str(error)) from error
|
|
912
|
+
return Delivered(delivered.to.handle, delivered.disposition, delivered.direct)
|
|
913
|
+
|
|
914
|
+
async def send_group(self, group: str, text: str, *, mode: str = MODE_FOLLOWUP) -> list[Delivered]:
|
|
915
|
+
"""Write to a group chat as this session's reader and return who took it.
|
|
916
|
+
|
|
917
|
+
Every other member that is connected is handed the message. The session must be a member (see
|
|
918
|
+
:meth:`join_group`).
|
|
919
|
+
|
|
920
|
+
Raises:
|
|
921
|
+
ConfigurationError: This client has not joined the sessions.
|
|
922
|
+
SendError: The group is unknown, this session is not in it, nobody else is connected, or nobody took it.
|
|
923
|
+
"""
|
|
924
|
+
self._check_open()
|
|
925
|
+
messenger = self._messenger
|
|
926
|
+
if messenger is None:
|
|
927
|
+
raise ConfigurationError(
|
|
928
|
+
"This client has not joined the sessions; build it with sessions_enabled=True and call join_sessions"
|
|
929
|
+
)
|
|
930
|
+
from zettcode.broker.models import SendError as BrokerSendError
|
|
931
|
+
from zettcode.broker.protocol import VIA_USER
|
|
932
|
+
|
|
933
|
+
try:
|
|
934
|
+
reply = await messenger.send_group(group, text, via=VIA_USER, mode=mode)
|
|
935
|
+
except BrokerSendError as error:
|
|
936
|
+
raise SendError(error.reason, str(error)) from error
|
|
937
|
+
return [Delivered(peer.handle, disposition) for peer, disposition in reply.delivered]
|
|
938
|
+
|
|
939
|
+
async def join_group(self, group: str) -> None:
|
|
940
|
+
"""Make this session a member of a group chat, so that it can write to it.
|
|
941
|
+
|
|
942
|
+
Raises:
|
|
943
|
+
ConfigurationError: This client has not joined the sessions.
|
|
944
|
+
SendError: The group is unknown or full.
|
|
945
|
+
"""
|
|
946
|
+
self._check_open()
|
|
947
|
+
messenger = self._messenger
|
|
948
|
+
if messenger is None:
|
|
949
|
+
raise ConfigurationError(
|
|
950
|
+
"This client has not joined the sessions; build it with sessions_enabled=True and call join_sessions"
|
|
951
|
+
)
|
|
952
|
+
from zettcode.broker.models import SendError as BrokerSendError
|
|
953
|
+
|
|
954
|
+
try:
|
|
955
|
+
await messenger.join_group(group)
|
|
956
|
+
except BrokerSendError as error:
|
|
957
|
+
raise SendError(error.reason, str(error)) from error
|
|
958
|
+
|
|
959
|
+
async def send_back(self, message: IncomingMessage, text: str) -> Delivered | None:
|
|
960
|
+
"""Send a turn's answer — or the reason it failed — back to the message's sender.
|
|
961
|
+
|
|
962
|
+
A chat window's ``direct`` message is the common case; a daemon also answers the agent that messaged it when
|
|
963
|
+
its turn failed. It goes as this session's agent speaking (not the reader), carrying the message's hop count
|
|
964
|
+
on, so two sessions cannot keep answering each other. A sender that has gone, a refusal or a lost connection
|
|
965
|
+
is not an error: the text is in this session's own conversation whatever happens to this copy, so ``None``
|
|
966
|
+
says it was not sent — an external sender has no session to receive it.
|
|
967
|
+
"""
|
|
968
|
+
from zettcode.broker.models import SendError as BrokerSendError
|
|
969
|
+
from zettcode.broker.protocol import VIA_AGENT
|
|
970
|
+
|
|
971
|
+
messenger = self._messenger
|
|
972
|
+
if messenger is None or not text.strip():
|
|
973
|
+
return None
|
|
974
|
+
try:
|
|
975
|
+
limit = messenger.client.limits.max_text
|
|
976
|
+
# An answer is not "change what you are doing": it must never steer the recipient's running turn.
|
|
977
|
+
delivered = await messenger.client.send(
|
|
978
|
+
message.sender_id, text[:limit], hops=message.hops, via=VIA_AGENT, mode=MODE_FOLLOWUP
|
|
979
|
+
)
|
|
980
|
+
except BrokerSendError:
|
|
981
|
+
return None
|
|
982
|
+
return Delivered(delivered.to.handle, delivered.disposition)
|
|
983
|
+
|
|
984
|
+
@staticmethod
|
|
985
|
+
def _receipt(receipt: Receipt) -> BrokerReceipt:
|
|
986
|
+
"""Return the broker's copy of what the program answered a message with."""
|
|
987
|
+
from zettcode.broker.models import Receipt as BrokerReceipt
|
|
988
|
+
|
|
989
|
+
return BrokerReceipt(receipt.accepted, receipt.reason, receipt.disposition, receipt.busy)
|
|
990
|
+
|
|
991
|
+
def _describe(self, *, busy: bool) -> None:
|
|
992
|
+
"""Tell the broker what this session is, when there is a connection to tell it."""
|
|
993
|
+
messenger = self._messenger
|
|
994
|
+
if messenger is None:
|
|
995
|
+
return
|
|
996
|
+
session = self.session
|
|
997
|
+
messenger.describe(session.id, session.title, busy=busy)
|
|
998
|
+
|
|
999
|
+
def _handle(self, session_id: str) -> Session:
|
|
1000
|
+
handle = self._handles.get(session_id)
|
|
1001
|
+
if handle is None:
|
|
1002
|
+
handle = self._handles[session_id] = Session(self, session_id)
|
|
1003
|
+
return handle
|
|
1004
|
+
|
|
1005
|
+
def _resume_now(self, session_id: str) -> None:
|
|
1006
|
+
"""Continue a stored session at construction, where nothing else can be running."""
|
|
1007
|
+
try:
|
|
1008
|
+
stored = self._agent.load_session(session_id)
|
|
1009
|
+
except (ValueError, OSError) as error:
|
|
1010
|
+
raise SessionNotFoundError(f"Cannot open session {session_id!r}: {error}") from error
|
|
1011
|
+
self._agent.install_session(stored)
|
|
1012
|
+
|
|
1013
|
+
async def _switch_to(self, session_id: str, by: RunStream | None = None) -> None:
|
|
1014
|
+
"""Make ``session_id`` the conversation the agent works on, reading it from the store if it is stored.
|
|
1015
|
+
|
|
1016
|
+
``by`` is the run that is starting and already holds the busy slot; it may switch, nobody else may.
|
|
1017
|
+
"""
|
|
1018
|
+
self._check_open()
|
|
1019
|
+
async with self._switching:
|
|
1020
|
+
if self._agent.session_id == session_id:
|
|
1021
|
+
return
|
|
1022
|
+
if self._stream is not None and self._stream is not by:
|
|
1023
|
+
self._check_free()
|
|
1024
|
+
try:
|
|
1025
|
+
stored = await asyncio.to_thread(self._agent.load_session, session_id)
|
|
1026
|
+
except (ValueError, OSError) as error:
|
|
1027
|
+
if session_id not in self._fresh:
|
|
1028
|
+
raise SessionNotFoundError(f"Cannot open session {session_id!r}: {error}") from error
|
|
1029
|
+
self._agent.use_session(session_id) # started here and never written: there is nothing to read
|
|
1030
|
+
return
|
|
1031
|
+
self._agent.install_session(stored)
|
|
1032
|
+
|
|
1033
|
+
# -- what to run on --
|
|
1034
|
+
@property
|
|
1035
|
+
def models(self) -> tuple[ModelInfo, ...]:
|
|
1036
|
+
"""Return the configured models; the one the next run uses is marked ``active``."""
|
|
1037
|
+
from .models import model_info
|
|
1038
|
+
|
|
1039
|
+
active = self._agent.active_model
|
|
1040
|
+
return tuple(model_info(entry, active=entry is active or entry == active) for entry in self._agent.models)
|
|
1041
|
+
|
|
1042
|
+
@property
|
|
1043
|
+
def model(self) -> ModelInfo:
|
|
1044
|
+
"""Return the model the next run uses.
|
|
1045
|
+
|
|
1046
|
+
Raises:
|
|
1047
|
+
ConfigurationError: No model is configured yet. A front end that paints before there is one asks
|
|
1048
|
+
:attr:`active_model`, which answers ``None`` instead.
|
|
1049
|
+
"""
|
|
1050
|
+
from zettcode.config import NO_MODEL_REFUSAL
|
|
1051
|
+
|
|
1052
|
+
from .models import model_info
|
|
1053
|
+
|
|
1054
|
+
entry = self._agent.active_model
|
|
1055
|
+
if entry is None:
|
|
1056
|
+
raise ConfigurationError(NO_MODEL_REFUSAL)
|
|
1057
|
+
return model_info(entry, active=True)
|
|
1058
|
+
|
|
1059
|
+
@property
|
|
1060
|
+
def active_model(self) -> ModelInfo | None:
|
|
1061
|
+
"""Return the model the next run uses, or ``None`` on a machine that has none configured yet."""
|
|
1062
|
+
from .models import model_info
|
|
1063
|
+
|
|
1064
|
+
entry = self._agent.active_model
|
|
1065
|
+
return None if entry is None else model_info(entry, active=True)
|
|
1066
|
+
|
|
1067
|
+
def use_model(self, name: str | ModelInfo) -> ModelInfo:
|
|
1068
|
+
"""Choose the model for later runs, by the name :attr:`models` lists.
|
|
1069
|
+
|
|
1070
|
+
Raises:
|
|
1071
|
+
ConfigurationError: No such model, or two share the name (give one a ``display_model``).
|
|
1072
|
+
"""
|
|
1073
|
+
self._check_open()
|
|
1074
|
+
wanted = name.name if isinstance(name, ModelInfo) else name
|
|
1075
|
+
try:
|
|
1076
|
+
self._agent.use_model(wanted)
|
|
1077
|
+
except ValueError as error:
|
|
1078
|
+
raise ConfigurationError(f"{error}. Configured: {', '.join(info.name for info in self.models)}") from error
|
|
1079
|
+
return self.model
|
|
1080
|
+
|
|
1081
|
+
# -- the models the configuration file lists, edited while the client runs --
|
|
1082
|
+
@property
|
|
1083
|
+
def config_file(self) -> Path:
|
|
1084
|
+
"""Return the configuration file the models are read from and written to."""
|
|
1085
|
+
return self._agent.config_file
|
|
1086
|
+
|
|
1087
|
+
def model_entries(self) -> tuple[tuple[ModelInfo, Mapping[str, object]], ...]:
|
|
1088
|
+
"""Return each model the file lists with the keys the file writes for it, in the file's order.
|
|
1089
|
+
|
|
1090
|
+
The file is the source of truth for editing, not the models the client started with: a person may have changed
|
|
1091
|
+
it by hand since. The keys are what is *written*: a token left to an environment variable is absent, and the
|
|
1092
|
+
token a model has is never in them.
|
|
1093
|
+
|
|
1094
|
+
Raises:
|
|
1095
|
+
ConfigurationError: The file cannot be read, is not valid, or lays its models out in a way that cannot be
|
|
1096
|
+
changed safely.
|
|
1097
|
+
"""
|
|
1098
|
+
from .models import model_info
|
|
1099
|
+
|
|
1100
|
+
self._check_open()
|
|
1101
|
+
active = self._agent.active_model
|
|
1102
|
+
try:
|
|
1103
|
+
found = self._agent.file_models()
|
|
1104
|
+
except ValueError as error:
|
|
1105
|
+
raise ConfigurationError(str(error)) from error
|
|
1106
|
+
|
|
1107
|
+
def is_active(model: Any) -> bool:
|
|
1108
|
+
# The file's model and the one in use are read separately, so they are the same entry by what names it
|
|
1109
|
+
# (the token may differ, e.g. when it comes from the environment), not by being the same object.
|
|
1110
|
+
if active is None:
|
|
1111
|
+
return False
|
|
1112
|
+
return (model.shown_name, model.model, model.base_url) == (active.shown_name, active.model, active.base_url)
|
|
1113
|
+
|
|
1114
|
+
return tuple((model_info(model, active=is_active(model)), entry) for model, entry in found)
|
|
1115
|
+
|
|
1116
|
+
def add_model(self, entry: Mapping[str, object]) -> ModelInfo:
|
|
1117
|
+
"""Add one model to the file and to this client, and return it. The model in use does not change.
|
|
1118
|
+
|
|
1119
|
+
The entry uses the file's own keys (see :func:`zettcode.sdk.add_model`). The file is written first, so a write
|
|
1120
|
+
that fails leaves the client as it was.
|
|
1121
|
+
|
|
1122
|
+
Raises:
|
|
1123
|
+
ConfigurationError: The entry is not a model that can be called, or the file cannot be edited.
|
|
1124
|
+
"""
|
|
1125
|
+
from .models import model_info
|
|
1126
|
+
|
|
1127
|
+
self._check_open()
|
|
1128
|
+
try:
|
|
1129
|
+
return model_info(self._agent.add_configured_model(entry))
|
|
1130
|
+
except ValueError as error:
|
|
1131
|
+
raise ConfigurationError(str(error)) from error
|
|
1132
|
+
|
|
1133
|
+
def update_model(self, which: str | int, changes: Mapping[str, object]) -> ModelInfo:
|
|
1134
|
+
"""Change one model in the file and in this client; keys not named stay as they are.
|
|
1135
|
+
|
|
1136
|
+
As :func:`zettcode.sdk.update_model` does for the file alone: the token is kept unless a new one is given,
|
|
1137
|
+
and a key whose value is ``None`` is removed from the entry.
|
|
1138
|
+
|
|
1139
|
+
Args:
|
|
1140
|
+
which: The model's name (its display name, or its id), or its place in :meth:`model_entries` counted from
|
|
1141
|
+
zero, which tells apart two entries that share a name.
|
|
1142
|
+
changes: The file's keys to set, or to remove with ``None``.
|
|
1143
|
+
|
|
1144
|
+
Raises:
|
|
1145
|
+
ConfigurationError: No such model, or the result is not usable.
|
|
1146
|
+
"""
|
|
1147
|
+
from .models import model_info
|
|
1148
|
+
|
|
1149
|
+
self._check_open()
|
|
1150
|
+
try:
|
|
1151
|
+
index = self._file_index(which)
|
|
1152
|
+
entry = dict(self._agent.file_models()[index][1])
|
|
1153
|
+
for key, value in changes.items():
|
|
1154
|
+
if value is None:
|
|
1155
|
+
entry.pop(key, None)
|
|
1156
|
+
else:
|
|
1157
|
+
entry[key] = value
|
|
1158
|
+
return model_info(self._agent.update_configured_model(index, entry))
|
|
1159
|
+
except ValueError as error:
|
|
1160
|
+
raise ConfigurationError(str(error)) from error
|
|
1161
|
+
|
|
1162
|
+
def remove_model(self, which: str | int) -> ModelInfo:
|
|
1163
|
+
"""Remove one model from the file and this client; return the model in use afterwards.
|
|
1164
|
+
|
|
1165
|
+
Args:
|
|
1166
|
+
which: The model's name, or its place in :meth:`model_entries` counted from zero.
|
|
1167
|
+
|
|
1168
|
+
Raises:
|
|
1169
|
+
ConfigurationError: No such model, or it is the only one.
|
|
1170
|
+
"""
|
|
1171
|
+
from .models import model_info
|
|
1172
|
+
|
|
1173
|
+
self._check_open()
|
|
1174
|
+
try:
|
|
1175
|
+
now = self._agent.remove_configured_model(self._file_index(which))
|
|
1176
|
+
except ValueError as error:
|
|
1177
|
+
raise ConfigurationError(str(error)) from error
|
|
1178
|
+
return model_info(now, active=True)
|
|
1179
|
+
|
|
1180
|
+
def _file_index(self, which: str | int) -> int:
|
|
1181
|
+
"""Return the place in the file of a model named by its display name, its id, or its place."""
|
|
1182
|
+
from zettcode.config import model_index
|
|
1183
|
+
|
|
1184
|
+
models = [model for model, _entry in self._agent.file_models()]
|
|
1185
|
+
if isinstance(which, int) and not isinstance(which, bool):
|
|
1186
|
+
if 0 <= which < len(models):
|
|
1187
|
+
return which
|
|
1188
|
+
raise ValueError(f"There is no model number {which}; the file lists {len(models)}")
|
|
1189
|
+
if not isinstance(which, str):
|
|
1190
|
+
raise ValueError(f"{which!r} is not a model name or a model number")
|
|
1191
|
+
index = model_index(models, which)
|
|
1192
|
+
if index is None:
|
|
1193
|
+
raise ValueError(f"No model called {which!r}; configured: {', '.join(m.shown_name for m in models)}")
|
|
1194
|
+
return index
|
|
1195
|
+
|
|
1196
|
+
def use_compact_percent(self, percent: float) -> tuple[ModelInfo, str | None]:
|
|
1197
|
+
"""Summarize the conversation at ``percent`` of the active model's window, from the next request on.
|
|
1198
|
+
|
|
1199
|
+
This is the terminal's ``/context`` slider. The running session changes at once; the configuration file is
|
|
1200
|
+
rewritten too, so the point survives a restart.
|
|
1201
|
+
|
|
1202
|
+
Args:
|
|
1203
|
+
percent: Share of the window, more than 0 and at most 100.
|
|
1204
|
+
|
|
1205
|
+
Returns:
|
|
1206
|
+
The active model as it is now, and why the file was not written (``None`` when it was): the change then
|
|
1207
|
+
holds for this run only.
|
|
1208
|
+
|
|
1209
|
+
Raises:
|
|
1210
|
+
ConfigurationError: ``percent`` is not between 0 and 100.
|
|
1211
|
+
"""
|
|
1212
|
+
self._check_open()
|
|
1213
|
+
try:
|
|
1214
|
+
_, unwritten = self._agent.change_compact_percent(percent)
|
|
1215
|
+
except ValueError as error:
|
|
1216
|
+
raise ConfigurationError(str(error)) from error
|
|
1217
|
+
return self.model, unwritten
|
|
1218
|
+
|
|
1219
|
+
@property
|
|
1220
|
+
def effort(self) -> str:
|
|
1221
|
+
"""Return the reasoning level the next run uses."""
|
|
1222
|
+
return self._agent.effort
|
|
1223
|
+
|
|
1224
|
+
@property
|
|
1225
|
+
def efforts(self) -> tuple[str, ...]:
|
|
1226
|
+
"""Return the levels the active model accepts."""
|
|
1227
|
+
return self._agent.efforts
|
|
1228
|
+
|
|
1229
|
+
def use_effort(self, name: str) -> str:
|
|
1230
|
+
"""Choose how much reasoning later runs may spend.
|
|
1231
|
+
|
|
1232
|
+
Raises:
|
|
1233
|
+
ConfigurationError: The level does not exist, or the active model does not take it (the message lists
|
|
1234
|
+
the ones it does).
|
|
1235
|
+
"""
|
|
1236
|
+
self._check_open()
|
|
1237
|
+
try:
|
|
1238
|
+
return self._agent.use_effort(name)
|
|
1239
|
+
except ValueError as error:
|
|
1240
|
+
raise ConfigurationError(str(error)) from error
|
|
1241
|
+
|
|
1242
|
+
@property
|
|
1243
|
+
def permission(self) -> str:
|
|
1244
|
+
"""Return how much of the shell may run without asking: ``deny``, ``ask``, ``safe`` or ``full``."""
|
|
1245
|
+
return str(self._agent.permission.value)
|
|
1246
|
+
|
|
1247
|
+
@property
|
|
1248
|
+
def permissions(self) -> tuple[str, ...]:
|
|
1249
|
+
"""Return the permission levels there are."""
|
|
1250
|
+
return self._agent.permissions
|
|
1251
|
+
|
|
1252
|
+
def use_permission(self, name: str, *, remember: bool = True) -> str:
|
|
1253
|
+
"""Choose how much of the shell may run without asking, from the next command on.
|
|
1254
|
+
|
|
1255
|
+
``safe`` runs every command in an operating-system sandbox that writes only the workspace and ``/tmp`` (where
|
|
1256
|
+
the machine has one); ``full`` runs them with the user's own access. ``auto``, the old name of ``full``, is
|
|
1257
|
+
still taken. The level is remembered by the session, so opening it again starts there; ``permission=`` on the
|
|
1258
|
+
client itself is for that client's runs and is not written down. ``remember=False`` applies the level to this
|
|
1259
|
+
client for now and leaves the session's remembered level alone, for a decision made for one run only (the
|
|
1260
|
+
terminal's "stop asking" in an approval).
|
|
1261
|
+
|
|
1262
|
+
Raises:
|
|
1263
|
+
ConfigurationError: Not one of the levels.
|
|
1264
|
+
SessionLockedError: Another process has the session open; the level is part of the session.
|
|
1265
|
+
"""
|
|
1266
|
+
self._check_open()
|
|
1267
|
+
if remember and self.session.locked:
|
|
1268
|
+
raise SessionLockedError("This session is open in another ZettCode process, so it is read-only here.")
|
|
1269
|
+
try:
|
|
1270
|
+
return str(self._agent.use_permission(name, remember=remember).value)
|
|
1271
|
+
except ValueError as error:
|
|
1272
|
+
raise ConfigurationError(str(error)) from error
|
|
1273
|
+
|
|
1274
|
+
@property
|
|
1275
|
+
def harness(self) -> bool:
|
|
1276
|
+
"""Return whether the just-in-time harness is on: the model may write small Python tools and run them."""
|
|
1277
|
+
return self._agent.harness
|
|
1278
|
+
|
|
1279
|
+
def use_harness(self, on: bool) -> bool:
|
|
1280
|
+
"""Turn the just-in-time harness on or off, from the next prompt on. It starts off."""
|
|
1281
|
+
self._check_open()
|
|
1282
|
+
return self._agent.use_harness(on)
|
|
1283
|
+
|
|
1284
|
+
# -- what is loaded --
|
|
1285
|
+
@property
|
|
1286
|
+
def usage(self) -> Usage:
|
|
1287
|
+
"""Return what the current session has spent."""
|
|
1288
|
+
from .translation import usage_from
|
|
1289
|
+
|
|
1290
|
+
return usage_from(self._agent.usage)
|
|
1291
|
+
|
|
1292
|
+
@property
|
|
1293
|
+
def plugins(self) -> PluginReport:
|
|
1294
|
+
"""Return the plugins that loaded, the ones the next start leaves out, and the ones that did not load."""
|
|
1295
|
+
bundle = self._agent.runtime.plugins
|
|
1296
|
+
return PluginReport(
|
|
1297
|
+
tuple(plugin.name for plugin in bundle.plugins),
|
|
1298
|
+
tuple(bundle.failures),
|
|
1299
|
+
entries=tuple(_plugin_entry(entry) for entry in self._plugin_catalog()),
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
def set_plugin_enabled(self, name: str, enabled: bool) -> PluginEntry:
|
|
1303
|
+
"""Record whether the next start does the work of one plugin, and return it as the list now shows it.
|
|
1304
|
+
|
|
1305
|
+
Nothing loads or unloads in this process: a switch is read once, when a program starts, which is why the entry
|
|
1306
|
+
says what the *next* start does. A compiled-in plugin is switched by a configuration key, an installed one by
|
|
1307
|
+
`[plugins] disable` in the configuration; which key is written is the plugin's own business.
|
|
1308
|
+
|
|
1309
|
+
Args:
|
|
1310
|
+
name: The plugin's origin — the distribution's entry-point name, or a compiled-in plugin's own name.
|
|
1311
|
+
enabled: True to let it work next time, False to leave it out.
|
|
1312
|
+
|
|
1313
|
+
Returns:
|
|
1314
|
+
The plugin's entry, with the switch applied.
|
|
1315
|
+
|
|
1316
|
+
Raises:
|
|
1317
|
+
ValueError: When the name is not one this installation has, is compiled into the program with no setting,
|
|
1318
|
+
is switched off in the configuration file, or the file cannot be written.
|
|
1319
|
+
"""
|
|
1320
|
+
from zettcode.engine.plugins import applied, set_enabled
|
|
1321
|
+
from zettcode.engine.plugins.switches import applied_installed
|
|
1322
|
+
|
|
1323
|
+
runtime = self._agent.runtime
|
|
1324
|
+
result = set_enabled(self.config, name, enabled=enabled, bundle=runtime.plugins)
|
|
1325
|
+
if result is not None:
|
|
1326
|
+
runtime.config = applied(runtime.config, result, enabled)
|
|
1327
|
+
else:
|
|
1328
|
+
runtime.config = applied_installed(runtime.config, {name: enabled})
|
|
1329
|
+
found = next(
|
|
1330
|
+
(entry for entry in self._plugin_catalog() if name in (entry.origin, entry.name)),
|
|
1331
|
+
None,
|
|
1332
|
+
)
|
|
1333
|
+
if found is None: # pragma: no cover - set_enabled refuses a name it cannot find
|
|
1334
|
+
raise ValueError(f"No plugin is called {name!r}")
|
|
1335
|
+
return _plugin_entry(found)
|
|
1336
|
+
|
|
1337
|
+
def _plugin_catalog(self) -> tuple[Any, ...]:
|
|
1338
|
+
"""Return every plugin this client knows, as the engine works it out."""
|
|
1339
|
+
from zettcode.engine.plugins import catalog
|
|
1340
|
+
|
|
1341
|
+
return catalog(self.config, self._agent.runtime.plugins)
|
|
1342
|
+
|
|
1343
|
+
def mentions(self, query: str = "", *, limit: int = 50) -> tuple[MentionInfo, ...]:
|
|
1344
|
+
"""Return the ``@`` resources whose name matches ``query``, for a completion menu.
|
|
1345
|
+
|
|
1346
|
+
The same resources a prompt resolves: the workspace's files, the skills on the search paths, and whatever a
|
|
1347
|
+
plugin offers. The query is what follows the ``@`` (``"cli"`` for ``@cli``); an empty one lists what the
|
|
1348
|
+
providers consider likeliest. The result is capped, because a menu shows a screenful and a large workspace
|
|
1349
|
+
has more files than one.
|
|
1350
|
+
|
|
1351
|
+
Args:
|
|
1352
|
+
query: What was typed after the ``@``, without it.
|
|
1353
|
+
limit: Most entries to return; a positive integer.
|
|
1354
|
+
|
|
1355
|
+
Raises:
|
|
1356
|
+
ValueError: ``limit`` is not positive.
|
|
1357
|
+
"""
|
|
1358
|
+
self._check_open()
|
|
1359
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
|
|
1360
|
+
raise ValueError("limit must be a positive integer")
|
|
1361
|
+
registry = self._mention_registry()
|
|
1362
|
+
return tuple(
|
|
1363
|
+
MentionInfo(mention.token, mention.kind, mention.name, mention.description)
|
|
1364
|
+
for mention in registry.candidates(query)[:limit]
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
def _mention_registry(self) -> MentionRegistry:
|
|
1368
|
+
"""Return the ``@`` providers: the other sessions first, then what the plugins offer.
|
|
1369
|
+
|
|
1370
|
+
The order is the terminal's, and it decides more than a menu: a draft that names a session means that
|
|
1371
|
+
session even when a file happens to have the same name.
|
|
1372
|
+
"""
|
|
1373
|
+
from zettcode.mentions import MentionRegistry, SessionMentions
|
|
1374
|
+
|
|
1375
|
+
providers = self._agent.plugin_mentions
|
|
1376
|
+
messenger = self._link
|
|
1377
|
+
if messenger is not None:
|
|
1378
|
+
providers = (SessionMentions(messenger.peers), *providers)
|
|
1379
|
+
return MentionRegistry(providers)
|
|
1380
|
+
|
|
1381
|
+
# -- what the workspace spent, and what a session holds --
|
|
1382
|
+
def _ledger(self) -> Any:
|
|
1383
|
+
"""Return the reader of this workspace's ledger, made once."""
|
|
1384
|
+
if self._ledger_reader is None:
|
|
1385
|
+
from zettcode.engine.storage.ledger import LedgerReader
|
|
1386
|
+
|
|
1387
|
+
self._ledger_reader = LedgerReader(self.config.store, self.config.workspace)
|
|
1388
|
+
return self._ledger_reader
|
|
1389
|
+
|
|
1390
|
+
async def usage_ledger(self) -> WorkspaceUsage:
|
|
1391
|
+
"""Return what every stored session of this workspace has spent, as the ``/usage`` page shows it.
|
|
1392
|
+
|
|
1393
|
+
The numbers are read back from the stored conversations, so they cover the whole workspace and not only
|
|
1394
|
+
this conversation, and they agree with the files rather than with what this process remembers. The read runs
|
|
1395
|
+
off the event loop and reads only what changed since the last call on this client.
|
|
1396
|
+
"""
|
|
1397
|
+
from .translation import ledger_from
|
|
1398
|
+
|
|
1399
|
+
self._check_open()
|
|
1400
|
+
return ledger_from(await asyncio.to_thread(self._ledger().read))
|
|
1401
|
+
|
|
1402
|
+
async def session_stats(self, session_ids: Sequence[str] | None = None) -> tuple[SessionStatsInfo, ...]:
|
|
1403
|
+
"""Return the stored totals of some sessions: entries, replies, tokens and the cached share.
|
|
1404
|
+
|
|
1405
|
+
This is what the ``/resume`` panel's columns are made of, and it reads a session's file only when that file
|
|
1406
|
+
has changed since it was summarized. The current session is read when no ids are given.
|
|
1407
|
+
"""
|
|
1408
|
+
from .translation import stats_from
|
|
1409
|
+
|
|
1410
|
+
self._check_open()
|
|
1411
|
+
wanted = list(session_ids) if session_ids is not None else [self.session.id]
|
|
1412
|
+
if not wanted:
|
|
1413
|
+
return ()
|
|
1414
|
+
found = await asyncio.to_thread(self._ledger().stats, wanted)
|
|
1415
|
+
return tuple(stats_from(found[session_id]) for session_id in wanted if session_id in found)
|
|
1416
|
+
|
|
1417
|
+
# -- self-compaction --
|
|
1418
|
+
@property
|
|
1419
|
+
def self_compaction(self) -> bool:
|
|
1420
|
+
"""Return whether the model may compact this session's context itself."""
|
|
1421
|
+
return self._agent.runtime.self_compacting(self.session.id)
|
|
1422
|
+
|
|
1423
|
+
@property
|
|
1424
|
+
def self_compaction_choice(self) -> bool | None:
|
|
1425
|
+
"""Return this session's own choice (``None`` when it follows the configuration's default)."""
|
|
1426
|
+
return self._agent.runtime.self_compaction_choice(self.session.id)
|
|
1427
|
+
|
|
1428
|
+
def use_self_compaction(self, choice: bool | None) -> bool:
|
|
1429
|
+
"""Let the model compact this session itself (``True``), stop it (``False``), or follow the default (``None``).
|
|
1430
|
+
|
|
1431
|
+
The choice is stored in the session, so it is there when the session is reopened, and it applies to the next
|
|
1432
|
+
request (the tool is offered when one starts).
|
|
1433
|
+
|
|
1434
|
+
Raises:
|
|
1435
|
+
SessionLockedError: Another process has the session open; the choice is part of the session.
|
|
1436
|
+
OSError: The choice could not be written; the one in force is unchanged.
|
|
1437
|
+
"""
|
|
1438
|
+
self._check_open()
|
|
1439
|
+
if self.session.locked:
|
|
1440
|
+
raise SessionLockedError("This session is open in another ZettCode process, so it is read-only here.")
|
|
1441
|
+
self._agent.runtime.choose_self_compaction(self.session.id, choice)
|
|
1442
|
+
return self.self_compaction
|
|
1443
|
+
|
|
1444
|
+
@property
|
|
1445
|
+
def self_compaction_default(self) -> bool:
|
|
1446
|
+
"""Return what a session that has made no choice does: ``[compaction] self`` in the configuration."""
|
|
1447
|
+
return self._agent.self_compaction_default
|
|
1448
|
+
|
|
1449
|
+
def use_self_compaction_default(self, enabled: bool) -> Path:
|
|
1450
|
+
"""Write the default every session without a choice follows into the configuration file, and apply it now.
|
|
1451
|
+
|
|
1452
|
+
The file is written first and the running configuration changed second, so a failed write leaves both as
|
|
1453
|
+
they were. A file that does not exist is not created.
|
|
1454
|
+
|
|
1455
|
+
Returns:
|
|
1456
|
+
The file written.
|
|
1457
|
+
|
|
1458
|
+
Raises:
|
|
1459
|
+
ConfigurationError: There is no configuration file, or it cannot be read, edited safely or written.
|
|
1460
|
+
"""
|
|
1461
|
+
self._check_open()
|
|
1462
|
+
try:
|
|
1463
|
+
return self._agent.remember_self_compaction(enabled)
|
|
1464
|
+
except ValueError as error:
|
|
1465
|
+
raise ConfigurationError(str(error)) from error
|
|
1466
|
+
|
|
1467
|
+
async def check_models(self, *, timeout: float = 30.0) -> tuple[ModelCheck, ...]:
|
|
1468
|
+
"""Ask every configured model one short question, as ``zettcode doctor`` does, and report which answer.
|
|
1469
|
+
|
|
1470
|
+
The questions go to the provider adapters directly: no session is written, no tool is offered, and no
|
|
1471
|
+
extension runs, so what is measured is the entry and the endpoint. They are asked at once, and a model that
|
|
1472
|
+
does not answer within ``timeout`` seconds is reported as not answering rather than raised.
|
|
1473
|
+
"""
|
|
1474
|
+
from zettcode.doctor import diagnose
|
|
1475
|
+
|
|
1476
|
+
from .translation import checks_from
|
|
1477
|
+
|
|
1478
|
+
self._check_open()
|
|
1479
|
+
return checks_from(await diagnose(self.config.models, timeout=timeout))
|
|
1480
|
+
|
|
1481
|
+
# -- running, on the current session --
|
|
1482
|
+
def prompt(
|
|
1483
|
+
self,
|
|
1484
|
+
prompt: Prompt,
|
|
1485
|
+
*,
|
|
1486
|
+
images: Sequence[Image] = (),
|
|
1487
|
+
mentions: bool = False,
|
|
1488
|
+
instructions: str | None = None,
|
|
1489
|
+
on_approval: ApprovalHandler | None = None,
|
|
1490
|
+
on_question: QuestionHandler | None = None,
|
|
1491
|
+
on_event: Callable[[AnyEvent], object] | None = None,
|
|
1492
|
+
timeout: float | None = None,
|
|
1493
|
+
raw: bool = False,
|
|
1494
|
+
keep_events: bool = False,
|
|
1495
|
+
defer_answers: bool = False,
|
|
1496
|
+
cache: CacheMode | None = None,
|
|
1497
|
+
) -> RunStream:
|
|
1498
|
+
"""Send a prompt on the current session. Await the call for the result, or iterate it for the events.
|
|
1499
|
+
|
|
1500
|
+
::
|
|
1501
|
+
|
|
1502
|
+
result = await client.prompt("explain this module")
|
|
1503
|
+
async with client.prompt("explain this module") as run:
|
|
1504
|
+
async for event in run:
|
|
1505
|
+
...
|
|
1506
|
+
|
|
1507
|
+
Takes the arguments, and raises the errors, of :meth:`Session.prompt`.
|
|
1508
|
+
"""
|
|
1509
|
+
return self.session.prompt(
|
|
1510
|
+
prompt,
|
|
1511
|
+
images=images,
|
|
1512
|
+
mentions=mentions,
|
|
1513
|
+
instructions=instructions,
|
|
1514
|
+
on_approval=on_approval,
|
|
1515
|
+
on_question=on_question,
|
|
1516
|
+
on_event=on_event,
|
|
1517
|
+
timeout=timeout,
|
|
1518
|
+
raw=raw,
|
|
1519
|
+
keep_events=keep_events,
|
|
1520
|
+
defer_answers=defer_answers,
|
|
1521
|
+
cache=cache,
|
|
1522
|
+
)
|
|
1523
|
+
|
|
1524
|
+
#: The same method as :meth:`prompt`, for code that says "stream" when it means to iterate.
|
|
1525
|
+
stream = prompt
|
|
1526
|
+
|
|
1527
|
+
async def run(
|
|
1528
|
+
self,
|
|
1529
|
+
prompt: Prompt,
|
|
1530
|
+
*,
|
|
1531
|
+
images: Sequence[Image] = (),
|
|
1532
|
+
mentions: bool = False,
|
|
1533
|
+
instructions: str | None = None,
|
|
1534
|
+
on_approval: ApprovalHandler | None = None,
|
|
1535
|
+
on_question: QuestionHandler | None = None,
|
|
1536
|
+
on_event: Callable[[AnyEvent], object] | None = None,
|
|
1537
|
+
timeout: float | None = None,
|
|
1538
|
+
keep_events: bool = False,
|
|
1539
|
+
cache: CacheMode | None = None,
|
|
1540
|
+
) -> RunResult:
|
|
1541
|
+
"""Send a prompt and wait for the result; ``await client.prompt(...)`` spelled as a call."""
|
|
1542
|
+
return await self.prompt(
|
|
1543
|
+
prompt,
|
|
1544
|
+
images=images,
|
|
1545
|
+
mentions=mentions,
|
|
1546
|
+
instructions=instructions,
|
|
1547
|
+
on_approval=on_approval,
|
|
1548
|
+
on_question=on_question,
|
|
1549
|
+
on_event=on_event,
|
|
1550
|
+
timeout=timeout,
|
|
1551
|
+
keep_events=keep_events,
|
|
1552
|
+
cache=cache,
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
def __repr__(self) -> str:
|
|
1556
|
+
state = "closed" if self._closed else f"session={self._agent.session_id!r}"
|
|
1557
|
+
return f"Client({str(self.workspace)!r}, {state})"
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
class Sessions:
|
|
1561
|
+
"""The conversations stored for the client's workspace."""
|
|
1562
|
+
|
|
1563
|
+
def __init__(self, client: Client) -> None:
|
|
1564
|
+
self._client = client
|
|
1565
|
+
#: A store per other workspace, kept so a conversation read again is parsed only when its file changed.
|
|
1566
|
+
self._stores: dict[Path, Any] = {}
|
|
1567
|
+
|
|
1568
|
+
def _store(self, workspace: str | Path | None) -> Any:
|
|
1569
|
+
"""Return the store of a workspace: this client's own, or one opened to read another project's sessions."""
|
|
1570
|
+
own = self._client._agent.runtime.persistence
|
|
1571
|
+
if workspace is None or not str(workspace).strip():
|
|
1572
|
+
return own
|
|
1573
|
+
target = Path(workspace).expanduser().resolve()
|
|
1574
|
+
if target == own.workspace:
|
|
1575
|
+
return own
|
|
1576
|
+
store = self._stores.get(target)
|
|
1577
|
+
if store is None:
|
|
1578
|
+
from zettcode.engine.storage.store import SessionStore
|
|
1579
|
+
|
|
1580
|
+
store = self._stores[target] = SessionStore(own.root, target)
|
|
1581
|
+
return store
|
|
1582
|
+
|
|
1583
|
+
async def history_of(
|
|
1584
|
+
self, session_id: str, *, workspace: str | Path | None = None, full: bool = True
|
|
1585
|
+
) -> list[Message]:
|
|
1586
|
+
"""Return the messages of any stored session by its id, without making it current or taking its lock.
|
|
1587
|
+
|
|
1588
|
+
Args:
|
|
1589
|
+
session_id: The session to read.
|
|
1590
|
+
workspace: The project the session belongs to; ``None`` is this client's own. Sessions are stored per
|
|
1591
|
+
project, so a session of another directory is found only when its directory is named.
|
|
1592
|
+
full: The whole conversation, compacted messages included, rather than what the model is working with.
|
|
1593
|
+
|
|
1594
|
+
Raises:
|
|
1595
|
+
SessionNotFoundError: No such session, or it has no messages yet, or its file cannot be read.
|
|
1596
|
+
ClientClosedError: The client was closed.
|
|
1597
|
+
"""
|
|
1598
|
+
self._client._check_open()
|
|
1599
|
+
store = self._store(workspace)
|
|
1600
|
+
try:
|
|
1601
|
+
stored = await asyncio.to_thread(store.read, session_id)
|
|
1602
|
+
except (ValueError, OSError) as error:
|
|
1603
|
+
raise SessionNotFoundError(f"Cannot read session {session_id}: {error}") from error
|
|
1604
|
+
if stored.header is None:
|
|
1605
|
+
raise SessionNotFoundError(f"Session {session_id} has no messages yet")
|
|
1606
|
+
return messages_of(stored, full=full)
|
|
1607
|
+
|
|
1608
|
+
def stamp(self, session_id: str, *, workspace: str | Path | None = None) -> tuple[int, int] | None:
|
|
1609
|
+
"""Return a value that changes whenever a stored session's file does, or ``None`` when it has none.
|
|
1610
|
+
|
|
1611
|
+
It costs one ``stat``, so a front end can ask for it often and read the conversation only when it moved.
|
|
1612
|
+
"""
|
|
1613
|
+
try:
|
|
1614
|
+
seen = self._store(workspace).session_path(session_id).stat()
|
|
1615
|
+
except (OSError, ValueError):
|
|
1616
|
+
return None
|
|
1617
|
+
return (seen.st_mtime_ns, seen.st_size)
|
|
1618
|
+
|
|
1619
|
+
def open_elsewhere(self, session_id: str, *, workspace: str | Path | None = None) -> bool:
|
|
1620
|
+
"""Return whether some process has a stored session open, which is what holds its lock.
|
|
1621
|
+
|
|
1622
|
+
A snapshot: the lock is taken for an instant to find out, and a process that opens the session in that instant
|
|
1623
|
+
may be told to try again (a terminal keeps trying). ``False`` for a session that has no folder yet.
|
|
1624
|
+
"""
|
|
1625
|
+
from zettcode.engine.storage.locks import LOCK_FILE
|
|
1626
|
+
from zettcode.filelock import is_held
|
|
1627
|
+
|
|
1628
|
+
try:
|
|
1629
|
+
path = self._store(workspace).session_dir(session_id) / LOCK_FILE
|
|
1630
|
+
except ValueError:
|
|
1631
|
+
return False
|
|
1632
|
+
return path.exists() and is_held(path)
|
|
1633
|
+
|
|
1634
|
+
async def list(self, *, limit: int = 20) -> list[SessionInfo]:
|
|
1635
|
+
"""Return the stored sessions, most recently active first.
|
|
1636
|
+
|
|
1637
|
+
Raises:
|
|
1638
|
+
ValueError: ``limit`` is not a positive number.
|
|
1639
|
+
"""
|
|
1640
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
|
|
1641
|
+
raise ValueError("limit must be a positive integer")
|
|
1642
|
+
self._client._check_open()
|
|
1643
|
+
stored = await self._client._agent.list_sessions(limit=limit)
|
|
1644
|
+
return [SessionInfo(item.session_id, item.title, item.created_at, item.updated_at) for item in stored]
|
|
1645
|
+
|
|
1646
|
+
def new(self) -> Session:
|
|
1647
|
+
"""Start an empty conversation and make it current.
|
|
1648
|
+
|
|
1649
|
+
Raises:
|
|
1650
|
+
SessionBusyError: A run is in progress.
|
|
1651
|
+
"""
|
|
1652
|
+
client = self._client
|
|
1653
|
+
client._check_open()
|
|
1654
|
+
client._check_free()
|
|
1655
|
+
session_id = client._agent.new_session()
|
|
1656
|
+
client._fresh.add(session_id)
|
|
1657
|
+
return client._handle(session_id)
|
|
1658
|
+
|
|
1659
|
+
async def open(self, session_id: str) -> Session:
|
|
1660
|
+
"""Continue a stored conversation and make it current.
|
|
1661
|
+
|
|
1662
|
+
The session continues on the model it last used, when the configuration still has that model.
|
|
1663
|
+
|
|
1664
|
+
Raises:
|
|
1665
|
+
SessionNotFoundError: No such session, or its file cannot be read (an id that would leave the store
|
|
1666
|
+
is also reported this way).
|
|
1667
|
+
SessionBusyError: A run is in progress.
|
|
1668
|
+
"""
|
|
1669
|
+
client = self._client
|
|
1670
|
+
client._check_open()
|
|
1671
|
+
if not isinstance(session_id, str) or not session_id.strip():
|
|
1672
|
+
raise SessionNotFoundError("A session id is required")
|
|
1673
|
+
client._check_free()
|
|
1674
|
+
await client._switch_to(session_id.strip())
|
|
1675
|
+
return client._handle(session_id.strip())
|
|
1676
|
+
|
|
1677
|
+
async def read(self, session_id: str) -> StoredSession:
|
|
1678
|
+
"""Read a stored conversation without making it current: the expensive half of :meth:`open`.
|
|
1679
|
+
|
|
1680
|
+
Parsing the file is most of what opening a long conversation costs, and it touches nothing the client is
|
|
1681
|
+
using, so it is safe to run while a run is going or on a worker thread of your own. The result is a value:
|
|
1682
|
+
draw it, measure it, or hand it to :meth:`install` on the loop that owns the client.
|
|
1683
|
+
|
|
1684
|
+
Raises:
|
|
1685
|
+
SessionNotFoundError: No such session, or its file cannot be read.
|
|
1686
|
+
ClientClosedError: The client was closed.
|
|
1687
|
+
"""
|
|
1688
|
+
self._client._check_open()
|
|
1689
|
+
return await asyncio.to_thread(self.read_blocking, session_id)
|
|
1690
|
+
|
|
1691
|
+
def read_blocking(self, session_id: str) -> StoredSession:
|
|
1692
|
+
"""Read a stored conversation on the calling thread: :meth:`read` for code that is already on a worker.
|
|
1693
|
+
|
|
1694
|
+
A front end that prepares what it will draw on a thread of its own (or before it has an event loop at all)
|
|
1695
|
+
reads here, so one thread does the read and the preparation. Do not call it on the loop.
|
|
1696
|
+
|
|
1697
|
+
Raises:
|
|
1698
|
+
SessionNotFoundError: No such session, or its file cannot be read.
|
|
1699
|
+
ClientClosedError: The client was closed.
|
|
1700
|
+
"""
|
|
1701
|
+
self._client._check_open()
|
|
1702
|
+
if not isinstance(session_id, str) or not session_id.strip():
|
|
1703
|
+
raise SessionNotFoundError("A session id is required")
|
|
1704
|
+
wanted = session_id.strip()
|
|
1705
|
+
try:
|
|
1706
|
+
stored = self._client._agent.load_session(wanted)
|
|
1707
|
+
except (ValueError, OSError) as error:
|
|
1708
|
+
raise SessionNotFoundError(f"Cannot open session {wanted!r}: {error}") from error
|
|
1709
|
+
return self._stored(stored)
|
|
1710
|
+
|
|
1711
|
+
def install(self, stored: StoredSession) -> Session:
|
|
1712
|
+
"""Make a conversation that :meth:`read` returned the current one: the cheap half of :meth:`open`.
|
|
1713
|
+
|
|
1714
|
+
Everything it does is assignment (the session, its totals, the model it last used), so it can run where the rest
|
|
1715
|
+
of a front end does, between two frames.
|
|
1716
|
+
|
|
1717
|
+
Raises:
|
|
1718
|
+
SessionBusyError: A run is in progress.
|
|
1719
|
+
ClientClosedError: The client was closed.
|
|
1720
|
+
"""
|
|
1721
|
+
client = self._client
|
|
1722
|
+
client._check_open()
|
|
1723
|
+
client._check_free()
|
|
1724
|
+
client._agent.install_session(stored._engine)
|
|
1725
|
+
return client._handle(stored.id)
|
|
1726
|
+
|
|
1727
|
+
def _stored(self, stored: Any) -> StoredSession:
|
|
1728
|
+
"""Return the SDK's value for what the engine read."""
|
|
1729
|
+
from .translation import usage_from
|
|
1730
|
+
|
|
1731
|
+
return StoredSession(
|
|
1732
|
+
id=stored.session_id,
|
|
1733
|
+
title=stored.title,
|
|
1734
|
+
model=stored.model,
|
|
1735
|
+
usage=usage_from(stored.usage),
|
|
1736
|
+
records=stored.session,
|
|
1737
|
+
_engine=stored,
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
async def latest(self) -> Session | None:
|
|
1741
|
+
"""Continue the most recently active session, or return ``None`` when the workspace has none."""
|
|
1742
|
+
found = await self.list(limit=1)
|
|
1743
|
+
return await self.open(found[0].id) if found else None
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
async def prompt(
|
|
1747
|
+
text: Prompt,
|
|
1748
|
+
/,
|
|
1749
|
+
*,
|
|
1750
|
+
workspace: str | Path | None = None,
|
|
1751
|
+
config: ZettCodeConfig | None = None,
|
|
1752
|
+
config_path: str | Path | None = None,
|
|
1753
|
+
models: Sequence[ModelConfig | Mapping[str, object]] | None = None,
|
|
1754
|
+
model: str | None = None,
|
|
1755
|
+
effort: str | None = None,
|
|
1756
|
+
permission: str | None = None,
|
|
1757
|
+
resume: str | None = None,
|
|
1758
|
+
images: Sequence[Image] = (),
|
|
1759
|
+
mentions: bool = False,
|
|
1760
|
+
instructions: str | None = None,
|
|
1761
|
+
on_approval: ApprovalHandler | None = None,
|
|
1762
|
+
on_question: QuestionHandler | None = None,
|
|
1763
|
+
on_event: Callable[[AnyEvent], object] | None = None,
|
|
1764
|
+
timeout: float | None = None,
|
|
1765
|
+
cache: CacheMode | None = None,
|
|
1766
|
+
**settings: Any,
|
|
1767
|
+
) -> RunResult:
|
|
1768
|
+
"""Send one prompt and close everything again: a client, a run, the result.
|
|
1769
|
+
|
|
1770
|
+
Takes the arguments of :class:`Client` and of :meth:`Session.run`. For more than one prompt, keep a
|
|
1771
|
+
:class:`Client` open instead, so the conversation carries on.
|
|
1772
|
+
|
|
1773
|
+
The configuration settings are the ones :class:`Client` names, passed straight through; they are typed there,
|
|
1774
|
+
not here, because this form takes a prompt and a run's options beside them and one more list would bury it.
|
|
1775
|
+
"""
|
|
1776
|
+
async with Client(
|
|
1777
|
+
workspace,
|
|
1778
|
+
config=config,
|
|
1779
|
+
config_path=config_path,
|
|
1780
|
+
models=models,
|
|
1781
|
+
model=model,
|
|
1782
|
+
effort=effort,
|
|
1783
|
+
permission=permission,
|
|
1784
|
+
resume=resume,
|
|
1785
|
+
**settings,
|
|
1786
|
+
) as client:
|
|
1787
|
+
return await client.run(
|
|
1788
|
+
text,
|
|
1789
|
+
images=images,
|
|
1790
|
+
mentions=mentions,
|
|
1791
|
+
instructions=instructions,
|
|
1792
|
+
on_approval=on_approval,
|
|
1793
|
+
on_question=on_question,
|
|
1794
|
+
on_event=on_event,
|
|
1795
|
+
timeout=timeout,
|
|
1796
|
+
cache=cache,
|
|
1797
|
+
)
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
def prompt_sync(text: Prompt, /, **options: object) -> RunResult:
|
|
1801
|
+
"""Send one prompt from code that is not async; the blocking form of :func:`prompt`.
|
|
1802
|
+
|
|
1803
|
+
Raises:
|
|
1804
|
+
RuntimeError: Called from inside a running event loop, where blocking would stall it; ``await prompt(...)``.
|
|
1805
|
+
"""
|
|
1806
|
+
try:
|
|
1807
|
+
asyncio.get_running_loop()
|
|
1808
|
+
except RuntimeError:
|
|
1809
|
+
return asyncio.run(prompt(text, **options)) # type: ignore[arg-type]
|
|
1810
|
+
raise RuntimeError("prompt_sync() cannot be called from a running event loop; use `await zettcode.sdk.prompt(...)`")
|