soothe-cli 0.1.0__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.
Files changed (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,823 @@
1
+ """Unified daemon event processor with pluggable rendering.
2
+
3
+ This module implements RFC-0019's unified event processing architecture.
4
+ EventProcessor handles all event routing, state management, and filtering,
5
+ delegating display to RendererProtocol implementations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
14
+ from soothe_sdk.events import (
15
+ PLAN_CREATED,
16
+ PLAN_STEP_COMPLETED,
17
+ PLAN_STEP_STARTED,
18
+ SUBAGENT_RESEARCH_INTERNAL_LLM,
19
+ )
20
+ from soothe_sdk.protocol import preview_first
21
+ from soothe_sdk.verbosity import VerbosityTier, classify_event_to_tier
22
+
23
+ from soothe_cli.shared.display_policy import DisplayPolicy, VerbosityLevel, normalize_verbosity
24
+ from soothe_cli.shared.message_processing import (
25
+ accumulate_tool_call_chunks,
26
+ coerce_tool_call_args_to_dict,
27
+ extract_tool_brief,
28
+ finalize_pending_tool_call,
29
+ normalize_tool_calls_list,
30
+ strip_internal_tags,
31
+ tool_calls_have_any_arg_dict,
32
+ try_parse_pending_tool_call_args,
33
+ )
34
+ from soothe_cli.shared.presentation_engine import PresentationEngine
35
+ from soothe_cli.shared.processor_state import ProcessorState
36
+ from soothe_cli.shared.rendering import update_name_map_from_tool_calls
37
+ from soothe_cli.shared.tui_trace_log import log_tui_trace
38
+
39
+ if TYPE_CHECKING:
40
+ from soothe_sdk import Plan
41
+
42
+ from soothe_cli.shared.renderer_protocol import RendererProtocol
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ _MSG_PAIR_LEN = 2
47
+
48
+
49
+ class EventProcessor:
50
+ """Unified daemon event processor with pluggable rendering.
51
+
52
+ Handles all event routing, state management, and filtering.
53
+ Delegates display to RendererProtocol implementation.
54
+
55
+ Usage:
56
+ renderer = CliRenderer(verbosity="normal")
57
+ processor = EventProcessor(renderer, verbosity="normal")
58
+
59
+ # In event loop:
60
+ processor.process_event(event)
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ renderer: RendererProtocol,
66
+ *,
67
+ verbosity: VerbosityLevel = "normal",
68
+ presentation_engine: PresentationEngine | None = None,
69
+ tui_debug: bool = False,
70
+ ) -> None:
71
+ """Initialize processor with renderer and verbosity level.
72
+
73
+ Args:
74
+ renderer: Callback interface for display.
75
+ verbosity: Progress visibility level.
76
+ presentation_engine: Shared engine; if omitted, uses renderer's
77
+ ``presentation_engine`` when present, else a new instance.
78
+ tui_debug: When True, emit INFO logs on logger ``soothe.ux.tui.trace`` (IG-129).
79
+ """
80
+ self._renderer = renderer
81
+ self._verbosity = normalize_verbosity(verbosity)
82
+ self._tui_debug = tui_debug
83
+
84
+ rebind = getattr(renderer, "_rebind_presentation", None)
85
+ shared_from_renderer = getattr(renderer, "presentation_engine", None)
86
+ if presentation_engine is not None:
87
+ self._presentation = presentation_engine
88
+ # Avoid rebuilding StreamDisplayPipeline when renderer already uses this engine.
89
+ if callable(rebind) and shared_from_renderer is not presentation_engine:
90
+ rebind(presentation_engine)
91
+ elif isinstance(shared_from_renderer, PresentationEngine):
92
+ self._presentation = shared_from_renderer
93
+ else:
94
+ self._presentation = PresentationEngine()
95
+
96
+ self._policy = DisplayPolicy(verbosity=self._verbosity)
97
+ self._state = ProcessorState()
98
+
99
+ @property
100
+ def current_plan(self) -> Plan | None:
101
+ """Read-only access to current plan for renderers."""
102
+ return self._state.current_plan
103
+
104
+ @property
105
+ def thread_id(self) -> str:
106
+ """Current thread ID."""
107
+ return self._state.thread_id
108
+
109
+ @property
110
+ def state(self) -> ProcessorState:
111
+ """Read-only access to processor state."""
112
+ return self._state
113
+
114
+ @property
115
+ def multi_step_active(self) -> bool:
116
+ """Whether multi-step plan is active (suppress intermediate text)."""
117
+ return self._state.multi_step_active
118
+
119
+ def _emit_assistant_text(
120
+ self,
121
+ text: str,
122
+ *,
123
+ is_main: bool,
124
+ is_streaming: bool,
125
+ ) -> None:
126
+ """Forward assistant text unless a custom final response already locked the stream."""
127
+ if is_main and self._presentation.final_answer_locked:
128
+ return
129
+ payload = self._maybe_extract_quiet_answer(text)
130
+ log_tui_trace(
131
+ tui_debug=self._tui_debug,
132
+ event="processor.emit_assistant_text",
133
+ is_main=is_main,
134
+ is_streaming=is_streaming,
135
+ chars=len(payload),
136
+ )
137
+ self._renderer.on_assistant_text(
138
+ payload,
139
+ is_main=is_main,
140
+ is_streaming=is_streaming,
141
+ )
142
+
143
+ def process_event(self, event: dict[str, Any]) -> None:
144
+ """Main entry point - routes event to appropriate handler.
145
+
146
+ Args:
147
+ event: Daemon event dictionary with 'type' key.
148
+ """
149
+ event_type = event.get("type", "")
150
+ log_tui_trace(
151
+ tui_debug=self._tui_debug,
152
+ event="processor.process_event",
153
+ event_type=event_type,
154
+ )
155
+
156
+ if event_type == "status":
157
+ self._handle_status(event)
158
+ elif event_type == "event":
159
+ self._handle_stream_event(event)
160
+ elif event_type == "error":
161
+ self._handle_error_event(event)
162
+ elif event_type == "command_response":
163
+ self._handle_command_response(event)
164
+ elif event_type == "clear":
165
+ self._handle_clear_event(event)
166
+
167
+ def _handle_command_response(self, event: dict[str, Any]) -> None:
168
+ """Handle command response from daemon (RFC-404)."""
169
+ command = event.get("command")
170
+ data = event.get("data")
171
+ error = event.get("error")
172
+
173
+ if error:
174
+ self._renderer.on_error(error)
175
+ return
176
+
177
+ # Find rendering handler from registry
178
+ from soothe_cli.shared.command_router import find_command_by_daemon_command
179
+
180
+ entry = find_command_by_daemon_command(command)
181
+ if entry and entry.get("handler") and data:
182
+ handler = entry["handler"]
183
+ handler(self._renderer.console, data)
184
+ else:
185
+ # Default: pretty print JSON
186
+ import json
187
+
188
+ from rich.panel import Panel
189
+
190
+ self._renderer.console.print(
191
+ Panel(json.dumps(data, indent=2, default=str), title=command, border_style="cyan")
192
+ )
193
+
194
+ def _handle_clear_event(self, event: dict[str, Any]) -> None:
195
+ """Handle clear event from daemon."""
196
+ # Clear local UI state if renderer supports it
197
+ if hasattr(self._renderer, "clear"):
198
+ self._renderer.clear()
199
+
200
+ def _handle_status(self, event: dict[str, Any]) -> None:
201
+ """Process status changes, update thread_id, call on_status_change."""
202
+ state_str = event.get("state", "unknown")
203
+ tid_raw = event.get("thread_id", self._state.thread_id)
204
+
205
+ # Keep existing thread_id when daemon sends empty handshake
206
+ tid = self._state.thread_id if tid_raw in (None, "") else str(tid_raw)
207
+ previous_thread_id = self._state.thread_id
208
+ self._state.thread_id = tid
209
+ log_tui_trace(
210
+ tui_debug=self._tui_debug,
211
+ event="processor.status",
212
+ state=state_str,
213
+ thread_id=tid,
214
+ )
215
+
216
+ # Clear session state on thread change
217
+ if tid and tid != previous_thread_id:
218
+ self._state.clear_session()
219
+ self._presentation.reset_session()
220
+
221
+ self._renderer.on_status_change(state_str)
222
+
223
+ # On turn end, finalize streaming and call hook
224
+ if state_str in {"idle", "stopped"}:
225
+ self._state.reset_turn()
226
+ self._presentation.reset_turn()
227
+ self._renderer.on_turn_end()
228
+
229
+ def _handle_stream_event(self, event: dict[str, Any]) -> None:
230
+ """Route to messages or custom event handlers."""
231
+ mode = event.get("mode", "")
232
+ namespace = tuple(event.get("namespace", []))
233
+ data = event.get("data")
234
+ log_tui_trace(
235
+ tui_debug=self._tui_debug,
236
+ event="processor.stream_event",
237
+ mode=mode,
238
+ namespace=namespace,
239
+ )
240
+
241
+ if mode == "messages":
242
+ self._handle_messages(data, namespace)
243
+ elif mode == "custom" and isinstance(data, dict):
244
+ self._handle_custom_event(data, namespace)
245
+
246
+ def _handle_error_event(self, event: dict[str, Any]) -> None:
247
+ """Handle error events."""
248
+ error = event.get("message", event.get("error", "Unknown error"))
249
+ context = event.get("code")
250
+ self._renderer.on_error(error, context=context)
251
+
252
+ def _handle_messages(
253
+ self,
254
+ data: Any,
255
+ namespace: tuple[str, ...],
256
+ ) -> None:
257
+ """Process AIMessage/ToolMessage with deduplication and streaming."""
258
+ if isinstance(data, (list, tuple)) and len(data) == _MSG_PAIR_LEN:
259
+ msg, metadata = data
260
+ elif isinstance(data, dict):
261
+ return
262
+ else:
263
+ return
264
+
265
+ # Skip summarization messages
266
+ if metadata and isinstance(metadata, dict) and metadata.get("lc_source") == "summarization":
267
+ return
268
+
269
+ is_main = not namespace
270
+ msg_kind: str
271
+ if isinstance(msg, AIMessage):
272
+ msg_kind = "AIMessageChunk" if isinstance(msg, AIMessageChunk) else "AIMessage"
273
+ elif isinstance(msg, ToolMessage):
274
+ msg_kind = "ToolMessage"
275
+ elif isinstance(msg, dict):
276
+ msg_kind = str(msg.get("type", "dict"))
277
+ else:
278
+ msg_kind = type(msg).__name__
279
+ log_tui_trace(
280
+ tui_debug=self._tui_debug,
281
+ event="processor.messages",
282
+ msg_kind=msg_kind,
283
+ is_main=is_main,
284
+ )
285
+
286
+ if isinstance(msg, AIMessage):
287
+ self._handle_ai_message(msg, is_main=is_main, namespace=namespace)
288
+ elif isinstance(msg, ToolMessage):
289
+ self._handle_tool_message(msg, is_main=is_main, namespace=namespace)
290
+ elif isinstance(msg, dict):
291
+ self._handle_dict_message(msg, is_main=is_main, namespace=namespace)
292
+
293
+ def _handle_ai_message(
294
+ self,
295
+ msg: AIMessage,
296
+ *,
297
+ is_main: bool,
298
+ namespace: tuple[str, ...], # noqa: ARG002
299
+ ) -> None:
300
+ """Handle AIMessage objects."""
301
+ # Update name_map from tool calls
302
+ update_name_map_from_tool_calls(msg, self._state.name_map)
303
+
304
+ # Deduplication (complete messages only, chunks share IDs)
305
+ msg_id = msg.id or ""
306
+ is_chunk = isinstance(msg, AIMessageChunk)
307
+ if not is_chunk:
308
+ if msg_id in self._state.seen_message_ids:
309
+ return
310
+ self._state.seen_message_ids.add(msg_id)
311
+
312
+ raw_tcs = getattr(msg, "tool_calls", None) or []
313
+ tcs = normalize_tool_calls_list(raw_tcs)
314
+ has_tc_args = tool_calls_have_any_arg_dict(raw_tcs)
315
+
316
+ # Accumulate streaming tool args (IG-053)
317
+ tool_call_chunks = getattr(msg, "tool_call_chunks", None) or []
318
+ accumulate_tool_call_chunks(
319
+ self._state.pending_tool_calls,
320
+ tool_call_chunks,
321
+ is_main=is_main,
322
+ )
323
+
324
+ # Emit pending tool calls with complete args
325
+ self._emit_pending_tool_calls(is_main)
326
+
327
+ # Process content blocks
328
+ tool_call_emitted_from_blocks = False
329
+ if hasattr(msg, "content_blocks") and msg.content_blocks:
330
+ for block in msg.content_blocks:
331
+ if not isinstance(block, dict):
332
+ continue
333
+ btype = block.get("type")
334
+ if btype == "text":
335
+ text = block.get("text", "")
336
+ # Suppress during internal context (research internal LLM responses)
337
+ if self._state.internal_context_active and not is_main:
338
+ continue
339
+ # Always pass to renderer for accumulation, let renderer decide display
340
+ if text:
341
+ cleaned = self._clean_assistant_text(text, is_streaming=is_chunk)
342
+ if cleaned:
343
+ self._emit_assistant_text(
344
+ cleaned,
345
+ is_main=is_main,
346
+ is_streaming=is_chunk,
347
+ )
348
+ elif btype in ("tool_call", "tool_call_chunk"):
349
+ if has_tc_args:
350
+ continue
351
+ name = block.get("name", "")
352
+ if name and self._presentation.tier_visible(
353
+ VerbosityTier.DETAILED, self._verbosity
354
+ ):
355
+ coerced = coerce_tool_call_args_to_dict(block.get("args"))
356
+ # Skip if no args - will be emitted when tool result arrives
357
+ if not coerced:
358
+ continue
359
+ tool_call_id = block.get("id", "")
360
+ self._renderer.on_tool_call(
361
+ name,
362
+ coerced,
363
+ tool_call_id,
364
+ is_main=is_main,
365
+ )
366
+ tool_call_emitted_from_blocks = True
367
+ # Log tool invocation for audit trail
368
+ logger.info(
369
+ "tool_call name=%s id=%s args=%s is_main=%s",
370
+ name,
371
+ tool_call_id,
372
+ preview_first(str(coerced), 200) if coerced else "{}",
373
+ is_main,
374
+ )
375
+ elif is_main and isinstance(msg.content, str) and msg.content:
376
+ # Always pass to renderer for accumulation, let renderer decide display
377
+ cleaned = self._clean_assistant_text(msg.content, is_streaming=is_chunk)
378
+ if cleaned:
379
+ self._emit_assistant_text(
380
+ cleaned,
381
+ is_main=is_main,
382
+ is_streaming=is_chunk,
383
+ )
384
+
385
+ # Handle tool_calls attribute
386
+ # IMPORTANT: Only emit if we have non-empty args. Otherwise, let the accumulation
387
+ # from tool_call_chunks happen and emit when tool result arrives.
388
+ if tcs:
389
+ for tc in tcs:
390
+ name = tc.get("name", "")
391
+ if not name or not self._presentation.tier_visible(
392
+ VerbosityTier.DETAILED, self._verbosity
393
+ ):
394
+ continue
395
+ tc_args = coerce_tool_call_args_to_dict(tc.get("args"))
396
+
397
+ # Skip chunks with empty args - they'll come from tool_call_chunks
398
+ if is_chunk and not tc_args and not has_tc_args:
399
+ continue
400
+
401
+ # Skip if args are empty - will be emitted via finalize_pending_tool_call
402
+ if not tc_args and not tool_call_emitted_from_blocks:
403
+ continue
404
+
405
+ if has_tc_args:
406
+ tool_call_id = tc.get("id", "")
407
+ # Deduplicate tool calls by ID
408
+ if tool_call_id and tool_call_id in self._state.emitted_tool_call_ids:
409
+ continue
410
+ if tool_call_id:
411
+ self._state.emitted_tool_call_ids.add(tool_call_id)
412
+ self._renderer.on_tool_call(name, tc_args, tool_call_id, is_main=is_main)
413
+ # Log tool invocation for audit trail
414
+ logger.info(
415
+ "tool_call name=%s id=%s args=%s is_main=%s",
416
+ name,
417
+ tool_call_id,
418
+ preview_first(str(tc_args), 200) if tc_args else "{}",
419
+ is_main,
420
+ )
421
+
422
+ def _handle_tool_message(
423
+ self,
424
+ msg: ToolMessage,
425
+ *,
426
+ is_main: bool,
427
+ namespace: tuple[str, ...], # noqa: ARG002
428
+ ) -> None:
429
+ """Handle ToolMessage objects."""
430
+ if not self._presentation.tier_visible(VerbosityTier.DETAILED, self._verbosity):
431
+ return
432
+
433
+ tool_name = getattr(msg, "name", "tool")
434
+ tool_call_id = getattr(msg, "tool_call_id", None) or ""
435
+
436
+ # Deduplicate tool results by ID
437
+ if tool_call_id and tool_call_id in self._state.emitted_tool_result_ids:
438
+ return
439
+ if tool_call_id:
440
+ self._state.emitted_tool_result_ids.add(tool_call_id)
441
+
442
+ content = msg.content if isinstance(msg.content, str) else str(msg.content)
443
+ brief = extract_tool_brief(tool_name, content)
444
+
445
+ # Finalize pending tool call if needed (IG-053)
446
+ parsed_args, pending, needs_emit, raw_args_str = finalize_pending_tool_call(
447
+ self._state.pending_tool_calls,
448
+ tool_call_id,
449
+ )
450
+ if needs_emit:
451
+ # Pass raw args for display fallback when parsed args unavailable
452
+ args_to_display = parsed_args or ({"_raw": raw_args_str} if raw_args_str else {})
453
+ self._renderer.on_tool_call(
454
+ pending.get("name") or tool_name,
455
+ args_to_display,
456
+ tool_call_id,
457
+ is_main=pending.get("is_main", is_main),
458
+ )
459
+
460
+ # Determine if error
461
+ is_error = any(
462
+ indicator in content.lower()
463
+ for indicator in ["error", "failed", "exception", "traceback"]
464
+ )
465
+
466
+ # Log tool result for audit trail
467
+ logger.info(
468
+ "tool_result name=%s id=%s status=%s result=%s is_main=%s",
469
+ tool_name,
470
+ tool_call_id,
471
+ "error" if is_error else "success",
472
+ preview_first(brief, 300) if brief else "",
473
+ is_main,
474
+ )
475
+
476
+ self._renderer.on_tool_result(
477
+ tool_name,
478
+ brief,
479
+ tool_call_id,
480
+ is_error=is_error,
481
+ is_main=is_main,
482
+ )
483
+
484
+ def _handle_dict_message(
485
+ self,
486
+ msg: dict[str, Any],
487
+ *,
488
+ is_main: bool,
489
+ namespace: tuple[str, ...], # noqa: ARG002
490
+ ) -> None:
491
+ """Handle deserialized dict messages (after JSON transport)."""
492
+ msg_type = msg.get("type", "")
493
+ msg_id = msg.get("id", "")
494
+ is_chunk = msg_type == "AIMessageChunk"
495
+
496
+ # Handle ToolMessage dicts (serialized via model_dump)
497
+ if msg_type in ("ToolMessage", "tool"):
498
+ self._handle_tool_message_dict(msg, is_main=is_main)
499
+ return
500
+
501
+ if not is_chunk:
502
+ if msg_id and msg_id in self._state.seen_message_ids:
503
+ return
504
+ if msg_id:
505
+ self._state.seen_message_ids.add(msg_id)
506
+
507
+ # Accumulate streaming tool args from tool_call_chunks (IG-053)
508
+ tool_call_chunks = msg.get("tool_call_chunks", [])
509
+ if isinstance(tool_call_chunks, list) and tool_call_chunks:
510
+ accumulate_tool_call_chunks(
511
+ self._state.pending_tool_calls,
512
+ tool_call_chunks,
513
+ is_main=is_main,
514
+ )
515
+
516
+ # Process content blocks or content string
517
+ blocks = msg.get("content_blocks") or []
518
+ if not blocks:
519
+ content = msg.get("content", "")
520
+ if isinstance(content, list):
521
+ blocks = content
522
+ elif is_main and isinstance(content, str) and content:
523
+ # Always pass to renderer for accumulation, let renderer decide display
524
+ cleaned = self._clean_assistant_text(content, is_streaming=is_chunk)
525
+ if cleaned:
526
+ self._emit_assistant_text(
527
+ cleaned,
528
+ is_main=is_main,
529
+ is_streaming=is_chunk,
530
+ )
531
+
532
+ for block in blocks:
533
+ if not isinstance(block, dict):
534
+ continue
535
+ btype = block.get("type")
536
+ if btype == "text":
537
+ text = block.get("text", "")
538
+ # Always pass to renderer for accumulation, let renderer decide display
539
+ if text:
540
+ cleaned = self._clean_assistant_text(text, is_streaming=is_chunk)
541
+ if cleaned:
542
+ self._emit_assistant_text(
543
+ cleaned,
544
+ is_main=is_main,
545
+ is_streaming=is_chunk,
546
+ )
547
+ elif btype in ("tool_call_chunk", "tool_call"):
548
+ name = block.get("name", "")
549
+ if name and self._presentation.tier_visible(VerbosityTier.NORMAL, self._verbosity):
550
+ args = coerce_tool_call_args_to_dict(block.get("args", {}))
551
+ tool_call_id = block.get("id", "")
552
+ # Deduplicate tool calls
553
+ if tool_call_id and tool_call_id in self._state.emitted_tool_call_ids:
554
+ continue
555
+ if tool_call_id:
556
+ self._state.emitted_tool_call_ids.add(tool_call_id)
557
+ self._renderer.on_tool_call(name, args, tool_call_id, is_main=is_main)
558
+ # Log tool invocation for audit trail
559
+ logger.info(
560
+ "tool_call name=%s id=%s args=%s is_main=%s",
561
+ name,
562
+ tool_call_id,
563
+ preview_first(str(args), 200) if args else "{}",
564
+ is_main,
565
+ )
566
+
567
+ # Handle tool_calls from serialized AIMessage (model_dump produces tool_calls not tool_call_chunks)
568
+ # IMPORTANT: Only emit if we have non-empty args. Otherwise, let the accumulation
569
+ # from tool_call_chunks happen and emit when tool result arrives.
570
+ tool_calls = msg.get("tool_calls", [])
571
+ if isinstance(tool_calls, list):
572
+ for tc in tool_calls:
573
+ if isinstance(tc, dict):
574
+ name = tc.get("name", "")
575
+ if name and self._presentation.tier_visible(
576
+ VerbosityTier.NORMAL, self._verbosity
577
+ ):
578
+ args = coerce_tool_call_args_to_dict(tc.get("args", {}))
579
+ tool_call_id = tc.get("id", "")
580
+
581
+ # Skip emitting if args are empty - they'll come from tool_call_chunks
582
+ # and will be emitted when the tool result arrives (via finalize_pending_tool_call)
583
+ if not args:
584
+ continue
585
+
586
+ # Deduplicate tool calls
587
+ if tool_call_id and tool_call_id in self._state.emitted_tool_call_ids:
588
+ continue
589
+ if tool_call_id:
590
+ self._state.emitted_tool_call_ids.add(tool_call_id)
591
+ self._renderer.on_tool_call(name, args, tool_call_id, is_main=is_main)
592
+ # Log tool invocation for audit trail
593
+ logger.info(
594
+ "tool_call name=%s id=%s args=%s is_main=%s",
595
+ name,
596
+ tool_call_id,
597
+ preview_first(str(args), 200) if args else "{}",
598
+ is_main,
599
+ )
600
+
601
+ def _handle_tool_message_dict(
602
+ self,
603
+ msg: dict[str, Any],
604
+ *,
605
+ is_main: bool,
606
+ ) -> None:
607
+ """Handle ToolMessage dict (serialized via model_dump).
608
+
609
+ Args:
610
+ msg: ToolMessage serialized as dict.
611
+ is_main: True if from main agent.
612
+ """
613
+ if not self._presentation.tier_visible(VerbosityTier.DETAILED, self._verbosity):
614
+ return
615
+
616
+ tool_name = msg.get("name", "tool")
617
+ tool_call_id = msg.get("tool_call_id", "")
618
+
619
+ # Deduplicate tool results by ID
620
+ if tool_call_id and tool_call_id in self._state.emitted_tool_result_ids:
621
+ return
622
+ if tool_call_id:
623
+ self._state.emitted_tool_result_ids.add(tool_call_id)
624
+
625
+ content = msg.get("content", "")
626
+ if not isinstance(content, str):
627
+ content = str(content)
628
+
629
+ brief = extract_tool_brief(tool_name, content)
630
+
631
+ # Finalize pending tool call if needed (IG-053)
632
+ parsed_args, pending, needs_emit, raw_args_str = finalize_pending_tool_call(
633
+ self._state.pending_tool_calls,
634
+ tool_call_id,
635
+ )
636
+ if needs_emit:
637
+ # Pass raw args for display fallback when parsed args unavailable
638
+ args_to_display = parsed_args or ({"_raw": raw_args_str} if raw_args_str else {})
639
+ self._renderer.on_tool_call(
640
+ pending.get("name") or tool_name,
641
+ args_to_display,
642
+ tool_call_id,
643
+ is_main=pending.get("is_main", is_main),
644
+ )
645
+
646
+ # Determine if error
647
+ is_error = any(
648
+ indicator in content.lower()
649
+ for indicator in ["error", "failed", "exception", "traceback"]
650
+ )
651
+
652
+ # Log tool result for audit trail
653
+ logger.info(
654
+ "tool_result name=%s id=%s status=%s result=%s is_main=%s",
655
+ tool_name,
656
+ tool_call_id,
657
+ "error" if is_error else "success",
658
+ preview_first(brief, 300) if brief else "",
659
+ is_main,
660
+ )
661
+
662
+ self._renderer.on_tool_result(
663
+ tool_name,
664
+ brief,
665
+ tool_call_id,
666
+ is_error=is_error,
667
+ is_main=is_main,
668
+ )
669
+
670
+ def _emit_pending_tool_calls(self, is_main: bool) -> None: # noqa: FBT001
671
+ """Emit pending tool calls that have complete JSON args."""
672
+ for tc_id, pending in list(self._state.pending_tool_calls.items()):
673
+ if pending["emitted"]:
674
+ continue
675
+ # Deduplicate
676
+ if tc_id in self._state.emitted_tool_call_ids:
677
+ pending["emitted"] = True
678
+ continue
679
+ parsed_args = try_parse_pending_tool_call_args(pending)
680
+ if parsed_args is not None and self._presentation.tier_visible(
681
+ VerbosityTier.DETAILED, self._verbosity
682
+ ):
683
+ self._state.emitted_tool_call_ids.add(tc_id)
684
+ self._renderer.on_tool_call(
685
+ pending["name"],
686
+ parsed_args,
687
+ tc_id,
688
+ is_main=pending.get("is_main", is_main),
689
+ )
690
+ pending["emitted"] = True
691
+
692
+ def _handle_custom_event(
693
+ self,
694
+ data: dict[str, Any],
695
+ namespace: tuple[str, ...],
696
+ ) -> None:
697
+ """Process protocol/progress events."""
698
+ etype = data.get("type", "")
699
+
700
+ # Handle internal context tracking for research events
701
+ if etype == SUBAGENT_RESEARCH_INTERNAL_LLM:
702
+ self._state.internal_context_active = True
703
+ return # Don't display internal events
704
+
705
+ # Exit internal context on non-internal research events
706
+ if (
707
+ etype.startswith("soothe.subagent.research.")
708
+ and etype != SUBAGENT_RESEARCH_INTERNAL_LLM
709
+ ):
710
+ self._state.internal_context_active = False
711
+
712
+ # Tool events are now visible at NORMAL verbosity (RFC-0020 CLI Stream Display Pipeline)
713
+ # They are processed through on_progress_event -> StreamDisplayPipeline
714
+
715
+ # Handle chitchat/final responses through shared cleaner path
716
+ if etype in {
717
+ "soothe.output.chitchat.responding",
718
+ "soothe.output.autonomous.final_report.reporting",
719
+ }:
720
+ content = data.get("content", data.get("summary", ""))
721
+ if content and self._presentation.tier_visible(VerbosityTier.QUIET, self._verbosity):
722
+ cleaned = self._clean_assistant_text(content)
723
+ if cleaned:
724
+ self._emit_assistant_text(
725
+ cleaned,
726
+ is_main=True,
727
+ is_streaming=False,
728
+ )
729
+ self._presentation.mark_final_answer_locked()
730
+ return
731
+
732
+ category = classify_event_to_tier(etype, namespace)
733
+
734
+ # Check for multi-step plan from PLAN_CREATED event
735
+ if etype == PLAN_CREATED and len(data.get("steps", [])) > 1:
736
+ self._state.multi_step_active = True
737
+
738
+ # Agentic loop started: track multi-iteration but suppress the goal echo
739
+ # (the goal just duplicates the user's input shown above)
740
+ # Note: Continue to renderer.on_progress_event() to synchronize renderer state (IG-143 fix)
741
+ if etype == "soothe.cognition.agent_loop.started" and data.get("max_iterations", 1) > 1:
742
+ self._state.multi_step_active = True
743
+
744
+ # Update plan state and call specific hooks
745
+ if etype == PLAN_CREATED:
746
+ self._handle_plan_created(data)
747
+ elif etype == PLAN_STEP_STARTED:
748
+ self._handle_plan_step_started(data)
749
+ elif etype == PLAN_STEP_COMPLETED:
750
+ self._handle_plan_step_completed(data)
751
+ elif category == VerbosityTier.QUIET and "error" in etype:
752
+ error_text = data.get("error", data.get("message", str(etype)))
753
+ self._renderer.on_error(error_text)
754
+ elif self._presentation.tier_visible(category, self._verbosity):
755
+ self._renderer.on_progress_event(etype, data, namespace=namespace)
756
+
757
+ def _handle_plan_created(self, data: dict[str, Any]) -> None:
758
+ """Handle plan creation event."""
759
+ from soothe_sdk import Plan, PlanStep
760
+
761
+ steps = [
762
+ PlanStep(
763
+ id=s.get("id", str(i)),
764
+ description=s.get("description", ""),
765
+ depends_on=s.get("depends_on", []),
766
+ status="pending",
767
+ )
768
+ for i, s in enumerate(data.get("steps", []))
769
+ ]
770
+ plan = Plan(
771
+ goal=data.get("goal", ""),
772
+ steps=steps,
773
+ reasoning=data.get("reasoning"),
774
+ is_plan_only=data.get("is_plan_only", False),
775
+ )
776
+ self._state.current_plan = plan
777
+ self._renderer.on_plan_created(plan)
778
+
779
+ def _handle_plan_step_started(self, data: dict[str, Any]) -> None:
780
+ """Handle plan step started event."""
781
+ step_id = data.get("step_id", "")
782
+ description = data.get("description", "")
783
+
784
+ if self._state.current_plan:
785
+ for step in self._state.current_plan.steps:
786
+ if step.id == step_id:
787
+ step.status = "in_progress"
788
+ break
789
+
790
+ self._renderer.on_plan_step_started(step_id, description)
791
+
792
+ def _handle_plan_step_completed(self, data: dict[str, Any]) -> None:
793
+ """Handle plan step completed event."""
794
+ step_id = data.get("step_id", "")
795
+ success = data.get("success", False)
796
+ duration_ms = data.get("duration_ms", 0)
797
+
798
+ if self._state.current_plan:
799
+ for step in self._state.current_plan.steps:
800
+ if step.id == step_id:
801
+ step.status = "completed" if success else "failed"
802
+ break
803
+
804
+ self._renderer.on_plan_step_completed(step_id, success, duration_ms)
805
+
806
+ def _clean_assistant_text(self, text: str, *, is_streaming: bool = False) -> str:
807
+ """Apply shared response cleaning for user-facing assistant text.
808
+
809
+ Args:
810
+ text: Text to clean.
811
+ is_streaming: If True, preserve boundary whitespace for proper
812
+ streaming chunk concatenation.
813
+ """
814
+ return self._policy.filter_content(
815
+ strip_internal_tags(text),
816
+ preserve_boundary_whitespace=is_streaming,
817
+ )
818
+
819
+ def _maybe_extract_quiet_answer(self, text: str) -> str:
820
+ """Apply quiet-mode answer extraction with fallback."""
821
+ if self._verbosity == "quiet":
822
+ return self._policy.extract_quiet_answer(text)
823
+ return text