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/cli.py
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
"""``zettcode daemon``: the background agents this machine is running, and how they are started and stopped.
|
|
2
|
+
|
|
3
|
+
The command is a management surface only. Nothing is ever sent through it — a daemon is reached the way any other
|
|
4
|
+
session is, with ``send_message``, using its session id. What is here is the life around that:
|
|
5
|
+
|
|
6
|
+
zettcode daemon list
|
|
7
|
+
zettcode daemon spawn
|
|
8
|
+
zettcode daemon update <session-id>
|
|
9
|
+
zettcode daemon prompt <session-id> TEXT...
|
|
10
|
+
zettcode daemon log <session-id>
|
|
11
|
+
zettcode daemon stop <session-id> [--force]
|
|
12
|
+
zettcode daemon kill <session-id> [--force]
|
|
13
|
+
zettcode daemon delete <session-id>
|
|
14
|
+
|
|
15
|
+
A daemon runs at the shell level it was started with and with nobody to ask, which is why nothing here asks:
|
|
16
|
+
starting one is the decision, and it is taken by whoever runs ``spawn``.
|
|
17
|
+
|
|
18
|
+
The handlers are thin on purpose: they build a :class:`~.state.Registry` or a :class:`~.process.Daemons` and
|
|
19
|
+
print what it answers. What to run, how to wait for it and what to do when it dies is in those classes, not here.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
from zettcode.table import block, table
|
|
31
|
+
|
|
32
|
+
from . import PERMISSIONS
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
35
|
+
from collections.abc import Sequence
|
|
36
|
+
|
|
37
|
+
from .state import Record, State
|
|
38
|
+
|
|
39
|
+
__all__ = ["daemon_program"]
|
|
40
|
+
|
|
41
|
+
EXIT_OK = 0
|
|
42
|
+
EXIT_FAILED = 1
|
|
43
|
+
EXIT_USAGE = 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
47
|
+
"""Return the parser for ``zettcode daemon`` and its subcommands."""
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="zettcode daemon",
|
|
50
|
+
description="Run and manage background agents: a session another session can message.",
|
|
51
|
+
)
|
|
52
|
+
commands = parser.add_subparsers(dest="command", metavar="COMMAND", required=True)
|
|
53
|
+
|
|
54
|
+
listing = commands.add_parser(
|
|
55
|
+
"list",
|
|
56
|
+
help="show every daemon on this machine, alive or dead",
|
|
57
|
+
description="Show every daemon recorded under ~/.zettcode/daemon, whichever workspace it works in.",
|
|
58
|
+
)
|
|
59
|
+
listing.add_argument("--json", action="store_true", help="print the daemons as JSON, for a program to read")
|
|
60
|
+
|
|
61
|
+
models = commands.add_parser(
|
|
62
|
+
"model",
|
|
63
|
+
help="show the models a daemon can run on, and which one it would pick",
|
|
64
|
+
description=(
|
|
65
|
+
"Show every model the configuration lists, as `spawn --model` takes them: the first is what a daemon "
|
|
66
|
+
"runs on when no model is given."
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
models.add_argument(
|
|
70
|
+
"-w",
|
|
71
|
+
"--workspace",
|
|
72
|
+
metavar="DIR",
|
|
73
|
+
type=Path,
|
|
74
|
+
default=Path.cwd(),
|
|
75
|
+
help="directory whose configuration is read (default: the current directory)",
|
|
76
|
+
)
|
|
77
|
+
models.add_argument("--json", action="store_true", help="print the models as JSON, for a program to read")
|
|
78
|
+
|
|
79
|
+
spawn = commands.add_parser(
|
|
80
|
+
"spawn",
|
|
81
|
+
help="start a daemon and print its session id",
|
|
82
|
+
description=(
|
|
83
|
+
"Start a background agent in a workspace and print the session id other sessions message it by. "
|
|
84
|
+
"It runs without asking anyone; its shell level is the one given."
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
spawn.add_argument(
|
|
88
|
+
"-w",
|
|
89
|
+
"--workspace",
|
|
90
|
+
metavar="DIR",
|
|
91
|
+
type=Path,
|
|
92
|
+
default=Path.cwd(),
|
|
93
|
+
help="directory the daemon works in (default: the current directory)",
|
|
94
|
+
)
|
|
95
|
+
spawn.add_argument(
|
|
96
|
+
"--model", metavar="NAME", default=None, help="the model to run on (default: the first configured)"
|
|
97
|
+
)
|
|
98
|
+
spawn.add_argument("--effort", metavar="LEVEL", default=None, help="the reasoning level (default: the model's own)")
|
|
99
|
+
spawn.add_argument(
|
|
100
|
+
"-n",
|
|
101
|
+
"--name",
|
|
102
|
+
metavar="LABEL",
|
|
103
|
+
default=None,
|
|
104
|
+
help="a label for this daemon, shown by `daemon list` (default: none)",
|
|
105
|
+
)
|
|
106
|
+
spawn.add_argument(
|
|
107
|
+
"--permission",
|
|
108
|
+
metavar="LEVEL",
|
|
109
|
+
choices=PERMISSIONS,
|
|
110
|
+
default="full",
|
|
111
|
+
help=(
|
|
112
|
+
"shell level it runs at: safe puts every command in a sandbox, deny allows none (default: full). "
|
|
113
|
+
"There is no ask: nobody is there to answer"
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
spawn.add_argument(
|
|
117
|
+
"--title",
|
|
118
|
+
metavar="TEXT",
|
|
119
|
+
default=None,
|
|
120
|
+
help="what to call its session (default: it names itself from its first answer)",
|
|
121
|
+
)
|
|
122
|
+
spawn.add_argument(
|
|
123
|
+
"--resume",
|
|
124
|
+
metavar="SESSION",
|
|
125
|
+
default=None,
|
|
126
|
+
help="continue a stored session instead of starting an empty one",
|
|
127
|
+
)
|
|
128
|
+
spawn.add_argument("--json", action="store_true", help="print the new daemon as JSON, for a program to read")
|
|
129
|
+
|
|
130
|
+
stop = commands.add_parser(
|
|
131
|
+
"stop",
|
|
132
|
+
help="stop daemons by session id, and keep them listed so they can be started again",
|
|
133
|
+
description=(
|
|
134
|
+
"Stop each daemon named. Its record stays, shown as dead, so the daemon is still listed and `spawn "
|
|
135
|
+
"--resume ID` starts it again. A daemon in the middle of a turn is not stopped without --force."
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
stop.add_argument("session", metavar="SESSION", nargs="+", help="the session id `daemon list` shows")
|
|
139
|
+
stop.add_argument("--force", action="store_true", help="stop it even in the middle of a turn")
|
|
140
|
+
stop.add_argument("--json", action="store_true", help="print the outcome as JSON, for a program to read")
|
|
141
|
+
|
|
142
|
+
kill = commands.add_parser(
|
|
143
|
+
"kill",
|
|
144
|
+
help="stop daemons by session id, and remove their records",
|
|
145
|
+
description=(
|
|
146
|
+
"Stop each daemon named and remove its record; the session itself stays, so `zettcode resume ID` "
|
|
147
|
+
"opens the conversation afterwards. A daemon in the middle of a turn is not stopped without --force."
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
kill.add_argument("session", metavar="SESSION", nargs="+", help="the session id `daemon list` shows")
|
|
151
|
+
kill.add_argument("--force", action="store_true", help="stop it even in the middle of a turn")
|
|
152
|
+
kill.add_argument("--json", action="store_true", help="print the outcome as JSON, for a program to read")
|
|
153
|
+
|
|
154
|
+
delete = commands.add_parser(
|
|
155
|
+
"delete",
|
|
156
|
+
help="remove daemons for good: the record and the conversation, which cannot be brought back",
|
|
157
|
+
description=(
|
|
158
|
+
"Delete each daemon named, and the session it ran. This cannot be undone. A daemon that is running is "
|
|
159
|
+
"refused: stop it first."
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
delete.add_argument("session", metavar="SESSION", nargs="+", help="the session id `daemon list` shows")
|
|
163
|
+
delete.add_argument("--json", action="store_true", help="print the outcome as JSON, for a program to read")
|
|
164
|
+
|
|
165
|
+
update = commands.add_parser(
|
|
166
|
+
"update",
|
|
167
|
+
help="change what is recorded about a daemon: its label, or its session's name",
|
|
168
|
+
description=(
|
|
169
|
+
"Change a daemon's label, and name its session. A running daemon's session is its own, so `--title` is "
|
|
170
|
+
"refused while it runs — stop it first; `--name` may be changed at any time."
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
update.add_argument("session", metavar="SESSION", help="the session id `daemon list` shows")
|
|
174
|
+
update.add_argument(
|
|
175
|
+
"--title",
|
|
176
|
+
metavar="TEXT",
|
|
177
|
+
default=None,
|
|
178
|
+
help="the session's name, shown by `list` and by `zettcode resume`",
|
|
179
|
+
)
|
|
180
|
+
update.add_argument(
|
|
181
|
+
"--name",
|
|
182
|
+
metavar="LABEL",
|
|
183
|
+
default=None,
|
|
184
|
+
help="what the daemon is for, shown by `list` (an empty label clears it)",
|
|
185
|
+
)
|
|
186
|
+
update.add_argument(
|
|
187
|
+
"--model",
|
|
188
|
+
metavar="NAME",
|
|
189
|
+
default=None,
|
|
190
|
+
help="the model it runs from the next turn on (a running daemon; the conversation is kept)",
|
|
191
|
+
)
|
|
192
|
+
update.add_argument(
|
|
193
|
+
"--effort",
|
|
194
|
+
metavar="LEVEL",
|
|
195
|
+
default=None,
|
|
196
|
+
help="the reasoning level it runs from the next turn on (a running daemon)",
|
|
197
|
+
)
|
|
198
|
+
update.add_argument("--json", action="store_true", help="print the daemon as JSON, for a program to read")
|
|
199
|
+
|
|
200
|
+
prompt = commands.add_parser(
|
|
201
|
+
"prompt",
|
|
202
|
+
help="send a prompt to a daemon through the session broker",
|
|
203
|
+
description=(
|
|
204
|
+
"Send a daemon a message through the session broker, as a program outside every session would. Its turn "
|
|
205
|
+
"runs and the answer is stored in its session: `zettcode resume <id>` reads it, or `daemon log` shows "
|
|
206
|
+
"what it did. The sender is named, not a session, so the daemon does not answer back."
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
prompt.add_argument("session", metavar="SESSION", help="the session id `daemon list` shows")
|
|
210
|
+
prompt.add_argument("text", metavar="TEXT", nargs="+", help="what to ask it")
|
|
211
|
+
prompt.add_argument("--json", action="store_true", help="print the receipt as JSON, for a program to read")
|
|
212
|
+
|
|
213
|
+
log = commands.add_parser(
|
|
214
|
+
"log",
|
|
215
|
+
help="show what a daemon wrote about itself",
|
|
216
|
+
description=(
|
|
217
|
+
"Print the last lines a daemon wrote: the tools it ran, the turns that failed, the settings it took up. "
|
|
218
|
+
"The conversation itself is not here — `zettcode resume <id>` reads that."
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
log.add_argument("session", metavar="SESSION", help="the session id `daemon list` shows")
|
|
222
|
+
log.add_argument(
|
|
223
|
+
"-n", "--lines", type=_positive, default=200, metavar="N", help="how many last lines (default 200)"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Started by `spawn`, never by hand: it is the process a reader's shell would otherwise own.
|
|
227
|
+
run = commands.add_parser(
|
|
228
|
+
"run",
|
|
229
|
+
help="the process `spawn` starts; not meant to be run by hand",
|
|
230
|
+
description="Run one daemon in this process. `spawn` starts it detached, with the arguments below.",
|
|
231
|
+
)
|
|
232
|
+
run.add_argument("--token", required=True, help=argparse.SUPPRESS)
|
|
233
|
+
run.add_argument("--workspace", required=True, type=Path, help=argparse.SUPPRESS)
|
|
234
|
+
run.add_argument("--session", default=None, help=argparse.SUPPRESS)
|
|
235
|
+
run.add_argument("--model", default=None, help=argparse.SUPPRESS)
|
|
236
|
+
run.add_argument("--effort", default=None, help=argparse.SUPPRESS)
|
|
237
|
+
run.add_argument("--name", default=None, help=argparse.SUPPRESS)
|
|
238
|
+
run.add_argument("--title", default=None, help=argparse.SUPPRESS)
|
|
239
|
+
run.add_argument("--permission", default="full", choices=PERMISSIONS, help=argparse.SUPPRESS)
|
|
240
|
+
return parser
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def daemon_program(argv: Sequence[str] | None = None) -> int:
|
|
244
|
+
"""Run ``zettcode daemon`` for these arguments and return the exit status."""
|
|
245
|
+
parser = _build_parser()
|
|
246
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
247
|
+
if args.command == "list":
|
|
248
|
+
return _list(args.json)
|
|
249
|
+
if args.command == "model":
|
|
250
|
+
return _models(args.workspace, args.json)
|
|
251
|
+
if args.command == "spawn":
|
|
252
|
+
return _spawn(args)
|
|
253
|
+
if args.command == "update":
|
|
254
|
+
return _update(args)
|
|
255
|
+
if args.command == "prompt":
|
|
256
|
+
return _prompt(args)
|
|
257
|
+
if args.command == "log":
|
|
258
|
+
return _log(args.session, args.lines)
|
|
259
|
+
if args.command == "kill":
|
|
260
|
+
return _kill(args.session, args.json, force=args.force, keep=False)
|
|
261
|
+
if args.command == "stop":
|
|
262
|
+
return _kill(args.session, args.json, force=args.force, keep=True)
|
|
263
|
+
if args.command == "delete":
|
|
264
|
+
return _delete(args.session, args.json)
|
|
265
|
+
return _run(args)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _list(as_json: bool) -> int:
|
|
269
|
+
"""Print every daemon recorded, with what it is doing and whether its process is still there."""
|
|
270
|
+
from .state import Registry
|
|
271
|
+
|
|
272
|
+
found = sorted(Registry().all(), key=lambda record: record.state.started_at if record.state else 0.0, reverse=True)
|
|
273
|
+
if as_json:
|
|
274
|
+
print(json.dumps([_record_json(record) for record in found], indent=2))
|
|
275
|
+
return EXIT_OK
|
|
276
|
+
if not found:
|
|
277
|
+
print("No daemons. Start one with: zettcode daemon spawn")
|
|
278
|
+
return EXIT_OK
|
|
279
|
+
for index, record in enumerate(found):
|
|
280
|
+
if index:
|
|
281
|
+
print()
|
|
282
|
+
for line in _entry(record):
|
|
283
|
+
print(line)
|
|
284
|
+
if any(record.dead for record in found):
|
|
285
|
+
print("\nA dead record is one whose process is gone; `zettcode daemon kill <id>` removes it.")
|
|
286
|
+
return EXIT_OK
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _entry(record: Record) -> list[str]:
|
|
290
|
+
"""Return one daemon as ``list`` prints it: its id and state first, then one field per line."""
|
|
291
|
+
state = record.state
|
|
292
|
+
head = f"{record.session_id} {record.status} {_age(record.seconds())}"
|
|
293
|
+
if state is None:
|
|
294
|
+
return [head, *block([("name", "-"), ("workspace", "not recorded")])]
|
|
295
|
+
fields = [
|
|
296
|
+
("name", state.name or "-"),
|
|
297
|
+
("title", state.title or "-"),
|
|
298
|
+
("workspace", _short_path(state.workspace)),
|
|
299
|
+
("model", f"{state.model or '?'}" + (f" · effort {state.effort}" if state.effort else "")),
|
|
300
|
+
("context", _context_cell(state)),
|
|
301
|
+
("input", _tokens_cell(state.input_tokens)),
|
|
302
|
+
("output", _tokens_cell(state.output_tokens)),
|
|
303
|
+
("shell", state.permission or "?"),
|
|
304
|
+
]
|
|
305
|
+
if state.requested_model is not None or state.requested_effort is not None:
|
|
306
|
+
wanted = state.requested_model or state.model or "?"
|
|
307
|
+
level = f" · effort {state.requested_effort}" if state.requested_effort is not None else ""
|
|
308
|
+
fields.append(("next", f"{wanted}{level}"))
|
|
309
|
+
if state.requested_error is not None:
|
|
310
|
+
fields.append(("error", state.requested_error))
|
|
311
|
+
if state.broker is False and not record.dead:
|
|
312
|
+
fields.append(("broker", "offline: not reachable, and it keeps trying"))
|
|
313
|
+
return [head, *block(fields)]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _tokens_cell(count: int | None) -> str:
|
|
317
|
+
"""Return a token count in the short form a line has room for; ``None`` is a daemon that has counted none."""
|
|
318
|
+
if count is None:
|
|
319
|
+
return "-"
|
|
320
|
+
if count < 1_000:
|
|
321
|
+
return str(count)
|
|
322
|
+
# The thresholds are where rounding would carry into the next unit: 999_950 tokens is 1M, not 1000k.
|
|
323
|
+
if count < 999_950:
|
|
324
|
+
return _short(count / 1_000, "k")
|
|
325
|
+
if count < 999_950_000:
|
|
326
|
+
return _short(count / 1_000_000, "M")
|
|
327
|
+
return _short(count / 1_000_000_000, "G")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _short(value: float, unit: str) -> str:
|
|
331
|
+
"""Return one decimal of ``value`` with ``unit``, dropping a ``.0`` that says nothing."""
|
|
332
|
+
return f"{value:.1f}{unit}".replace(f".0{unit}", unit)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _context_cell(state: State) -> str:
|
|
336
|
+
"""Return how full the context was, of what window and as a share of it, when the daemon recorded both."""
|
|
337
|
+
if not state.context_tokens:
|
|
338
|
+
return "-"
|
|
339
|
+
if not state.context_window or state.context_tokens >= state.context_window:
|
|
340
|
+
# At or past the window this is not a share of it: the shell refuses such a figure for the same reason.
|
|
341
|
+
return _tokens_cell(state.context_tokens)
|
|
342
|
+
share = round(100 * state.context_tokens / state.context_window)
|
|
343
|
+
return f"{_tokens_cell(state.context_tokens)} of {_tokens_cell(state.context_window)} · {share}%"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _record_json(record: Record) -> dict[str, object]:
|
|
347
|
+
"""Return one daemon as plain data."""
|
|
348
|
+
state = record.state
|
|
349
|
+
return {
|
|
350
|
+
"session": record.session_id,
|
|
351
|
+
"name": state.name if state is not None else None,
|
|
352
|
+
"workspace": state.workspace if state is not None else None,
|
|
353
|
+
"model": state.model if state is not None else None,
|
|
354
|
+
"effort": state.effort if state is not None else None,
|
|
355
|
+
"permission": state.permission if state is not None else None,
|
|
356
|
+
"status": record.status,
|
|
357
|
+
"pid": state.pid if state is not None else None,
|
|
358
|
+
"seconds": round(record.seconds(), 1),
|
|
359
|
+
"title": state.title if state is not None else None,
|
|
360
|
+
"requested_model": state.requested_model if state is not None else None,
|
|
361
|
+
"requested_effort": state.requested_effort if state is not None else None,
|
|
362
|
+
"requested_error": state.requested_error if state is not None else None,
|
|
363
|
+
"broker": state.broker if state is not None else None,
|
|
364
|
+
"input_tokens": state.input_tokens if state is not None else None,
|
|
365
|
+
"output_tokens": state.output_tokens if state is not None else None,
|
|
366
|
+
"context_tokens": state.context_tokens if state is not None else None,
|
|
367
|
+
"context_window": state.context_window if state is not None else None,
|
|
368
|
+
"folder": str(record.folder),
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _spawn(args: argparse.Namespace) -> int:
|
|
373
|
+
"""Start one daemon, wait until it can be messaged, and print what to do with it."""
|
|
374
|
+
from .process import Daemons, SpawnFailed
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
record = Daemons().start(
|
|
378
|
+
args.workspace,
|
|
379
|
+
model=args.model,
|
|
380
|
+
effort=args.effort,
|
|
381
|
+
resume=args.resume,
|
|
382
|
+
name=args.name,
|
|
383
|
+
title=args.title,
|
|
384
|
+
permission=args.permission,
|
|
385
|
+
)
|
|
386
|
+
except SpawnFailed as error:
|
|
387
|
+
sys.stderr.write(f"zettcode daemon: could not start: {error}\n")
|
|
388
|
+
if error.log is not None:
|
|
389
|
+
sys.stderr.write(f"the daemon's first words are in {error.log}\n")
|
|
390
|
+
return EXIT_FAILED
|
|
391
|
+
state = record.state
|
|
392
|
+
if args.json:
|
|
393
|
+
print(json.dumps(_record_json(record), indent=2))
|
|
394
|
+
return EXIT_OK
|
|
395
|
+
print(f"session {record.session_id}")
|
|
396
|
+
if state is not None:
|
|
397
|
+
if state.name:
|
|
398
|
+
print(f"name {state.name}")
|
|
399
|
+
if state.title:
|
|
400
|
+
print(f"title {state.title}")
|
|
401
|
+
print(f"workspace {state.workspace}")
|
|
402
|
+
print(f"model {state.model or '?'}" + (f" · effort {state.effort}" if state.effort else ""))
|
|
403
|
+
print(f"shell {state.permission or '?'}")
|
|
404
|
+
print()
|
|
405
|
+
print(f'send_message(to="{record.session_id}", message="...")')
|
|
406
|
+
return EXIT_OK
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _models(workspace: Path, as_json: bool) -> int:
|
|
410
|
+
"""Print the models `spawn --model` can name, marking the one a daemon would run on by default.
|
|
411
|
+
|
|
412
|
+
Read with the SDK's file reader, so no client is built and no session is touched: the names are the ones
|
|
413
|
+
`spawn --model` matches, which is what the reader needs to choose.
|
|
414
|
+
"""
|
|
415
|
+
from zettcode.sdk.models import list_models
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
found = list_models()
|
|
419
|
+
except ValueError as error:
|
|
420
|
+
sys.stderr.write(f"zettcode daemon: {error}\n")
|
|
421
|
+
return EXIT_FAILED
|
|
422
|
+
if not found:
|
|
423
|
+
sys.stderr.write(
|
|
424
|
+
f"zettcode daemon: no models configured for {workspace}; add one with /config, or in the file\n"
|
|
425
|
+
)
|
|
426
|
+
return EXIT_FAILED
|
|
427
|
+
if as_json:
|
|
428
|
+
print(
|
|
429
|
+
json.dumps(
|
|
430
|
+
[
|
|
431
|
+
{
|
|
432
|
+
"name": model.name,
|
|
433
|
+
"model": model.model,
|
|
434
|
+
"default": index == 0,
|
|
435
|
+
"context_window": model.context_window,
|
|
436
|
+
"multimodal": model.multimodal,
|
|
437
|
+
"api": model.api,
|
|
438
|
+
"efforts": list(model.efforts),
|
|
439
|
+
}
|
|
440
|
+
for index, model in enumerate(found)
|
|
441
|
+
],
|
|
442
|
+
indent=2,
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
return EXIT_OK
|
|
446
|
+
rows: list[tuple[str, ...]] = [("NAME", "MODEL", "API", "IMAGES", "CONTEXT", "EFFORTS")]
|
|
447
|
+
for index, model in enumerate(found):
|
|
448
|
+
rows.append(
|
|
449
|
+
(
|
|
450
|
+
model.name + (" (default)" if index == 0 else ""),
|
|
451
|
+
model.model,
|
|
452
|
+
model.api,
|
|
453
|
+
"yes" if model.multimodal else "no",
|
|
454
|
+
f"{model.context_window:,}",
|
|
455
|
+
", ".join(model.efforts),
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
for line in table(rows):
|
|
459
|
+
print(line)
|
|
460
|
+
print("\nPick one with: zettcode daemon spawn --model <name>")
|
|
461
|
+
return EXIT_OK
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _kill(sessions: Sequence[str], as_json: bool, *, force: bool = False, keep: bool = False) -> int:
|
|
465
|
+
"""Stop each daemon named (and remove its record unless ``keep``); report one line per session."""
|
|
466
|
+
from .process import Daemons, StillWorking
|
|
467
|
+
from .state import Registry
|
|
468
|
+
|
|
469
|
+
registry = Registry()
|
|
470
|
+
daemons = Daemons(registry)
|
|
471
|
+
outcomes: list[dict[str, str]] = []
|
|
472
|
+
failed = False
|
|
473
|
+
for session in sessions:
|
|
474
|
+
record = registry.find(session)
|
|
475
|
+
if record is None:
|
|
476
|
+
failed = True
|
|
477
|
+
outcomes.append({"session": session, "result": "no daemon with that session"})
|
|
478
|
+
continue
|
|
479
|
+
try:
|
|
480
|
+
outcomes.append({"session": session, "result": daemons.stop(record, force=force, keep=keep)})
|
|
481
|
+
except StillWorking as error:
|
|
482
|
+
failed = True
|
|
483
|
+
outcomes.append({"session": session, "result": f"not stopped: {error}"})
|
|
484
|
+
return _report(outcomes, as_json, failed)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _delete(sessions: Sequence[str], as_json: bool) -> int:
|
|
488
|
+
"""Delete each daemon named, record and conversation; report one line per session."""
|
|
489
|
+
from .process import Daemons, StillRunning, UpdateFailed
|
|
490
|
+
from .state import Registry
|
|
491
|
+
|
|
492
|
+
registry = Registry()
|
|
493
|
+
daemons = Daemons(registry)
|
|
494
|
+
outcomes: list[dict[str, str]] = []
|
|
495
|
+
failed = False
|
|
496
|
+
for session in sessions:
|
|
497
|
+
record = registry.find(session)
|
|
498
|
+
if record is None:
|
|
499
|
+
failed = True
|
|
500
|
+
outcomes.append({"session": session, "result": "no daemon with that session"})
|
|
501
|
+
continue
|
|
502
|
+
try:
|
|
503
|
+
outcomes.append({"session": session, "result": daemons.delete(record)})
|
|
504
|
+
except (StillRunning, UpdateFailed) as error:
|
|
505
|
+
failed = True
|
|
506
|
+
outcomes.append({"session": session, "result": f"not deleted: {error}"})
|
|
507
|
+
return _report(outcomes, as_json, failed)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _report(outcomes: list[dict[str, str]], as_json: bool, failed: bool) -> int:
|
|
511
|
+
"""Print one outcome per session, as JSON or as lines, and return the exit status."""
|
|
512
|
+
if as_json:
|
|
513
|
+
print(json.dumps(outcomes, indent=2))
|
|
514
|
+
else:
|
|
515
|
+
for outcome in outcomes:
|
|
516
|
+
print(f"{outcome['session']} {outcome['result']}")
|
|
517
|
+
return EXIT_FAILED if failed else EXIT_OK
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _update(args: argparse.Namespace) -> int:
|
|
521
|
+
"""Change a daemon's label, its session's name, or what it runs on, and print what is recorded now."""
|
|
522
|
+
from .process import Daemons, UpdateFailed
|
|
523
|
+
from .state import Registry
|
|
524
|
+
|
|
525
|
+
if args.title is None and args.name is None and args.model is None and args.effort is None:
|
|
526
|
+
sys.stderr.write("nothing to change: give --title, --name, --model or --effort\n")
|
|
527
|
+
return EXIT_USAGE
|
|
528
|
+
registry = Registry()
|
|
529
|
+
record = registry.find(args.session)
|
|
530
|
+
if record is None:
|
|
531
|
+
sys.stderr.write(f"no daemon with that session: {args.session}\n")
|
|
532
|
+
return EXIT_FAILED
|
|
533
|
+
try:
|
|
534
|
+
updated = Daemons(registry).update(
|
|
535
|
+
record, title=args.title, name=args.name, model=args.model, effort=args.effort
|
|
536
|
+
)
|
|
537
|
+
except UpdateFailed as error:
|
|
538
|
+
sys.stderr.write(f"zettcode daemon: {error}\n")
|
|
539
|
+
return EXIT_FAILED
|
|
540
|
+
if args.json:
|
|
541
|
+
print(json.dumps(_record_json(updated), indent=2))
|
|
542
|
+
return EXIT_OK
|
|
543
|
+
state = updated.state
|
|
544
|
+
print(f"session {updated.session_id}")
|
|
545
|
+
if state is not None:
|
|
546
|
+
print(f"name {state.name or '-'}")
|
|
547
|
+
print(f"title {state.title or '-'}")
|
|
548
|
+
print(f"model {state.model or '?'}" + (f" · effort {state.effort}" if state.effort else ""))
|
|
549
|
+
if state.requested_model is not None or state.requested_effort is not None:
|
|
550
|
+
wanted = state.requested_model or state.model or "?"
|
|
551
|
+
level = f" · effort {state.requested_effort}" if state.requested_effort is not None else ""
|
|
552
|
+
print(f"next {wanted}{level}")
|
|
553
|
+
if state.requested_error is not None:
|
|
554
|
+
print(f"error {state.requested_error}")
|
|
555
|
+
if state.broker is False and not updated.dead:
|
|
556
|
+
print("broker offline: not reachable, and it keeps trying")
|
|
557
|
+
return EXIT_OK
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _prompt(args: argparse.Namespace) -> int:
|
|
561
|
+
"""Send a daemon a prompt through the session broker, and say what the broker did with it."""
|
|
562
|
+
import httpx
|
|
563
|
+
|
|
564
|
+
from zettcode.broker.files import broker_dir, read_note
|
|
565
|
+
from zettcode.broker.protocol import PROTOCOL_VERSION, Refused
|
|
566
|
+
|
|
567
|
+
from .state import Registry
|
|
568
|
+
|
|
569
|
+
record = Registry().find(args.session)
|
|
570
|
+
if record is None:
|
|
571
|
+
sys.stderr.write(f"no daemon with that session: {args.session}\n")
|
|
572
|
+
return EXIT_FAILED
|
|
573
|
+
note = read_note(broker_dir())
|
|
574
|
+
if note is None:
|
|
575
|
+
sys.stderr.write("no session broker is running, so a daemon cannot be reached\n")
|
|
576
|
+
return EXIT_FAILED
|
|
577
|
+
if note.version != PROTOCOL_VERSION:
|
|
578
|
+
sys.stderr.write(f"the session broker speaks protocol {note.version}, not {PROTOCOL_VERSION}; restart it\n")
|
|
579
|
+
return EXIT_FAILED
|
|
580
|
+
text = " ".join(args.text).strip()
|
|
581
|
+
if not text:
|
|
582
|
+
sys.stderr.write("a prompt needs some text\n")
|
|
583
|
+
return EXIT_USAGE
|
|
584
|
+
body = {
|
|
585
|
+
"to": record.session_id,
|
|
586
|
+
"text": text,
|
|
587
|
+
"via": "external",
|
|
588
|
+
"from": {"name": "zettcode daemon prompt"},
|
|
589
|
+
}
|
|
590
|
+
try:
|
|
591
|
+
response = httpx.post(
|
|
592
|
+
f"http://127.0.0.1:{note.port}/messages",
|
|
593
|
+
json=body,
|
|
594
|
+
headers={"Authorization": f"Bearer {note.token}"},
|
|
595
|
+
# The broker waits for the recipient's ack (ten seconds by default) and only then answers; a timeout
|
|
596
|
+
# as short as that would turn "it may still be delivered" into "the broker is unreachable".
|
|
597
|
+
timeout=15.0,
|
|
598
|
+
trust_env=False,
|
|
599
|
+
)
|
|
600
|
+
except httpx.HTTPError as error:
|
|
601
|
+
sys.stderr.write(f"zettcode daemon: the broker could not be reached: {error}\n")
|
|
602
|
+
return EXIT_FAILED
|
|
603
|
+
try:
|
|
604
|
+
answer = response.json()
|
|
605
|
+
except ValueError:
|
|
606
|
+
answer = None
|
|
607
|
+
if response.status_code != 200 or not isinstance(answer, dict):
|
|
608
|
+
refused = Refused.from_wire(answer.get("error") if isinstance(answer, dict) else None)
|
|
609
|
+
sys.stderr.write(f"zettcode daemon: {refused}\n")
|
|
610
|
+
return EXIT_FAILED
|
|
611
|
+
disposition = answer.get("disposition")
|
|
612
|
+
disposition = disposition if isinstance(disposition, str) and disposition else "sent"
|
|
613
|
+
if args.json:
|
|
614
|
+
print(json.dumps({"session": record.session_id, "disposition": disposition}, indent=2))
|
|
615
|
+
else:
|
|
616
|
+
print(f"{record.session_id} {disposition}")
|
|
617
|
+
return EXIT_OK
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _log(session: str, lines: int) -> int:
|
|
621
|
+
"""Print the last lines a daemon wrote about itself."""
|
|
622
|
+
from zettcode.sdk.daemons import daemon_log
|
|
623
|
+
from zettcode.sdk.errors import SessionNotFoundError
|
|
624
|
+
|
|
625
|
+
try:
|
|
626
|
+
text = daemon_log(session, lines=lines)
|
|
627
|
+
except SessionNotFoundError:
|
|
628
|
+
sys.stderr.write(f"no daemon with that session: {session}\n")
|
|
629
|
+
return EXIT_FAILED
|
|
630
|
+
print(text or "(nothing logged)")
|
|
631
|
+
return EXIT_OK
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _run(args: argparse.Namespace) -> int:
|
|
635
|
+
"""Run the daemon process itself; this is what ``spawn`` starts."""
|
|
636
|
+
import asyncio
|
|
637
|
+
|
|
638
|
+
from .run import Options, serve
|
|
639
|
+
|
|
640
|
+
options = Options(
|
|
641
|
+
workspace=args.workspace,
|
|
642
|
+
token=args.token,
|
|
643
|
+
session=args.session,
|
|
644
|
+
model=args.model,
|
|
645
|
+
effort=args.effort,
|
|
646
|
+
name=args.name,
|
|
647
|
+
title=args.title,
|
|
648
|
+
permission=args.permission,
|
|
649
|
+
)
|
|
650
|
+
try:
|
|
651
|
+
return asyncio.run(serve(options))
|
|
652
|
+
except KeyboardInterrupt:
|
|
653
|
+
return EXIT_OK
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _positive(text: str) -> int:
|
|
657
|
+
"""Parse a count of lines for ``daemon log``: one at least, refused rather than quietly changed."""
|
|
658
|
+
try:
|
|
659
|
+
value = int(text)
|
|
660
|
+
except ValueError:
|
|
661
|
+
raise argparse.ArgumentTypeError(f"{text!r} is not a number") from None
|
|
662
|
+
if value < 1:
|
|
663
|
+
raise argparse.ArgumentTypeError("at least one line")
|
|
664
|
+
return value
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _age(seconds: float) -> str:
|
|
668
|
+
"""Say how long a daemon has been running, in the short form a table has room for."""
|
|
669
|
+
total = max(0, int(seconds))
|
|
670
|
+
if total < 60:
|
|
671
|
+
return f"{total}s"
|
|
672
|
+
minutes = total // 60
|
|
673
|
+
if minutes < 60:
|
|
674
|
+
return f"{minutes}m"
|
|
675
|
+
hours, minutes = divmod(minutes, 60)
|
|
676
|
+
return f"{hours}h {minutes:02d}m"
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _short_path(path: str) -> str:
|
|
680
|
+
"""Shorten an absolute path for a table cell: ``~`` for the home directory."""
|
|
681
|
+
try:
|
|
682
|
+
home = str(Path.home())
|
|
683
|
+
except RuntimeError: # pragma: no cover - no home directory
|
|
684
|
+
return path
|
|
685
|
+
if path == home:
|
|
686
|
+
return "~"
|
|
687
|
+
if path.startswith(home + "/"):
|
|
688
|
+
return "~/" + path[len(home) + 1 :]
|
|
689
|
+
return path
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
if __name__ == "__main__":
|
|
693
|
+
raise SystemExit(daemon_program())
|