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,356 @@
1
+ """What the daemons leave on disk: the folder each one records itself in, and the registry of those folders.
2
+
3
+ A daemon's conversation is an ordinary session, so it is stored where every session is
4
+ (``~/.zettcode/sessions/<workspace>/<session-id>/`` where the layout is that of the session store); nothing here
5
+ duplicates it. This module owns the other half — the process:
6
+
7
+ ~/.zettcode/daemon/<workspace path, levels joined with ->/
8
+ ├── workspace.txt <- the path the folder belongs to, as the session store writes it
9
+ └── <session-id>/
10
+ ├── state.json <- pid, status, model, effort, when it started (see :class:`State`)
11
+ ├── state.lock <- held while state.json is read and written, so two writers do not clobber
12
+ ├── heartbeat <- touched every few seconds: a pid that lives but has stopped working
13
+ └── log <- what the daemon had to say: a failed turn, a refused message
14
+
15
+ The folder is named after the session id, so the id is the one handle a reader needs: ``daemon kill``, ``list`` and
16
+ ``send_message(to=...)`` all take it. A :class:`Record` is one such folder; :class:`Registry` is all of them, and
17
+ :meth:`Registry.record` is the only way to a folder, created or not.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import shutil
25
+ import time
26
+ from dataclasses import asdict, dataclass, fields
27
+ from pathlib import Path
28
+ from typing import TYPE_CHECKING
29
+
30
+ from zettcode.filelock import SingletonLock
31
+
32
+ if TYPE_CHECKING: # pragma: no cover - annotations only
33
+ from collections.abc import Callable
34
+
35
+ #: Bumped when the meaning of a field changes, so a file written by another version is not trusted blindly.
36
+ PROTOCOL = 1
37
+
38
+ #: How long a start may take before a state file that never appeared counts as a failed spawn.
39
+ START_SECONDS = 10.0
40
+
41
+ #: What a daemon is doing, as ``list`` words it. ``starting`` is a spawn the broker has not answered yet;
42
+ #: ``stopping`` is a kill that has been asked for and not yet landed.
43
+ STATUSES = ("starting", "idle", "working", "stopping")
44
+
45
+ #: How stale a heartbeat may be and still count as alive: three times the tick the daemon writes it on.
46
+ STALE_SECONDS = 30.0
47
+
48
+ #: The names of the files one daemon's folder holds.
49
+ STATE_FILE = "state.json"
50
+ BEAT_FILE = "heartbeat"
51
+ LOG_FILE = "log"
52
+
53
+ #: The lock a write to ``state.json`` is taken on: a daemon refreshes it while a reader may be relabelling it, and
54
+ #: a read-modify-write must not overwrite what the other just wrote.
55
+ STATE_LOCK = "state.lock"
56
+
57
+ #: How long a state write may wait for the other writer. Both hold it for one small file, so a wait is a moment.
58
+ STATE_LOCK_WAIT = 2.0
59
+
60
+ #: The file in the daemon folder that says which workspace path the folder belongs to.
61
+ OWNER_FILE = "workspace.txt"
62
+
63
+
64
+ def pid_runs(pid: int) -> bool:
65
+ """Return whether a process with this id exists on this machine.
66
+
67
+ POSIX asks with signal zero. Windows cannot: ``os.kill(pid, 0)`` there *is* ``CTRL_C_EVENT``, so it would send a
68
+ Ctrl+C rather than ask anything. It goes through the kernel's own handle instead.
69
+ """
70
+ if pid <= 0:
71
+ return False
72
+ if os.name == "nt": # pragma: no cover - exercised on Windows only
73
+ return _windows_pid_runs(pid)
74
+ try:
75
+ os.kill(pid, 0)
76
+ except ProcessLookupError:
77
+ return False
78
+ except PermissionError:
79
+ return True # somebody else's process, so it is there
80
+ except OSError:
81
+ return False
82
+ return True
83
+
84
+
85
+ def _windows_pid_runs(pid: int) -> bool: # pragma: no cover - exercised on Windows only
86
+ """Return whether a process with this id exists, by asking the kernel rather than signalling it."""
87
+ import ctypes
88
+
89
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
90
+ process_query_limited_information = 0x1000
91
+ still_active = 259
92
+ handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
93
+ if not handle:
94
+ return False
95
+ try:
96
+ code = ctypes.c_ulong()
97
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
98
+ return False
99
+ return code.value == still_active
100
+ finally:
101
+ kernel32.CloseHandle(handle)
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class State:
106
+ """What one daemon says about itself, as its ``state.json`` holds it.
107
+
108
+ Attributes:
109
+ session_id: The session the daemon runs; its folder name, and the id a reader passes to ``kill``.
110
+ workspace: The directory it works in, absolute.
111
+ pid: The process id, so a live one can be found and a dead one noticed.
112
+ status: One of :data:`STATUSES`.
113
+ token: The word the process that spawned it waits for, so a spawn can tell its own child from an old one.
114
+ started_at: Seconds since the epoch, for the "for 8m" column.
115
+ model: The model it was started on, as the entry is shown; ``None`` until it has one.
116
+ effort: The reasoning level it was started on, or ``None`` for the model's own default.
117
+ title: The session's name, once it has one: what a reader recognises it by.
118
+ name: The label the reader gave this daemon when starting it, if any: what it is *for*.
119
+ permission: The shell level it runs at: ``deny``, ``safe`` or ``full``.
120
+ requested_model: A model a reader asked for while it runs, to be used from the next turn.
121
+ requested_effort: A reasoning level a reader asked for while it runs, likewise.
122
+ requested_error: Why the last requested model or level could not be put in force, or ``None``.
123
+ broker: Whether the session broker was reachable at the last check, or ``None`` before one.
124
+ input_tokens: Everything the session has sent to the model so far, cache reads included.
125
+ output_tokens: Everything the model has generated for it, reasoning included.
126
+ context_tokens: How full the context was on the newest call, or ``None`` before one was made.
127
+ context_window: The window of the model it runs on, or ``None`` when it recorded no model.
128
+ """
129
+
130
+ session_id: str
131
+ workspace: str
132
+ pid: int
133
+ status: str
134
+ token: str
135
+ started_at: float
136
+ model: str | None = None
137
+ effort: str | None = None
138
+ title: str | None = None
139
+ name: str | None = None
140
+ permission: str | None = None
141
+ requested_model: str | None = None
142
+ requested_effort: str | None = None
143
+ requested_error: str | None = None
144
+ broker: bool | None = None
145
+ input_tokens: int | None = None
146
+ output_tokens: int | None = None
147
+ context_tokens: int | None = None
148
+ context_window: int | None = None
149
+ keep: bool = False
150
+ protocol: int = PROTOCOL
151
+
152
+ @classmethod
153
+ def load(cls, folder: Path) -> State | None:
154
+ """Return the state recorded in ``folder``, or ``None`` when there is none to believe.
155
+
156
+ A file that does not parse, or that names another protocol, is no answer: the folder is then reported by its
157
+ name alone, which is enough to kill it and see it is not one of ours.
158
+ """
159
+ try:
160
+ data = json.loads((folder / STATE_FILE).read_text(encoding="utf-8"))
161
+ except (OSError, ValueError):
162
+ return None
163
+ if not isinstance(data, dict) or data.get("protocol") != PROTOCOL:
164
+ return None
165
+ known = {item.name for item in fields(cls)}
166
+ try:
167
+ return cls(**{key: value for key, value in data.items() if key in known})
168
+ except TypeError:
169
+ return None
170
+
171
+ def save(self, folder: Path) -> None:
172
+ """Write this state into ``folder`` atomically, so a reader never sees half of it."""
173
+ folder.mkdir(parents=True, exist_ok=True)
174
+ temporary = folder / f".state.{os.getpid()}.tmp"
175
+ temporary.write_text(json.dumps(asdict(self), indent=2) + "\n", encoding="utf-8")
176
+ os.replace(temporary, folder / STATE_FILE)
177
+
178
+
179
+ class Record:
180
+ """One daemon's folder: what it said about itself, and the proofs of life around it.
181
+
182
+ Reading a record reads its state once, at construction, and whether the process is there: a command that lists
183
+ ten daemons asks the disk ten times, and nothing between two reads of one record can change the answer without
184
+ the file changing too.
185
+ """
186
+
187
+ def __init__(self, folder: Path) -> None:
188
+ """Take the folder, read what is in it, and decide whether its process is still there."""
189
+ self.folder = folder
190
+ self.state: State | None = State.load(folder)
191
+ self.alive = self._alive()
192
+
193
+ def __repr__(self) -> str: # pragma: no cover - debugging only
194
+ return f"Record({str(self.folder)!r}, {self.status})"
195
+
196
+ @property
197
+ def session_id(self) -> str:
198
+ """Return the session the folder belongs to: the recorded one, or the folder's name."""
199
+ return self.state.session_id if self.state is not None else self.folder.name
200
+
201
+ @property
202
+ def status(self) -> str:
203
+ """Return what to show as its status: ``dead`` when the process is gone, else the state it wrote."""
204
+ if not self.alive:
205
+ return "dead"
206
+ return self.state.status if self.state is not None else "unknown"
207
+
208
+ @property
209
+ def dead(self) -> bool:
210
+ """Return whether this is an entry whose process is gone."""
211
+ return not self.alive
212
+
213
+ @property
214
+ def log_path(self) -> Path:
215
+ """Return the file the daemon writes its own account into."""
216
+ return self.folder / LOG_FILE
217
+
218
+ def seconds(self, now: float | None = None) -> float:
219
+ """Return how long it has been running, or zero when it never said."""
220
+ if self.state is None:
221
+ return 0.0
222
+ return max(0.0, (now if now is not None else time.time()) - self.state.started_at)
223
+
224
+ def _alive(self) -> bool:
225
+ """Return whether the process the state names is running and keeping its heartbeat.
226
+
227
+ A pid alone is not enough: pids are reused, and a daemon that wedged now holds a file forever. The heartbeat
228
+ is the part a wedged process stops writing, so both have to say yes.
229
+ """
230
+ if self.state is None or self.state.pid <= 0 or not pid_runs(self.state.pid):
231
+ return False
232
+ try:
233
+ age = time.time() - (self.folder / BEAT_FILE).stat().st_mtime
234
+ except OSError:
235
+ return False
236
+ return age <= STALE_SECONDS
237
+
238
+ def save(self, state: State) -> None:
239
+ """Write this record's state, and believe what was written."""
240
+ state.save(self.folder)
241
+ self.state = state
242
+
243
+ def change(self, produce: Callable[[State | None], State], *, wait: float = STATE_LOCK_WAIT) -> State:
244
+ """Read the state under a lock, let ``produce`` make the new one, write it, and believe it.
245
+
246
+ A daemon writes its state as a turn begins and ends; a reader changes its label while the daemon runs. Both
247
+ go through here, so the file is read *inside* the lock: a write cannot overwrite a field the other writer
248
+ put there after this caller read the record.
249
+
250
+ Args:
251
+ produce: Makes the new state from the one on disk, or from ``None`` when there is none.
252
+ wait: Seconds to wait for the lock. A caller on an event loop passes ``0`` and takes a failure for an
253
+ answer, rather than blocking the loop on a lock another process holds for a moment.
254
+ """
255
+ lock = SingletonLock(self.folder / STATE_LOCK)
256
+ if not lock.acquire(wait=wait):
257
+ raise OSError("another process is writing the daemon's state")
258
+ try:
259
+ state = produce(State.load(self.folder))
260
+ state.save(self.folder)
261
+ self.state = state
262
+ return state
263
+ finally:
264
+ lock.release()
265
+
266
+ def beat(self) -> None:
267
+ """Write the heartbeat: proof that this process is still doing its rounds."""
268
+ try:
269
+ (self.folder / BEAT_FILE).write_text(str(time.time()), encoding="utf-8")
270
+ except OSError:
271
+ pass
272
+
273
+ def log(self, text: str) -> None:
274
+ """Append one line to the daemon's log; a log that cannot be written is not worth failing a daemon over."""
275
+ stamp = time.strftime("%Y-%m-%d %H:%M:%S")
276
+ try:
277
+ with self.log_path.open("a", encoding="utf-8") as log:
278
+ log.write(f"{stamp} {text}\n")
279
+ except OSError:
280
+ pass
281
+
282
+ def remove(self) -> None:
283
+ """Remove this record and the folder it lives in, leaving its session where it is."""
284
+ shutil.rmtree(self.folder, ignore_errors=True)
285
+ parent = self.folder.parent
286
+ try:
287
+ if parent.is_dir() and not any(parent.iterdir()):
288
+ parent.rmdir()
289
+ except OSError:
290
+ pass
291
+
292
+
293
+ class Registry:
294
+ """Every daemon recorded under one root: the folders, and the workspace each folder belongs to.
295
+
296
+ The root is ``$ZETTCODE_DAEMON_DIR`` or ``~/.zettcode/daemon``. A workspace's folders sit under a name derived
297
+ from its path by the session store's own helper, so the two trees say the same thing about the same project.
298
+ """
299
+
300
+ def __init__(self, root: Path | None = None) -> None:
301
+ """Point at a root, or at the default one."""
302
+ self.root = root if root is not None else _default_root()
303
+
304
+ def workspace(self, workspace: str | Path, *, create: bool = False) -> Path:
305
+ """Return the folder this workspace's daemons are recorded in.
306
+
307
+ Args:
308
+ workspace: The directory the daemons work in.
309
+ create: Make the folder and write the note saying whose it is, when it is not there yet.
310
+ """
311
+ # Imported here, not at the top: `engine.storage` is the session store's package, and loading it would make
312
+ # `daemon --help` pay for the whole store. Only two path helpers are wanted, and only when a folder is.
313
+ from zettcode.engine.storage.layout import claim_workspace, open_workspace
314
+
315
+ folder = open_workspace(self.root, workspace)
316
+ if create:
317
+ claim_workspace(folder, workspace)
318
+ return folder
319
+
320
+ def record(self, workspace: str | Path, session_id: str, *, create: bool = False) -> Record:
321
+ """Return the record one daemon keeps itself in, made when ``create`` is set."""
322
+ folder = self.workspace(workspace, create=create) / session_id
323
+ if create:
324
+ folder.mkdir(parents=True, exist_ok=True)
325
+ return Record(folder)
326
+
327
+ def all(self) -> tuple[Record, ...]:
328
+ """Return every daemon recorded under the root, whichever workspace it works in, in no particular order.
329
+
330
+ A folder with no readable state is still a record: its process may be there (a crash halfway through a
331
+ start), and a reader who wants it gone can kill it by the name of the folder.
332
+ """
333
+ found: list[Record] = []
334
+ for workspace in self._folders(self.root):
335
+ for folder in self._folders(workspace):
336
+ if any((folder / name).exists() for name in (STATE_FILE, BEAT_FILE, LOG_FILE)):
337
+ found.append(Record(folder))
338
+ return tuple(found)
339
+
340
+ def find(self, session_id: str) -> Record | None:
341
+ """Return the daemon whose session is ``session_id``, whichever workspace it works in."""
342
+ return next((record for record in self.all() if record.session_id == session_id), None)
343
+
344
+ @staticmethod
345
+ def _folders(path: Path) -> tuple[Path, ...]:
346
+ """Return the directories directly under ``path``, or nothing when it cannot be read."""
347
+ try:
348
+ return tuple(sorted(item for item in path.iterdir() if item.is_dir()))
349
+ except OSError:
350
+ return ()
351
+
352
+
353
+ def _default_root() -> Path:
354
+ """Return the folder the daemons are recorded under: ``$ZETTCODE_DAEMON_DIR``, or ``~/.zettcode/daemon``."""
355
+ override = os.getenv("ZETTCODE_DAEMON_DIR")
356
+ return Path(override).expanduser() if override else Path.home() / ".zettcode" / "daemon"
zettcode/doctor.py ADDED
@@ -0,0 +1,167 @@
1
+ """Find out whether the configured models work: what ``zettcode doctor`` does.
2
+
3
+ A model entry can be wrong in ways that only show up on the first request: a token that was revoked, a base URL
4
+ with a typo, a model id the endpoint does not serve, an endpoint that speaks the other protocol. The reader finds out
5
+ in the middle of a task. This module asks every configured model one trivial question, all at once, and says for each
6
+ whether it answered and, if it did not, in the provider's own words why not.
7
+
8
+ The question costs a handful of tokens: a one-line prompt at the cheapest reasoning level the entry accepts. Nothing
9
+ is written to a session, no tool is offered, and no extension runs: the request goes to the provider adapter
10
+ directly, so what is measured is the entry and the endpoint and nothing of this program.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import time
17
+ from collections.abc import Awaitable, Callable, Sequence
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from zettcode.config import ModelConfig, ZettCodeConfig
23
+
24
+ #: Seconds one model may take to answer before it is reported as not answering.
25
+ TIMEOUT = 30.0
26
+
27
+ #: What every model is asked. One short line, so the reply is a token or two.
28
+ QUESTION = "Reply with the single word: ok"
29
+
30
+ #: Characters of a provider's explanation kept in the report; the rest is a page of HTML or a stack of JSON.
31
+ DETAIL_LIMIT = 200
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class Probe:
36
+ """What asking one model came to.
37
+
38
+ Attributes:
39
+ model: The entry that was asked.
40
+ ok: Whether it answered.
41
+ seconds: How long the answer, or the failure, took.
42
+ detail: When it answered, the reply (trimmed); when it did not, why.
43
+ tokens: ``(input, output)`` tokens the answer cost, or ``None`` when it did not answer or did not say.
44
+ """
45
+
46
+ model: ModelConfig
47
+ ok: bool
48
+ seconds: float
49
+ detail: str = ""
50
+ tokens: tuple[int, int] | None = None
51
+
52
+
53
+ def _one_line(text: str) -> str:
54
+ """Return ``text`` as a single line no longer than :data:`DETAIL_LIMIT`."""
55
+ line = " ".join(text.split())
56
+ return line if len(line) <= DETAIL_LIMIT else line[: DETAIL_LIMIT - 1] + "…"
57
+
58
+
59
+ async def _ask(model: ModelConfig) -> tuple[str, tuple[int, int] | None]:
60
+ """Send the question to ``model`` and return its reply and the tokens it cost; raises what the provider raises."""
61
+ from zett_agent.messages import UserMessage
62
+ from zett_agent.model import ModelEventType, ModelRequest
63
+
64
+ from zettcode.engine.core import _build_provider
65
+
66
+ provider = _build_provider(model)
67
+ try:
68
+ request = ModelRequest(messages=[UserMessage(content=QUESTION)], reasoning_effort=model.efforts[0])
69
+ async for event in provider.stream(request):
70
+ if event.type is not ModelEventType.RESPONSE or event.response is None:
71
+ continue
72
+ response = event.response
73
+ stop = response.stop
74
+ if stop is not None and stop.reason.value == "refusal":
75
+ raise RuntimeError(f"the model refused the question: {stop.describe()}")
76
+ content = response.message.content
77
+ reply = content if isinstance(content, str) else "".join(getattr(part, "text", "") for part in content)
78
+ usage = response.usage
79
+ tokens = (usage.input_tokens, usage.output_tokens) if usage.input_tokens or usage.output_tokens else None
80
+ return reply, tokens
81
+ raise RuntimeError("the stream ended without a reply")
82
+ finally:
83
+ await provider.aclose()
84
+
85
+
86
+ async def probe(
87
+ model: ModelConfig,
88
+ *,
89
+ timeout: float = TIMEOUT,
90
+ ask: Callable[[ModelConfig], Awaitable[tuple[str, tuple[int, int] | None]]] = _ask,
91
+ ) -> Probe:
92
+ """Ask one model the question and report how it went; never raises.
93
+
94
+ Args:
95
+ model: The entry to ask.
96
+ timeout: Seconds to wait for the answer.
97
+ ask: Sends the question; injectable so a test needs no network.
98
+ """
99
+ started = time.monotonic()
100
+ try:
101
+ reply, tokens = await asyncio.wait_for(ask(model), timeout)
102
+ except asyncio.TimeoutError:
103
+ return Probe(model, False, time.monotonic() - started, f"no answer within {timeout:g} seconds")
104
+ except Exception as error: # noqa: BLE001 - every kind of failure is an answer to "does it work"
105
+ text = _one_line(str(error)) or type(error).__name__
106
+ return Probe(model, False, time.monotonic() - started, text)
107
+ return Probe(model, True, time.monotonic() - started, _one_line(reply), tokens)
108
+
109
+
110
+ async def diagnose(
111
+ models: Sequence[ModelConfig],
112
+ *,
113
+ timeout: float = TIMEOUT,
114
+ ask: Callable[[ModelConfig], Awaitable[tuple[str, tuple[int, int] | None]]] = _ask,
115
+ ) -> list[Probe]:
116
+ """Ask every model at once and return the probes in the order the models are configured."""
117
+ return list(await asyncio.gather(*(probe(model, timeout=timeout, ask=ask) for model in models)))
118
+
119
+
120
+ def format_report(probes: Sequence[Probe]) -> list[str]:
121
+ """Return the lines that tell the reader which models work, one per model, and a closing tally."""
122
+ if not probes:
123
+ return ["no models are configured"]
124
+ width = max(len(probe.model.shown_name) for probe in probes)
125
+ lines: list[str] = []
126
+ for item in probes:
127
+ mark = "ok " if item.ok else "FAIL"
128
+ name = item.model.shown_name.ljust(width)
129
+ if item.ok:
130
+ cost = f", {item.tokens[0]} in / {item.tokens[1]} out" if item.tokens else ""
131
+ lines.append(f"{mark} {name} {item.seconds:.1f}s {item.model.api.value}{cost}")
132
+ else:
133
+ lines.append(f"{mark} {name} {item.seconds:.1f}s {item.model.api.value}: {item.detail}")
134
+ failed = sum(not item.ok for item in probes)
135
+ lines.append(
136
+ f"{len(probes)} model{'s' * (len(probes) != 1)} checked: "
137
+ + ("all answered" if not failed else f"{len(probes) - failed} answered, {failed} did not")
138
+ )
139
+ return lines
140
+
141
+
142
+ def doctor_program(
143
+ config: ZettCodeConfig,
144
+ *,
145
+ timeout: float = TIMEOUT,
146
+ ask: Callable[[ModelConfig], Awaitable[tuple[str, tuple[int, int] | None]]] = _ask,
147
+ say: Callable[[str], None] = print,
148
+ ) -> int:
149
+ """Check the configured models from the command line: what ``zettcode doctor`` does.
150
+
151
+ Args:
152
+ config: The settings, whose models are asked.
153
+ timeout: Seconds each model may take.
154
+ ask: Sends the question; injectable so a test needs no network.
155
+ say: Where the report goes.
156
+
157
+ Returns:
158
+ ``0`` when every model answered, ``1`` when one did not or none is configured.
159
+ """
160
+ from zettcode.paths import config_path
161
+ from zettcode.version import __version__
162
+
163
+ say(f"ZettCode {__version__} · config {config_path()}")
164
+ probes = asyncio.run(diagnose(config.models, timeout=timeout, ask=ask))
165
+ for line in format_report(probes):
166
+ say(line)
167
+ return 0 if probes and all(item.ok for item in probes) else 1