zettcode-server 0.1.0__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_server/cli.py ADDED
@@ -0,0 +1,114 @@
1
+ """The ``zettcode-server`` program: parse the command line, then serve until stopped.
2
+
3
+ Nothing heavy is imported until a server is actually started: ``--help`` and ``--version`` answer without loading
4
+ FastAPI, the agent, or the SDK, which is what keeps the command that explains the program the fastest one it has.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import __version__
15
+ from .settings import DEFAULT_HOST, DEFAULT_PERMISSION, DEFAULT_PORT, TOKEN_VARIABLE, Settings
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ """Return the parser: the workspace, the socket, the token, and how a run starts."""
20
+ parser = argparse.ArgumentParser(
21
+ prog="zettcode-server",
22
+ description=(
23
+ "Serve the ZettCode coding agent over HTTP, for one workspace. Prompts stream as server-sent events; "
24
+ "sessions, models, settings, usage and the context report are plain JSON."
25
+ ),
26
+ )
27
+ parser.add_argument(
28
+ "-w",
29
+ "--workspace",
30
+ metavar="DIR",
31
+ type=Path,
32
+ default=Path.cwd(),
33
+ help="directory the agent works in (default: the current directory)",
34
+ )
35
+ parser.add_argument("--host", default=DEFAULT_HOST, help=f"interface to bind (default: {DEFAULT_HOST})")
36
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"port to bind (default: {DEFAULT_PORT})")
37
+ parser.add_argument(
38
+ "--token",
39
+ default=None,
40
+ help=f"bearer token every endpoint but /health requires (default: ${TOKEN_VARIABLE}, or none)",
41
+ )
42
+ parser.add_argument(
43
+ "--permission",
44
+ choices=("deny", "ask", "safe", "auto"),
45
+ default=DEFAULT_PERMISSION,
46
+ help=f"shell permission a run starts at (default: {DEFAULT_PERMISSION})",
47
+ )
48
+ parser.add_argument("--model", default=None, help="model to start on, by name (default: the first configured)")
49
+ parser.add_argument("--effort", default=None, help="reasoning effort to start on (default: the model's own)")
50
+ parser.add_argument("--config", metavar="FILE", type=Path, default=None, help="configuration file to read")
51
+ parser.add_argument(
52
+ "--log-level",
53
+ choices=("critical", "error", "warning", "info", "debug", "trace"),
54
+ default="info",
55
+ help="uvicorn's log level (default: info)",
56
+ )
57
+ parser.add_argument("--version", action="version", version=version_text())
58
+ return parser
59
+
60
+
61
+ def version_text() -> str:
62
+ """Return the line ``--version`` prints: the server, and the agent it serves."""
63
+ from zettcode import __version__ as agent
64
+
65
+ return f"zettcode-server {__version__} (zettcode {agent})"
66
+
67
+
68
+ def settings_from(argv: list[str] | None = None) -> Settings:
69
+ """Return the settings the arguments name; ``--help`` and ``--version`` exit here, before any import."""
70
+ args = build_parser().parse_args(argv)
71
+ if not 0 < args.port < 65536:
72
+ raise SystemExit(f"zettcode-server: --port must be between 1 and 65535, not {args.port}")
73
+ workspace = args.workspace.expanduser()
74
+ if not workspace.is_dir():
75
+ raise SystemExit(f"zettcode-server: no such directory: {workspace}")
76
+ return Settings(
77
+ workspace=workspace.resolve(),
78
+ host=args.host,
79
+ port=args.port,
80
+ token=args.token or os.environ.get(TOKEN_VARIABLE) or None,
81
+ permission=args.permission,
82
+ model=args.model,
83
+ effort=args.effort,
84
+ config_path=args.config.expanduser() if args.config is not None else None,
85
+ log_level=args.log_level,
86
+ )
87
+
88
+
89
+ def main(argv: list[str] | None = None) -> None:
90
+ """Installed console-script entry point: serve until interrupted."""
91
+ settings = settings_from(sys.argv[1:] if argv is None else argv)
92
+ from .app import create_app
93
+
94
+ try:
95
+ import uvicorn
96
+ except ImportError as error: # pragma: no cover - the wheel declares it
97
+ raise SystemExit(f"zettcode-server: uvicorn is not installed: {error}") from error
98
+
99
+ token = "on" if settings.token else "off"
100
+ print(
101
+ f"{version_text()} · {settings.url} · workspace {settings.workspace} · permission {settings.permission} "
102
+ f"· token {token}",
103
+ flush=True,
104
+ )
105
+ uvicorn.run(
106
+ create_app(settings),
107
+ host=settings.host,
108
+ port=settings.port,
109
+ log_level=settings.log_level,
110
+ )
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
@@ -0,0 +1,472 @@
1
+ """The one agent a server process owns: who may run, and how a run's events reach a client.
2
+
3
+ A server serves one workspace, so it has one client and one conversation at a time — the same rule the terminal and
4
+ the SDK follow. A prompt that arrives while another runs is refused with ``busy`` instead of queued: the caller can
5
+ see that nothing happened, and a long run is never silently joined by a second.
6
+
7
+ A run started over HTTP has no terminal to answer it, so the answers travel with the request or over the endpoints
8
+ that post back into the run: :class:`~zettcode_server.schemas.ApprovalPolicy` decides shell commands up front
9
+ (``deny``, ``auto``, ``commands``) or defers each one to the client (``ask``); questions are declined unless the
10
+ request says ``questions: ask``. A deferred request waits for ``POST /runs/approvals`` or ``POST /runs/questions``
11
+ for ``answer_timeout`` seconds and then takes the safe default, so no client can leave a run suspended forever.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import base64
18
+ from collections.abc import AsyncIterator, Callable
19
+ from dataclasses import dataclass, field
20
+ from typing import TYPE_CHECKING, Literal, cast
21
+
22
+ from zettcode.sdk.errors import SessionBusyError
23
+ from zettcode.sdk.events import ApprovalRequested, QuestionAsked
24
+ from zettcode.sdk.handlers import ApprovalHandler, approve_all, approve_commands, deny_all
25
+
26
+ from .schemas import ApprovalPolicy, PendingOut, PromptRequest, ResumeRequest
27
+ from .serialize import event_payload, result_out
28
+
29
+ if TYPE_CHECKING: # pragma: no cover - annotations only
30
+ from zettcode.sdk.client import Client, RunStream
31
+ from zettcode.sdk.types import (
32
+ Image,
33
+ MentionInfo,
34
+ ModelCheck,
35
+ ModelInfo,
36
+ ResumeInfo,
37
+ RunResult,
38
+ SessionStatsInfo,
39
+ WorkspaceUsage,
40
+ )
41
+
42
+ from .settings import Settings
43
+
44
+ #: Builds the client the server runs on; the tests pass one that answers without a model.
45
+ ClientFactory = Callable[[], "Client"]
46
+
47
+ #: One frame of a run: its SSE event name, and the JSON payload under it.
48
+ Frame = tuple[str, dict[str, object]]
49
+
50
+ #: What a run may be asked to do, whether it starts a prompt or continues one.
51
+ Request = PromptRequest | ResumeRequest
52
+
53
+
54
+ def approval_handler(policy: ApprovalPolicy) -> ApprovalHandler | None:
55
+ """Return the handler a run answers shell commands with, or ``None`` when the client is asked instead.
56
+
57
+ Raises:
58
+ ValueError: ``commands`` names a command that is blank or holds a shell character, which the SDK refuses
59
+ because ``git status; rm -rf ~`` begins with an approved command too.
60
+ """
61
+ if policy.mode == "auto":
62
+ return approve_all
63
+ if policy.mode == "commands":
64
+ return approve_commands(*policy.commands)
65
+ if policy.mode == "ask":
66
+ return None
67
+ return deny_all
68
+
69
+
70
+ def images_of(request: Request) -> tuple[Image, ...]:
71
+ """Return the pictures a prompt carries, decoded.
72
+
73
+ Raises:
74
+ ValueError: The base64 is not base64, or a picture is of a type no model takes.
75
+ """
76
+ from zettcode.sdk.types import Image
77
+
78
+ images = getattr(request, "images", ())
79
+ decoded: list[Image] = []
80
+ for entry in images:
81
+ try:
82
+ data = base64.b64decode(entry.data, validate=True)
83
+ except Exception as error: # noqa: BLE001 - any decoding failure is the same client mistake
84
+ raise ValueError(f"The image data is not valid base64: {error}") from error
85
+ decoded.append(Image(data, entry.media_type))
86
+ return tuple(decoded)
87
+
88
+
89
+ def prepare(request: Request) -> None:
90
+ """Refuse a request that cannot run before its stream opens, where a status code still reaches the client."""
91
+ approval_handler(request.approvals)
92
+ images_of(request)
93
+
94
+
95
+ @dataclass
96
+ class Pending:
97
+ """An approval or a question the run is suspended on, and the slot its answer settles.
98
+
99
+ Attributes:
100
+ kind: What is waiting.
101
+ call_id: The tool call it belongs to, as the client saw it in the stream.
102
+ event: The SDK event to answer.
103
+ answered: Set once an answer arrived; the run stops waiting on it.
104
+ """
105
+
106
+ kind: Literal["approval", "question"]
107
+ call_id: str
108
+ event: ApprovalRequested | QuestionAsked
109
+ answered: asyncio.Event = field(default_factory=asyncio.Event)
110
+
111
+ def describe(self) -> PendingOut:
112
+ """Return what a reconnecting client needs to show the request again."""
113
+ if isinstance(self.event, ApprovalRequested):
114
+ return PendingOut(
115
+ kind="approval",
116
+ call_id=self.call_id,
117
+ command=self.event.command,
118
+ timeout_seconds=self.event.timeout_seconds,
119
+ remember_supported=self.event.remember_supported,
120
+ )
121
+ return PendingOut(
122
+ kind="question",
123
+ call_id=self.call_id,
124
+ question=self.event.question,
125
+ options=list(self.event.options),
126
+ allow_multiple=self.event.allow_multiple,
127
+ )
128
+
129
+
130
+ class NoModelError(RuntimeError):
131
+ """No model is configured yet, so there is nothing to run a prompt with.
132
+
133
+ A fresh machine has no ``[[models]]`` entry; the server still starts, lists and edits the configuration, and
134
+ answers this on what needs an agent. ``POST /models`` is what turns it into a working server.
135
+ """
136
+
137
+
138
+ class Runner:
139
+ """The agent, one run at a time, and the events and answers of the run that is going.
140
+
141
+ The client is optional: a workspace with no configured model has none, and the server still serves the
142
+ configuration endpoints so one can be set up from the editor that is using it. Everything that needs the agent
143
+ raises :class:`NoModelError`, which the API maps to ``409 no_model``.
144
+
145
+ Args:
146
+ settings: The workspace and the level a run starts at.
147
+ factory: Builds the client; by default an SDK :class:`~zettcode.sdk.Client` on the settings' workspace.
148
+ ``None`` from it means "no model configured", which is how the tests cover the setup path.
149
+ """
150
+
151
+ def __init__(self, settings: Settings, factory: ClientFactory | None = None) -> None:
152
+ self.settings = settings
153
+ self._factory = factory
154
+ self._client: Client | None = None
155
+ self._run: RunStream | None = None
156
+ self._pending: Pending | None = None
157
+ self._lock = asyncio.Lock()
158
+
159
+ # -- life -----------------------------------------------------------------
160
+ async def start(self) -> None:
161
+ """Open the client, or start without one when no model is configured yet.
162
+
163
+ The SDK refuses a configuration with no models, and that is the state of every fresh machine. Rather than
164
+ refuse to serve, the server keeps the configuration endpoints open and answers what needs a model with
165
+ ``no_model``.
166
+ """
167
+ if self._client is not None:
168
+ return
169
+ if self._factory is not None:
170
+ self._client = self._factory()
171
+ return
172
+ from zettcode.sdk import Client
173
+ from zettcode.sdk.errors import ConfigurationError
174
+
175
+ try:
176
+ self._client = await asyncio.to_thread(
177
+ lambda: Client(
178
+ self.settings.workspace,
179
+ config_path=self.settings.config_path,
180
+ model=self.settings.model,
181
+ effort=self.settings.effort,
182
+ permission=self.settings.permission,
183
+ )
184
+ )
185
+ except ConfigurationError as error:
186
+ if "No models configured" not in str(error):
187
+ raise
188
+ self._client = None
189
+
190
+ @property
191
+ def ready(self) -> bool:
192
+ """Return whether a model is configured, so a prompt can run."""
193
+ return self._client is not None
194
+
195
+ async def close(self) -> None:
196
+ """Stop anything still running and close the client, which releases the session lock."""
197
+ await self.cancel()
198
+ client, self._client = self._client, None
199
+ if client is not None:
200
+ await client.aclose()
201
+
202
+ @property
203
+ def client(self) -> Client:
204
+ """Return the open client.
205
+
206
+ Raises:
207
+ NoModelError: No model is configured yet, so there is no agent to drive.
208
+ """
209
+ if self._client is None:
210
+ raise NoModelError("No model is configured; add one with POST /models")
211
+ return self._client
212
+
213
+ @property
214
+ def maybe_client(self) -> Client | None:
215
+ """Return the client, or ``None`` before a model is configured."""
216
+ return self._client
217
+
218
+ @property
219
+ def busy(self) -> bool:
220
+ """Return whether a prompt is running."""
221
+ return self._lock.locked()
222
+
223
+ @property
224
+ def pending(self) -> PendingOut | None:
225
+ """Return the approval or question the run waits on, if it is waiting on one."""
226
+ return None if self._pending is None else self._pending.describe()
227
+
228
+ # -- running --------------------------------------------------------------
229
+ async def stream(self, request: Request, *, resume: bool = False) -> AsyncIterator[Frame]:
230
+ """Run one prompt, or continue an interrupted one, and yield its events and then its result.
231
+
232
+ The stream ends with ``result`` when the model finished, ``error`` when the run failed, and ``cancelled`` when
233
+ it was stopped from outside — a closed connection included, which is what stops a run whose reader is gone.
234
+ """
235
+ if self._lock.locked():
236
+ raise SessionBusyError("A run is already in progress; wait for it or cancel it")
237
+ handler = approval_handler(request.approvals)
238
+ images = images_of(request)
239
+ await self._lock.acquire()
240
+ run: RunStream | None = None
241
+ try:
242
+ run = self._start(request, handler, images, resume)
243
+ self._run = run
244
+ async for event in run:
245
+ yield "event", event_payload(event)
246
+ note = await self._settle(event, request)
247
+ if note:
248
+ yield "event", {"type": "notice", "text": note, "level": "warn"}
249
+ result = run.result
250
+ if result is None:
251
+ yield "cancelled", {"type": "cancelled"}
252
+ else:
253
+ yield "result", result_payload(result)
254
+ finally:
255
+ self._pending = None
256
+ run, self._run = self._run, None
257
+ if run is not None:
258
+ await run.aclose()
259
+ self._lock.release()
260
+
261
+ async def ask_stream(self, question: str, timeout: float | None) -> AsyncIterator[Frame]:
262
+ """Answer a side question and yield the answer as it is written, in the frames a prompt uses.
263
+
264
+ The question holds the run lock while it is answered, so a prompt cannot start under it and
265
+ ``/runs/cancel`` stops it like any other run; it ends in ``result``, ``error`` or ``cancelled``.
266
+ """
267
+ if self._lock.locked():
268
+ raise SessionBusyError("A run is already in progress; ask again when it ends")
269
+ await self._lock.acquire()
270
+ try:
271
+ run = self.client.session.ask_stream(question, timeout=timeout)
272
+ self._run = run
273
+ async for event in run:
274
+ yield "event", event_payload(event)
275
+ result = run.result
276
+ if result is None:
277
+ yield "cancelled", {"type": "cancelled"}
278
+ else:
279
+ yield "result", result_payload(result)
280
+ finally:
281
+ current, self._run = self._run, None
282
+ if current is not None:
283
+ await current.aclose()
284
+ self._lock.release()
285
+
286
+ def _start(
287
+ self, request: Request, handler: ApprovalHandler | None, images: tuple[Image, ...], resume: bool
288
+ ) -> RunStream:
289
+ """Start the SDK's run; kept apart so a failure to start does not look like a failure mid-stream."""
290
+ session = self.client.session
291
+ if resume:
292
+ return session.resume_stream(on_approval=handler, timeout=request.timeout, keep_events=request.keep_events)
293
+ prompt = cast(PromptRequest, request)
294
+ return self.client.prompt(
295
+ prompt.text,
296
+ images=images,
297
+ mentions=prompt.mentions,
298
+ on_approval=handler,
299
+ timeout=request.timeout,
300
+ keep_events=request.keep_events,
301
+ )
302
+
303
+ async def _settle(self, event: object, request: Request) -> str | None:
304
+ """Wait for the client's answer when the request asked for one; return a note when none came.
305
+
306
+ Called after the event has gone down the stream and before the next one is asked for, which is exactly the
307
+ window in which the SDK leaves a request unanswered and has not yet applied its own default.
308
+ """
309
+ if isinstance(event, ApprovalRequested) and request.approvals.mode == "ask":
310
+ note = await self._hold(Pending("approval", event.call_id, event), request.answer_timeout)
311
+ if note and not event.answered:
312
+ event.deny()
313
+ return note
314
+ if isinstance(event, QuestionAsked) and request.questions == "ask":
315
+ note = await self._hold(Pending("question", event.call_id, event), request.answer_timeout)
316
+ if note and not event.answered:
317
+ event.decline()
318
+ return note
319
+ return None
320
+
321
+ async def _hold(self, pending: Pending, seconds: float) -> str | None:
322
+ """Publish a pending request and wait for its answer; return why it was given up on, or ``None``."""
323
+ self._pending = pending
324
+ try:
325
+ await asyncio.wait_for(pending.answered.wait(), seconds)
326
+ return None
327
+ except asyncio.TimeoutError:
328
+ if pending.kind == "approval":
329
+ return f"No answer within {seconds:g}s; the command was denied."
330
+ return f"No answer within {seconds:g}s; the question was declined."
331
+ finally:
332
+ self._pending = None
333
+
334
+ async def resolve_approval(self, call_id: str, approve: bool, remember: bool = False) -> bool:
335
+ """Answer the approval the run waits on; ``False`` when it names one that is not waiting."""
336
+ pending = self._pending
337
+ if pending is None or pending.kind != "approval" or pending.call_id != call_id:
338
+ return False
339
+ event = cast(ApprovalRequested, pending.event)
340
+ if event.answered:
341
+ return False
342
+ event.approve(remember=remember) if approve else event.deny()
343
+ pending.answered.set()
344
+ return True
345
+
346
+ async def resolve_question(self, call_id: str, answer: str | None) -> bool:
347
+ """Answer, or decline with ``None``, the question the run waits on."""
348
+ pending = self._pending
349
+ if pending is None or pending.kind != "question" or pending.call_id != call_id:
350
+ return False
351
+ event = cast(QuestionAsked, pending.event)
352
+ if event.answered:
353
+ return False
354
+ event.answer(answer) if answer is not None else event.decline()
355
+ pending.answered.set()
356
+ return True
357
+
358
+ async def cancel(self) -> bool:
359
+ """Stop the run in progress, if there is one, and return whether there was.
360
+
361
+ The stream of a cancelled run sees its events end and reports ``cancelled``; its reader is not an error.
362
+ """
363
+ run = self._run
364
+ if run is None:
365
+ return False
366
+ await run.aclose()
367
+ return True
368
+
369
+ # -- the conversation, without running it --------------------------------
370
+ def steer(self, message: str) -> bool:
371
+ """Send a message into the run in progress; ``False`` when nothing is running to take it."""
372
+ return self.client.session.steer(message)
373
+
374
+ async def resumable(self) -> ResumeInfo | None:
375
+ """Return where the conversation was interrupted, or ``None`` when it was not."""
376
+ return await self.client.session.resumable()
377
+
378
+ # -- the models the config file lists ------------------------------------
379
+ def models_now(self) -> tuple[ModelInfo, ...]:
380
+ """Return the configured models, from the client when there is one and from the file when there is not."""
381
+ if self._client is not None:
382
+ return tuple(self._client.models)
383
+ from zettcode.sdk import list_models
384
+
385
+ return list_models(self.settings.config_path)
386
+
387
+ @property
388
+ def current_model(self) -> str | None:
389
+ """Return the model a run would use, or ``None`` before one is configured."""
390
+ return None if self._client is None else self._client.model.name
391
+
392
+ async def reload(self, *, model: str | None = None) -> None:
393
+ """Rebuild the client after the configuration file changed, keeping the conversation and its settings.
394
+
395
+ The SDK reads the models when a client is made, so a model added, changed or removed on disk reaches the agent
396
+ by making the client again. The session id is carried over (a fresh, never-stored one simply starts again), as
397
+ are the effort and permission, and the model named stays if it is still configured.
398
+
399
+ Raises:
400
+ SessionBusyError: A run is in progress; the configuration of a running conversation is not swapped.
401
+ """
402
+ if self.busy:
403
+ raise SessionBusyError("A run is in progress; change the models when it ends")
404
+ previous = self._client
405
+ session = previous.session.id if previous is not None else None
406
+ permission = previous.permission if previous is not None else self.settings.permission
407
+ effort = previous.effort if previous is not None else self.settings.effort
408
+ wanted = model or (previous.model.name if previous is not None else None)
409
+ if previous is not None:
410
+ await previous.aclose()
411
+ self._client = None
412
+ if self._factory is not None:
413
+ self._client = self._factory()
414
+ if self._client is not None and model is not None:
415
+ self._client.use_model(model)
416
+ return
417
+
418
+ from zettcode.sdk import Client, list_models
419
+ from zettcode.sdk.errors import SessionNotFoundError
420
+
421
+ models = list_models(self.settings.config_path)
422
+ if not models:
423
+ return
424
+ names = {entry.name for entry in models}
425
+ chosen = wanted if wanted in names else None
426
+
427
+ async def open_client(resume: str | None):
428
+ return await asyncio.to_thread(
429
+ lambda: Client(
430
+ self.settings.workspace,
431
+ config_path=self.settings.config_path,
432
+ model=chosen,
433
+ effort=effort,
434
+ permission=permission,
435
+ resume=resume,
436
+ )
437
+ )
438
+
439
+ try:
440
+ self._client = await open_client(session)
441
+ except SessionNotFoundError:
442
+ self._client = await open_client(None)
443
+
444
+ # -- what a terminal shows around the conversation -----------------------
445
+ async def mentions(self, query: str, limit: int) -> tuple[MentionInfo, ...]:
446
+ """Return the ``@`` resources matching ``query``; the workspace walk runs off the event loop."""
447
+ return await asyncio.to_thread(self.client.mentions, query, limit=limit)
448
+
449
+ async def usage_ledger(self) -> WorkspaceUsage:
450
+ """Return what every stored session of the workspace spent."""
451
+ return await self.client.usage_ledger()
452
+
453
+ async def session_stats(self, session_ids: list[str] | None) -> tuple[SessionStatsInfo, ...]:
454
+ """Return the stored totals of some sessions, or of the current one."""
455
+ return await self.client.session_stats(session_ids)
456
+
457
+ def self_compaction(self) -> tuple[bool | None, bool]:
458
+ """Return the session's own choice and whether self-compaction is in force."""
459
+ return self.client.self_compaction_choice, self.client.self_compaction
460
+
461
+ async def use_self_compaction(self, choice: bool | None) -> bool:
462
+ """Save the session's choice; the write runs off the event loop."""
463
+ return await asyncio.to_thread(self.client.use_self_compaction, choice)
464
+
465
+ async def check_models(self, timeout: float) -> tuple[ModelCheck, ...]:
466
+ """Ask every configured model one short question."""
467
+ return await self.client.check_models(timeout=timeout)
468
+
469
+
470
+ def result_payload(result: RunResult) -> dict[str, object]:
471
+ """Return a finished run as JSON: its answer, its usage and its tool calls."""
472
+ return result_out(result).model_dump(mode="json")