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.
@@ -0,0 +1,9 @@
1
+ """The plain commands of the plugin system, for a program that runs one.
2
+
3
+ A plugin registers these on ``ctx.commands``: text for any front end to show, or a preset for a turn. They are
4
+ re-exported here because the server may not import the engine — the SDK is the door it has.
5
+ """
6
+
7
+ from zettcode.engine.plugins.commands import PlainCommand, PlainCommandContext, TextCommandResult, TurnCommandResult
8
+
9
+ __all__ = ["PlainCommand", "PlainCommandContext", "TextCommandResult", "TurnCommandResult"]
@@ -0,0 +1,244 @@
1
+ """Background agents, from code: start one, list them, read what one logged, stop one.
2
+
3
+ A daemon is a session with nobody at the keyboard (see ``docs/guide/daemons.md``). ``zettcode daemon`` is its
4
+ command line; this module is the same thing for a program, so a front end that manages daemons does not parse the
5
+ output of a command or reach into the folders the daemons keep their records in::
6
+
7
+ from zettcode.sdk.daemons import list_daemons, spawn_daemon, stop_daemon
8
+
9
+ daemon = await spawn_daemon("~/project", name="reviewer", permission="safe")
10
+ print(daemon.session_id) # message it with ``Client.send`` or the ``send_message`` tool
11
+ for item in list_daemons():
12
+ print(item.session_id, item.status)
13
+ await stop_daemon(daemon.session_id)
14
+
15
+ A daemon is a separate process, so none of this is bound by the one-workspace-per-process rule of a
16
+ :class:`~zettcode.sdk.client.Client`. Nothing here imports the daemon's own code until it is used.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ from .errors import ConfigurationError, DaemonStartError, SessionNotFoundError
26
+ from .types import DaemonInfo
27
+
28
+ if TYPE_CHECKING: # pragma: no cover - annotations only
29
+ from zettcode.daemon.state import Record
30
+
31
+ __all__ = ["PERMISSIONS", "daemon_log", "delete_daemon", "list_daemons", "spawn_daemon", "stop_daemon", "update_daemon"]
32
+
33
+ #: The shell levels a daemon can be given. There is no ``ask``: nobody is there to answer it.
34
+ PERMISSIONS = ("deny", "safe", "full")
35
+
36
+
37
+ def _info(record: Record) -> DaemonInfo:
38
+ """Return the SDK's value for a daemon's record."""
39
+ state = record.state
40
+ if state is None:
41
+ return DaemonInfo(session_id=record.session_id, status=record.status)
42
+ return DaemonInfo(
43
+ session_id=record.session_id,
44
+ workspace=state.workspace,
45
+ status=record.status,
46
+ name=state.name,
47
+ title=state.title,
48
+ model=state.model,
49
+ effort=state.effort,
50
+ permission=state.permission,
51
+ pid=state.pid,
52
+ started_at=state.started_at,
53
+ input_tokens=state.input_tokens,
54
+ output_tokens=state.output_tokens,
55
+ context_tokens=state.context_tokens,
56
+ context_window=state.context_window,
57
+ requested_model=state.requested_model,
58
+ requested_effort=state.requested_effort,
59
+ requested_error=state.requested_error,
60
+ broker=state.broker,
61
+ )
62
+
63
+
64
+ def list_daemons() -> list[DaemonInfo]:
65
+ """Return every daemon recorded on this machine, whichever workspace it works in, newest first.
66
+
67
+ A daemon whose process is gone is listed with the status ``dead`` until it is stopped, which removes the record.
68
+ This reads a few small files per daemon, so it is cheap to call often.
69
+ """
70
+ from zettcode.daemon.state import Registry
71
+
72
+ found = sorted(Registry().all(), key=lambda record: record.state.started_at if record.state else 0.0, reverse=True)
73
+ return [_info(record) for record in found]
74
+
75
+
76
+ async def spawn_daemon(
77
+ workspace: str | Path,
78
+ *,
79
+ model: str | None = None,
80
+ effort: str | None = None,
81
+ resume: str | None = None,
82
+ name: str | None = None,
83
+ title: str | None = None,
84
+ permission: str = "safe",
85
+ ) -> DaemonInfo:
86
+ """Start a background agent and return it once it can be messaged.
87
+
88
+ Args:
89
+ workspace: The directory it works in; it has to exist.
90
+ model: The model to run on, by the name :func:`~zettcode.sdk.models.list_models` gives, or ``None`` for the
91
+ first configured.
92
+ effort: The reasoning level, or ``None`` for the model's own.
93
+ resume: A stored session of that workspace to continue instead of starting an empty one.
94
+ name: A label saying what the daemon is for, recorded beside it.
95
+ title: What to call the session. ``None`` lets it name itself from its first answer.
96
+ permission: Its shell level: ``deny`` (no shell), ``safe`` (every command in a sandbox) or ``full``. The
97
+ default is ``safe``, not the ``full`` the command line starts with: a program starting an agent that
98
+ asks nobody should have to say so.
99
+
100
+ Raises:
101
+ ConfigurationError: The permission is not one of :data:`PERMISSIONS`, or the workspace is not a directory.
102
+ DaemonStartError: The process exited, or never became reachable; the message is what it said.
103
+ """
104
+ if permission not in PERMISSIONS:
105
+ raise ConfigurationError(f"A daemon's permission is one of {', '.join(PERMISSIONS)}, not {permission!r}")
106
+ folder = Path(workspace).expanduser()
107
+ if not folder.is_dir():
108
+ raise ConfigurationError(f"No such directory: {folder}")
109
+ from zettcode.daemon.process import Daemons, SpawnFailed
110
+
111
+ def start() -> Record:
112
+ return Daemons().start(
113
+ folder,
114
+ model=model,
115
+ effort=effort,
116
+ resume=resume,
117
+ name=name or None,
118
+ title=title or None,
119
+ permission=permission,
120
+ )
121
+
122
+ try:
123
+ record = await asyncio.to_thread(start)
124
+ except SpawnFailed as error:
125
+ raise DaemonStartError(str(error), str(error.log) if error.log is not None else None) from error
126
+ return _info(record)
127
+
128
+
129
+ async def stop_daemon(session_id: str, *, force: bool = False, keep: bool = False) -> str:
130
+ """Stop a daemon and say in one sentence what happened.
131
+
132
+ The session itself stays, with everything the daemon did, so it can be opened afterwards. By default the
133
+ daemon's record is removed too; with ``keep`` it stays, listed as ``dead``, so the daemon can be started again
134
+ (``spawn_daemon(..., resume=session_id)``) or removed for good with :func:`delete_daemon`. A daemon whose
135
+ process was already gone only has its record removed (or kept).
136
+
137
+ Args:
138
+ session_id: The daemon, as :func:`list_daemons` gives it.
139
+ force: Stop it even in the middle of a turn, which throws that turn away.
140
+ keep: Leave the record, as a dead one, instead of removing it.
141
+
142
+ Raises:
143
+ SessionNotFoundError: No daemon has that session.
144
+ ConfigurationError: It is working on a turn and ``force`` was not given.
145
+ """
146
+ from zettcode.daemon.process import Daemons, StillWorking
147
+ from zettcode.daemon.state import Registry
148
+
149
+ registry = Registry()
150
+ record = registry.find(session_id)
151
+ if record is None:
152
+ raise SessionNotFoundError(f"No daemon runs session {session_id!r}")
153
+ try:
154
+ return await asyncio.to_thread(lambda: Daemons(registry).stop(record, force=force, keep=keep))
155
+ except StillWorking as error:
156
+ raise ConfigurationError(str(error)) from error
157
+
158
+
159
+ async def delete_daemon(session_id: str) -> str:
160
+ """Remove a daemon for good: its record and its conversation. This cannot be undone.
161
+
162
+ Raises:
163
+ SessionNotFoundError: No daemon has that session.
164
+ ConfigurationError: Its process is still running (stop it first), or its session is open in another process.
165
+ """
166
+ from zettcode.daemon.process import Daemons, StillRunning, UpdateFailed
167
+ from zettcode.daemon.state import Registry
168
+
169
+ registry = Registry()
170
+ record = registry.find(session_id)
171
+ if record is None:
172
+ raise SessionNotFoundError(f"No daemon runs session {session_id!r}")
173
+ try:
174
+ return await asyncio.to_thread(Daemons(registry).delete, record)
175
+ except (StillRunning, UpdateFailed) as error:
176
+ raise ConfigurationError(str(error)) from error
177
+
178
+
179
+ async def update_daemon(
180
+ session_id: str,
181
+ *,
182
+ title: str | None = None,
183
+ name: str | None = None,
184
+ model: str | None = None,
185
+ effort: str | None = None,
186
+ ) -> DaemonInfo:
187
+ """Change what is recorded about a daemon: its session's name, its own label, or what it runs on.
188
+
189
+ The label may be changed while the daemon runs; it keeps what a reader put there. The title is the session's,
190
+ and a session is written to only by the process holding its lock, so naming one waits until the daemon is
191
+ stopped (a running daemon names its own session from the first answer, and ``name_it`` costs nothing once it
192
+ has one). A model or an effort is a request the running daemon puts in force before its next turn; a daemon
193
+ that is not running cannot be moved to another model.
194
+
195
+ Args:
196
+ session_id: The daemon, as :func:`list_daemons` gives it.
197
+ title: The name to give its session, or ``None`` to leave it.
198
+ name: The label to record, ``""`` to clear it, or ``None`` to leave it.
199
+ model: The model it should run from the next turn on, by display name or id, or ``None`` to leave it.
200
+ effort: The reasoning level it should run from the next turn on, or ``None`` to leave it.
201
+
202
+ Raises:
203
+ SessionNotFoundError: No daemon has that session.
204
+ ConfigurationError: The daemon still runs, its session is open in another process, the title is blank or
205
+ too long, or the model or effort is not one this machine has configured.
206
+ """
207
+ from zettcode.daemon.process import Daemons, UpdateFailed
208
+ from zettcode.daemon.state import Registry
209
+
210
+ registry = Registry()
211
+ record = registry.find(session_id)
212
+ if record is None:
213
+ raise SessionNotFoundError(f"No daemon runs session {session_id!r}")
214
+ try:
215
+ updated = await asyncio.to_thread(
216
+ Daemons(registry).update, record, title=title, name=name, model=model, effort=effort
217
+ )
218
+ except UpdateFailed as error:
219
+ raise ConfigurationError(str(error)) from error
220
+ return _info(updated)
221
+
222
+
223
+ def daemon_log(session_id: str, *, lines: int = 200) -> str:
224
+ """Return the last ``lines`` lines a daemon wrote about itself: the tools it ran and the turns that failed.
225
+
226
+ Raises:
227
+ SessionNotFoundError: No daemon has that session.
228
+ """
229
+ from zettcode.daemon.state import Registry
230
+
231
+ record = Registry().find(session_id)
232
+ if record is None:
233
+ raise SessionNotFoundError(f"No daemon runs session {session_id!r}")
234
+ wanted = max(1, lines)
235
+ try:
236
+ with record.log_path.open("rb") as handle:
237
+ # The end of the file is enough: a log that has grown for weeks is not read whole for its last lines.
238
+ handle.seek(0, 2)
239
+ size = handle.tell()
240
+ handle.seek(max(0, size - wanted * 400))
241
+ tail = handle.read().decode("utf-8", errors="replace")
242
+ except OSError:
243
+ return ""
244
+ return "\n".join(tail.splitlines()[-wanted:])
zettcode/sdk/errors.py ADDED
@@ -0,0 +1,132 @@
1
+ """The errors the SDK raises.
2
+
3
+ Everything the SDK raises on purpose is a :class:`ZettCodeError`, so a program can catch one class for "ZettCode
4
+ refused or failed". The original error is kept as ``__cause__`` wherever there was one, and a few of these also
5
+ inherit the built-in a caller would already be catching (``LookupError``, ``TimeoutError``), so existing handlers
6
+ keep working.
7
+
8
+ Cancellation is never wrapped: ``asyncio.CancelledError`` goes through untouched, because swallowing or renaming it
9
+ breaks the caller's own cancellation.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __all__ = [
15
+ "ClientClosedError",
16
+ "ConfigurationError",
17
+ "DaemonStartError",
18
+ "NothingToDoError",
19
+ "RunError",
20
+ "RunTimeoutError",
21
+ "SendError",
22
+ "SessionBusyError",
23
+ "SessionLockedError",
24
+ "SessionNotFoundError",
25
+ "WorkspaceConflictError",
26
+ "ZettCodeError",
27
+ ]
28
+
29
+
30
+ class ZettCodeError(Exception):
31
+ """Base class of every error the SDK raises on purpose."""
32
+
33
+
34
+ class ConfigurationError(ZettCodeError, ValueError):
35
+ """The configuration, or something chosen from it, is not usable.
36
+
37
+ Raised for an unreadable config file, an unknown setting name, a model, reasoning effort, or permission level
38
+ that does not exist, and a workspace that is not a directory. The message says which, and what would have worked.
39
+ """
40
+
41
+
42
+ class ClientClosedError(ZettCodeError):
43
+ """The client was closed, so it can no longer run anything."""
44
+
45
+
46
+ class SessionBusyError(ZettCodeError):
47
+ """A run is already in progress on this client.
48
+
49
+ A client has one conversation running at a time, exactly as the terminal application does. Wait for the run to
50
+ end, stop it (leave the ``async for`` or cancel the task), or use another client for parallel work.
51
+ """
52
+
53
+
54
+ class SessionLockedError(ZettCodeError):
55
+ """Another process has this session open, so this one may only read it.
56
+
57
+ Sessions are locked by the process that has them open. Reading history is still possible; running, renaming,
58
+ compacting and exporting are refused until the other process lets go.
59
+ """
60
+
61
+
62
+ class SessionNotFoundError(ZettCodeError, LookupError):
63
+ """The store holds no session with that id, or its file cannot be read."""
64
+
65
+
66
+ class NothingToDoError(ZettCodeError):
67
+ """The request is fine but there is nothing for it to act on.
68
+
69
+ Continuing a conversation that was not interrupted, or compacting one that is already compacted. The message
70
+ says why, in the application's own words.
71
+ """
72
+
73
+
74
+ class WorkspaceConflictError(ZettCodeError):
75
+ """Another client in this process already works in a different directory.
76
+
77
+ The coding tools resolve relative paths against the process's working directory, which is global to the
78
+ process, so two clients on different workspaces would edit each other's files. Close the other client, or run the
79
+ second one in a process of its own.
80
+ """
81
+
82
+
83
+ class RunError(ZettCodeError):
84
+ """A run ended with an error from the model, the provider, or the network.
85
+
86
+ Attributes:
87
+ retryable: Whether trying the same request again could succeed: a timeout, a dropped connection, a rate
88
+ limit, or a server error. A rejected key or a malformed request is not.
89
+ partial_text: What the model had already said when the error came, which is often still worth keeping.
90
+ """
91
+
92
+ def __init__(self, message: str, *, retryable: bool = False, partial_text: str = "") -> None:
93
+ super().__init__(message)
94
+ self.retryable = retryable
95
+ self.partial_text = partial_text
96
+
97
+
98
+ class RunTimeoutError(RunError, TimeoutError):
99
+ """A run took longer than the ``timeout`` it was given and was stopped.
100
+
101
+ The conversation keeps whatever the run had already added to it, as it does for a run the reader stops.
102
+ """
103
+
104
+ def __init__(self, seconds: float, *, partial_text: str = "") -> None:
105
+ super().__init__(f"The run did not finish within {seconds:g}s", retryable=True, partial_text=partial_text)
106
+ self.seconds = seconds
107
+
108
+
109
+ class SendError(ZettCodeError):
110
+ """A message to another session did not arrive.
111
+
112
+ Attributes:
113
+ reason: ``offline`` when there is no broker, ``lost`` when the connection ended with the message in flight (it
114
+ may or may not have been delivered), otherwise the broker's own reason (``unknown``, ``ambiguous``,
115
+ ``rejected``, ``timeout``, ``loop``, ``rate``, ``empty``, ``too_long``).
116
+ """
117
+
118
+ def __init__(self, reason: str, message: str) -> None:
119
+ super().__init__(message)
120
+ self.reason = reason
121
+
122
+
123
+ class DaemonStartError(ZettCodeError):
124
+ """A background agent was started and never became reachable.
125
+
126
+ Attributes:
127
+ log: The file the child process wrote its first words to, when there is one: the reason it gave is in it.
128
+ """
129
+
130
+ def __init__(self, message: str, log: str | None = None) -> None:
131
+ super().__init__(message)
132
+ self.log = log