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/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())
|