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/sdk/handlers.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Answering what the model asks of the program: a shell command to approve, a question to answer.
|
|
2
|
+
|
|
3
|
+
A run cannot be unattended without a rule for these, because the agent is suspended inside the tool call until
|
|
4
|
+
someone answers. The rule is: pass a handler to the run and it answers before the event is yielded; answer the event
|
|
5
|
+
yourself while you are iterating; or do neither, and a command is denied and a question declined. Nothing hangs.
|
|
6
|
+
|
|
7
|
+
A handler may be a plain function or a coroutine function. What it returns decides the answer::
|
|
8
|
+
|
|
9
|
+
def only_git(request):
|
|
10
|
+
return request.command.startswith("git ") # True approves, False denies
|
|
11
|
+
|
|
12
|
+
async def ask_a_person(request):
|
|
13
|
+
request.approve(remember=True) # answering yourself is fine; return None
|
|
14
|
+
|
|
15
|
+
``None`` leaves the request unanswered, which means the default (deny, decline) applies once the stream is asked for
|
|
16
|
+
its next event. A run started with ``defer_answers=True`` does not apply the default then: it keeps the request open,
|
|
17
|
+
so a handler that only shows the request to a person (and returns ``None``) can have it answered later, from a panel,
|
|
18
|
+
by the event's own ``approve`` or ``answer``. Whatever is still open when the run ends gets the default.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import inspect
|
|
24
|
+
from collections.abc import Awaitable, Callable
|
|
25
|
+
from typing import TypeAlias
|
|
26
|
+
|
|
27
|
+
from .events import ApprovalRequested, QuestionAsked
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ApprovalHandler",
|
|
31
|
+
"QuestionHandler",
|
|
32
|
+
"approve_all",
|
|
33
|
+
"approve_commands",
|
|
34
|
+
"deny_all",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
#: Decides one shell command: ``True`` approves, ``False`` denies, ``None`` leaves it to the request's own methods.
|
|
38
|
+
ApprovalHandler: TypeAlias = Callable[[ApprovalRequested], "bool | None | Awaitable[bool | None]"]
|
|
39
|
+
|
|
40
|
+
#: Answers one question: the text answers it, ``None`` leaves it to the request's own methods.
|
|
41
|
+
QuestionHandler: TypeAlias = Callable[[QuestionAsked], "str | None | Awaitable[str | None]"]
|
|
42
|
+
|
|
43
|
+
#: Characters that let a shell run something other than the command that was typed: chaining, pipes,
|
|
44
|
+
#: redirection, substitution, grouping and newlines. A command holding one is never matched by a prefix rule.
|
|
45
|
+
SHELL_META = frozenset(";&|<>$`\n\r(){}\\")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def approve_all(request: ApprovalRequested) -> bool:
|
|
49
|
+
"""Approve every command. Only for a workspace and a model you would trust with a shell."""
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def deny_all(request: ApprovalRequested) -> bool:
|
|
54
|
+
"""Deny every command; the model is told and carries on without running anything."""
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def approve_commands(*commands: str) -> ApprovalHandler:
|
|
59
|
+
"""Return a handler that approves only the commands named, and denies the rest.
|
|
60
|
+
|
|
61
|
+
A command matches when it *is* one of ``commands`` or continues one with arguments (``"git status"`` matches
|
|
62
|
+
``git status`` and ``git status -s``, not ``git statusx``). It never matches a command holding a character the
|
|
63
|
+
shell treats specially (``;``, ``&``, ``|``, redirection, substitution, newlines), because ``git status; rm -rf
|
|
64
|
+
~`` starts with ``git status`` too.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
commands: The commands to allow, such as ``"git status"``, ``"git diff"``, ``"pytest"``.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: A command is blank, or holds a character the shell treats specially.
|
|
71
|
+
"""
|
|
72
|
+
allowed: list[str] = []
|
|
73
|
+
for command in commands:
|
|
74
|
+
command = command.strip()
|
|
75
|
+
if not command:
|
|
76
|
+
raise ValueError("A command to allow cannot be blank")
|
|
77
|
+
if SHELL_META & set(command):
|
|
78
|
+
raise ValueError(f"{command!r} holds a shell character; allow the plain command and not a pipeline")
|
|
79
|
+
allowed.append(command)
|
|
80
|
+
rules = tuple(allowed)
|
|
81
|
+
|
|
82
|
+
def decide(request: ApprovalRequested) -> bool:
|
|
83
|
+
text = request.command.strip()
|
|
84
|
+
if not text or SHELL_META & set(text):
|
|
85
|
+
return False
|
|
86
|
+
return any(text == rule or text.startswith(rule + " ") for rule in rules)
|
|
87
|
+
|
|
88
|
+
return decide
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def apply_approval(request: ApprovalRequested, handler: ApprovalHandler | None) -> None:
|
|
92
|
+
"""Let ``handler`` answer ``request``; it stays unanswered when there is none or it declines to say.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
TypeError: The handler returned something other than ``True``, ``False`` or ``None``; a truthy string
|
|
96
|
+
answering "yes" is exactly the mistake that must not approve a shell command.
|
|
97
|
+
"""
|
|
98
|
+
if handler is None:
|
|
99
|
+
return
|
|
100
|
+
outcome = handler(request)
|
|
101
|
+
if inspect.isawaitable(outcome):
|
|
102
|
+
outcome = await outcome
|
|
103
|
+
if request.answered or outcome is None:
|
|
104
|
+
return
|
|
105
|
+
if not isinstance(outcome, bool):
|
|
106
|
+
raise TypeError(f"An approval handler returns True, False or None, not {type(outcome).__name__}")
|
|
107
|
+
request.approve() if outcome else request.deny()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def apply_question(request: QuestionAsked, handler: QuestionHandler | None) -> None:
|
|
111
|
+
"""Let ``handler`` answer ``request``; a blank answer or ``None`` leaves it for the default.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
TypeError: The handler returned something other than text or ``None``.
|
|
115
|
+
"""
|
|
116
|
+
if handler is None:
|
|
117
|
+
return
|
|
118
|
+
outcome = handler(request)
|
|
119
|
+
if inspect.isawaitable(outcome):
|
|
120
|
+
outcome = await outcome
|
|
121
|
+
if request.answered or outcome is None:
|
|
122
|
+
return
|
|
123
|
+
if not isinstance(outcome, str):
|
|
124
|
+
raise TypeError(f"A question handler returns text or None, not {type(outcome).__name__}")
|
|
125
|
+
if outcome.strip():
|
|
126
|
+
request.answer(outcome)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""This session's line to the others: who is active, a message sent, a message received.
|
|
2
|
+
|
|
3
|
+
The broker client (:mod:`zettcode.broker.client`) knows the wire; this is the part the rest of the
|
|
4
|
+
application holds. The shell tells it what the session is now and gives it a place to hand a received
|
|
5
|
+
message to, and the ``send_message`` tool and the ``@`` completion ask it who else is there.
|
|
6
|
+
|
|
7
|
+
It owns one fact the client cannot know: which message the running turn is answering. An agent that
|
|
8
|
+
is answering another agent carries that message's hop count on, which is what stops two sessions
|
|
9
|
+
that keep replying to each other; a turn that began with the reader's own words starts at none.
|
|
10
|
+
|
|
11
|
+
Nothing here blocks or raises into the shell. Without a broker every method answers as if nobody
|
|
12
|
+
else were running, and a send says so.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from zettcode.broker.client import BrokerClient, Delivered, Incoming, Receipt, RosterEntry, SessionInfo
|
|
22
|
+
from zettcode.broker.models import GroupDelivered, GroupInfo
|
|
23
|
+
from zettcode.broker.protocol import VIA_AGENT, ImageData
|
|
24
|
+
from zettcode.config import ZettCodeConfig
|
|
25
|
+
from zettcode.modes import MODE_STEER
|
|
26
|
+
from zettcode.relay import Relayed
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
#: How long pieces of text are gathered before they are sent as one, so a stream is a few lines a second and not one per token.
|
|
33
|
+
STREAM_BATCH = 0.06
|
|
34
|
+
|
|
35
|
+
#: The most characters of text sent in one stream line, well inside what the broker passes (see ``MAX_STREAM``).
|
|
36
|
+
STREAM_CHUNK = 4000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Messenger:
|
|
40
|
+
"""The shell's and the agent's one handle on the other sessions."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, client: BrokerClient | None = None, *, workspace: str | Path = "") -> None:
|
|
43
|
+
"""Wrap a client; one is built for the default broker when none is given.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
client: The connection to use. Its callbacks are taken over, so give a client that nothing
|
|
47
|
+
else is listening to.
|
|
48
|
+
workspace: The directory this session works in, which other sessions are shown.
|
|
49
|
+
"""
|
|
50
|
+
self.workspace = str(workspace)
|
|
51
|
+
self.client = client if client is not None else BrokerClient()
|
|
52
|
+
self.client.on_message = self._received
|
|
53
|
+
self.client.on_roster = self._roster_changed
|
|
54
|
+
self.client.on_conflict = self._conflicted
|
|
55
|
+
self.client.on_problem = self._troubled
|
|
56
|
+
#: Where a received message goes; the shell sets it. It returns what this session says to the
|
|
57
|
+
#: sender: whether it took the message, and what will become of it.
|
|
58
|
+
self.receiver: Callable[[Relayed], Receipt] | None = None
|
|
59
|
+
#: Called, on the loop, whenever the roster or the connection changes, so a view can repaint.
|
|
60
|
+
self.on_change: Callable[[], None] | None = None
|
|
61
|
+
#: Called with a sentence when another process holds this session's id, and with ``None`` after.
|
|
62
|
+
self.on_conflict: Callable[[str | None], None] | None = None
|
|
63
|
+
#: Called with a sentence when the broker has been out of reach for a while, and with ``None`` when it is back.
|
|
64
|
+
self.on_problem: Callable[[str | None], None] | None = None
|
|
65
|
+
#: The message the running turn answers, if it answers one.
|
|
66
|
+
self.answering: Relayed | None = None
|
|
67
|
+
#: Whether a message the sender marked ``direct`` is run as this session's own prompt; said in the roster.
|
|
68
|
+
self.direct = False
|
|
69
|
+
self._started = False
|
|
70
|
+
self._piece: tuple[str, list[str]] | None = None
|
|
71
|
+
self._flush: asyncio.TimerHandle | None = None
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def create(cls, config: ZettCodeConfig) -> Messenger | None:
|
|
75
|
+
"""Return the messenger this configuration asks for, or ``None`` when sessions are switched off."""
|
|
76
|
+
if not config.sessions_enabled:
|
|
77
|
+
return None
|
|
78
|
+
return cls(workspace=config.workspace)
|
|
79
|
+
|
|
80
|
+
# -- the life of the connection ---------------------------------------------------------------
|
|
81
|
+
@property
|
|
82
|
+
def started(self) -> bool:
|
|
83
|
+
"""Return whether :meth:`start` has run, which is when it is safe to tell it about the session."""
|
|
84
|
+
return self._started
|
|
85
|
+
|
|
86
|
+
async def start(self) -> None:
|
|
87
|
+
"""Begin connecting in the background. Returns at once, whether or not a broker exists."""
|
|
88
|
+
self._started = True
|
|
89
|
+
await self.client.start()
|
|
90
|
+
|
|
91
|
+
async def close(self) -> None:
|
|
92
|
+
"""Leave the broker. Quick, and safe to call twice."""
|
|
93
|
+
self._started = False
|
|
94
|
+
self.receiver = None
|
|
95
|
+
await self.client.close()
|
|
96
|
+
|
|
97
|
+
# -- what this session is ---------------------------------------------------------------------
|
|
98
|
+
def describe(self, session_id: str, title: str | None, *, busy: bool) -> None:
|
|
99
|
+
"""Say what this session is now. Cheap when nothing changed, so callers need not check.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
session_id: The active session.
|
|
103
|
+
title: Its title, or ``None`` while it has none.
|
|
104
|
+
busy: Whether a request or command is running.
|
|
105
|
+
"""
|
|
106
|
+
info = SessionInfo(
|
|
107
|
+
session_id=session_id,
|
|
108
|
+
title=title or "",
|
|
109
|
+
workspace=self.workspace,
|
|
110
|
+
status="working" if busy else "idle",
|
|
111
|
+
direct=self.direct,
|
|
112
|
+
)
|
|
113
|
+
current = self.client.session
|
|
114
|
+
if current is not None and (current.session_id, current.title, current.status, current.direct) == (
|
|
115
|
+
info.session_id,
|
|
116
|
+
info.title,
|
|
117
|
+
info.status,
|
|
118
|
+
info.direct,
|
|
119
|
+
):
|
|
120
|
+
return
|
|
121
|
+
self.client.set_session(info)
|
|
122
|
+
|
|
123
|
+
def retire(self) -> None:
|
|
124
|
+
"""Say that this process has no session to be reached at: it only shows one that another process has open."""
|
|
125
|
+
if self.client.session is not None:
|
|
126
|
+
self.client.set_session(None)
|
|
127
|
+
|
|
128
|
+
# -- the others -------------------------------------------------------------------------------
|
|
129
|
+
@property
|
|
130
|
+
def connected(self) -> bool:
|
|
131
|
+
"""Return whether the broker is reachable right now."""
|
|
132
|
+
return self.client.connected
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def conflict(self) -> str | None:
|
|
136
|
+
"""Return why this session is not registered, or ``None``."""
|
|
137
|
+
return self.client.conflict
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def handle(self) -> str:
|
|
141
|
+
"""Return the name the others call this session by, or ``""`` before it is registered."""
|
|
142
|
+
mine = self.client.session
|
|
143
|
+
if mine is None:
|
|
144
|
+
return ""
|
|
145
|
+
return next((entry.handle for entry in self.client.roster if entry.session_id == mine.session_id), "")
|
|
146
|
+
|
|
147
|
+
def peers(self) -> tuple[RosterEntry, ...]:
|
|
148
|
+
"""Return the other active sessions, from memory: this is called as a draft is typed."""
|
|
149
|
+
return self.client.others()
|
|
150
|
+
|
|
151
|
+
def groups(self) -> tuple[GroupInfo, ...]:
|
|
152
|
+
"""Return the group chats this session may write to, from memory."""
|
|
153
|
+
return self.client.groups
|
|
154
|
+
|
|
155
|
+
async def manage_group(self, action: str, name: str, members: list[str] | None = None) -> GroupInfo | None:
|
|
156
|
+
"""Create a group, change who is in it, leave it or delete it (see :meth:`BrokerClient.manage_group`)."""
|
|
157
|
+
return await self.client.manage_group(action, name, members)
|
|
158
|
+
|
|
159
|
+
async def join_group(self, name: str) -> GroupInfo | None:
|
|
160
|
+
"""Put this session in a group as the broker's owner may (see :meth:`BrokerClient.join_group`)."""
|
|
161
|
+
return await self.client.join_group(name)
|
|
162
|
+
|
|
163
|
+
async def history(self, *, with_: str = "", group: str = "", limit: int = 20) -> tuple[list[dict[str, Any]], str]:
|
|
164
|
+
"""Return stored messages with one session, in a group, or all this session's own (see the client)."""
|
|
165
|
+
return await self.client.history(with_=with_, group=group, limit=limit)
|
|
166
|
+
|
|
167
|
+
# -- watching a turn --------------------------------------------------------------------------
|
|
168
|
+
@property
|
|
169
|
+
def on_stream(self) -> Callable[[str, dict[str, Any]], None] | None:
|
|
170
|
+
"""Called with a watched session's id and one thing its turn said."""
|
|
171
|
+
return self.client.on_stream
|
|
172
|
+
|
|
173
|
+
@on_stream.setter
|
|
174
|
+
def on_stream(self, callback: Callable[[str, dict[str, Any]], None] | None) -> None:
|
|
175
|
+
self.client.on_stream = callback
|
|
176
|
+
|
|
177
|
+
def watch(self, session_id: str) -> None:
|
|
178
|
+
"""Ask to be told what a session's turns say as they happen."""
|
|
179
|
+
self.client.watch(session_id)
|
|
180
|
+
|
|
181
|
+
def unwatch(self, session_id: str) -> None:
|
|
182
|
+
"""Stop being told."""
|
|
183
|
+
self.client.unwatch(session_id)
|
|
184
|
+
|
|
185
|
+
def stream(self, kind: str, **fields: Any) -> None:
|
|
186
|
+
"""Say one thing about the running turn to whoever watches it; a no-op when nobody does.
|
|
187
|
+
|
|
188
|
+
Pieces of text are gathered for :data:`STREAM_BATCH` seconds and go as one; anything else sends what was
|
|
189
|
+
gathered first, so the order is the order they happened in.
|
|
190
|
+
"""
|
|
191
|
+
if not self.client.watched:
|
|
192
|
+
self._piece = None
|
|
193
|
+
if self._flush is not None:
|
|
194
|
+
self._flush.cancel()
|
|
195
|
+
self._flush = None
|
|
196
|
+
return
|
|
197
|
+
if kind in ("text", "thinking"):
|
|
198
|
+
text = fields.get("text", "")
|
|
199
|
+
if self._piece is not None and self._piece[0] != kind:
|
|
200
|
+
self._send_piece()
|
|
201
|
+
if self._piece is None:
|
|
202
|
+
self._piece = (kind, [])
|
|
203
|
+
self._piece[1].append(text)
|
|
204
|
+
if self._flush is None:
|
|
205
|
+
self._flush = asyncio.get_running_loop().call_later(STREAM_BATCH, self._send_piece)
|
|
206
|
+
return
|
|
207
|
+
self._send_piece()
|
|
208
|
+
self.client.publish({"t": kind, **fields})
|
|
209
|
+
|
|
210
|
+
def _send_piece(self) -> None:
|
|
211
|
+
if self._flush is not None:
|
|
212
|
+
self._flush.cancel()
|
|
213
|
+
self._flush = None
|
|
214
|
+
piece, self._piece = self._piece, None
|
|
215
|
+
if piece is None:
|
|
216
|
+
return
|
|
217
|
+
kind, parts = piece
|
|
218
|
+
if kind != "text":
|
|
219
|
+
self.client.publish({"t": kind})
|
|
220
|
+
return
|
|
221
|
+
# A burst of text is cut into lines the broker will pass: one longer than it allows is dropped there, whole.
|
|
222
|
+
text = "".join(parts)
|
|
223
|
+
for start in range(0, len(text), STREAM_CHUNK):
|
|
224
|
+
self.client.publish({"t": "text", "text": text[start : start + STREAM_CHUNK]})
|
|
225
|
+
|
|
226
|
+
# -- sending ----------------------------------------------------------------------------------
|
|
227
|
+
async def send(
|
|
228
|
+
self,
|
|
229
|
+
to: str,
|
|
230
|
+
text: str,
|
|
231
|
+
*,
|
|
232
|
+
via: str = VIA_AGENT,
|
|
233
|
+
direct: bool = False,
|
|
234
|
+
mode: str = MODE_STEER,
|
|
235
|
+
images: Sequence[ImageData] = (),
|
|
236
|
+
) -> Delivered:
|
|
237
|
+
"""Send a message and return once the other session has taken it.
|
|
238
|
+
|
|
239
|
+
A message sent by this session's agent while it is answering another one carries that message's
|
|
240
|
+
hop count on; a message the reader sent starts again at none.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
to: The session's handle or id.
|
|
244
|
+
text: What to say.
|
|
245
|
+
via: How it came: the agent's tool, or the reader.
|
|
246
|
+
direct: Ask a session that can to run it as its own reader's prompt and answer the sender.
|
|
247
|
+
mode: What a busy recipient does with it: ``"steer"`` (the default) hands it to the running request,
|
|
248
|
+
``"followup"`` waits until that request ends.
|
|
249
|
+
images: Pictures to send with a ``direct`` message.
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
SendError: Nobody was reached, and why.
|
|
253
|
+
"""
|
|
254
|
+
hops = self.answering.hops if self.answering is not None and via == VIA_AGENT else 0
|
|
255
|
+
return await self.client.send(to, text, hops=hops, via=via, direct=direct, mode=mode, images=images)
|
|
256
|
+
|
|
257
|
+
async def send_group(
|
|
258
|
+
self, group: str, text: str, *, via: str = VIA_AGENT, mode: str = MODE_STEER
|
|
259
|
+
) -> GroupDelivered:
|
|
260
|
+
"""Write to a group chat this session is in; the hop count is carried on as for :meth:`send`.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
SendError: Nobody was reached, and why (``denied`` when this session is not in the group).
|
|
264
|
+
"""
|
|
265
|
+
hops = self.answering.hops if self.answering is not None and via == VIA_AGENT else 0
|
|
266
|
+
return await self.client.send_group(group, text, hops=hops, via=via, mode=mode)
|
|
267
|
+
|
|
268
|
+
# -- what the client reports ------------------------------------------------------------------
|
|
269
|
+
def _received(self, incoming: Incoming) -> Receipt:
|
|
270
|
+
"""Turn the client's message into the shell's and ask the shell to take it."""
|
|
271
|
+
receiver = self.receiver
|
|
272
|
+
if receiver is None:
|
|
273
|
+
return Receipt(False, "this session is not taking messages yet")
|
|
274
|
+
sender = incoming.sender
|
|
275
|
+
return receiver(
|
|
276
|
+
Relayed(
|
|
277
|
+
sender_id=sender.session_id,
|
|
278
|
+
sender=sender.handle,
|
|
279
|
+
text=incoming.text,
|
|
280
|
+
via=incoming.via,
|
|
281
|
+
hops=incoming.hops,
|
|
282
|
+
direct=incoming.direct,
|
|
283
|
+
mode=incoming.mode,
|
|
284
|
+
group=incoming.group,
|
|
285
|
+
images=incoming.images,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def _roster_changed(self, roster: tuple[RosterEntry, ...]) -> None:
|
|
290
|
+
if self.on_change is not None:
|
|
291
|
+
self.on_change()
|
|
292
|
+
|
|
293
|
+
def _conflicted(self, message: str | None) -> None:
|
|
294
|
+
if self.on_conflict is not None:
|
|
295
|
+
self.on_conflict(message)
|
|
296
|
+
|
|
297
|
+
def _troubled(self, message: str | None) -> None:
|
|
298
|
+
if self.on_problem is not None:
|
|
299
|
+
self.on_problem(message)
|
zettcode/sdk/models.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The models a config file holds: list them, add one, change one, remove one.
|
|
2
|
+
|
|
3
|
+
An editor that hosts ZettCode has to be able to set a model up before there is any client: a fresh machine has no
|
|
4
|
+
``~/.zettcode/config.toml``, and a client cannot be made without a model. These functions work on the file itself, so
|
|
5
|
+
they answer with no client, no provider, and no network — the terminal's ``/config`` edits the same file with the same
|
|
6
|
+
helpers, so a model added here reads there, and the other way round.
|
|
7
|
+
|
|
8
|
+
Nothing is believed until it is read back: the new file is parsed and every entry validated before the call returns,
|
|
9
|
+
and a write that would not parse is undone.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from zettcode import config as _config
|
|
18
|
+
from zettcode.config_edit import add_model as insert_model
|
|
19
|
+
from zettcode.config_edit import file_entries, replace_model, write_text_atomically
|
|
20
|
+
from zettcode.paths import config_path
|
|
21
|
+
|
|
22
|
+
from .types import ModelInfo
|
|
23
|
+
|
|
24
|
+
__all__ = ["add_model", "list_models", "remove_model", "update_model"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def model_info(model: _config.ModelConfig, *, active: bool = False) -> ModelInfo:
|
|
28
|
+
"""Return one configured model as an SDK value; ``active`` is for a client to say, a file cannot."""
|
|
29
|
+
return ModelInfo(
|
|
30
|
+
name=model.shown_name,
|
|
31
|
+
model=model.model,
|
|
32
|
+
context_window=model.context_window,
|
|
33
|
+
multimodal=model.multimodal,
|
|
34
|
+
efforts=tuple(level.value for level in model.efforts),
|
|
35
|
+
active=active,
|
|
36
|
+
compact_percent=model.compact_percent,
|
|
37
|
+
web_search=model.web_search,
|
|
38
|
+
web_search_name=model.web_search_name,
|
|
39
|
+
display_model=model.display_model,
|
|
40
|
+
base_url=model.base_url,
|
|
41
|
+
api=model.api.value,
|
|
42
|
+
temperature=model.temperature,
|
|
43
|
+
can_search=model.can_search,
|
|
44
|
+
search_name=model.search_name,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _infos(models: tuple[_config.ModelConfig, ...]) -> tuple[ModelInfo, ...]:
|
|
49
|
+
"""Return the configured models as SDK values; none is marked active, which only a client knows."""
|
|
50
|
+
return tuple(model_info(model) for model in models)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _path(path: str | Path | None) -> Path:
|
|
54
|
+
"""Return the config file an edit goes to."""
|
|
55
|
+
return config_path(path)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_text(target: Path) -> str:
|
|
59
|
+
"""Return the file's text, or nothing when it does not exist yet."""
|
|
60
|
+
return target.read_text(encoding="utf-8") if target.is_file() else ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write(target: Path, before: str | None, text: str) -> tuple[_config.ModelConfig, ...]:
|
|
64
|
+
"""Write ``text``, read it back, and undo the write when what came back cannot be used."""
|
|
65
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
write_text_atomically(target, text)
|
|
67
|
+
try:
|
|
68
|
+
return _config.read_models(target)
|
|
69
|
+
except ValueError:
|
|
70
|
+
if before is None:
|
|
71
|
+
target.unlink(missing_ok=True)
|
|
72
|
+
else:
|
|
73
|
+
write_text_atomically(target, before)
|
|
74
|
+
raise
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def list_models(path: str | Path | None = None) -> tuple[ModelInfo, ...]:
|
|
78
|
+
"""Return the models the config file lists, or nothing when it lists none.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: The file does not parse, or an entry is not usable as one.
|
|
82
|
+
"""
|
|
83
|
+
return _infos(_config.read_models(_path(path)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def add_model(entry: Mapping[str, object], *, path: str | Path | None = None) -> tuple[ModelInfo, ...]:
|
|
87
|
+
"""Add one model to the config file and return every model it now lists.
|
|
88
|
+
|
|
89
|
+
The entry uses the file's own keys — ``model``, ``token``, and optionally ``display_model``, ``base_url``,
|
|
90
|
+
``anthropic``, ``responses_api``, ``multimodal``, ``web_search``, ``web_search_name``, ``context_window``,
|
|
91
|
+
``compact_percent``,
|
|
92
|
+
``reasoning_efforts`` — and is validated by reading the file back before returning. The first model is written
|
|
93
|
+
by hand (the file has no block to insert after); later ones go through the same editor the terminal's ``/config``
|
|
94
|
+
uses, so the rest of the file, comments included, is left as it was.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ValueError: The entry is not a usable model, or the file would not parse.
|
|
98
|
+
"""
|
|
99
|
+
target = _path(path)
|
|
100
|
+
# Unconditionally: `read_models` answers () for a file that is not there yet and refuses one that is not a
|
|
101
|
+
# file, which is what a caller naming a directory has to hear rather than an IsADirectoryError from the write.
|
|
102
|
+
_config.read_models(target)
|
|
103
|
+
before = target.read_text(encoding="utf-8") if target.is_file() else None
|
|
104
|
+
text = insert_model(before or "", entry, where=target)
|
|
105
|
+
return _infos(_write(target, before, text))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _find(models: tuple[_config.ModelConfig, ...], name: str) -> int:
|
|
109
|
+
"""Return the place of the model called ``name`` (its display name, or its id), as the runtime resolves it."""
|
|
110
|
+
index = _config.model_index(models, name)
|
|
111
|
+
if index is None:
|
|
112
|
+
raise ValueError(f"No model called {name!r}; configured: {', '.join(model.shown_name for model in models)}")
|
|
113
|
+
return index
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def update_model(name: str, changes: Mapping[str, object], *, path: str | Path | None = None) -> tuple[ModelInfo, ...]:
|
|
117
|
+
"""Change the model called ``name``, leaving every key not named as it is, and return the new list.
|
|
118
|
+
|
|
119
|
+
``changes`` holds the file's keys. The token is kept unless a new one is given, so a window or a display name can
|
|
120
|
+
be changed without repeating a secret. A key whose value is ``None`` is removed from the entry, which is how a
|
|
121
|
+
temperature is cleared and how one protocol flag gives way to another without being left behind as ``false``.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ValueError: No model is called ``name``, or the result is not usable.
|
|
125
|
+
"""
|
|
126
|
+
target = _path(path)
|
|
127
|
+
models = _config.read_models(target)
|
|
128
|
+
index = _find(models, name)
|
|
129
|
+
before = _read_text(target)
|
|
130
|
+
# The file's own keys, not the model's: a token the environment supplied belongs to the configuration, and
|
|
131
|
+
# writing it here would copy a secret into the file this dialog shows.
|
|
132
|
+
entry = dict(file_entries(before, where=target)[index])
|
|
133
|
+
for key, value in changes.items():
|
|
134
|
+
if value is None:
|
|
135
|
+
entry.pop(key, None)
|
|
136
|
+
else:
|
|
137
|
+
entry[key] = value
|
|
138
|
+
text = replace_model(before, index, entry, where=target)
|
|
139
|
+
return _infos(_write(target, before, text))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def remove_model(name: str, *, path: str | Path | None = None) -> tuple[ModelInfo, ...]:
|
|
143
|
+
"""Remove the model called ``name`` and return the rest.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
ValueError: No model is called ``name``, or it is the last one: a configuration with no models is one no
|
|
147
|
+
client can be made from, so another has to be added first.
|
|
148
|
+
"""
|
|
149
|
+
from zettcode.config_edit import remove_model as drop_model
|
|
150
|
+
|
|
151
|
+
target = _path(path)
|
|
152
|
+
models = _config.read_models(target)
|
|
153
|
+
index = _find(models, name)
|
|
154
|
+
before = _read_text(target)
|
|
155
|
+
text = drop_model(before, index, where=target)
|
|
156
|
+
return _infos(_write(target, before, text))
|
zettcode/sdk/py.typed
ADDED
|
File without changes
|