zettcode-gateway 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/gateway/__init__.py +22 -0
- zettcode/gateway/app.py +454 -0
- zettcode/gateway/backend.py +368 -0
- zettcode/gateway/chat.py +142 -0
- zettcode/gateway/cli.py +495 -0
- zettcode/gateway/py.typed +0 -0
- zettcode/gateway/runfile.py +145 -0
- zettcode/gateway/service.py +1219 -0
- zettcode/gateway/settings.py +62 -0
- zettcode/gateway/static/app.css +1434 -0
- zettcode/gateway/static/app.js +3 -0
- zettcode/gateway/static/assets/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
- zettcode/gateway/static/chunks/chunk-Y6SLVHK3.js +1 -0
- zettcode/gateway/static/chunks/render-G6G3TSB3.js +341 -0
- zettcode/gateway/static/icon.png +0 -0
- zettcode/gateway/static/index.html +23 -0
- zettcode/gateway/static/logo.svg +205 -0
- zettcode/gateway/static/manifest.webmanifest +11 -0
- zettcode_gateway-0.3.4.dist-info/METADATA +21 -0
- zettcode_gateway-0.3.4.dist-info/RECORD +40 -0
- zettcode_gateway-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""What the gateway needs from the agent, as an interface, and the implementation that gets it from the SDK.
|
|
2
|
+
|
|
3
|
+
The gateway's logic (:mod:`.service`) is written against :class:`Backend`, so it can be tested with a backend that
|
|
4
|
+
answers from memory. :class:`SdkBackend` is the real one and the only module of the gateway that touches
|
|
5
|
+
``zettcode.sdk.Client``: it is a session of its own, registered with the broker like a terminal's, which has a person
|
|
6
|
+
at it and runs no model.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from zettcode.modes import MODE_FOLLOWUP
|
|
19
|
+
from zettcode.sdk import daemons as sdk_daemons
|
|
20
|
+
from zettcode.sdk.client import Client
|
|
21
|
+
from zettcode.sdk.errors import ConfigurationError, SendError, SessionLockedError, SessionNotFoundError, ZettCodeError
|
|
22
|
+
from zettcode.sdk.models import add_model, list_models, remove_model, update_model
|
|
23
|
+
from zettcode.sdk.types import (
|
|
24
|
+
DaemonInfo,
|
|
25
|
+
Delivered,
|
|
26
|
+
Image,
|
|
27
|
+
IncomingMessage,
|
|
28
|
+
Message,
|
|
29
|
+
ModelInfo,
|
|
30
|
+
Peer,
|
|
31
|
+
Receipt,
|
|
32
|
+
SendLimits,
|
|
33
|
+
StreamEvent,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
#: What the gateway's own session is called, so the sessions it writes to can tell who is speaking.
|
|
37
|
+
TITLE = "Web gateway"
|
|
38
|
+
|
|
39
|
+
#: The file in the gateway's folder that remembers the id of its session, so a restart is the same session to the
|
|
40
|
+
#: daemons that were told where to answer.
|
|
41
|
+
HOST_FILE = "host.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GatewayStartError(ZettCodeError):
|
|
45
|
+
"""The gateway could not become a session: no model is configured, or another gateway is running."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Backend(ABC):
|
|
49
|
+
"""The agent as the gateway sees it: who is there, what they said, and how to speak to them."""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def gateway_id(self) -> str:
|
|
54
|
+
"""Return the id the gateway is registered under: what a daemon replies to."""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def handle(self) -> str:
|
|
59
|
+
"""Return the name the others call the gateway by, or an empty string before it is registered."""
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def reachable(self) -> bool:
|
|
64
|
+
"""Return whether the broker is connected: without it nobody can be written to."""
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def limits(self) -> SendLimits:
|
|
69
|
+
"""Return what the broker accepts from a sender."""
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
async def start(self, on_message: Callable[[IncomingMessage], Receipt], on_change: Callable[[], None]) -> None:
|
|
73
|
+
"""Become reachable. ``on_message`` is told of each message another session sends; ``on_change`` of any
|
|
74
|
+
change in who is there or whether the broker is."""
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
async def close(self) -> None:
|
|
78
|
+
"""Leave the broker and release the session. Safe to call twice."""
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def peers(self) -> tuple[Peer, ...]:
|
|
82
|
+
"""Return the other registered sessions, from memory."""
|
|
83
|
+
|
|
84
|
+
def watch(self, session_id: str) -> None: # noqa: B027 - a backend with no streams watches nothing
|
|
85
|
+
"""Ask to be told what a session's turns say as they happen. Asking twice is the same as once."""
|
|
86
|
+
|
|
87
|
+
def unwatch(self, session_id: str) -> None: # noqa: B027
|
|
88
|
+
"""Stop being told what a session's turns say."""
|
|
89
|
+
|
|
90
|
+
def on_stream(self, callback: Callable[[StreamEvent], None]) -> None: # noqa: B027
|
|
91
|
+
"""Call ``callback`` on the event loop with every event of a watched session's turn."""
|
|
92
|
+
|
|
93
|
+
def observe(self, callback: Callable[[dict[str, Any]], None] | None) -> None: # noqa: B027
|
|
94
|
+
"""Call ``callback`` with every message that passes through the broker, and when who is here changes."""
|
|
95
|
+
|
|
96
|
+
async def graph(self) -> dict[str, Any]:
|
|
97
|
+
"""Return the broker's picture of who talks to whom (see ``Client.traffic_graph``); empty without a broker."""
|
|
98
|
+
return {"sessions": [], "groups": [], "edges": [], "recent": []}
|
|
99
|
+
|
|
100
|
+
async def older(self, before: int, limit: int, group: str = "") -> dict[str, Any]:
|
|
101
|
+
"""Return the stored messages before id ``before`` (see ``Client.traffic_older``); a group's own with ``group``."""
|
|
102
|
+
return {"messages": [], "more": False}
|
|
103
|
+
|
|
104
|
+
async def send_group(self, group: str, text: str) -> list[Delivered]:
|
|
105
|
+
"""Write to a group chat as the person at the gateway and return who took it (see ``Client.send_group``)."""
|
|
106
|
+
raise SendError("offline", "not connected to the session broker")
|
|
107
|
+
|
|
108
|
+
async def join_group(self, group: str) -> None:
|
|
109
|
+
"""Make the gateway a member of a group so that it can write to it (see ``Client.join_group``)."""
|
|
110
|
+
raise SendError("offline", "not connected to the session broker")
|
|
111
|
+
|
|
112
|
+
async def timeline(self, since: float, until: float, limit: int) -> dict[str, Any]:
|
|
113
|
+
"""Return the stored messages of a stretch of the past (see ``Client.traffic_timeline``)."""
|
|
114
|
+
return {"messages": [], "truncated": False, "span": None, "now": until}
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
async def send(self, to: str, text: str, *, direct: bool = False, images: Sequence[Image] = ()) -> Delivered:
|
|
118
|
+
"""Send a message as the person at the gateway and return once the session has taken it.
|
|
119
|
+
|
|
120
|
+
A backend sends it as the person at the gateway and never steers a turn the page does not show: a message
|
|
121
|
+
waits for the session's current request to end (see the implementation for the mode it uses).
|
|
122
|
+
|
|
123
|
+
With ``direct``, a session that can (``Peer.direct``) runs it as its reader's own prompt and sends its final
|
|
124
|
+
answer back here; for any other the flag is ignored. Only a ``direct`` message may carry ``images``.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
zettcode.sdk.errors.SendError: Nobody was reached, and why.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def locked(self, session_id: str, workspace: str) -> bool: # noqa: ARG002
|
|
131
|
+
"""Return whether another process has this stored session open (its lock is held)."""
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
async def history(self, session_id: str, workspace: str) -> list[Message]:
|
|
136
|
+
"""Return a stored conversation of any session, or raise ``SessionNotFoundError``."""
|
|
137
|
+
|
|
138
|
+
@abstractmethod
|
|
139
|
+
def stamp(self, session_id: str, workspace: str) -> tuple[int, int] | None:
|
|
140
|
+
"""Return a value that changes when the stored conversation does, or ``None`` when there is none."""
|
|
141
|
+
|
|
142
|
+
@abstractmethod
|
|
143
|
+
def models(self) -> list[ModelInfo]:
|
|
144
|
+
"""Return the models a daemon can run on, the first being the default."""
|
|
145
|
+
|
|
146
|
+
@abstractmethod
|
|
147
|
+
def add_model(self, entry: dict[str, object]) -> list[ModelInfo]:
|
|
148
|
+
"""Write a model into the configuration file and return every model it now lists (``ValueError`` if unusable)."""
|
|
149
|
+
|
|
150
|
+
@abstractmethod
|
|
151
|
+
def update_model(self, name: str, changes: dict[str, object]) -> list[ModelInfo]:
|
|
152
|
+
"""Change one model; a key whose value is ``None`` is removed, and the token is kept unless given."""
|
|
153
|
+
|
|
154
|
+
@abstractmethod
|
|
155
|
+
def remove_model(self, name: str) -> list[ModelInfo]:
|
|
156
|
+
"""Remove one model; the last cannot go (``ValueError``)."""
|
|
157
|
+
|
|
158
|
+
@abstractmethod
|
|
159
|
+
def daemons(self) -> list[DaemonInfo]:
|
|
160
|
+
"""Return every daemon on this machine, newest first."""
|
|
161
|
+
|
|
162
|
+
@abstractmethod
|
|
163
|
+
async def spawn(
|
|
164
|
+
self,
|
|
165
|
+
workspace: Path,
|
|
166
|
+
*,
|
|
167
|
+
model: str | None,
|
|
168
|
+
effort: str | None,
|
|
169
|
+
resume: str | None,
|
|
170
|
+
name: str | None,
|
|
171
|
+
permission: str,
|
|
172
|
+
) -> DaemonInfo:
|
|
173
|
+
"""Start a daemon and return it once it can be messaged."""
|
|
174
|
+
|
|
175
|
+
@abstractmethod
|
|
176
|
+
async def stop(self, session_id: str, *, force: bool) -> str:
|
|
177
|
+
"""Stop a daemon and keep its record, listed as offline, and say what happened."""
|
|
178
|
+
|
|
179
|
+
@abstractmethod
|
|
180
|
+
async def delete(self, session_id: str) -> str:
|
|
181
|
+
"""Remove a stopped daemon for good, record and conversation, and say what happened."""
|
|
182
|
+
|
|
183
|
+
@abstractmethod
|
|
184
|
+
def log(self, session_id: str, lines: int) -> str:
|
|
185
|
+
"""Return the end of a daemon's own log."""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class SdkBackend(Backend):
|
|
189
|
+
"""The backend that is a real session: an SDK client that joined the broker, and the SDK's daemon calls."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, workspace: Path, directory: Path) -> None:
|
|
192
|
+
"""Remember where the gateway's session works and where it keeps its id; nothing starts until :meth:`start`."""
|
|
193
|
+
self.workspace = workspace
|
|
194
|
+
self.directory = directory
|
|
195
|
+
self._client: Client | None = None
|
|
196
|
+
|
|
197
|
+
# -- the session ------------------------------------------------------------------------------
|
|
198
|
+
async def start(self, on_message: Callable[[IncomingMessage], Receipt], on_change: Callable[[], None]) -> None:
|
|
199
|
+
"""Make the client, take its old id back when it has one, join the broker and watch who is there.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
GatewayStartError: No model is configured (the session needs one to exist, though it never calls it), or
|
|
203
|
+
another gateway holds the session.
|
|
204
|
+
"""
|
|
205
|
+
self.workspace.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
client = self._build(self._remembered())
|
|
207
|
+
try:
|
|
208
|
+
if client.session.locked:
|
|
209
|
+
raise GatewayStartError("another gateway is already running for this user")
|
|
210
|
+
if client.session.title is None:
|
|
211
|
+
await client.session.rename(TITLE)
|
|
212
|
+
self._remember(client.session.id)
|
|
213
|
+
if not await client.join_sessions(on_message):
|
|
214
|
+
raise GatewayStartError("sessions are switched off in the configuration, so nothing can be reached")
|
|
215
|
+
except BaseException:
|
|
216
|
+
await client.aclose()
|
|
217
|
+
raise
|
|
218
|
+
client.watch_peers(on_change)
|
|
219
|
+
self._client = client
|
|
220
|
+
|
|
221
|
+
def _build(self, resume: str | None) -> Client:
|
|
222
|
+
"""Return the client; a session that was remembered and has since gone is replaced by a new one."""
|
|
223
|
+
settings = {
|
|
224
|
+
"sessions_enabled": True,
|
|
225
|
+
"update_enabled": False,
|
|
226
|
+
"plugins_enabled": False,
|
|
227
|
+
"mcp_enabled": False,
|
|
228
|
+
"skills_enabled": False,
|
|
229
|
+
"agents_md_enabled": False,
|
|
230
|
+
"ask_user_enabled": False,
|
|
231
|
+
"subagent_enabled": False,
|
|
232
|
+
"permission": "deny",
|
|
233
|
+
}
|
|
234
|
+
try:
|
|
235
|
+
return Client(self.workspace, resume=resume, **settings) # type: ignore[arg-type]
|
|
236
|
+
except SessionNotFoundError:
|
|
237
|
+
return Client(self.workspace, **settings) # type: ignore[arg-type]
|
|
238
|
+
except ConfigurationError as error:
|
|
239
|
+
raise GatewayStartError(f"{error}. Add a model with `zettcode` (/config) or in the config file.") from error
|
|
240
|
+
except SessionLockedError as error:
|
|
241
|
+
raise GatewayStartError("another gateway is already running for this user") from error
|
|
242
|
+
|
|
243
|
+
def _remembered(self) -> str | None:
|
|
244
|
+
try:
|
|
245
|
+
data = json.loads((self.directory / HOST_FILE).read_text(encoding="utf-8"))
|
|
246
|
+
except (OSError, ValueError):
|
|
247
|
+
return None
|
|
248
|
+
session = data.get("session") if isinstance(data, dict) else None
|
|
249
|
+
return session if isinstance(session, str) and session else None
|
|
250
|
+
|
|
251
|
+
def _remember(self, session_id: str) -> None:
|
|
252
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
temporary = self.directory / f".{HOST_FILE}.{os.getpid()}.tmp"
|
|
254
|
+
temporary.write_text(json.dumps({"session": session_id}), encoding="utf-8")
|
|
255
|
+
os.replace(temporary, self.directory / HOST_FILE)
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def client(self) -> Client:
|
|
259
|
+
"""Return the client; asking before :meth:`start` is a bug of the caller's."""
|
|
260
|
+
if self._client is None:
|
|
261
|
+
raise GatewayStartError("the gateway has not started")
|
|
262
|
+
return self._client
|
|
263
|
+
|
|
264
|
+
async def close(self) -> None:
|
|
265
|
+
client, self._client = self._client, None
|
|
266
|
+
if client is not None:
|
|
267
|
+
client.watch_peers(None)
|
|
268
|
+
await client.aclose()
|
|
269
|
+
|
|
270
|
+
# -- who is there and what was said -----------------------------------------------------------
|
|
271
|
+
@property
|
|
272
|
+
def gateway_id(self) -> str:
|
|
273
|
+
return self.client.session.id
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def handle(self) -> str:
|
|
277
|
+
return self.client.handle
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def reachable(self) -> bool:
|
|
281
|
+
return self._client is not None and self._client.reachable
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def limits(self) -> SendLimits:
|
|
285
|
+
return self.client.send_limits
|
|
286
|
+
|
|
287
|
+
def peers(self) -> tuple[Peer, ...]:
|
|
288
|
+
return self.client.peers()
|
|
289
|
+
|
|
290
|
+
def watch(self, session_id: str) -> None:
|
|
291
|
+
self.client.watch(session_id)
|
|
292
|
+
|
|
293
|
+
def unwatch(self, session_id: str) -> None:
|
|
294
|
+
self.client.unwatch(session_id)
|
|
295
|
+
|
|
296
|
+
def on_stream(self, callback: Callable[[StreamEvent], None]) -> None:
|
|
297
|
+
self.client.watch_streams(callback)
|
|
298
|
+
|
|
299
|
+
def observe(self, callback: Callable[[dict[str, Any]], None] | None) -> None:
|
|
300
|
+
self.client.watch_traffic(callback)
|
|
301
|
+
|
|
302
|
+
async def graph(self) -> dict[str, Any]:
|
|
303
|
+
return await self.client.traffic_graph()
|
|
304
|
+
|
|
305
|
+
async def older(self, before: int, limit: int, group: str = "") -> dict[str, Any]:
|
|
306
|
+
return await self.client.traffic_older(before, limit, group)
|
|
307
|
+
|
|
308
|
+
async def send_group(self, group: str, text: str) -> list[Delivered]:
|
|
309
|
+
return await self.client.send_group(group, text, mode=MODE_FOLLOWUP)
|
|
310
|
+
|
|
311
|
+
async def join_group(self, group: str) -> None:
|
|
312
|
+
await self.client.join_group(group)
|
|
313
|
+
|
|
314
|
+
async def timeline(self, since: float, until: float, limit: int) -> dict[str, Any]:
|
|
315
|
+
return await self.client.traffic_timeline(since, until, limit)
|
|
316
|
+
|
|
317
|
+
async def send(self, to: str, text: str, *, direct: bool = False, images: Sequence[Image] = ()) -> Delivered:
|
|
318
|
+
# A page keeps a conversation instead of watching the session's live turn, so its message never steers one:
|
|
319
|
+
# a person typing here cannot see what they would be interrupting.
|
|
320
|
+
return await self.client.send(to, text, direct=direct, mode=MODE_FOLLOWUP, images=images)
|
|
321
|
+
|
|
322
|
+
def locked(self, session_id: str, workspace: str) -> bool:
|
|
323
|
+
return self.client.sessions.open_elsewhere(session_id, workspace=workspace or None)
|
|
324
|
+
|
|
325
|
+
async def history(self, session_id: str, workspace: str) -> list[Message]:
|
|
326
|
+
return await self.client.sessions.history_of(session_id, workspace=workspace or None, full=True)
|
|
327
|
+
|
|
328
|
+
def stamp(self, session_id: str, workspace: str) -> tuple[int, int] | None:
|
|
329
|
+
return self.client.sessions.stamp(session_id, workspace=workspace or None)
|
|
330
|
+
|
|
331
|
+
# -- daemons ----------------------------------------------------------------------------------
|
|
332
|
+
def models(self) -> list[ModelInfo]:
|
|
333
|
+
return list(list_models())
|
|
334
|
+
|
|
335
|
+
def add_model(self, entry: dict[str, object]) -> list[ModelInfo]:
|
|
336
|
+
return list(add_model(entry))
|
|
337
|
+
|
|
338
|
+
def update_model(self, name: str, changes: dict[str, object]) -> list[ModelInfo]:
|
|
339
|
+
return list(update_model(name, changes))
|
|
340
|
+
|
|
341
|
+
def remove_model(self, name: str) -> list[ModelInfo]:
|
|
342
|
+
return list(remove_model(name))
|
|
343
|
+
|
|
344
|
+
def daemons(self) -> list[DaemonInfo]:
|
|
345
|
+
return sdk_daemons.list_daemons()
|
|
346
|
+
|
|
347
|
+
async def spawn(
|
|
348
|
+
self,
|
|
349
|
+
workspace: Path,
|
|
350
|
+
*,
|
|
351
|
+
model: str | None,
|
|
352
|
+
effort: str | None,
|
|
353
|
+
resume: str | None,
|
|
354
|
+
name: str | None,
|
|
355
|
+
permission: str,
|
|
356
|
+
) -> DaemonInfo:
|
|
357
|
+
return await sdk_daemons.spawn_daemon(
|
|
358
|
+
workspace, model=model, effort=effort, resume=resume, name=name, permission=permission
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
async def stop(self, session_id: str, *, force: bool) -> str:
|
|
362
|
+
return await sdk_daemons.stop_daemon(session_id, force=force, keep=True)
|
|
363
|
+
|
|
364
|
+
async def delete(self, session_id: str) -> str:
|
|
365
|
+
return await sdk_daemons.delete_daemon(session_id)
|
|
366
|
+
|
|
367
|
+
def log(self, session_id: str, lines: int) -> str:
|
|
368
|
+
return sdk_daemons.daemon_log(session_id, lines=lines)
|
zettcode/gateway/chat.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""A stored conversation as a chat: the SDK's messages turned into the items a chat view draws.
|
|
2
|
+
|
|
3
|
+
The gateway shows a session the way a messenger shows a contact, so a conversation is not a transcript of model
|
|
4
|
+
turns but a list of things said (by this gateway, by the session) and a few quiet marks for what the agent did in
|
|
5
|
+
between. This module is that translation and nothing else: no I/O, and nothing about HTTP, so every rule here is a
|
|
6
|
+
function of the messages it is given.
|
|
7
|
+
|
|
8
|
+
An item is a plain dict with a ``kind``:
|
|
9
|
+
|
|
10
|
+
* ``out`` -- this gateway said it (a message the stored conversation records as relayed from the gateway's id);
|
|
11
|
+
* ``from`` -- another session or program said it to this one (``sender`` and ``via`` say who);
|
|
12
|
+
* ``user`` -- what the person at that session typed, when it is a session with a person at it;
|
|
13
|
+
* ``in`` -- what the session's agent answered in words;
|
|
14
|
+
* ``reply`` -- the agent used ``send_message`` to speak to this gateway, which is the way a daemon answers;
|
|
15
|
+
* ``tool`` -- a tool the agent ran, with a short account of its arguments and how it ended;
|
|
16
|
+
* ``note`` -- the summary a compaction left in place of older messages.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Iterable, Sequence
|
|
24
|
+
|
|
25
|
+
from zettcode.sdk.types import Message, brief_of
|
|
26
|
+
|
|
27
|
+
#: The line added after what the reader typed, asking the agent to answer with ``send_message``. A daemon answers a
|
|
28
|
+
#: message only when its model chooses to, and a daemon that goes quiet cannot be told from one with nothing to say.
|
|
29
|
+
REPLY_REQUEST = (
|
|
30
|
+
'[Web gateway: when you have finished, reply with send_message(to="{id}", message=...). '
|
|
31
|
+
"If there is nothing to report, say so in one line.]"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
#: Matches the request above whatever id it carries, so the chat shows what the reader typed and not the scaffolding.
|
|
35
|
+
_BEFORE, _AFTER = (re.escape(part) for part in REPLY_REQUEST.split("{id}"))
|
|
36
|
+
_REPLY_REQUEST = re.compile(rf"\n\n{_BEFORE}[^\"]*{_AFTER}\Z")
|
|
37
|
+
|
|
38
|
+
#: Longest tool output kept on an item; the chat shows it folded and has no use for more.
|
|
39
|
+
OUTPUT_LIMIT = 1_200
|
|
40
|
+
|
|
41
|
+
#: The tool an agent speaks to another session with.
|
|
42
|
+
SEND_TOOL = "send_message"
|
|
43
|
+
|
|
44
|
+
#: The kinds that are words said to the reader of the chat, as opposed to what the agent did.
|
|
45
|
+
SPOKEN = frozenset({"in", "reply"})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def with_reply_request(text: str, gateway_id: str) -> str:
|
|
49
|
+
"""Return the text to send: what the reader typed, then the request to answer with ``send_message``."""
|
|
50
|
+
return f"{text}\n\n{REPLY_REQUEST.format(id=gateway_id)}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def strip_reply_request(text: str) -> str:
|
|
54
|
+
"""Return what the reader typed, without the request :func:`with_reply_request` added."""
|
|
55
|
+
return _REPLY_REQUEST.sub("", text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def items_of(messages: Sequence[Message], gateway_id: str, *, handle: str = "") -> list[dict[str, object]]:
|
|
59
|
+
"""Return the chat items for a conversation, oldest first.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
messages: The conversation as the SDK reads it.
|
|
63
|
+
gateway_id: The id this gateway is registered under, which marks a message as its own.
|
|
64
|
+
handle: The name the others call this gateway by, which an agent may address a reply to instead of the id.
|
|
65
|
+
"""
|
|
66
|
+
results: dict[str, tuple[bool, str]] = {
|
|
67
|
+
message.tool_call_id: (message.ok, message.text)
|
|
68
|
+
for message in messages
|
|
69
|
+
if message.role == "tool" and message.tool_call_id
|
|
70
|
+
}
|
|
71
|
+
ours = {value for value in (gateway_id, handle) if value}
|
|
72
|
+
items: list[dict[str, object]] = []
|
|
73
|
+
for message in messages:
|
|
74
|
+
if message.role == "summary":
|
|
75
|
+
items.append({"kind": "note", "text": message.text})
|
|
76
|
+
elif message.role == "user":
|
|
77
|
+
items.append(_user_item(message, gateway_id))
|
|
78
|
+
elif message.role == "assistant":
|
|
79
|
+
text = message.text.strip()
|
|
80
|
+
if text:
|
|
81
|
+
items.append({"kind": "in", "text": text})
|
|
82
|
+
for call in message.tool_calls:
|
|
83
|
+
items.append(_tool_item(call.id, call.name, call.arguments, results, ours))
|
|
84
|
+
return items
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def picture_id(data: bytes) -> str:
|
|
88
|
+
"""Return the name a picture is served under: a prefix of its hash, so the same file is the same address."""
|
|
89
|
+
return hashlib.sha256(data).hexdigest()[:20]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _user_item(message: Message, gateway_id: str) -> dict[str, object]:
|
|
93
|
+
text = strip_reply_request(message.text)
|
|
94
|
+
relay = message.relay
|
|
95
|
+
if relay is None:
|
|
96
|
+
item: dict[str, object] = {"kind": "user", "text": text}
|
|
97
|
+
elif relay.sender_id == gateway_id:
|
|
98
|
+
item = {"kind": "out", "text": text}
|
|
99
|
+
else:
|
|
100
|
+
item = {"kind": "from", "text": text, "sender": relay.sender, "via": relay.via}
|
|
101
|
+
if message.pictures:
|
|
102
|
+
# Named, not inlined: the pictures are fetched one by one and cached, not sent again with every refresh.
|
|
103
|
+
item["images"] = [
|
|
104
|
+
{"id": picture_id(picture.data), "media_type": picture.media_type, "bytes": len(picture.data)}
|
|
105
|
+
for picture in message.pictures
|
|
106
|
+
]
|
|
107
|
+
return item
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _tool_item(
|
|
111
|
+
call_id: str, name: str, arguments: dict[str, object], results: dict[str, tuple[bool, str]], ours: set[str]
|
|
112
|
+
) -> dict[str, object]:
|
|
113
|
+
spoken_to = arguments.get("to")
|
|
114
|
+
said = arguments.get("message")
|
|
115
|
+
if name == SEND_TOOL and isinstance(spoken_to, str) and spoken_to in ours and isinstance(said, str):
|
|
116
|
+
return {"kind": "reply", "text": said.strip()}
|
|
117
|
+
item: dict[str, object] = {"kind": "tool", "name": name, "brief": brief_of(arguments)}
|
|
118
|
+
if call_id in results:
|
|
119
|
+
ok, output = results[call_id]
|
|
120
|
+
item["ok"] = ok
|
|
121
|
+
item["output"] = output if len(output) <= OUTPUT_LIMIT else output[:OUTPUT_LIMIT] + "\n..."
|
|
122
|
+
return item
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def spoken(items: Iterable[dict[str, object]]) -> int:
|
|
126
|
+
"""Return how many things the session said to this gateway: what a chat counts as unread."""
|
|
127
|
+
return sum(1 for item in items if item["kind"] in SPOKEN)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def preview(items: Sequence[dict[str, object]], limit: int = 90) -> str:
|
|
131
|
+
"""Return the line a chat list shows under a name: the last thing said, with ``You:`` before what we said."""
|
|
132
|
+
for item in reversed(items):
|
|
133
|
+
kind = item["kind"]
|
|
134
|
+
if kind not in ("out", "in", "reply", "from", "user"):
|
|
135
|
+
continue
|
|
136
|
+
line = " ".join(str(item["text"]).split())
|
|
137
|
+
if not line:
|
|
138
|
+
continue
|
|
139
|
+
if kind == "out":
|
|
140
|
+
line = f"You: {line}"
|
|
141
|
+
return line if len(line) <= limit else line[: limit - 1] + "\u2026"
|
|
142
|
+
return ""
|