zettcode-sdk 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zettcode/daemon/__init__.py +17 -0
- zettcode/daemon/__main__.py +10 -0
- zettcode/daemon/cli.py +693 -0
- zettcode/daemon/companions.py +63 -0
- zettcode/daemon/process.py +559 -0
- zettcode/daemon/run.py +678 -0
- zettcode/daemon/state.py +356 -0
- zettcode/doctor.py +167 -0
- zettcode/mentions.py +320 -0
- zettcode/sdk/__init__.py +195 -0
- zettcode/sdk/builtins.py +608 -0
- zettcode/sdk/client.py +1810 -0
- zettcode/sdk/commands.py +9 -0
- zettcode/sdk/daemons.py +244 -0
- zettcode/sdk/errors.py +132 -0
- zettcode/sdk/events.py +434 -0
- zettcode/sdk/extensions/__init__.py +6 -0
- zettcode/sdk/extensions/mcp.py +276 -0
- zettcode/sdk/extensions/messaging.py +281 -0
- zettcode/sdk/handlers.py +126 -0
- zettcode/sdk/messenger.py +299 -0
- zettcode/sdk/models.py +156 -0
- zettcode/sdk/py.typed +0 -0
- zettcode/sdk/session.py +1212 -0
- zettcode/sdk/translation.py +384 -0
- zettcode/sdk/types.py +907 -0
- zettcode/table.py +95 -0
- zettcode_sdk-0.3.4.dist-info/METADATA +21 -0
- zettcode_sdk-0.3.4.dist-info/RECORD +30 -0
- zettcode_sdk-0.3.4.dist-info/WHEEL +4 -0
zettcode/sdk/commands.py
ADDED
|
@@ -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"]
|
zettcode/sdk/daemons.py
ADDED
|
@@ -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
|