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/events.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""The events a run produces, as plain typed values.
|
|
2
|
+
|
|
3
|
+
``zett-agent`` streams one ``AgentEvent`` with a type tag and a set of optional fields whose meaning depends on the
|
|
4
|
+
tag, and the application's own extras (a shell approval, a question for the reader, token totals) ride on it as
|
|
5
|
+
``CUSTOM`` events with a name and a loose payload. This module turns that into one frozen dataclass per thing that
|
|
6
|
+
happened, so a program matches on a class and reads fields that are always there::
|
|
7
|
+
|
|
8
|
+
async for event in session.stream("fix the failing test"):
|
|
9
|
+
match event:
|
|
10
|
+
case TextDelta(text=text):
|
|
11
|
+
print(text, end="")
|
|
12
|
+
case ApprovalRequested() as ask:
|
|
13
|
+
ask.approve() if "rm " not in ask.command else ask.deny()
|
|
14
|
+
|
|
15
|
+
Two events ask something of the program: :class:`ApprovalRequested` and :class:`QuestionAsked`. The agent is
|
|
16
|
+
suspended inside the tool call until they are answered, which is why each carries the methods that answer it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import TYPE_CHECKING, TypeAlias
|
|
23
|
+
|
|
24
|
+
from .types import Compaction, Usage
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only; nothing here imports the agent
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
|
|
29
|
+
from zett_agent.events import AgentEvent
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"AnyEvent",
|
|
33
|
+
"ApprovalRequested",
|
|
34
|
+
"CompactionDelta",
|
|
35
|
+
"CompactionFinished",
|
|
36
|
+
"CompactionStarted",
|
|
37
|
+
"Event",
|
|
38
|
+
"Finished",
|
|
39
|
+
"ModelStopped",
|
|
40
|
+
"Notice",
|
|
41
|
+
"QuestionAsked",
|
|
42
|
+
"RawEvent",
|
|
43
|
+
"ReasoningDelta",
|
|
44
|
+
"ReasoningFinished",
|
|
45
|
+
"ReasoningStarted",
|
|
46
|
+
"ServerToolFinished",
|
|
47
|
+
"ServerToolStarted",
|
|
48
|
+
"Steered",
|
|
49
|
+
"SteeringFinished",
|
|
50
|
+
"SteeringInterrupted",
|
|
51
|
+
"TextDelta",
|
|
52
|
+
"ToolFinished",
|
|
53
|
+
"ToolStarted",
|
|
54
|
+
"UsageUpdated",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Reply:
|
|
59
|
+
"""The one-shot slot an answerable event settles through.
|
|
60
|
+
|
|
61
|
+
The first answer wins and is sent exactly once. Asking again for the same thing is harmless, and asking for the
|
|
62
|
+
opposite is a mistake worth raising, because the agent has already acted on the first.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
__slots__ = ("_send", "outcome")
|
|
66
|
+
|
|
67
|
+
def __init__(self, send: Callable[[object], None]) -> None:
|
|
68
|
+
self._send = send
|
|
69
|
+
self.outcome: object | None = None
|
|
70
|
+
|
|
71
|
+
def settle(self, outcome: object) -> None:
|
|
72
|
+
"""Send ``outcome`` unless an answer was already sent; raise if the earlier one differs."""
|
|
73
|
+
if self.outcome is not None:
|
|
74
|
+
if self.outcome != outcome:
|
|
75
|
+
raise ValueError(f"Already answered ({self.outcome!r}); it cannot be answered again as {outcome!r}")
|
|
76
|
+
return
|
|
77
|
+
self._send(outcome)
|
|
78
|
+
self.outcome = outcome
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class Event:
|
|
83
|
+
"""Base of every event.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
session_id: The session the run belongs to.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
session_id: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class TextDelta(Event):
|
|
94
|
+
"""A piece of the model's answer.
|
|
95
|
+
|
|
96
|
+
Attributes:
|
|
97
|
+
text: The new text, to be appended to what came before.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
text: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class ReasoningDelta(Event):
|
|
105
|
+
"""A piece of the model's reasoning, for the providers that report it.
|
|
106
|
+
|
|
107
|
+
Attributes:
|
|
108
|
+
text: The new text.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
text: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class ReasoningStarted(Event):
|
|
116
|
+
"""The model began a span of reasoning; :class:`ReasoningDelta` events follow until :class:`ReasoningFinished`."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class ReasoningFinished(Event):
|
|
121
|
+
"""The span of reasoning ended, whether the model went on to answer or to call a tool."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class ToolStarted(Event):
|
|
126
|
+
"""The model called a tool and it is starting.
|
|
127
|
+
|
|
128
|
+
Attributes:
|
|
129
|
+
call_id: Identity that ties this to its :class:`ToolFinished`.
|
|
130
|
+
name: The tool.
|
|
131
|
+
arguments: What it was called with.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
call_id: str
|
|
135
|
+
name: str
|
|
136
|
+
arguments: dict[str, object]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True)
|
|
140
|
+
class ToolFinished(Event):
|
|
141
|
+
"""A tool call ended, in success, in failure, or because it never ran.
|
|
142
|
+
|
|
143
|
+
Attributes:
|
|
144
|
+
call_id: The call it answers.
|
|
145
|
+
name: The tool.
|
|
146
|
+
output: What the tool returned, or the failure text.
|
|
147
|
+
ok: Whether it succeeded.
|
|
148
|
+
skipped: Whether it never ran because steering took over.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
call_id: str
|
|
152
|
+
name: str
|
|
153
|
+
output: str
|
|
154
|
+
ok: bool
|
|
155
|
+
skipped: bool = False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True, slots=True)
|
|
159
|
+
class ServerToolStarted(Event):
|
|
160
|
+
"""A tool the endpoint runs on the model's behalf started, such as a web search.
|
|
161
|
+
|
|
162
|
+
The agent never calls these: the provider does, and reports them for display. A search is the one a reader cares
|
|
163
|
+
about, because the query and the pages are the story of the answer.
|
|
164
|
+
|
|
165
|
+
Attributes:
|
|
166
|
+
call_id: Identity that ties this to its :class:`ServerToolFinished`.
|
|
167
|
+
name: The tool, such as ``web_search``.
|
|
168
|
+
arguments: What it was asked, when the provider said at the start; ``{"query": ...}`` for a search.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
call_id: str
|
|
172
|
+
name: str
|
|
173
|
+
arguments: dict[str, object]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True, slots=True)
|
|
177
|
+
class ServerToolFinished(Event):
|
|
178
|
+
"""A hosted tool ended: the pages a search found, or the provider's error code.
|
|
179
|
+
|
|
180
|
+
Attributes:
|
|
181
|
+
call_id: The call it answers.
|
|
182
|
+
name: The tool.
|
|
183
|
+
arguments: What it was asked, gathered across the input the provider streamed.
|
|
184
|
+
output: A readable summary of the result — for a search, the pages — never the provider's opaque payload. A
|
|
185
|
+
call the provider closed without a result says so here, because there is no result to show.
|
|
186
|
+
ok: Whether it succeeded. ``False`` for a call that failed, and for one that ended without a reported result:
|
|
187
|
+
the call is over, and nothing about what it returned is known.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
call_id: str
|
|
191
|
+
name: str
|
|
192
|
+
arguments: dict[str, object]
|
|
193
|
+
output: str
|
|
194
|
+
ok: bool
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True, slots=True)
|
|
198
|
+
class ApprovalRequested(Event):
|
|
199
|
+
"""The model wants to run a shell command and the permission level asks first.
|
|
200
|
+
|
|
201
|
+
The agent waits until this is answered. Call :meth:`approve` or :meth:`deny` before asking the stream for its
|
|
202
|
+
next event; a request still unanswered by then is denied, so an unattended run never hangs. A handler passed to
|
|
203
|
+
the run answers before the event is yielded, and then :attr:`answered` is already true. A run started with
|
|
204
|
+
``defer_answers=True`` keeps the request open instead, for a program that asks its reader somewhere else; it is
|
|
205
|
+
denied only if the run ends first.
|
|
206
|
+
|
|
207
|
+
Attributes:
|
|
208
|
+
call_id: The tool call the answer belongs to.
|
|
209
|
+
command: The command the model wants to run.
|
|
210
|
+
timeout_seconds: The limit the command would run under, when the tool states one.
|
|
211
|
+
remember_supported: Whether approving can be remembered for the rest of the run.
|
|
212
|
+
work_dir: The directory the command runs in, when the call names one instead of the workspace. What is
|
|
213
|
+
remembered is still the command text alone, so an approval remembered in one directory is offered for
|
|
214
|
+
the same command anywhere; the panel says where this one runs.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
call_id: str
|
|
218
|
+
command: str
|
|
219
|
+
timeout_seconds: int | None = None
|
|
220
|
+
remember_supported: bool = False
|
|
221
|
+
_reply: _Reply | None = field(default=None, repr=False, compare=False)
|
|
222
|
+
# After ``_reply`` on purpose: both have defaults, and a field added before a positional one would bind an
|
|
223
|
+
# older caller's reply to it, leaving the run waiting to be denied with nothing to show for it.
|
|
224
|
+
work_dir: str | None = None
|
|
225
|
+
|
|
226
|
+
def approve(self, *, remember: bool = False) -> None:
|
|
227
|
+
"""Let the command run; ``remember`` also lets the same kind of command run unasked afterwards."""
|
|
228
|
+
remember = remember and self.remember_supported
|
|
229
|
+
self._settle(("execute", remember))
|
|
230
|
+
|
|
231
|
+
def deny(self) -> None:
|
|
232
|
+
"""Refuse the command; the model is told, and carries on without it."""
|
|
233
|
+
self._settle(("abort", False))
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def answered(self) -> bool:
|
|
237
|
+
"""Return whether it was answered, by the program or by the run's handler."""
|
|
238
|
+
return self._reply is not None and self._reply.outcome is not None
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def approved(self) -> bool | None:
|
|
242
|
+
"""Return the answer: ``True`` approved, ``False`` denied, ``None`` while unanswered."""
|
|
243
|
+
outcome = self._reply.outcome if self._reply is not None else None
|
|
244
|
+
return None if outcome is None else outcome[0] == "execute" # type: ignore[index]
|
|
245
|
+
|
|
246
|
+
def _settle(self, outcome: tuple[str, bool]) -> None:
|
|
247
|
+
if self._reply is None:
|
|
248
|
+
raise RuntimeError("This request was not created by a run, so there is nothing to answer")
|
|
249
|
+
self._reply.settle(outcome)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True, slots=True)
|
|
253
|
+
class QuestionAsked(Event):
|
|
254
|
+
"""The model asked the reader a question and waits for the answer.
|
|
255
|
+
|
|
256
|
+
Call :meth:`answer` or :meth:`decline` before asking the stream for its next event. A question still unanswered
|
|
257
|
+
by then is declined, which tells the model the reader cancelled, so an unattended run never hangs.
|
|
258
|
+
|
|
259
|
+
Attributes:
|
|
260
|
+
call_id: The tool call the answer belongs to.
|
|
261
|
+
question: What it asked.
|
|
262
|
+
options: The choices it offered, if any; the answer may still be free text.
|
|
263
|
+
allow_multiple: Whether more than one option may be chosen.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
call_id: str
|
|
267
|
+
question: str
|
|
268
|
+
options: tuple[str, ...] = ()
|
|
269
|
+
allow_multiple: bool = False
|
|
270
|
+
_reply: _Reply | None = field(default=None, repr=False, compare=False)
|
|
271
|
+
|
|
272
|
+
def answer(self, text: str) -> None:
|
|
273
|
+
"""Answer it. The text goes to the model exactly as given (join several choices yourself)."""
|
|
274
|
+
if not isinstance(text, str) or not text.strip():
|
|
275
|
+
raise ValueError("An answer cannot be empty; call decline() to refuse the question")
|
|
276
|
+
self._settle(text)
|
|
277
|
+
|
|
278
|
+
def decline(self) -> None:
|
|
279
|
+
"""Tell the model the question was cancelled."""
|
|
280
|
+
self._settle(None)
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def answered(self) -> bool:
|
|
284
|
+
"""Return whether it was answered or declined."""
|
|
285
|
+
return self._reply is not None and self._reply.outcome is not None
|
|
286
|
+
|
|
287
|
+
def _settle(self, outcome: str | None) -> None:
|
|
288
|
+
if self._reply is None:
|
|
289
|
+
raise RuntimeError("This question was not created by a run, so there is nothing to answer")
|
|
290
|
+
self._reply.settle(("answer", outcome))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass(frozen=True, slots=True)
|
|
294
|
+
class UsageUpdated(Event):
|
|
295
|
+
"""The session's token totals after a model call.
|
|
296
|
+
|
|
297
|
+
Attributes:
|
|
298
|
+
usage: Cumulative for the session, not for this run; ``RunResult.usage`` is the run's own.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
usage: Usage
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass(frozen=True, slots=True)
|
|
305
|
+
class CompactionStarted(Event):
|
|
306
|
+
"""The conversation is being summarized to make room."""
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass(frozen=True, slots=True)
|
|
310
|
+
class CompactionDelta(Event):
|
|
311
|
+
"""A piece of the summary a compaction is writing, between :class:`CompactionStarted` and its end.
|
|
312
|
+
|
|
313
|
+
Attributes:
|
|
314
|
+
text: The new text, to be appended to what came before.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
text: str
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@dataclass(frozen=True, slots=True)
|
|
321
|
+
class CompactionFinished(Event):
|
|
322
|
+
"""A compaction ended.
|
|
323
|
+
|
|
324
|
+
Attributes:
|
|
325
|
+
applied: Whether it replaced the context; ``False`` when there was nothing worth folding.
|
|
326
|
+
compaction: What it did, when it applied.
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
applied: bool
|
|
330
|
+
compaction: Compaction | None = None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True, slots=True)
|
|
334
|
+
class ModelStopped(Event):
|
|
335
|
+
"""A model step ended for a reason other than finishing: refused, cut off, interrupted, paused.
|
|
336
|
+
|
|
337
|
+
Attributes:
|
|
338
|
+
reason: ``refusal``, ``max_output``, ``context_full``, ``paused``, ``interrupted`` or ``other``.
|
|
339
|
+
message: The provider's sentence about it, when it gave one.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
reason: str
|
|
343
|
+
message: str | None = None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass(frozen=True, slots=True)
|
|
347
|
+
class Steered(Event):
|
|
348
|
+
"""A message sent with ``steer`` reached the running request.
|
|
349
|
+
|
|
350
|
+
Attributes:
|
|
351
|
+
text: What was said.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
text: str
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@dataclass(frozen=True, slots=True)
|
|
358
|
+
class SteeringFinished(Event):
|
|
359
|
+
"""The request that adopted a :class:`Steered` message has answered it."""
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass(frozen=True, slots=True)
|
|
363
|
+
class SteeringInterrupted(Event):
|
|
364
|
+
"""A steering message was superseded by a newer one before the request got to it.
|
|
365
|
+
|
|
366
|
+
Attributes:
|
|
367
|
+
text: What the superseded message said.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
text: str
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@dataclass(frozen=True, slots=True)
|
|
374
|
+
class Notice(Event):
|
|
375
|
+
"""News that is not part of the answer, such as an MCP server that could not be reached.
|
|
376
|
+
|
|
377
|
+
Attributes:
|
|
378
|
+
text: One readable line.
|
|
379
|
+
level: How serious it is: ``"info"`` for progress (an MCP server is starting, or ready) and ``"warn"`` for
|
|
380
|
+
something the reader may need to act on (a server is down, a configuration entry is ignored).
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
text: str
|
|
384
|
+
level: str = "info"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@dataclass(frozen=True, slots=True)
|
|
388
|
+
class Finished(Event):
|
|
389
|
+
"""The run ended and the model has given its final answer.
|
|
390
|
+
|
|
391
|
+
Attributes:
|
|
392
|
+
text: The answer, which may be empty if the model only used tools.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
text: str
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@dataclass(frozen=True, slots=True)
|
|
399
|
+
class RawEvent(Event):
|
|
400
|
+
"""The underlying ``zett-agent`` event, for a program that wants what the SDK does not model.
|
|
401
|
+
|
|
402
|
+
Only produced when a run is started with ``raw=True``, and always *in addition to* the typed events.
|
|
403
|
+
|
|
404
|
+
Attributes:
|
|
405
|
+
event: The event as ``zett-agent`` produced it.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
event: AgentEvent
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
#: Every event a run can yield.
|
|
412
|
+
AnyEvent: TypeAlias = (
|
|
413
|
+
TextDelta
|
|
414
|
+
| ReasoningDelta
|
|
415
|
+
| ReasoningStarted
|
|
416
|
+
| ReasoningFinished
|
|
417
|
+
| ToolStarted
|
|
418
|
+
| ToolFinished
|
|
419
|
+
| ServerToolStarted
|
|
420
|
+
| ServerToolFinished
|
|
421
|
+
| ApprovalRequested
|
|
422
|
+
| QuestionAsked
|
|
423
|
+
| UsageUpdated
|
|
424
|
+
| CompactionStarted
|
|
425
|
+
| CompactionDelta
|
|
426
|
+
| CompactionFinished
|
|
427
|
+
| ModelStopped
|
|
428
|
+
| Steered
|
|
429
|
+
| SteeringFinished
|
|
430
|
+
| SteeringInterrupted
|
|
431
|
+
| Notice
|
|
432
|
+
| Finished
|
|
433
|
+
| RawEvent
|
|
434
|
+
)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""The integrations the SDK adds to the engine, loaded only by a runtime that asks for them.
|
|
2
|
+
|
|
3
|
+
The engine's own extensions are the machinery a request is made of. These are the ones that need something the engine
|
|
4
|
+
must not carry: the broker (messaging), `tiktoken` (context counting), and the MCP client. The builtins in
|
|
5
|
+
:mod:`zettcode.sdk.builtins` register them, so an embedding that uses the engine directly decides for itself.
|
|
6
|
+
"""
|