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
|
@@ -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 []
|