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/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(...)`")