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.
@@ -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
+ ]