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/run.py ADDED
@@ -0,0 +1,678 @@
1
+ """The background agent itself: one session, one broker connection, and the messages it answers.
2
+
3
+ It is not a new kind of agent. It is a session with nobody at the keyboard: the SDK client runs it, the broker
4
+ delivers what other sessions send, and every message becomes an ordinary relayed turn — stored in the session, and
5
+ answered with ``send_message`` if the model chooses to reply. :class:`Daemon` is the loop around that:
6
+
7
+ * one turn at a time, and up to :data:`MAX_WAITING` messages waiting behind it (the sender is told "busy" beyond
8
+ that), because a model call is not something to run several of at once in one session;
9
+ * nobody to ask: the daemon runs at the shell level it was started with (``full`` unless ``spawn`` said otherwise;
10
+ ``ask`` is refused, since there is no reader to answer it), and the ``ask_user`` tool is not offered at all;
11
+ * a state file and a heartbeat, so ``daemon list`` can say what it is doing and whether it is still doing it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import contextlib
18
+ import os
19
+ import signal
20
+ import time
21
+ from collections import deque
22
+ from dataclasses import dataclass, replace
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ from zettcode.error_text import may_pass
27
+ from zettcode.relay import batch_size
28
+ from zettcode.sdk.types import DIRECT_EMPTY, DIRECT_FAILED, IncomingMessage, Receipt
29
+
30
+ from .state import Record, Registry, State
31
+
32
+ if TYPE_CHECKING: # pragma: no cover - annotations only
33
+ from collections.abc import Callable, Mapping
34
+
35
+ from zettcode.sdk.client import Client
36
+
37
+ #: Messages that may wait behind the turn in progress. Past this the daemon says its inbox is full, and the broker
38
+ #: keeps the message and offers it again when there is room, so a message is neither lost nor living somewhere nobody
39
+ #: looks. They are run together (see :meth:`Inbox.done`), so a long inbox is one turn, not one per message.
40
+ MAX_WAITING = 64
41
+
42
+ #: How many times a turn that failed in a way that may pass is tried again, and how long to wait before each try.
43
+ #: The retry continues the interrupted request; a failure that would fail again is not retried at all.
44
+ TURN_RETRIES = 3
45
+ RETRY_DELAYS = (1.0, 2.0, 4.0)
46
+
47
+ #: How often the heartbeat is rewritten. A reader calls the record dead when it is older than
48
+ #: :data:`~.state.STALE_SECONDS`, so a missed beat or two is tolerated.
49
+ BEAT_SECONDS = 5.0
50
+
51
+ #: Exit statuses, as the daemon's own process reports them.
52
+ EXIT_OK = 0
53
+ EXIT_UNUSABLE = 2
54
+
55
+
56
+ class Inbox:
57
+ """The messages a daemon has taken and not yet finished, in the order they arrived.
58
+
59
+ The receiver the broker calls runs on the loop and must not block, so it never runs a turn itself: it starts one
60
+ when nothing is running, and queues the message otherwise. The cap is what the sender's agent reads in the
61
+ :class:`Receipt`, so it knows the daemon is busy rather than gone.
62
+ """
63
+
64
+ def __init__(self, start: Callable[[IncomingMessage], None], *, limit: int = MAX_WAITING) -> None:
65
+ """Start with nothing running."""
66
+ self._start = start
67
+ self._limit = limit
68
+ self._waiting: deque[IncomingMessage] = deque()
69
+ self._busy = False
70
+
71
+ @property
72
+ def busy(self) -> bool:
73
+ """Return whether a turn is running now."""
74
+ return self._busy
75
+
76
+ @property
77
+ def waiting(self) -> int:
78
+ """Return how many messages are waiting behind the one running."""
79
+ return len(self._waiting)
80
+
81
+ def offer(self, message: IncomingMessage) -> Receipt:
82
+ """Take a message: start it now when nothing runs, hold it when there is room, say so when there is not."""
83
+ if not self._busy:
84
+ self._busy = True
85
+ self._start(message)
86
+ return Receipt(True, disposition="started")
87
+ if len(self._waiting) < self._limit:
88
+ self._waiting.append(message)
89
+ return Receipt(True, disposition="queued")
90
+ # ``busy`` tells the broker this will pass: it keeps the message and offers it again when there is room, so
91
+ # the sender is not made to try again.
92
+ return Receipt(
93
+ False,
94
+ f"this daemon already has {self._limit} messages waiting; try again when it is less busy",
95
+ busy=True,
96
+ )
97
+
98
+ def done(self) -> IncomingMessage | None:
99
+ """Close the turn that ended: return the next message to run, or ``None`` when the daemon is idle again.
100
+
101
+ The messages that waited come back as one, in order (see :func:`~zettcode.relay.batch_size`), so a daemon that
102
+ was busy answers them in a single turn instead of one turn each; a chat window's message is always its own.
103
+ """
104
+ if self._waiting:
105
+ count = batch_size(tuple(self._waiting))
106
+ return IncomingMessage.merged([self._waiting.popleft() for _ in range(count)])
107
+ self._busy = False
108
+ return None
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class Options:
113
+ """What ``zettcode daemon run`` was asked to do.
114
+
115
+ Attributes:
116
+ workspace: The directory the session works in.
117
+ token: The word the process that spawned it waits for.
118
+ session: A stored session to continue, or ``None`` for a fresh one.
119
+ model: The model to run on, or ``None`` for the first configured.
120
+ effort: The reasoning level, or ``None`` for the model's own default.
121
+ name: A label the reader gave this daemon, so ``daemon list`` says what it is for.
122
+ title: What to call the session, when the reader named it at the start; ``None`` lets it name itself.
123
+ permission: The shell level it runs at: ``deny``, ``safe`` or ``full``. There is nobody to answer ``ask``.
124
+ """
125
+
126
+ workspace: Path
127
+ token: str
128
+ session: str | None = None
129
+ model: str | None = None
130
+ effort: str | None = None
131
+ name: str | None = None
132
+ title: str | None = None
133
+ permission: str = "full"
134
+
135
+
136
+ class Daemon:
137
+ """One background agent, held by the process that runs it: its client, its record, its inbox and its log.
138
+
139
+ ``serve`` is the whole life of the process: join the broker, say it is idle, answer messages until a signal
140
+ arrives, and take the record away on the way out. Everything else here is one step of that.
141
+ """
142
+
143
+ def __init__(self, client: Client, options: Options) -> None:
144
+ """Hold the session ``client`` runs, and the record its folder will hold."""
145
+ self.client = client
146
+ self.options = options
147
+ self.record: Record = Registry().record(options.workspace, client.session.id, create=True)
148
+ self.inbox = Inbox(self._start)
149
+ self.stopping = asyncio.Event()
150
+ #: When this process began, which is what the "for 8m" column counts from.
151
+ self.started_at = time.time()
152
+ #: How often this process has asked the model for a title. A session the model cannot name is left unnamed
153
+ #: rather than costing a call at the end of every turn.
154
+ self._naming_attempts = 0
155
+ #: When each running tool call began, by call id, so its end can say how long it took.
156
+ self._calls: dict[str, float] = {}
157
+
158
+ # -- the life of the process -------------------------------------------------------------------
159
+ def take(self, message: IncomingMessage) -> Receipt:
160
+ """Take a message from the broker: refused at once when it carries pictures this model cannot read.
161
+
162
+ The sender is told then and there, in words it can act on, rather than after a turn that was bound to fail.
163
+ """
164
+ if message.images:
165
+ try:
166
+ model = self.client.model
167
+ readable = model.multimodal
168
+ name = model.name
169
+ except Exception: # noqa: BLE001 - no model to ask is no model that reads pictures
170
+ readable, name = False, "this session's model"
171
+ if not readable:
172
+ return Receipt(False, f"{name} does not accept pictures; send the text alone or choose another model")
173
+ return self.inbox.offer(message)
174
+
175
+ async def serve(self) -> int:
176
+ """Answer messages until the process is asked to stop; return the exit status."""
177
+ _stop_on_signals(self.stopping)
178
+ # Written before the connection is attempted: a start that fails after this point leaves a record the start
179
+ # that made it can recognise as its own and remove, rather than a folder nobody can account for.
180
+ await self.mark("starting")
181
+ if not await self.client.join_sessions(self.take, direct=True):
182
+ self.record.log("the session broker is switched off, so no session can reach this daemon")
183
+ self.record.remove()
184
+ return EXIT_UNUSABLE
185
+ await self.mark("idle")
186
+ beating = asyncio.ensure_future(self._beat())
187
+ try:
188
+ await self.stopping.wait()
189
+ finally:
190
+ beating.cancel()
191
+ with contextlib.suppress(asyncio.CancelledError):
192
+ await beating
193
+ await self.mark("stopping")
194
+ # The record goes with the process: a daemon that stopped cleanly is not an entry a reader has to clean
195
+ # up, and the `kill` that asked it to stop waits for this rather than for a pid, which a zombie still
196
+ # answers. A stop that asked to keep it leaves it, as a dead one the reader can start again or delete.
197
+ if not self._kept():
198
+ self.record.remove()
199
+ return EXIT_OK
200
+
201
+ def _kept(self) -> bool:
202
+ """Return whether the stop that is ending this process asked for the record to stay."""
203
+ state = State.load(self.record.folder)
204
+ return state is not None and state.keep
205
+
206
+ # -- what it answers ---------------------------------------------------------------------------
207
+ def _start(self, message: IncomingMessage) -> None:
208
+ """Begin a turn, and keep the daemon busy until the last waiting message is done."""
209
+ asyncio.ensure_future(self._answer(message))
210
+
211
+ async def _answer(self, message: IncomingMessage) -> None:
212
+ """Run one turn, then the next waiting message, and go idle when there is none."""
213
+ try:
214
+ await self._apply_requests()
215
+ await self.mark("working")
216
+ await self._turn(message)
217
+ await self._name_it()
218
+ finally:
219
+ following = self.inbox.done()
220
+ if following is not None:
221
+ asyncio.ensure_future(self._answer(following))
222
+ else:
223
+ await self.mark("idle")
224
+
225
+ async def _apply_requests(self) -> None:
226
+ """Put a model or effort a reader asked for in force, then clear the request.
227
+
228
+ ``daemon update --model/--effort`` writes the request into the record, so the reader does not have to reach
229
+ this process; the beat tries it too, so a change lands without waiting for a message. The request is cleared
230
+ once tried — a model this configuration does not have would otherwise be attempted at every turn — and why
231
+ it could not be met is left in the record for ``daemon list`` to show. Clearing is by value: a request the
232
+ reader replaced while this one was being applied is kept for the next try.
233
+ """
234
+ state = State.load(self.record.folder)
235
+ if state is None or (state.requested_model is None and state.requested_effort is None):
236
+ return
237
+ wanted_model, wanted_effort = state.requested_model, state.requested_effort
238
+ failure: str | None = None
239
+ try:
240
+ if wanted_model is not None:
241
+ self.client.use_model(wanted_model)
242
+ if wanted_effort is not None:
243
+ self.client.use_effort(wanted_effort)
244
+ except Exception as error: # noqa: BLE001 - a request that cannot be met leaves the daemon working
245
+ failure = f"{type(error).__name__}: {error}"
246
+ self.record.log(f"the requested model or effort could not be applied: {failure}")
247
+ else:
248
+ self.record.log(f"now running {self.client.model.name} · effort {self.client.effort}")
249
+
250
+ def applied(current: State | None) -> State:
251
+ base = current or state
252
+ clears_model = base.requested_model == wanted_model
253
+ clears_effort = base.requested_effort == wanted_effort
254
+ return replace(
255
+ base,
256
+ requested_model=None if clears_model else base.requested_model,
257
+ requested_effort=None if clears_effort else base.requested_effort,
258
+ # A request the reader replaced while this one was being applied has not failed: the error is about
259
+ # the request that was tried, so it is written only when that one is the one left in the record.
260
+ requested_error=failure if clears_model and clears_effort else base.requested_error,
261
+ model=self.client.model.name,
262
+ effort=self.client.effort,
263
+ context_window=self._window(),
264
+ )
265
+
266
+ try:
267
+ self.record.change(applied, wait=0)
268
+ except OSError as error:
269
+ self.record.log(f"the applied request could not be cleared: {error}")
270
+
271
+ async def _turn(self, message: IncomingMessage) -> None:
272
+ """Run one message, trying again when the failure may pass, and tell the sender if it still fails.
273
+
274
+ A failure the provider may recover from — a timeout, a dropped connection, a rate limit, a server error —
275
+ is tried again up to :data:`TURN_RETRIES` times. The retry continues the interrupted request rather than
276
+ sending the prompt over: the library tells the model which tools were cut short and lets it decide, so a
277
+ tool is never run twice on its own. A failure that would fail again is not retried. Either way the sender is
278
+ told how the turn ended when it can be reached, so an agent that asked is not left waiting for an answer
279
+ that will not come.
280
+ """
281
+ from zettcode.sdk.errors import RunError
282
+
283
+ began = time.monotonic()
284
+ text = " ".join(message.text.split())
285
+ self.record.log(f"turn from {message.sender or message.sender_id[:8]} ({message.via}): {text[:120]}")
286
+ # No approval handler and no question handler, on purpose. The sandbox (`safe`) is the policy, so a command
287
+ # that stays inside it runs and one that does not is put to a reader — and there is none here, so it is
288
+ # refused. Approving on the daemon's behalf would turn a sandboxed run into a full one with nobody deciding.
289
+ why = ""
290
+ answer: str | None = None
291
+ succeeded = False
292
+ for attempt in range(TURN_RETRIES + 1):
293
+ try:
294
+ if attempt == 0:
295
+ await self._once(message)
296
+ else:
297
+ try:
298
+ if await self.client.session.resumable() is None:
299
+ # The conversation has nothing interrupted to pick up, so there is no request to
300
+ # continue; sending the prompt again is not what this loop does.
301
+ self.record.log("the turn is not retried: there is no interrupted request to continue")
302
+ break
303
+ answer = await self._resume()
304
+ except RunError:
305
+ # The continued request failed again: that is the turn failing, counted like any other.
306
+ raise
307
+ except Exception as error: # noqa: BLE001 - the turn's own failure is what is reported
308
+ self.record.log(f"the turn could not be continued: {type(error).__name__}: {error}")
309
+ break
310
+ succeeded = True
311
+ break
312
+ except RunError as error:
313
+ why = str(error)
314
+ retryable = error.retryable
315
+ except Exception as error: # noqa: BLE001 - a broken turn must not take the daemon down
316
+ why = f"{type(error).__name__}: {error}"
317
+ retryable = may_pass(error)
318
+ self.record.log(f"the turn failed (attempt {attempt + 1} of {TURN_RETRIES + 1}): {why}")
319
+ if not retryable:
320
+ self.record.log("the turn is not retried: this failure would happen again")
321
+ break
322
+ if attempt == TURN_RETRIES:
323
+ self.record.log(f"the turn failed after {TURN_RETRIES + 1} attempts")
324
+ break
325
+ await asyncio.sleep(RETRY_DELAYS[min(attempt, len(RETRY_DELAYS) - 1)])
326
+ if not succeeded:
327
+ await self._failed(message, why or "the turn failed")
328
+ return
329
+ self.record.log(f"turn finished in {time.monotonic() - began:.1f}s")
330
+ if message.direct and answer is not None:
331
+ # The first attempt answered the window itself; a continued one must do it too, or the window waits
332
+ # for a turn that has already finished. Delivering it outside the retry loop keeps a send failure
333
+ # from being taken for a turn failure and continued a second time.
334
+ await self._tell(message, answer if answer.strip() else DIRECT_EMPTY, what="how the continued turn ended")
335
+
336
+ async def _once(self, message: IncomingMessage) -> None:
337
+ """Run the message once: as this session's own prompt when it is direct, and as a relayed turn otherwise."""
338
+ if message.direct:
339
+ await self._direct(message)
340
+ return
341
+ async with self.client.session.relay(message) as run:
342
+ async for event in run:
343
+ self._see(event)
344
+
345
+ async def _resume(self) -> str:
346
+ """Pick up the request that failed where it stopped, so nobody's prompt is sent a second time."""
347
+ async with self.client.session.resume_stream() as run:
348
+ async for event in run:
349
+ self._see(event)
350
+ return run.result.text if run.result is not None else ""
351
+
352
+ async def _direct(self, message: IncomingMessage) -> None:
353
+ """Run a chat window's text as this session's own prompt: nothing frames it, and the answer goes back to it.
354
+
355
+ Nobody at a daemon reads its conversation as it runs, so the window is told how the turn ended: its answer, or
356
+ that it ended without one.
357
+ """
358
+ async with self.client.session.prompt(message.text, images=message.images) as run:
359
+ async for event in run:
360
+ self._see(event)
361
+ answer = run.result.text if run.result is not None else ""
362
+ await self._tell(message, answer if answer.strip() else DIRECT_EMPTY, what="how the turn ended")
363
+
364
+ async def _failed(self, message: IncomingMessage, why: str) -> None:
365
+ """Tell the sender that its turn failed, so it is not left waiting for an answer that will not come.
366
+
367
+ A sender with no session to answer — ``daemon prompt`` speaks as an external name — cannot be told; the
368
+ reason is left in the log, which is the only place it can be read afterwards.
369
+ """
370
+ reason = f"{DIRECT_FAILED}{why}"[:500]
371
+ # Every sender whose message was part of a merged turn is told, not only the first.
372
+ for item in message.batch or (message,):
373
+ await self._tell(item, reason, what=f"the turn failed: {why}")
374
+
375
+ async def _tell(self, message: IncomingMessage, text: str, *, what: str) -> None:
376
+ """Send a line back to the sender, and leave the fact in the log when there is nobody to receive it."""
377
+ delivered = await self.client.send_back(message, text)
378
+ if delivered is None:
379
+ self.record.log(f"the sender could not be told {what}")
380
+
381
+ async def _name_it(self) -> None:
382
+ """Ask the model for a title from the first exchange, unless the session has one.
383
+
384
+ A daemon has no reader to name its conversation, and what it is called is how the reader finds it again in
385
+ ``/resume``; the record's title is the session's, so the next mark carries it. Naming is a nicety: a model
386
+ that cannot name the session leaves it unnamed (twice is enough to try), and the turn is not failed for it.
387
+ """
388
+ if self._title() is not None or self._naming_attempts >= 2:
389
+ return
390
+ self._naming_attempts += 1
391
+ try:
392
+ await self.client.session.name_it()
393
+ except Exception as error: # noqa: BLE001 - a title is never worth failing a turn over
394
+ self.record.log(f"the session could not be named: {type(error).__name__}: {error}")
395
+
396
+ # -- what it says about itself -----------------------------------------------------------------
397
+ async def mark(self, status: str) -> None:
398
+ """Write the state file, so ``daemon list`` can say what this daemon is doing, and what it has spent.
399
+
400
+ The heartbeat is touched here as well as on its own clock: the first state a reader can see is written with
401
+ proof of life beside it, so a daemon is never listed as dead between its start and its first beat.
402
+ """
403
+ self.record.beat()
404
+ sent, made, context = self._spend()
405
+ state = State(
406
+ session_id=self.client.session.id,
407
+ workspace=str(self.options.workspace),
408
+ pid=os.getpid(),
409
+ status=status,
410
+ token=self.options.token,
411
+ started_at=self.started_at,
412
+ model=self.client.model.name,
413
+ effort=self.client.effort,
414
+ title=self._title(),
415
+ name=self.options.name, # replaced inside the lock by the label the record already holds
416
+ permission=self.options.permission,
417
+ input_tokens=sent,
418
+ output_tokens=made,
419
+ context_tokens=context,
420
+ context_window=self._window(),
421
+ broker=self._connected(),
422
+ )
423
+ # The lock is tried, not waited for: this runs on the event loop, and a reader changing a label holds it for
424
+ # a moment. A write that missed is made by the next mark — the turn did not fail for it.
425
+ for attempt in range(3):
426
+ try:
427
+ self.record.change(
428
+ lambda current: replace(
429
+ state,
430
+ name=self._label(current),
431
+ requested_model=current.requested_model if current is not None else None,
432
+ requested_effort=current.requested_effort if current is not None else None,
433
+ requested_error=current.requested_error if current is not None else None,
434
+ ),
435
+ wait=0,
436
+ )
437
+ return
438
+ except OSError as error:
439
+ if attempt == 2:
440
+ self.record.log(f"the state file could not be written: {error}")
441
+ return
442
+ await asyncio.sleep(0.02)
443
+
444
+ def _spend(self) -> tuple[int | None, int | None, int | None]:
445
+ """Return what the session has sent and generated, and how full the context was, when that is known."""
446
+ try:
447
+ usage = self.client.usage
448
+ except Exception: # noqa: BLE001 - counters are a nicety beside the status a reader needs
449
+ return None, None, None
450
+ return usage.input_tokens, usage.output_tokens, usage.context_tokens
451
+
452
+ def _window(self) -> int | None:
453
+ """Return the window of the model in use, or ``None`` when it cannot be read.
454
+
455
+ The counters are a nicety; a client that cannot answer must not take the state write, and with it the turn
456
+ loop, down. ``_spend`` does the same for what it reads.
457
+ """
458
+ try:
459
+ return self.client.model.context_window
460
+ except Exception: # noqa: BLE001 - see above
461
+ return None
462
+
463
+ def _connected(self) -> bool | None:
464
+ """Return whether the session broker is reachable now, or ``None`` when the client cannot say."""
465
+ try:
466
+ return self.client.reachable
467
+ except Exception: # noqa: BLE001 - connectivity is a nicety beside the status a reader needs
468
+ return None
469
+
470
+ async def _note_connection(self) -> None:
471
+ """Record a change in whether the session broker is reachable, so ``list`` shows a daemon nobody can reach.
472
+
473
+ A broker this session cannot register with — another protocol, a refused token — leaves the process healthy
474
+ and idle, which is indistinguishable from a reachable daemon in the record unless this is written down.
475
+ """
476
+ connected = self._connected()
477
+ if connected is None:
478
+ return
479
+ current = State.load(self.record.folder)
480
+ if current is None or current.broker is connected:
481
+ return
482
+ try:
483
+ self.record.change(lambda state: replace(state or current, broker=connected), wait=0)
484
+ except OSError as error:
485
+ self.record.log(f"the connection could not be recorded: {error}")
486
+
487
+ def _refresh_counters(self) -> None:
488
+ """Rewrite the counters while a turn runs, so ``daemon list`` does not stay at the numbers from its start.
489
+
490
+ The record is otherwise written when the status changes, which for one long turn is once at its start and
491
+ once at its end; a turn that goes through several model calls would show zeros throughout. The beat calls
492
+ this every five seconds, and it writes only when a number actually moved — the counters are as of the last
493
+ completed model call, so they do not move between calls.
494
+
495
+ Only the four counters are written: the rest of the record belongs to ``mark`` and to a reader, and this
496
+ runs beside a turn whose end will write its own status.
497
+ """
498
+ if not self.inbox.busy:
499
+ return
500
+ current = State.load(self.record.folder)
501
+ if current is None:
502
+ return
503
+ sent, made, context = self._spend()
504
+ if sent is None and made is None and context is None:
505
+ # The client could not say what it spent; writing Nones over real numbers would make `list` lose them.
506
+ return
507
+ window = self._window()
508
+ latest_window = window if window is not None else current.context_window
509
+ if (current.input_tokens, current.output_tokens, current.context_tokens, current.context_window) == (
510
+ sent,
511
+ made,
512
+ context,
513
+ latest_window,
514
+ ):
515
+ return
516
+ try:
517
+ self.record.change(
518
+ lambda fresh: replace(
519
+ fresh or current,
520
+ input_tokens=sent,
521
+ output_tokens=made,
522
+ context_tokens=context,
523
+ context_window=latest_window,
524
+ ),
525
+ wait=0,
526
+ )
527
+ except OSError as error:
528
+ self.record.log(f"the counters could not be written: {error}")
529
+
530
+ async def _beat(self) -> None:
531
+ """Keep the heartbeat fresh while this process lives, and take up a setting a reader asked for.
532
+
533
+ A request is applied between turns: a run in progress already holds the model and the level it started with,
534
+ and swapping them under it would change the compression it is halfway through.
535
+ """
536
+ while True:
537
+ self.record.beat()
538
+ if not self.inbox.busy:
539
+ await self._apply_requests()
540
+ else:
541
+ self._refresh_counters()
542
+ await self._note_connection()
543
+ await asyncio.sleep(BEAT_SECONDS)
544
+
545
+ def _title(self) -> str | None:
546
+ """Return the session's title, or ``None`` when it has none or cannot be read."""
547
+ try:
548
+ return self.client.session.title
549
+ except Exception: # noqa: BLE001 - a title is never worth failing a turn over
550
+ return None
551
+
552
+ def _label(self, current: State | None) -> str | None:
553
+ """Return this daemon's label: the one it was started with, or the one a reader set in the record since.
554
+
555
+ ``daemon update --name`` writes the record of a running daemon too, so when the state read inside the lock
556
+ is this daemon's own, its label is the answer; a record another run left behind is not.
557
+ """
558
+ if current is not None and current.session_id == self.client.session.id and current.token == self.options.token:
559
+ return current.name
560
+ return self.options.name
561
+
562
+ def _see(self, event: Any) -> None:
563
+ """Write the events worth knowing about: each tool with what it was for, one that failed, one that was slow."""
564
+ from zettcode.sdk.events import ToolFinished, ToolStarted
565
+
566
+ if isinstance(event, ToolStarted):
567
+ self._calls[event.call_id] = time.monotonic()
568
+ what = tool_detail(event.arguments)
569
+ self.record.log(f"tool {event.name} · {what}" if what else f"tool {event.name}")
570
+ elif isinstance(event, ToolFinished):
571
+ began = self._calls.pop(event.call_id, None)
572
+ took = time.monotonic() - began if began is not None else 0.0
573
+ if event.skipped:
574
+ self.record.log(f"tool {event.name} skipped: steering took over")
575
+ elif not event.ok:
576
+ reason = " ".join(event.output.split())[:200]
577
+ self.record.log(f"tool {event.name} failed after {took:.1f}s: {reason}")
578
+ elif took >= SLOW_TOOL_SECONDS:
579
+ self.record.log(f"tool {event.name} finished in {took:.1f}s")
580
+
581
+
582
+ #: Arguments that say what a tool call is about, in the order they are looked for.
583
+ DETAIL_KEYS = ("command", "file_path", "path", "pattern", "query", "url", "group", "to", "name", "message")
584
+ DETAIL_WIDTH = 160
585
+ #: A tool that ends faster than this is not worth a second line; one that takes longer says so.
586
+ SLOW_TOOL_SECONDS = 5.0
587
+
588
+
589
+ def tool_detail(arguments: Mapping[str, object]) -> str:
590
+ """Return one short line saying what a tool call is about: its command, file, pattern or recipient.
591
+
592
+ ``tool bash`` alone tells a reader nothing; ``tool bash · git status`` does. A group a message goes to is shown as
593
+ ``#name``, and a text is cut so a long prompt does not fill the log.
594
+ """
595
+ parts: list[str] = []
596
+ for key in DETAIL_KEYS:
597
+ value = arguments.get(key)
598
+ if isinstance(value, str) and value.strip():
599
+ line = " ".join(value.split())
600
+ parts.append(f"#{line.lstrip('#')}" if key == "group" else line)
601
+ if len(parts) == 2 or key in ("command", "file_path", "path", "pattern", "query", "url"):
602
+ break
603
+ text = " → ".join(parts)
604
+ return text if len(text) <= DETAIL_WIDTH else text[: DETAIL_WIDTH - 1] + "…"
605
+
606
+
607
+ async def serve(options: Options) -> int:
608
+ """Run the daemon ``options`` describes until it is stopped, and return the exit status."""
609
+ from zettcode.sdk.errors import ConfigurationError, SessionLockedError, SessionNotFoundError, WorkspaceConflictError
610
+
611
+ try:
612
+ client = _client(options)
613
+ except (ConfigurationError, SessionLockedError, SessionNotFoundError, WorkspaceConflictError) as error:
614
+ return _failed(str(error))
615
+ try:
616
+ async with client:
617
+ if options.session is not None:
618
+ try:
619
+ await client.sessions.open(options.session)
620
+ except (ConfigurationError, SessionNotFoundError, SessionLockedError) as error:
621
+ return _failed(str(error))
622
+ if options.title:
623
+ # The session this process holds, so the name is written where the daemon and `/resume` both read it.
624
+ try:
625
+ await client.session.rename(options.title)
626
+ except (OSError, ValueError, SessionLockedError) as error:
627
+ return _failed(str(error))
628
+ return await Daemon(client, options).serve()
629
+ except KeyboardInterrupt:
630
+ return EXIT_OK
631
+
632
+
633
+ def _client(options: Options) -> Client:
634
+ """Return the client a daemon runs on: its shell level, reachable, and with nobody to ask.
635
+
636
+ Three settings are the daemon's own, whatever the configuration says:
637
+
638
+ * ``permission=options.permission`` — chosen when it was started, and ``full`` unless that said otherwise. There
639
+ is no reader here to approve a command, so ``ask`` is refused before a daemon is made: it would only make
640
+ every command fail.
641
+ * ``sessions_enabled=True`` — being messaged is what a daemon is for; without the broker it can do nothing.
642
+ * ``ask_user_enabled=False`` — the ``ask_user`` tool is not offered at all. Declining the question would work
643
+ too, but it would cost the model a turn and teach it that its reader is unavailable, which is not the truth:
644
+ it has no reader.
645
+ """
646
+ from zettcode.sdk.client import Client
647
+
648
+ return Client(
649
+ options.workspace,
650
+ model=options.model,
651
+ effort=options.effort,
652
+ permission=options.permission,
653
+ sessions_enabled=True,
654
+ ask_user_enabled=False,
655
+ )
656
+
657
+
658
+ def _failed(said: str) -> int:
659
+ """Say why the daemon could not start, where the start that made it can read it."""
660
+ import sys
661
+
662
+ sys.stderr.write(f"zettcode daemon: {said}\n")
663
+ sys.stderr.flush()
664
+ return EXIT_UNUSABLE
665
+
666
+
667
+ def _stop_on_signals(stopping: asyncio.Event) -> None:
668
+ """Stop on SIGTERM and SIGINT, on either platform."""
669
+ loop = asyncio.get_running_loop()
670
+ for number in (signal.SIGTERM, signal.SIGINT, getattr(signal, "SIGBREAK", None)):
671
+ if number is None:
672
+ continue
673
+ try:
674
+ loop.add_signal_handler(number, stopping.set)
675
+ except (NotImplementedError, RuntimeError, ValueError):
676
+ # Windows: no loop-level handlers for these. A plain handler still stops an idle daemon.
677
+ with contextlib.suppress(ValueError, OSError, RuntimeError):
678
+ signal.signal(number, lambda *_: stopping.set())