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.
@@ -0,0 +1,384 @@
1
+ """Turning ``zett-agent``'s events into the SDK's.
2
+
3
+ Kept apart from :mod:`zettcode.sdk.events` on purpose: the event classes are plain dataclasses a program imports to
4
+ match on, and importing them must not load the agent. This module reads ``AgentEvent`` and the application's own
5
+ custom events, so it imports both, and is itself imported only when a run starts.
6
+
7
+ It is also what a front end that drives the agent's own event stream (the terminal does) reads them through, so that
8
+ there is one translation: :func:`translate` for the events, :class:`AgentBridge` for the answers the events wait on.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping, Sequence
14
+ from typing import TYPE_CHECKING, Protocol
15
+
16
+ from zett_agent.events import AgentEvent, AgentEventType
17
+ from zett_agent.extensions.shell_approval import SHELL_APPROVAL_EVENT_NAME
18
+ from zett_agent.messages import TextContent
19
+
20
+ from zettcode.engine.dialogue.ask import question_from
21
+ from zettcode.engine.extensions.mcp_notice import notice_from, notice_level_from
22
+ from zettcode.engine.extensions.usage import USAGE_EVENT_NAME, UsageSnapshot
23
+ from zettcode.engine.server_tools import ServerToolRun, describe_output, not_reported
24
+ from zettcode.engine.storage.ledger import Ledger, SessionStats, SessionUsage, Tally
25
+
26
+ from .events import (
27
+ AnyEvent,
28
+ ApprovalRequested,
29
+ CompactionDelta,
30
+ CompactionFinished,
31
+ CompactionStarted,
32
+ Finished,
33
+ ModelStopped,
34
+ Notice,
35
+ QuestionAsked,
36
+ ReasoningDelta,
37
+ ReasoningFinished,
38
+ ReasoningStarted,
39
+ ServerToolFinished,
40
+ ServerToolStarted,
41
+ Steered,
42
+ SteeringFinished,
43
+ SteeringInterrupted,
44
+ TextDelta,
45
+ ToolFinished,
46
+ ToolStarted,
47
+ UsageUpdated,
48
+ _Reply,
49
+ )
50
+ from .types import (
51
+ Compaction,
52
+ DayTotals,
53
+ ModelCheck,
54
+ ModelTotals,
55
+ SessionStatsInfo,
56
+ SessionTotals,
57
+ Totals,
58
+ Usage,
59
+ WorkspaceUsage,
60
+ )
61
+
62
+ __all__ = ["AgentBridge", "Bridge", "checks_from", "ledger_from", "stats_from", "translate", "usage_from"]
63
+
64
+
65
+ class Bridge(Protocol):
66
+ """How an answer reaches the suspended agent; the client supplies it."""
67
+
68
+ def approve(self, session_id: str, call_id: str, approve: bool, remember: bool) -> None:
69
+ """Send a shell approval decision."""
70
+
71
+ def answer(self, question: AskUserQuestion, text: str | None) -> None:
72
+ """Send the answer to a question, or decline it when ``text`` is ``None``."""
73
+
74
+
75
+ class AgentBridge:
76
+ """Carry the program's answers to the suspended agent, through the application's own agent."""
77
+
78
+ def __init__(self, agent: ZettCodeAgent) -> None:
79
+ self._agent = agent
80
+
81
+ def approve(self, session_id: str, call_id: str, approve: bool, remember: bool) -> None:
82
+ self._agent.respond_approval(session_id, call_id, "execute" if approve else "abort", remember)
83
+
84
+ def answer(self, question: AskUserQuestion, text: str | None) -> None:
85
+ if text is None:
86
+ self._agent.decline_ask(question)
87
+ else:
88
+ self._agent.answer_ask(question, text)
89
+
90
+
91
+ def usage_from(snapshot: UsageSnapshot) -> Usage:
92
+ """Return the SDK's copy of the application's usage counters."""
93
+ return Usage(
94
+ input_tokens=snapshot.input_tokens,
95
+ output_tokens=snapshot.output_tokens,
96
+ cache_read_tokens=snapshot.cache_read_tokens,
97
+ cache_write_tokens=snapshot.cache_write_tokens,
98
+ reasoning_tokens=snapshot.reasoning_tokens,
99
+ seconds=snapshot.seconds,
100
+ requests=snapshot.requests,
101
+ context_tokens=snapshot.context_tokens,
102
+ )
103
+
104
+
105
+ def _text_of(content: object) -> str:
106
+ """Return the plain text of a message body, which is a string or a list of content blocks."""
107
+ if isinstance(content, str):
108
+ return content
109
+ if isinstance(content, (list, tuple)):
110
+ return "\n".join(part.text for part in content if isinstance(part, TextContent))
111
+ return ""
112
+
113
+
114
+ def _arguments(value: object) -> dict[str, object]:
115
+ """Return a tool call's arguments as a plain dict, whatever mapping they arrived in."""
116
+ return dict(value) if isinstance(value, Mapping) else {}
117
+
118
+
119
+ def _payload(event: AgentEvent) -> dict[str, object]:
120
+ """Return a custom event's payload, or an empty one when it is missing or not a mapping."""
121
+ return dict(event.payload) if isinstance(event.payload, Mapping) else {}
122
+
123
+
124
+ def _optional_int(value: object) -> int | None:
125
+ """Return ``value`` as an int when it is a real number, else ``None`` (a bool is not a number here)."""
126
+ return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
127
+
128
+
129
+ def _work_dir_of(event: AgentEvent, call_id: str) -> str | None:
130
+ """Return the directory the approved call runs in, read from the call the event carries.
131
+
132
+ The approval event's payload names the command only, because that is what the approval is keyed by; the call
133
+ beside it still has the tool's arguments, and ``work_dir`` is the one the panel needs to say where it runs. The
134
+ call is matched by id rather than taken in order: the event carries one today, and a reader that says which
135
+ call it looked at stays right if that ever changes.
136
+ """
137
+ for call in getattr(event, "tool_calls", ()) or ():
138
+ if getattr(call, "id", None) != call_id:
139
+ continue
140
+ arguments = getattr(call, "arguments", None)
141
+ if isinstance(arguments, Mapping):
142
+ work_dir = arguments.get("work_dir")
143
+ if isinstance(work_dir, str) and work_dir:
144
+ return work_dir
145
+ return None
146
+
147
+
148
+ if TYPE_CHECKING: # pragma: no cover - annotations only
149
+ from zettcode.doctor import Probe
150
+ from zettcode.engine.agent import ZettCodeAgent
151
+ from zettcode.engine.dialogue.ask import AskUserQuestion
152
+
153
+
154
+ def totals_from(tally: Tally) -> Totals:
155
+ """Return one tally as the SDK's own counters."""
156
+ return Totals(
157
+ requests=tally.requests,
158
+ input_tokens=tally.input_tokens,
159
+ output_tokens=tally.output_tokens,
160
+ cache_read_tokens=tally.cache_read_tokens,
161
+ reasoning_tokens=tally.reasoning_tokens,
162
+ )
163
+
164
+
165
+ def ledger_from(ledger: Ledger) -> WorkspaceUsage:
166
+ """Return the workspace ledger as plain SDK values, ordered the way a page shows it."""
167
+ sessions = sorted(ledger.sessions, key=lambda item: item.updated, reverse=True)
168
+ models = sorted(ledger.models.items(), key=lambda pair: pair[1].total, reverse=True)
169
+ return WorkspaceUsage(
170
+ sessions=tuple(_session_totals(item) for item in sessions),
171
+ models=tuple(ModelTotals(name, totals_from(tally)) for name, tally in models),
172
+ days=tuple(DayTotals(day, totals_from(tally)) for day, tally in sorted(ledger.days.items())),
173
+ total=totals_from(ledger.total),
174
+ )
175
+
176
+
177
+ def _session_totals(usage: SessionUsage) -> SessionTotals:
178
+ """Return one session's ledger row."""
179
+ return SessionTotals(
180
+ id=usage.session_id,
181
+ title=usage.title,
182
+ started=usage.started,
183
+ updated=usage.updated,
184
+ tokens=totals_from(usage.tally),
185
+ models=usage.models,
186
+ entries=usage.entries,
187
+ )
188
+
189
+
190
+ def stats_from(stats: SessionStats) -> SessionStatsInfo:
191
+ """Return one session's stored totals."""
192
+ return SessionStatsInfo(
193
+ id=stats.session_id,
194
+ entries=stats.entries,
195
+ requests=stats.requests,
196
+ input_tokens=stats.input_tokens,
197
+ output_tokens=stats.output_tokens,
198
+ cache_read_tokens=stats.cache_read_tokens,
199
+ reasoning_tokens=stats.reasoning_tokens,
200
+ )
201
+
202
+
203
+ def checks_from(probes: Sequence[Probe]) -> tuple[ModelCheck, ...]:
204
+ """Return the doctor probes as SDK values."""
205
+ checks: list[ModelCheck] = []
206
+ for probe in probes:
207
+ tokens = probe.tokens
208
+ checks.append(
209
+ ModelCheck(
210
+ model=probe.model.shown_name,
211
+ model_id=probe.model.model,
212
+ ok=probe.ok,
213
+ seconds=probe.seconds,
214
+ detail=probe.detail,
215
+ input_tokens=tokens[0] if tokens else None,
216
+ output_tokens=tokens[1] if tokens else None,
217
+ )
218
+ )
219
+ return tuple(checks)
220
+
221
+
222
+ def translate(event: AgentEvent, bridge: Bridge, hosted: dict[str, str] | None = None) -> list[AnyEvent]:
223
+ """Return the SDK events one ``zett-agent`` event stands for; usually one, sometimes none or several.
224
+
225
+ Anything malformed is dropped rather than raised: this runs inside the stream of a live request, and a
226
+ partly-formed custom event from an extension must not be able to fail it.
227
+
228
+ Args:
229
+ event: The event to translate.
230
+ bridge: How an answer reaches the suspended agent.
231
+ hosted: What a hosted tool's input has streamed so far, by call, for the caller that keeps it across the run.
232
+ It is how a search that sends its query in fragments still shows the query; without it the start's own
233
+ input is all there is.
234
+ """
235
+ session = event.session_id or ""
236
+ kind = event.type
237
+ if kind is AgentEventType.TEXT_DELTA:
238
+ return [TextDelta(session, event.delta)] if event.delta else []
239
+ if kind is AgentEventType.REASONING_DELTA:
240
+ return [ReasoningDelta(session, event.delta)] if event.delta else []
241
+ if kind is AgentEventType.REASONING_STARTED:
242
+ return [ReasoningStarted(session)]
243
+ if kind is AgentEventType.REASONING_COMPLETED:
244
+ return [ReasoningFinished(session)]
245
+ if kind is AgentEventType.TOOL_STARTED:
246
+ return [ToolStarted(session, call.id, call.name, _arguments(call.arguments)) for call in event.tool_calls]
247
+ if kind in (AgentEventType.TOOL_COMPLETED, AgentEventType.TOOL_FAILED, AgentEventType.TOOL_SKIPPED):
248
+ return _tool_finished(event, session)
249
+ if kind is AgentEventType.SERVER_TOOL_STARTED:
250
+ return _server_tool_started(event, session, hosted)
251
+ if kind is AgentEventType.SERVER_TOOL_INPUT_DELTA:
252
+ delta = event.server_tool_input_delta
253
+ if delta is not None and hosted is not None:
254
+ hosted[delta.call_id] = hosted.get(delta.call_id, "") + delta.delta
255
+ return []
256
+ if kind in (AgentEventType.SERVER_TOOL_COMPLETED, AgentEventType.SERVER_TOOL_FAILED):
257
+ return _server_tool_finished(event, session, hosted)
258
+ if kind is AgentEventType.COMPACTION_STARTED:
259
+ return [CompactionStarted(session)]
260
+ if kind is AgentEventType.COMPACTION_TEXT_DELTA:
261
+ return [CompactionDelta(session, event.delta)] if event.delta else []
262
+ if kind is AgentEventType.COMPACTION_COMPLETED:
263
+ return [_compaction_finished(event, session)]
264
+ if kind is AgentEventType.MODEL_STOPPED:
265
+ stop = event.stop
266
+ reason = str(stop.reason.value) if stop is not None else "other"
267
+ return [ModelStopped(session, reason, (stop.message or None) if stop is not None else None)]
268
+ if kind is AgentEventType.STEERING_STARTED:
269
+ text = _text_of(event.steering_message.content) if event.steering_message is not None else ""
270
+ return [Steered(session, text)]
271
+ if kind is AgentEventType.STEERING_COMPLETED:
272
+ return [SteeringFinished(session)]
273
+ if kind is AgentEventType.STEERING_INTERRUPTED:
274
+ text = _text_of(event.steering_message.content) if event.steering_message is not None else ""
275
+ return [SteeringInterrupted(session, text)]
276
+ if kind is AgentEventType.RUN_COMPLETED:
277
+ return [Finished(session, _text_of(getattr(event.message, "content", "")))]
278
+ if kind is AgentEventType.CUSTOM:
279
+ return _custom(event, session, bridge)
280
+ return []
281
+
282
+
283
+ def _server_tool_started(event: AgentEvent, session: str, hosted: dict[str, str] | None) -> list[AnyEvent]:
284
+ """Return the start of a hosted call: its name and whatever input the provider gave up front."""
285
+ call = event.server_tool_call
286
+ if call is None:
287
+ return []
288
+ if hosted is not None:
289
+ hosted.setdefault(call.id, "")
290
+ return [ServerToolStarted(session, call.id, call.name, dict(call.input or {}))]
291
+
292
+
293
+ def _server_tool_finished(event: AgentEvent, session: str, hosted: dict[str, str] | None) -> list[AnyEvent]:
294
+ """Return the end of a hosted call: its query, gathered from the fragments, and a readable result."""
295
+ import json
296
+
297
+ result = event.server_tool_result
298
+ if result is None:
299
+ return []
300
+ arguments: dict[str, object] = {}
301
+ if hosted is not None:
302
+ fragments = hosted.pop(result.call_id, "")
303
+ if fragments:
304
+ try:
305
+ parsed = json.loads(fragments)
306
+ except ValueError:
307
+ parsed = None
308
+ if isinstance(parsed, dict):
309
+ arguments.update(parsed)
310
+ # A call closed with the adapter's "no result" marker did not succeed: it ended, and nothing about what it
311
+ # returned was reported, so the row must not be drawn as a completed one.
312
+ ok = event.type is AgentEventType.SERVER_TOOL_COMPLETED and not not_reported(result.output)
313
+ run = ServerToolRun(result.call_id, result.name, arguments, output=result.output, error=result.error_code)
314
+ return [ServerToolFinished(session, result.call_id, result.name, arguments, describe_output(run), ok)]
315
+
316
+
317
+ def _tool_finished(event: AgentEvent, session: str) -> list[AnyEvent]:
318
+ """Return the finished events of a tool event: one per call, with the message's text or the error's."""
319
+ skipped = event.type is AgentEventType.TOOL_SKIPPED
320
+ ok = event.type is AgentEventType.TOOL_COMPLETED
321
+ message = event.message
322
+ output = _text_of(getattr(message, "content", ""))
323
+ if not output and event.error is not None:
324
+ output = str(event.error)
325
+ if ok and message is not None and getattr(message, "success", True) is False:
326
+ ok = False
327
+ return [ToolFinished(session, call.id, call.name, output, ok, skipped) for call in event.tool_calls]
328
+
329
+
330
+ def _compaction_finished(event: AgentEvent, session: str) -> CompactionFinished:
331
+ """Return the compaction's end, carrying what it did when it replaced the context."""
332
+ detail = event.compaction
333
+ applied = bool(event.applied)
334
+ if not applied or detail is None:
335
+ return CompactionFinished(session, applied=applied)
336
+ compressed = max(0, detail.compressed_to - detail.compressed_from + 1)
337
+ kept = max(0, detail.kept_to - detail.kept_from + 1)
338
+ return CompactionFinished(session, True, Compaction(detail.summary, compressed, kept))
339
+
340
+
341
+ def _custom(event: AgentEvent, session: str, bridge: Bridge) -> list[AnyEvent]:
342
+ """Return the events an extension's custom event stands for."""
343
+ name = event.name
344
+ if name == SHELL_APPROVAL_EVENT_NAME:
345
+ payload = _payload(event)
346
+ call_id = payload.get("tool_call_id")
347
+ if not isinstance(call_id, str) or not call_id:
348
+ return []
349
+ owner = str(payload.get("session_id") or session)
350
+ command = payload.get("command")
351
+ work_dir = _work_dir_of(event, call_id)
352
+ reply = _Reply(lambda outcome: bridge.approve(owner, call_id, outcome[0] == "execute", outcome[1])) # type: ignore[index]
353
+ return [
354
+ ApprovalRequested(
355
+ owner,
356
+ call_id,
357
+ command if isinstance(command, str) else "",
358
+ _optional_int(payload.get("timeout_seconds")),
359
+ payload.get("remember_supported") is True,
360
+ reply,
361
+ work_dir=work_dir,
362
+ )
363
+ ]
364
+ if name == USAGE_EVENT_NAME:
365
+ return [UsageUpdated(session, usage_from(UsageSnapshot.from_payload(_payload(event))))]
366
+ question = question_from(event)
367
+ if question is not None:
368
+
369
+ def send(outcome: object) -> None:
370
+ text = outcome[1] # type: ignore[index]
371
+ bridge.answer(question, text)
372
+
373
+ return [
374
+ QuestionAsked(
375
+ question.session_id or session,
376
+ question.call_id,
377
+ question.question,
378
+ question.options,
379
+ question.allow_multiple,
380
+ _Reply(send),
381
+ )
382
+ ]
383
+ line = notice_from(event)
384
+ return [Notice(session, line, notice_level_from(event))] if line is not None else []