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.
- soothe_cli/__init__.py +5 -0
- soothe_cli/cli/__init__.py +1 -0
- soothe_cli/cli/commands/__init__.py +1 -0
- soothe_cli/cli/commands/autopilot_cmd.py +410 -0
- soothe_cli/cli/commands/config_cmd.py +277 -0
- soothe_cli/cli/commands/run_cmd.py +87 -0
- soothe_cli/cli/commands/status_cmd.py +121 -0
- soothe_cli/cli/commands/subagent_names.py +17 -0
- soothe_cli/cli/commands/thread_cmd.py +657 -0
- soothe_cli/cli/execution/__init__.py +6 -0
- soothe_cli/cli/execution/daemon.py +194 -0
- soothe_cli/cli/execution/headless.py +99 -0
- soothe_cli/cli/execution/launcher.py +31 -0
- soothe_cli/cli/main.py +509 -0
- soothe_cli/cli/renderer.py +444 -0
- soothe_cli/cli/stream/__init__.py +17 -0
- soothe_cli/cli/stream/context.py +138 -0
- soothe_cli/cli/stream/display_line.py +83 -0
- soothe_cli/cli/stream/formatter.py +412 -0
- soothe_cli/cli/stream/pipeline.py +521 -0
- soothe_cli/cli/utils.py +46 -0
- soothe_cli/config/__init__.py +5 -0
- soothe_cli/config/cli_config.py +155 -0
- soothe_cli/plan/__init__.py +5 -0
- soothe_cli/plan/rich_tree.py +54 -0
- soothe_cli/shared/__init__.py +107 -0
- soothe_cli/shared/command_router.py +246 -0
- soothe_cli/shared/config_loader.py +68 -0
- soothe_cli/shared/display_policy.py +413 -0
- soothe_cli/shared/essential_events.py +68 -0
- soothe_cli/shared/event_processor.py +823 -0
- soothe_cli/shared/message_processing.py +393 -0
- soothe_cli/shared/presentation_engine.py +173 -0
- soothe_cli/shared/processor_state.py +80 -0
- soothe_cli/shared/renderer_protocol.py +158 -0
- soothe_cli/shared/rendering.py +43 -0
- soothe_cli/shared/slash_commands.py +354 -0
- soothe_cli/shared/subagent_routing.py +63 -0
- soothe_cli/shared/suppression_state.py +188 -0
- soothe_cli/shared/tool_formatters/__init__.py +27 -0
- soothe_cli/shared/tool_formatters/base.py +109 -0
- soothe_cli/shared/tool_formatters/execution.py +297 -0
- soothe_cli/shared/tool_formatters/fallback.py +128 -0
- soothe_cli/shared/tool_formatters/file_ops.py +299 -0
- soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
- soothe_cli/shared/tool_formatters/media.py +291 -0
- soothe_cli/shared/tool_formatters/structured.py +202 -0
- soothe_cli/shared/tool_formatters/web.py +143 -0
- soothe_cli/shared/tool_output_formatter.py +227 -0
- soothe_cli/shared/tui_trace_log.py +40 -0
- soothe_cli/tui/__init__.py +5 -0
- soothe_cli/tui/_ask_user_types.py +50 -0
- soothe_cli/tui/_cli_context.py +27 -0
- soothe_cli/tui/_env_vars.py +56 -0
- soothe_cli/tui/_session_stats.py +114 -0
- soothe_cli/tui/_version.py +21 -0
- soothe_cli/tui/app.py +4992 -0
- soothe_cli/tui/app.tcss +302 -0
- soothe_cli/tui/command_registry.py +310 -0
- soothe_cli/tui/config.py +2381 -0
- soothe_cli/tui/daemon_session.py +233 -0
- soothe_cli/tui/file_ops.py +409 -0
- soothe_cli/tui/formatting.py +28 -0
- soothe_cli/tui/hooks.py +23 -0
- soothe_cli/tui/input.py +782 -0
- soothe_cli/tui/media_utils.py +471 -0
- soothe_cli/tui/model_config.py +518 -0
- soothe_cli/tui/output.py +69 -0
- soothe_cli/tui/project_utils.py +188 -0
- soothe_cli/tui/sessions.py +1248 -0
- soothe_cli/tui/skills/__init__.py +5 -0
- soothe_cli/tui/skills/invocation.py +74 -0
- soothe_cli/tui/skills/load.py +93 -0
- soothe_cli/tui/textual_adapter.py +1430 -0
- soothe_cli/tui/theme.py +838 -0
- soothe_cli/tui/tool_display.py +297 -0
- soothe_cli/tui/unicode_security.py +502 -0
- soothe_cli/tui/update_check.py +447 -0
- soothe_cli/tui/widgets/__init__.py +9 -0
- soothe_cli/tui/widgets/_links.py +63 -0
- soothe_cli/tui/widgets/approval.py +430 -0
- soothe_cli/tui/widgets/ask_user.py +392 -0
- soothe_cli/tui/widgets/autocomplete.py +666 -0
- soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
- soothe_cli/tui/widgets/autopilot_screen.py +64 -0
- soothe_cli/tui/widgets/chat_input.py +1834 -0
- soothe_cli/tui/widgets/clipboard.py +128 -0
- soothe_cli/tui/widgets/diff.py +240 -0
- soothe_cli/tui/widgets/editor.py +140 -0
- soothe_cli/tui/widgets/history.py +221 -0
- soothe_cli/tui/widgets/loading.py +194 -0
- soothe_cli/tui/widgets/mcp_viewer.py +352 -0
- soothe_cli/tui/widgets/message_store.py +693 -0
- soothe_cli/tui/widgets/messages.py +1720 -0
- soothe_cli/tui/widgets/model_selector.py +988 -0
- soothe_cli/tui/widgets/notification_settings.py +155 -0
- soothe_cli/tui/widgets/status.py +403 -0
- soothe_cli/tui/widgets/theme_selector.py +158 -0
- soothe_cli/tui/widgets/thread_selector.py +1865 -0
- soothe_cli/tui/widgets/tool_renderers.py +148 -0
- soothe_cli/tui/widgets/tool_widgets.py +254 -0
- soothe_cli/tui/widgets/tools.py +165 -0
- soothe_cli/tui/widgets/welcome.py +330 -0
- soothe_cli-0.1.0.dist-info/METADATA +100 -0
- soothe_cli-0.1.0.dist-info/RECORD +107 -0
- soothe_cli-0.1.0.dist-info/WHEEL +4 -0
- 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
|