soothe-client-python 0.9.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.
- soothe_client/__init__.py +171 -0
- soothe_client/appkit/__init__.py +138 -0
- soothe_client/appkit/attachments.py +118 -0
- soothe_client/appkit/broadcaster.py +116 -0
- soothe_client/appkit/chunk_filter.py +99 -0
- soothe_client/appkit/classifier.py +498 -0
- soothe_client/appkit/daemon_session.py +657 -0
- soothe_client/appkit/events.py +63 -0
- soothe_client/appkit/managed_client.py +163 -0
- soothe_client/appkit/observability.py +29 -0
- soothe_client/appkit/pool.py +258 -0
- soothe_client/appkit/query_gate.py +93 -0
- soothe_client/appkit/session_store.py +100 -0
- soothe_client/appkit/thinking_step.py +147 -0
- soothe_client/appkit/turn.py +362 -0
- soothe_client/appkit/turn_runner.py +565 -0
- soothe_client/errors.py +65 -0
- soothe_client/helpers.py +404 -0
- soothe_client/intent_hints.py +43 -0
- soothe_client/protocol_params.py +604 -0
- soothe_client/schemas.py +55 -0
- soothe_client/session.py +199 -0
- soothe_client/websocket.py +1626 -0
- soothe_client/ws_command_client.py +633 -0
- soothe_client_python-0.9.4.dist-info/METADATA +131 -0
- soothe_client_python-0.9.4.dist-info/RECORD +28 -0
- soothe_client_python-0.9.4.dist-info/WHEEL +4 -0
- soothe_client_python-0.9.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Event classifier for appkit (RFC-629 Layer 1).
|
|
2
|
+
|
|
3
|
+
Maps a stream of decoded daemon events into deliverable/streaming/terminal
|
|
4
|
+
outcomes, keyed on (namespace, mode, phase). Product apps pass their own
|
|
5
|
+
``deliverable_phases`` set; appkit stays product-agnostic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import IntEnum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from soothe_client.appkit.thinking_step import extract_thinking_step
|
|
16
|
+
from soothe_client.errors import DaemonError
|
|
17
|
+
|
|
18
|
+
EVENT_FINAL_REPORT = "soothe.output.autonomous.final_report.reported"
|
|
19
|
+
EVENT_LOOP_HISTORY_REPLAYED = "soothe.lifecycle.loop.history.replayed"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ChatEventTerminal(IntEnum):
|
|
23
|
+
"""How a processed event should end the query loop."""
|
|
24
|
+
|
|
25
|
+
CONTINUE = 0
|
|
26
|
+
DELIVERABLE_COMPLETE = 1
|
|
27
|
+
FAILED_COMPLETE = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class ChatEventResult:
|
|
32
|
+
"""Structured outcome of classifying one daemon event."""
|
|
33
|
+
|
|
34
|
+
terminal: ChatEventTerminal
|
|
35
|
+
content: str | None = None
|
|
36
|
+
thinking_step: str | None = None
|
|
37
|
+
completion_event: str | None = None
|
|
38
|
+
err: BaseException | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class ClassifierConfig:
|
|
43
|
+
"""Product-specific decisions an ``EventClassifier`` needs.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
deliverable_phases: Message ``phase`` values that may end a query with
|
|
47
|
+
user-facing text. Required.
|
|
48
|
+
min_deliverable_runes: Minimum trimmed rune count for a reply to be
|
|
49
|
+
persisted as final (avoids finishing on stub ACKs). Defaults to 8.
|
|
50
|
+
thinking_step_events: Optional override of the default thinking-step
|
|
51
|
+
event allowlist.
|
|
52
|
+
treat_status_idle_as_complete: When true, a status frame with
|
|
53
|
+
``state==idle`` and non-empty accumulated assistant text completes
|
|
54
|
+
the turn (typical for direct-model turns). Default false.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
deliverable_phases: frozenset[str] | set[str]
|
|
58
|
+
min_deliverable_runes: int = 8
|
|
59
|
+
thinking_step_events: frozenset[str] | set[str] | None = None
|
|
60
|
+
treat_status_idle_as_complete: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class EventClassifier:
|
|
64
|
+
"""Map decoded daemon events into deliverable/streaming/terminal outcomes."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, cfg: ClassifierConfig) -> None:
|
|
67
|
+
if not cfg.deliverable_phases:
|
|
68
|
+
raise ValueError("ClassifierConfig.deliverable_phases must not be empty")
|
|
69
|
+
self._deliverable_phases = frozenset(cfg.deliverable_phases)
|
|
70
|
+
self._min_deliverable_runes = (
|
|
71
|
+
cfg.min_deliverable_runes if cfg.min_deliverable_runes > 0 else 8
|
|
72
|
+
)
|
|
73
|
+
self._thinking_step_events = (
|
|
74
|
+
frozenset(cfg.thinking_step_events) if cfg.thinking_step_events is not None else None
|
|
75
|
+
)
|
|
76
|
+
self._treat_status_idle_as_complete = cfg.treat_status_idle_as_complete
|
|
77
|
+
|
|
78
|
+
def classify(self, msg: Any, accumulated: str = "") -> ChatEventResult:
|
|
79
|
+
"""Inspect one decoded event and return its outcome."""
|
|
80
|
+
return self._process_chat_event(msg, accumulated)
|
|
81
|
+
|
|
82
|
+
def is_deliverable_completion_event(self, event_type: str) -> bool:
|
|
83
|
+
"""Report whether a persisted completion_event is user-facing."""
|
|
84
|
+
if not event_type:
|
|
85
|
+
return False
|
|
86
|
+
if event_type in (
|
|
87
|
+
"status.idle",
|
|
88
|
+
"idle_timeout",
|
|
89
|
+
"query_timeout",
|
|
90
|
+
"stream_closed",
|
|
91
|
+
):
|
|
92
|
+
return True
|
|
93
|
+
if event_type == EVENT_FINAL_REPORT:
|
|
94
|
+
return True
|
|
95
|
+
prefix = "soothe.protocol.message."
|
|
96
|
+
if event_type.startswith(prefix):
|
|
97
|
+
return self.is_deliverable_loop_phase(event_type[len(prefix) :])
|
|
98
|
+
return "soothe.output" in event_type and "responded" in event_type
|
|
99
|
+
|
|
100
|
+
def is_deliverable_loop_phase(self, phase: str) -> bool:
|
|
101
|
+
"""Return whether ``phase`` is in the configured deliverable set."""
|
|
102
|
+
return phase in self._deliverable_phases
|
|
103
|
+
|
|
104
|
+
def is_substantive_assistant_reply(self, content: str) -> bool:
|
|
105
|
+
"""Report whether trimmed assistant text is long enough to persist."""
|
|
106
|
+
return len(list(content.strip())) >= self._min_deliverable_runes
|
|
107
|
+
|
|
108
|
+
def resolve_deliverable_final_content(
|
|
109
|
+
self,
|
|
110
|
+
event_result: ChatEventResult,
|
|
111
|
+
_accumulated: str = "",
|
|
112
|
+
) -> tuple[str, bool]:
|
|
113
|
+
"""Pick the user-visible reply for a completed query."""
|
|
114
|
+
if event_result.terminal != ChatEventTerminal.DELIVERABLE_COMPLETE:
|
|
115
|
+
return "", False
|
|
116
|
+
if not self.is_deliverable_completion_event(event_result.completion_event or ""):
|
|
117
|
+
return "", False
|
|
118
|
+
final = (event_result.content or "").strip()
|
|
119
|
+
if final:
|
|
120
|
+
return final, True
|
|
121
|
+
return "", False
|
|
122
|
+
|
|
123
|
+
def _deliverable_result(self, content: str, completion_event: str) -> ChatEventResult:
|
|
124
|
+
return ChatEventResult(
|
|
125
|
+
terminal=ChatEventTerminal.DELIVERABLE_COMPLETE,
|
|
126
|
+
content=content,
|
|
127
|
+
completion_event=completion_event,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _continue_result(self, content: str) -> ChatEventResult:
|
|
131
|
+
return ChatEventResult(terminal=ChatEventTerminal.CONTINUE, content=content)
|
|
132
|
+
|
|
133
|
+
def _failed_result(self, err: BaseException) -> ChatEventResult:
|
|
134
|
+
return ChatEventResult(terminal=ChatEventTerminal.FAILED_COMPLETE, err=err)
|
|
135
|
+
|
|
136
|
+
def _process_chat_event(self, msg: Any, accumulated: str) -> ChatEventResult:
|
|
137
|
+
if not isinstance(msg, dict):
|
|
138
|
+
return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
|
|
139
|
+
|
|
140
|
+
typ = msg.get("type")
|
|
141
|
+
if typ == "next":
|
|
142
|
+
return self._classify_next_envelope(msg)
|
|
143
|
+
|
|
144
|
+
if typ in ("response", "complete", "receipt_response", "connection_ack"):
|
|
145
|
+
return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
|
|
146
|
+
|
|
147
|
+
if typ == "status":
|
|
148
|
+
state = str(msg.get("state") or "").strip().lower()
|
|
149
|
+
if (
|
|
150
|
+
self._treat_status_idle_as_complete
|
|
151
|
+
and state == "idle"
|
|
152
|
+
and self.is_substantive_assistant_reply(accumulated)
|
|
153
|
+
):
|
|
154
|
+
return self._deliverable_result(accumulated.strip(), "status.idle")
|
|
155
|
+
return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
|
|
156
|
+
|
|
157
|
+
if typ == "error":
|
|
158
|
+
err_obj = msg.get("error") if isinstance(msg.get("error"), dict) else {}
|
|
159
|
+
code = err_obj.get("code") if isinstance(err_obj.get("code"), int) else -32603
|
|
160
|
+
message = (
|
|
161
|
+
err_obj.get("message")
|
|
162
|
+
if isinstance(err_obj.get("message"), str)
|
|
163
|
+
else "daemon error"
|
|
164
|
+
)
|
|
165
|
+
return self._failed_result(DaemonError(code, message, err_obj.get("data")))
|
|
166
|
+
|
|
167
|
+
if typ == "event":
|
|
168
|
+
return self._classify_event_payload(
|
|
169
|
+
msg.get("namespace"),
|
|
170
|
+
str(msg.get("mode") or ""),
|
|
171
|
+
msg.get("data"),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
|
|
175
|
+
|
|
176
|
+
def _classify_next_envelope(self, env: dict[str, Any]) -> ChatEventResult:
|
|
177
|
+
payload = env.get("payload") if isinstance(env.get("payload"), dict) else {}
|
|
178
|
+
inner_data = payload.get("data") if isinstance(payload.get("data"), dict) else None
|
|
179
|
+
if inner_data is not None:
|
|
180
|
+
inner_mode = str(inner_data.get("mode") or "")
|
|
181
|
+
if inner_mode:
|
|
182
|
+
return self._classify_event_payload(
|
|
183
|
+
inner_data.get("namespace", payload.get("namespace")),
|
|
184
|
+
inner_mode,
|
|
185
|
+
inner_data.get("data"),
|
|
186
|
+
)
|
|
187
|
+
mode = str(payload.get("mode") or "")
|
|
188
|
+
if mode:
|
|
189
|
+
return self._classify_event_payload(
|
|
190
|
+
payload.get("namespace"),
|
|
191
|
+
mode,
|
|
192
|
+
payload.get("data"),
|
|
193
|
+
)
|
|
194
|
+
return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
|
|
195
|
+
|
|
196
|
+
def _classify_event_payload(
|
|
197
|
+
self,
|
|
198
|
+
namespace: Any,
|
|
199
|
+
mode: str,
|
|
200
|
+
data: Any,
|
|
201
|
+
) -> ChatEventResult:
|
|
202
|
+
ns = _namespace_to_string(namespace)
|
|
203
|
+
data_map = _normalize_event_data(data)
|
|
204
|
+
|
|
205
|
+
if data_map is not None:
|
|
206
|
+
data_type = ns
|
|
207
|
+
dt = data_map.get("type")
|
|
208
|
+
if isinstance(dt, str) and dt:
|
|
209
|
+
data_type = dt
|
|
210
|
+
if data_type == EVENT_LOOP_HISTORY_REPLAYED:
|
|
211
|
+
return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
|
|
212
|
+
step, ok = extract_thinking_step(
|
|
213
|
+
data_type,
|
|
214
|
+
data_map,
|
|
215
|
+
self._thinking_step_events,
|
|
216
|
+
)
|
|
217
|
+
if ok:
|
|
218
|
+
return ChatEventResult(
|
|
219
|
+
terminal=ChatEventTerminal.CONTINUE,
|
|
220
|
+
thinking_step=step,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if mode == "messages":
|
|
224
|
+
result = self._classify_messages_mode(data, ns)
|
|
225
|
+
if result is not None:
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
if data_map is None:
|
|
229
|
+
return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
|
|
230
|
+
|
|
231
|
+
data_type = ns
|
|
232
|
+
dt = data_map.get("type")
|
|
233
|
+
if isinstance(dt, str) and dt:
|
|
234
|
+
data_type = dt
|
|
235
|
+
completion_event = data_type or ns
|
|
236
|
+
|
|
237
|
+
if _is_namespace_match(ns, data_type, "soothe.output") or _is_namespace_match(
|
|
238
|
+
ns, data_type, "responded"
|
|
239
|
+
):
|
|
240
|
+
content, ok = _extract_content_from_data(data_map)
|
|
241
|
+
if ok:
|
|
242
|
+
if self._is_final_output_event(data_type, ns):
|
|
243
|
+
return self._deliverable_result(content, completion_event)
|
|
244
|
+
return self._continue_result(content)
|
|
245
|
+
|
|
246
|
+
if (
|
|
247
|
+
_is_namespace_match(ns, data_type, "agent_loop.completed")
|
|
248
|
+
or _is_namespace_match(ns, data_type, "agent_loop.reasoned")
|
|
249
|
+
or _is_namespace_match(ns, data_type, "loop.completed")
|
|
250
|
+
):
|
|
251
|
+
content, ok = _extract_content_from_data(data_map)
|
|
252
|
+
if ok:
|
|
253
|
+
return self._continue_result(content)
|
|
254
|
+
|
|
255
|
+
if _is_namespace_match(ns, data_type, "final_report"):
|
|
256
|
+
content, ok = _extract_content_from_data(data_map)
|
|
257
|
+
if ok:
|
|
258
|
+
return self._deliverable_result(content, completion_event)
|
|
259
|
+
|
|
260
|
+
if "soothe.error." in data_type or "soothe.error." in ns:
|
|
261
|
+
err_type = data_type or ns
|
|
262
|
+
msg = data_map.get("message")
|
|
263
|
+
if isinstance(msg, str) and msg:
|
|
264
|
+
return self._failed_result(RuntimeError(f"{err_type}: {msg}"))
|
|
265
|
+
content, ok = _extract_content_from_data(data_map)
|
|
266
|
+
if ok:
|
|
267
|
+
return self._failed_result(RuntimeError(f"{err_type}: {content}"))
|
|
268
|
+
return self._failed_result(RuntimeError(err_type))
|
|
269
|
+
|
|
270
|
+
if (
|
|
271
|
+
_is_namespace_match(ns, data_type, "stream")
|
|
272
|
+
or _is_namespace_match(ns, data_type, "progress")
|
|
273
|
+
or _is_namespace_match(ns, data_type, "tool_call_updates_batch")
|
|
274
|
+
or _is_namespace_match(ns, data_type, "soothe.stream.tool_call.update")
|
|
275
|
+
):
|
|
276
|
+
delta = data_map.get("delta")
|
|
277
|
+
if isinstance(delta, str):
|
|
278
|
+
return self._continue_result(delta)
|
|
279
|
+
|
|
280
|
+
if (
|
|
281
|
+
_is_namespace_match(ns, data_type, "heartbeat")
|
|
282
|
+
or _is_namespace_match(ns, data_type, "system.daemon")
|
|
283
|
+
or _is_namespace_match(ns, data_type, "agent_loop.started")
|
|
284
|
+
or _is_namespace_match(ns, data_type, "intent.classified")
|
|
285
|
+
):
|
|
286
|
+
return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
|
|
287
|
+
|
|
288
|
+
return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
|
|
289
|
+
|
|
290
|
+
def _classify_messages_mode(self, data: Any, _ns: str) -> ChatEventResult | None:
|
|
291
|
+
if not isinstance(data, list) or not data:
|
|
292
|
+
return None
|
|
293
|
+
first = data[0]
|
|
294
|
+
if not isinstance(first, dict):
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
msg_type, raw_content, phase, has_payload = _first_message_payload(data)
|
|
298
|
+
if has_payload and raw_content and _is_streaming_message_type(msg_type):
|
|
299
|
+
return self._continue_result(raw_content)
|
|
300
|
+
|
|
301
|
+
loop_msg = _loop_ai_message(data)
|
|
302
|
+
if loop_msg is not None:
|
|
303
|
+
content = loop_msg["content"]
|
|
304
|
+
if content:
|
|
305
|
+
if _is_streaming_message_type(loop_msg["type"]):
|
|
306
|
+
return self._continue_result(content)
|
|
307
|
+
if self.is_deliverable_loop_phase(loop_msg["phase"]) and (
|
|
308
|
+
self.is_substantive_assistant_reply(content)
|
|
309
|
+
):
|
|
310
|
+
return self._deliverable_result(
|
|
311
|
+
content,
|
|
312
|
+
f"soothe.protocol.message.{loop_msg['phase']}",
|
|
313
|
+
)
|
|
314
|
+
return self._continue_result(content)
|
|
315
|
+
|
|
316
|
+
direct_content, direct_ok = self._messages_mode_assistant_content(data)
|
|
317
|
+
if direct_ok and self.is_substantive_assistant_reply(direct_content):
|
|
318
|
+
return self._deliverable_result(
|
|
319
|
+
direct_content,
|
|
320
|
+
"soothe.protocol.message.direct_model",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if has_payload and raw_content:
|
|
324
|
+
if _is_terminal_message_type(msg_type) or msg_type == "":
|
|
325
|
+
if self.is_deliverable_loop_phase(phase) and self.is_substantive_assistant_reply(
|
|
326
|
+
raw_content
|
|
327
|
+
):
|
|
328
|
+
return self._deliverable_result(
|
|
329
|
+
raw_content,
|
|
330
|
+
f"soothe.protocol.message.{phase}",
|
|
331
|
+
)
|
|
332
|
+
return self._continue_result(raw_content)
|
|
333
|
+
return self._continue_result(raw_content)
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
def _messages_mode_assistant_content(self, data: Any) -> tuple[str, bool]:
|
|
337
|
+
if not isinstance(data, list) or not data:
|
|
338
|
+
return "", False
|
|
339
|
+
msg_map = data[0]
|
|
340
|
+
if not isinstance(msg_map, dict):
|
|
341
|
+
return "", False
|
|
342
|
+
phase = msg_map.get("phase")
|
|
343
|
+
phase_s = phase.strip() if isinstance(phase, str) else ""
|
|
344
|
+
if phase_s:
|
|
345
|
+
return "", False
|
|
346
|
+
msg_type = msg_map.get("type") if isinstance(msg_map.get("type"), str) else ""
|
|
347
|
+
if msg_type and not _is_terminal_message_type(msg_type):
|
|
348
|
+
return "", False
|
|
349
|
+
content = _extract_content_from_message(msg_map).strip()
|
|
350
|
+
if not content:
|
|
351
|
+
return "", False
|
|
352
|
+
return content, True
|
|
353
|
+
|
|
354
|
+
def _is_final_output_event(self, data_type: str, ns: str) -> bool:
|
|
355
|
+
combined = f"{data_type} {ns}"
|
|
356
|
+
if "final_report" in combined:
|
|
357
|
+
return True
|
|
358
|
+
return any(phase in combined for phase in self._deliverable_phases)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _is_streaming_message_type(msg_type: str) -> bool:
|
|
362
|
+
return msg_type in ("AIMessageChunk", "ai_chunk", "message_chunk")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _is_terminal_message_type(msg_type: str) -> bool:
|
|
366
|
+
return msg_type in ("AIMessage", "ai", "assistant")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _first_message_payload(data: Any) -> tuple[str, str, str, bool]:
|
|
370
|
+
if not isinstance(data, list) or not data:
|
|
371
|
+
return "", "", "", False
|
|
372
|
+
msg_map = data[0]
|
|
373
|
+
if not isinstance(msg_map, dict):
|
|
374
|
+
return "", "", "", False
|
|
375
|
+
msg_type = msg_map.get("type") if isinstance(msg_map.get("type"), str) else ""
|
|
376
|
+
phase = msg_map.get("phase") if isinstance(msg_map.get("phase"), str) else ""
|
|
377
|
+
content = _extract_content_from_message(msg_map)
|
|
378
|
+
return msg_type, content, phase, True
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _loop_ai_message(data: Any) -> dict[str, str] | None:
|
|
382
|
+
if not isinstance(data, list) or not data:
|
|
383
|
+
return None
|
|
384
|
+
msg_map = data[0]
|
|
385
|
+
if not isinstance(msg_map, dict):
|
|
386
|
+
return None
|
|
387
|
+
phase_raw = msg_map.get("phase")
|
|
388
|
+
phase = phase_raw.strip() if isinstance(phase_raw, str) else ""
|
|
389
|
+
if not phase:
|
|
390
|
+
return None
|
|
391
|
+
msg_type = msg_map.get("type") if isinstance(msg_map.get("type"), str) else ""
|
|
392
|
+
content = _extract_content_from_message(msg_map)
|
|
393
|
+
return {"type": msg_type, "content": content, "phase": phase}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _extract_content_from_message(msg_map: dict[str, Any]) -> str:
|
|
397
|
+
c = msg_map.get("content")
|
|
398
|
+
if isinstance(c, str) and c:
|
|
399
|
+
return c
|
|
400
|
+
if isinstance(c, list) and c:
|
|
401
|
+
parts: list[str] = []
|
|
402
|
+
for item in c:
|
|
403
|
+
if isinstance(item, str):
|
|
404
|
+
parts.append(item)
|
|
405
|
+
continue
|
|
406
|
+
if isinstance(item, dict):
|
|
407
|
+
text = item.get("text")
|
|
408
|
+
if isinstance(text, str):
|
|
409
|
+
parts.append(text)
|
|
410
|
+
return "".join(parts)
|
|
411
|
+
blocks = msg_map.get("content_blocks")
|
|
412
|
+
if isinstance(blocks, list) and blocks:
|
|
413
|
+
parts = []
|
|
414
|
+
for blk in blocks:
|
|
415
|
+
if isinstance(blk, dict):
|
|
416
|
+
text = blk.get("text")
|
|
417
|
+
if isinstance(text, str):
|
|
418
|
+
parts.append(text)
|
|
419
|
+
return "".join(parts)
|
|
420
|
+
return ""
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
_CONTENT_KEYS = (
|
|
424
|
+
"final_stdout_message",
|
|
425
|
+
"completion_summary",
|
|
426
|
+
"content",
|
|
427
|
+
"text",
|
|
428
|
+
"response",
|
|
429
|
+
"output",
|
|
430
|
+
"message",
|
|
431
|
+
"report",
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _extract_content_from_data(data: dict[str, Any]) -> tuple[str, bool]:
|
|
436
|
+
if _is_subscription_metadata_map(data):
|
|
437
|
+
return "", False
|
|
438
|
+
for key in _CONTENT_KEYS:
|
|
439
|
+
val = data.get(key)
|
|
440
|
+
if isinstance(val, str) and val:
|
|
441
|
+
return val, True
|
|
442
|
+
nested = data.get("data")
|
|
443
|
+
if isinstance(nested, dict):
|
|
444
|
+
if _is_subscription_metadata_map(nested):
|
|
445
|
+
return "", False
|
|
446
|
+
for key in _CONTENT_KEYS:
|
|
447
|
+
val = nested.get(key)
|
|
448
|
+
if isinstance(val, str) and val:
|
|
449
|
+
return val, True
|
|
450
|
+
return "", False
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _is_subscription_metadata_map(data: dict[str, Any]) -> bool:
|
|
454
|
+
"""True for loop subscription / seq acks without assistant text fields."""
|
|
455
|
+
if "loop_id" not in data or "latest_seq" not in data:
|
|
456
|
+
return False
|
|
457
|
+
for key in ("content", "text", "response", "output", "message", "report", "answer"):
|
|
458
|
+
val = data.get(key)
|
|
459
|
+
if isinstance(val, str) and val.strip():
|
|
460
|
+
return False
|
|
461
|
+
return True
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _is_namespace_match(ns: str, data_type: str, pattern: str) -> bool:
|
|
465
|
+
return pattern in data_type or pattern in ns
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _namespace_to_string(namespace: Any) -> str:
|
|
469
|
+
if isinstance(namespace, str):
|
|
470
|
+
return namespace
|
|
471
|
+
if isinstance(namespace, list):
|
|
472
|
+
return ".".join(s for s in namespace if isinstance(s, str))
|
|
473
|
+
return ""
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _normalize_event_data(data: Any) -> dict[str, Any] | None:
|
|
477
|
+
if data is None:
|
|
478
|
+
return None
|
|
479
|
+
if isinstance(data, dict):
|
|
480
|
+
return data
|
|
481
|
+
if isinstance(data, str):
|
|
482
|
+
try:
|
|
483
|
+
parsed = json.loads(data)
|
|
484
|
+
except (json.JSONDecodeError, TypeError):
|
|
485
|
+
return None
|
|
486
|
+
if isinstance(parsed, dict):
|
|
487
|
+
return parsed
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
__all__ = [
|
|
492
|
+
"EVENT_FINAL_REPORT",
|
|
493
|
+
"EVENT_LOOP_HISTORY_REPLAYED",
|
|
494
|
+
"ChatEventTerminal",
|
|
495
|
+
"ChatEventResult",
|
|
496
|
+
"ClassifierConfig",
|
|
497
|
+
"EventClassifier",
|
|
498
|
+
]
|