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/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
+ """