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/__init__.py +33 -0
- zettcode_server/app.py +580 -0
- zettcode_server/cli.py +114 -0
- zettcode_server/runner.py +472 -0
- zettcode_server/schemas.py +555 -0
- zettcode_server/serialize.py +292 -0
- zettcode_server/settings.py +58 -0
- zettcode_server-0.1.0.dist-info/METADATA +66 -0
- zettcode_server-0.1.0.dist-info/RECORD +11 -0
- zettcode_server-0.1.0.dist-info/WHEEL +4 -0
- zettcode_server-0.1.0.dist-info/entry_points.txt +2 -0
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")
|