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,393 @@
|
|
|
1
|
+
"""Shared message processing utilities for CLI and TUI modes.
|
|
2
|
+
|
|
3
|
+
This module provides helper functions for message handling logic to ensure
|
|
4
|
+
consistent behavior between headless CLI mode and the TUI interface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from soothe_sdk.internal import strip_internal_tags # noqa: F401 — re-exported via shared.__init__
|
|
15
|
+
|
|
16
|
+
# ============================================================================
|
|
17
|
+
# Shared Tool Call Streaming Helpers (IG-053)
|
|
18
|
+
# ============================================================================
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def accumulate_tool_call_chunks(
|
|
22
|
+
pending_tool_calls: dict[str, dict[str, Any]],
|
|
23
|
+
tool_call_chunks: list[dict[str, Any]],
|
|
24
|
+
*,
|
|
25
|
+
is_main: bool = True,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Accumulate streaming tool call chunks into pending_tool_calls.
|
|
28
|
+
|
|
29
|
+
LangChain streams tool args in chunks - first chunk has the tool name but
|
|
30
|
+
empty args, subsequent chunks contain partial JSON strings. This function
|
|
31
|
+
tracks and accumulates them.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
pending_tool_calls: Dict to store pending tool calls (tool_call_id -> state).
|
|
35
|
+
tool_call_chunks: List of tool_call_chunk dicts from AIMessageChunk.
|
|
36
|
+
is_main: Whether this is from the main agent.
|
|
37
|
+
"""
|
|
38
|
+
for tcc in tool_call_chunks:
|
|
39
|
+
if not isinstance(tcc, dict):
|
|
40
|
+
continue
|
|
41
|
+
tc_id = tcc.get("id", "")
|
|
42
|
+
tc_name = tcc.get("name")
|
|
43
|
+
tc_args = tcc.get("args", "")
|
|
44
|
+
|
|
45
|
+
# First chunk with a tool name: register the pending tool call
|
|
46
|
+
if tc_name and tc_id and tc_id not in pending_tool_calls:
|
|
47
|
+
pending_tool_calls[tc_id] = {
|
|
48
|
+
"name": tc_name,
|
|
49
|
+
"args_str": tc_args if isinstance(tc_args, str) else "",
|
|
50
|
+
"emitted": False,
|
|
51
|
+
"is_main": is_main,
|
|
52
|
+
}
|
|
53
|
+
# Subsequent chunks: accumulate args
|
|
54
|
+
elif tc_args and isinstance(tc_args, str):
|
|
55
|
+
# Find the tool call to accumulate args for (by order, first non-emitted)
|
|
56
|
+
for pending in pending_tool_calls.values():
|
|
57
|
+
if not pending["emitted"]:
|
|
58
|
+
pending["args_str"] += tc_args
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def try_parse_pending_tool_call_args(
|
|
63
|
+
pending: dict[str, Any],
|
|
64
|
+
) -> dict[str, Any] | None:
|
|
65
|
+
"""Try to parse the accumulated args_str as JSON.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
pending: Pending tool call state dict with 'args_str' key.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Parsed args dict if valid JSON, None otherwise.
|
|
72
|
+
"""
|
|
73
|
+
args_str = pending.get("args_str", "")
|
|
74
|
+
if not args_str:
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
parsed = json.loads(args_str)
|
|
78
|
+
return parsed if isinstance(parsed, dict) else None
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def finalize_pending_tool_call(
|
|
84
|
+
pending_tool_calls: dict[str, dict[str, Any]],
|
|
85
|
+
tool_call_id: str,
|
|
86
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any], bool, str]:
|
|
87
|
+
"""Finalize and remove a pending tool call when its result arrives.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
pending_tool_calls: Dict of pending tool calls.
|
|
91
|
+
tool_call_id: ID of the tool call to finalize.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Tuple of (parsed_args, pending_state dict, needs_emit, raw_args_str).
|
|
95
|
+
- parsed_args: Parsed args dict if valid JSON, None otherwise.
|
|
96
|
+
- pending_state: The pending tool call state dict.
|
|
97
|
+
- needs_emit: True if the tool call wasn't emitted yet.
|
|
98
|
+
- raw_args_str: Raw args string for display fallback.
|
|
99
|
+
If not found, returns (None, {}, False, "").
|
|
100
|
+
"""
|
|
101
|
+
str_id = str(tool_call_id) if tool_call_id else ""
|
|
102
|
+
if not str_id or str_id not in pending_tool_calls:
|
|
103
|
+
return None, {}, False, ""
|
|
104
|
+
|
|
105
|
+
pending = pending_tool_calls[str_id]
|
|
106
|
+
parsed_args = None
|
|
107
|
+
needs_emit = not pending.get("emitted", False)
|
|
108
|
+
raw_args_str = pending.get("args_str", "")
|
|
109
|
+
|
|
110
|
+
if needs_emit:
|
|
111
|
+
# Try to parse args one more time
|
|
112
|
+
if raw_args_str:
|
|
113
|
+
with contextlib.suppress(json.JSONDecodeError):
|
|
114
|
+
result = json.loads(raw_args_str)
|
|
115
|
+
if isinstance(result, dict):
|
|
116
|
+
parsed_args = result
|
|
117
|
+
pending["emitted"] = True
|
|
118
|
+
|
|
119
|
+
# Clean up the pending entry
|
|
120
|
+
del pending_tool_calls[str_id]
|
|
121
|
+
return parsed_args, pending, needs_emit, raw_args_str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def extract_tool_brief(tool_name: str, content: str | dict | Any, max_length: int = 120) -> str:
|
|
125
|
+
r"""Extract a concise one-line summary from tool result content.
|
|
126
|
+
|
|
127
|
+
Uses semantic formatters to provide tool-specific summaries with meaningful
|
|
128
|
+
metrics (size, count, status) instead of simple truncation.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
tool_name: Name of the tool that produced the content.
|
|
132
|
+
content: Tool result content (string, dict, or ToolOutput).
|
|
133
|
+
max_length: Maximum length of the brief (default 120, unused for semantic formatting).
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Semantic brief suitable for display.
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
>>> extract_tool_brief("read_file", "Hello\\nWorld\\n")
|
|
140
|
+
"✓ Read 12 B (2 lines)"
|
|
141
|
+
>>> extract_tool_brief("run_command", "output")
|
|
142
|
+
"✓ Done (6 chars output)"
|
|
143
|
+
"""
|
|
144
|
+
# Use semantic formatter for tool-specific summarization
|
|
145
|
+
from soothe_cli.shared.tool_output_formatter import ToolOutputFormatter
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
formatter = ToolOutputFormatter()
|
|
149
|
+
brief = formatter.format(tool_name, content)
|
|
150
|
+
return brief.to_display()
|
|
151
|
+
except Exception:
|
|
152
|
+
# Fallback to simple truncation if formatter fails
|
|
153
|
+
if isinstance(content, str):
|
|
154
|
+
# Web search/crawl tools return structured output with summary on first line
|
|
155
|
+
web_tools = {"search_web", "crawl_web"}
|
|
156
|
+
if tool_name in web_tools:
|
|
157
|
+
first_line = content.split("\n", 1)[0].strip()
|
|
158
|
+
if first_line:
|
|
159
|
+
return first_line[:max_length]
|
|
160
|
+
return content.replace("\n", " ")[:max_length]
|
|
161
|
+
if isinstance(content, dict):
|
|
162
|
+
# Simple dict formatting
|
|
163
|
+
return f"Dict with {len(content)} fields"
|
|
164
|
+
return str(content)[:max_length]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def coerce_tool_call_args_to_dict(raw: Any) -> dict[str, Any]:
|
|
168
|
+
"""Normalize tool arguments for display.
|
|
169
|
+
|
|
170
|
+
``tool_call_chunk`` content blocks use a JSON string; merged ``tool_calls`` use dicts
|
|
171
|
+
(see LangChain ``ToolCall`` / ``ToolCallChunk``).
|
|
172
|
+
"""
|
|
173
|
+
if isinstance(raw, dict):
|
|
174
|
+
return raw
|
|
175
|
+
if isinstance(raw, str):
|
|
176
|
+
s = raw.strip()
|
|
177
|
+
if not s:
|
|
178
|
+
return {}
|
|
179
|
+
try:
|
|
180
|
+
parsed = json.loads(s)
|
|
181
|
+
except json.JSONDecodeError:
|
|
182
|
+
return {}
|
|
183
|
+
if isinstance(parsed, dict):
|
|
184
|
+
return parsed
|
|
185
|
+
return {}
|
|
186
|
+
return {}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def coerce_tool_call_entry_to_dict(tc: Any) -> dict[str, Any] | None:
|
|
190
|
+
"""Normalize a ``tool_calls`` entry to a plain dict (handles Pydantic models)."""
|
|
191
|
+
if isinstance(tc, dict):
|
|
192
|
+
return tc
|
|
193
|
+
model_dump = getattr(tc, "model_dump", None)
|
|
194
|
+
if callable(model_dump):
|
|
195
|
+
try:
|
|
196
|
+
dumped = model_dump()
|
|
197
|
+
if isinstance(dumped, dict):
|
|
198
|
+
return dumped
|
|
199
|
+
except Exception:
|
|
200
|
+
return None
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def normalize_tool_calls_list(raw: list[Any]) -> list[dict[str, Any]]:
|
|
205
|
+
"""Coerce ``msg.tool_calls`` to ``dict`` entries for display logic."""
|
|
206
|
+
out: list[dict[str, Any]] = []
|
|
207
|
+
for tc in raw:
|
|
208
|
+
coerced = coerce_tool_call_entry_to_dict(tc)
|
|
209
|
+
if coerced:
|
|
210
|
+
out.append(coerced)
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def tool_calls_have_any_arg_dict(tc_list: list[Any]) -> bool:
|
|
215
|
+
"""True if any tool call has non-empty coerced argument dict."""
|
|
216
|
+
return any(
|
|
217
|
+
coerce_tool_call_args_to_dict(tc.get("args")) for tc in normalize_tool_calls_list(tc_list)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# Argument display mapping for tool calls (see IG-053)
|
|
222
|
+
# Maps tool name to list of argument keys to display (supports multiple args)
|
|
223
|
+
_ARG_DISPLAY_MAP: dict[str, list[str]] = {
|
|
224
|
+
# File operations — deepagents uses ``file_path`` for read/write/edit (see IG-053)
|
|
225
|
+
"read_file": ["file_path", "path"],
|
|
226
|
+
"write_file": ["file_path", "path"],
|
|
227
|
+
"delete_file": ["file_path", "path"],
|
|
228
|
+
"file_info": ["path", "file_path"],
|
|
229
|
+
"edit_file_lines": ["path", "file_path"],
|
|
230
|
+
"insert_lines": ["path", "file_path"],
|
|
231
|
+
"delete_lines": ["path", "file_path"],
|
|
232
|
+
"apply_diff": ["path", "file_path"],
|
|
233
|
+
"edit_file": ["file_path", "path"],
|
|
234
|
+
"ls": ["path", "pattern"], # Multiple args support
|
|
235
|
+
"glob": ["pattern", "path"], # Glob tool
|
|
236
|
+
"list_files": ["pattern"],
|
|
237
|
+
"search_files": ["pattern"],
|
|
238
|
+
# Execution - show command/code
|
|
239
|
+
"run_command": ["command", "args"], # Multiple args support
|
|
240
|
+
"run_python": ["code"],
|
|
241
|
+
"run_background": ["command"],
|
|
242
|
+
"kill_process": ["pid"],
|
|
243
|
+
"execute": ["command"], # Alias for run_command
|
|
244
|
+
# Search - show pattern/query
|
|
245
|
+
"search_web": ["query"],
|
|
246
|
+
"crawl_web": ["url"],
|
|
247
|
+
# Research - show topic
|
|
248
|
+
"research": ["topic", "domain"],
|
|
249
|
+
# Media - show file path
|
|
250
|
+
"analyze_image": ["image_path"],
|
|
251
|
+
"analyze_video": ["video_path"],
|
|
252
|
+
"transcribe_audio": ["audio_path"],
|
|
253
|
+
# Goals - show description or ID
|
|
254
|
+
"create_goal": ["description"],
|
|
255
|
+
"complete_goal": ["goal_id"],
|
|
256
|
+
"fail_goal": ["goal_id"],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _normalize_tool_name_for_arg_map(tool_name: str) -> str:
|
|
261
|
+
"""Map API tool names (any casing) to snake_case for `_ARG_DISPLAY_MAP` lookup."""
|
|
262
|
+
if not tool_name:
|
|
263
|
+
return tool_name
|
|
264
|
+
# PascalCase / camelCase -> snake_case; already-snake names pass through
|
|
265
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", tool_name).lower()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def format_tool_call_args(tool_name: str, tool_call: dict[str, Any]) -> str:
|
|
269
|
+
"""Format key tool arguments for display (see IG-053).
|
|
270
|
+
|
|
271
|
+
Extracts the most relevant argument(s) for each tool type to show
|
|
272
|
+
in activity events. Supports multiple arguments per tool.
|
|
273
|
+
|
|
274
|
+
Path arguments are converted from deepagents workspace-relative format
|
|
275
|
+
to actual OS paths and abbreviated for display.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
tool_name: Tool name as emitted by the model (snake_case or PascalCase).
|
|
279
|
+
tool_call: Tool call dict with 'args' key containing arguments.
|
|
280
|
+
May also contain '_raw' key with raw args string for fallback display.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Formatted argument string like "file_name.md" or "/Users/dev/.../file.md, pattern"
|
|
284
|
+
(without outer parentheses - caller adds them).
|
|
285
|
+
Returns "..." when args are empty but tool is known.
|
|
286
|
+
Returns "" if tool is unknown and no args.
|
|
287
|
+
|
|
288
|
+
Examples:
|
|
289
|
+
>>> format_tool_call_args("read_file", {"args": {"path": "config.yml"}})
|
|
290
|
+
'config.yml'
|
|
291
|
+
>>> format_tool_call_args("run_command", {"args": {"command": "ls", "args": "-la"}})
|
|
292
|
+
'ls, -la'
|
|
293
|
+
>>> format_tool_call_args("read_file", {"args": {}})
|
|
294
|
+
'...'
|
|
295
|
+
>>> format_tool_call_args("read_file", {"args": {}, "_raw": '{"path": "file.txt"}'})
|
|
296
|
+
'file.txt'
|
|
297
|
+
"""
|
|
298
|
+
from soothe_sdk import convert_and_abbreviate_path, is_path_argument
|
|
299
|
+
|
|
300
|
+
max_value_length = 40 # Max length for displayed values
|
|
301
|
+
|
|
302
|
+
args = coerce_tool_call_args_to_dict(tool_call.get("args"))
|
|
303
|
+
internal = _normalize_tool_name_for_arg_map(tool_name)
|
|
304
|
+
key_args = _ARG_DISPLAY_MAP.get(internal)
|
|
305
|
+
|
|
306
|
+
# Check for raw args string fallback
|
|
307
|
+
raw_args_str = tool_call.get("_raw") or tool_call.get("raw_args_str", "")
|
|
308
|
+
|
|
309
|
+
# If args are empty, try to extract from raw args string
|
|
310
|
+
if not args and raw_args_str:
|
|
311
|
+
# Try to parse raw string as JSON
|
|
312
|
+
with contextlib.suppress(json.JSONDecodeError):
|
|
313
|
+
parsed_raw = json.loads(raw_args_str)
|
|
314
|
+
if isinstance(parsed_raw, dict):
|
|
315
|
+
args = parsed_raw
|
|
316
|
+
|
|
317
|
+
# If args are still empty but tool is known, show placeholder
|
|
318
|
+
if not args:
|
|
319
|
+
if key_args:
|
|
320
|
+
# Try to extract value from partial raw args string
|
|
321
|
+
if raw_args_str:
|
|
322
|
+
# Try regex extraction for common patterns like "path": "value" or "path":"value"
|
|
323
|
+
for key_arg in key_args:
|
|
324
|
+
# Match patterns like "key": "value" or "key":"value"
|
|
325
|
+
pattern = '"' + key_arg + '"\\s*:\\s*"([^"]+)"'
|
|
326
|
+
match = re.search(pattern, raw_args_str)
|
|
327
|
+
if match:
|
|
328
|
+
value = match.group(1)
|
|
329
|
+
if is_path_argument(key_arg):
|
|
330
|
+
value = convert_and_abbreviate_path(value, max_length=max_value_length)
|
|
331
|
+
return value
|
|
332
|
+
# Also match non-string values like "key": 123 or "key": true
|
|
333
|
+
pattern2 = '"' + key_arg + '"\\s*:\\s*([^,\\}]+)'
|
|
334
|
+
match2 = re.search(pattern2, raw_args_str)
|
|
335
|
+
if match2:
|
|
336
|
+
val = match2.group(1).strip()
|
|
337
|
+
# Truncate if too long
|
|
338
|
+
if len(val) > max_value_length:
|
|
339
|
+
val = val[: max_value_length - 3] + "..."
|
|
340
|
+
return val
|
|
341
|
+
return "..."
|
|
342
|
+
return ""
|
|
343
|
+
|
|
344
|
+
if not key_args:
|
|
345
|
+
return ""
|
|
346
|
+
|
|
347
|
+
# Extract values for all configured argument keys
|
|
348
|
+
values = []
|
|
349
|
+
for key_arg in key_args:
|
|
350
|
+
if key_arg in args:
|
|
351
|
+
value = str(args[key_arg])
|
|
352
|
+
# Convert and abbreviate path arguments
|
|
353
|
+
if is_path_argument(key_arg):
|
|
354
|
+
value = convert_and_abbreviate_path(value, max_value_length)
|
|
355
|
+
elif len(value) > max_value_length:
|
|
356
|
+
# Truncate non-path long values
|
|
357
|
+
value = value[: max_value_length - 3] + "..."
|
|
358
|
+
values.append(value)
|
|
359
|
+
|
|
360
|
+
if not values:
|
|
361
|
+
# Model may use different arg names than _ARG_DISPLAY_MAP; still show something useful.
|
|
362
|
+
if args:
|
|
363
|
+
parts: list[str] = []
|
|
364
|
+
# Skip internal keys like _raw, _internal, etc.
|
|
365
|
+
skip_keys = {"_raw", "_internal", "raw_args_str"}
|
|
366
|
+
for k, v in list(args.items())[:3]:
|
|
367
|
+
if k in skip_keys:
|
|
368
|
+
continue
|
|
369
|
+
s = str(v)
|
|
370
|
+
# Convert and abbreviate path arguments
|
|
371
|
+
if is_path_argument(k):
|
|
372
|
+
s = convert_and_abbreviate_path(s, max_value_length)
|
|
373
|
+
elif len(s) > max_value_length:
|
|
374
|
+
s = s[: max_value_length - 3] + "..."
|
|
375
|
+
parts.append(s)
|
|
376
|
+
if parts:
|
|
377
|
+
return ", ".join(parts)
|
|
378
|
+
# All args were internal keys, check for raw_args_str
|
|
379
|
+
raw = args.get("_raw") or args.get("raw_args_str", "")
|
|
380
|
+
if raw:
|
|
381
|
+
# Try to extract from raw JSON
|
|
382
|
+
for key_arg in key_args:
|
|
383
|
+
pattern = '"' + key_arg + '"\\s*:\\s*"([^"]+)"'
|
|
384
|
+
match = re.search(pattern, raw)
|
|
385
|
+
if match:
|
|
386
|
+
value = match.group(1)
|
|
387
|
+
if is_path_argument(key_arg):
|
|
388
|
+
value = convert_and_abbreviate_path(value, max_value_length)
|
|
389
|
+
return value
|
|
390
|
+
# Known tool but no matching args found
|
|
391
|
+
return "..."
|
|
392
|
+
|
|
393
|
+
return ", ".join(values)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Unified presentation decisions for CLI/TUI surfaces.
|
|
2
|
+
|
|
3
|
+
This module centralizes display-time suppression and summarization rules so
|
|
4
|
+
renderers stay focused on output transport (stdout/stderr or widgets).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from soothe_sdk import log_preview
|
|
14
|
+
from soothe_sdk.verbosity import VerbosityTier, should_show
|
|
15
|
+
|
|
16
|
+
from soothe_cli.shared.display_policy import VerbosityLevel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PresentationState:
|
|
21
|
+
"""Stateful suppression metadata for presentation decisions."""
|
|
22
|
+
|
|
23
|
+
last_reason_key: str = ""
|
|
24
|
+
last_reason_at_s: float = 0.0
|
|
25
|
+
last_reason_by_step: dict[str, float] | None = None
|
|
26
|
+
final_answer_locked: bool = False
|
|
27
|
+
|
|
28
|
+
# Action deduplication tracking (IG-143)
|
|
29
|
+
last_action_text: str = ""
|
|
30
|
+
last_action_time: float = 0.0
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
"""Initialize mutable map defaults safely."""
|
|
34
|
+
if self.last_reason_by_step is None:
|
|
35
|
+
self.last_reason_by_step = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PresentationEngine:
|
|
39
|
+
"""Small policy engine for presentation-specific decisions."""
|
|
40
|
+
|
|
41
|
+
_REASON_DEDUP_WINDOW_S = 8.0
|
|
42
|
+
_REASON_STEP_RATE_LIMIT_S = 5.0
|
|
43
|
+
_TOOL_RESULT_MAX_CHARS = 180
|
|
44
|
+
_ACTION_DEDUP_WINDOW_S = 5.0
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
"""Initialize presentation state."""
|
|
48
|
+
self._state = PresentationState()
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def final_answer_locked(self) -> bool:
|
|
52
|
+
"""True after a custom final/chitchat response was emitted for this turn."""
|
|
53
|
+
return self._state.final_answer_locked
|
|
54
|
+
|
|
55
|
+
def mark_final_answer_locked(self) -> None:
|
|
56
|
+
"""Record that the final user-visible answer was already emitted (e.g. custom event)."""
|
|
57
|
+
self._state.final_answer_locked = True
|
|
58
|
+
|
|
59
|
+
def reset_turn(self) -> None:
|
|
60
|
+
"""Clear per-turn presentation state (reason dedup, final lock, action dedup)."""
|
|
61
|
+
self._state.last_reason_key = ""
|
|
62
|
+
self._state.last_reason_at_s = 0.0
|
|
63
|
+
self._state.last_reason_by_step.clear()
|
|
64
|
+
self._state.final_answer_locked = False
|
|
65
|
+
self._state.last_action_text = ""
|
|
66
|
+
self._state.last_action_time = 0.0
|
|
67
|
+
|
|
68
|
+
def reset_session(self) -> None:
|
|
69
|
+
"""Clear presentation state for a new session (e.g. thread change)."""
|
|
70
|
+
self.reset_turn()
|
|
71
|
+
|
|
72
|
+
def tier_visible(self, tier: VerbosityTier, verbosity: VerbosityLevel) -> bool:
|
|
73
|
+
"""Return whether content at the given verbosity tier should display."""
|
|
74
|
+
return should_show(tier, verbosity)
|
|
75
|
+
|
|
76
|
+
def should_emit_reason(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
content: str,
|
|
80
|
+
step_id: str | None = None,
|
|
81
|
+
now_s: float | None = None,
|
|
82
|
+
) -> bool:
|
|
83
|
+
"""Decide whether a reason line should be emitted.
|
|
84
|
+
|
|
85
|
+
Applies short-window de-duplication and per-step rate limiting.
|
|
86
|
+
"""
|
|
87
|
+
now = now_s if now_s is not None else time.monotonic()
|
|
88
|
+
normalized = self._normalize_reason(content)
|
|
89
|
+
if not normalized:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
# Global dedup window for near-identical reason updates.
|
|
93
|
+
if (
|
|
94
|
+
normalized == self._state.last_reason_key
|
|
95
|
+
and (now - self._state.last_reason_at_s) < self._REASON_DEDUP_WINDOW_S
|
|
96
|
+
):
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
# Per-step rate limit for noisy repeated updates.
|
|
100
|
+
if step_id:
|
|
101
|
+
last_step_at = self._state.last_reason_by_step.get(step_id, 0.0)
|
|
102
|
+
if (now - last_step_at) < self._REASON_STEP_RATE_LIMIT_S:
|
|
103
|
+
return False
|
|
104
|
+
self._state.last_reason_by_step[step_id] = now
|
|
105
|
+
|
|
106
|
+
self._state.last_reason_key = normalized
|
|
107
|
+
self._state.last_reason_at_s = now
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
def summarize_tool_result(self, text: str) -> str:
|
|
111
|
+
"""Convert noisy tool results into concise one-line summaries."""
|
|
112
|
+
compact = " ".join(text.split())
|
|
113
|
+
if not compact:
|
|
114
|
+
return compact
|
|
115
|
+
|
|
116
|
+
# If payload looks like a huge list/dict dump, replace with a compact marker.
|
|
117
|
+
if (compact.startswith("[") and compact.endswith("]")) or (
|
|
118
|
+
compact.startswith("{") and compact.endswith("}")
|
|
119
|
+
):
|
|
120
|
+
return "Tool result received (structured payload)."
|
|
121
|
+
|
|
122
|
+
return log_preview(compact, self._TOOL_RESULT_MAX_CHARS)
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _normalize_reason(content: str) -> str:
|
|
126
|
+
lowered = content.lower().strip()
|
|
127
|
+
lowered = re.sub(r"\(\d+%\s+sure\)", "", lowered)
|
|
128
|
+
return re.sub(r"\s+", " ", lowered)
|
|
129
|
+
|
|
130
|
+
def should_emit_action(
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
action_text: str,
|
|
134
|
+
now_s: float | None = None,
|
|
135
|
+
) -> bool:
|
|
136
|
+
"""Deduplicate repeated action summaries within 5s window.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
action_text: Action summary text (may include confidence).
|
|
140
|
+
now_s: Optional timestamp (defaults to monotonic time).
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
True if action should be emitted, False if duplicate.
|
|
144
|
+
"""
|
|
145
|
+
normalized = self._normalize_action(action_text)
|
|
146
|
+
now = now_s if now_s is not None else time.monotonic()
|
|
147
|
+
|
|
148
|
+
# Dedup identical actions within window
|
|
149
|
+
if (
|
|
150
|
+
normalized == self._state.last_action_text
|
|
151
|
+
and (now - self._state.last_action_time) < self._ACTION_DEDUP_WINDOW_S
|
|
152
|
+
):
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
# Update state
|
|
156
|
+
self._state.last_action_text = normalized
|
|
157
|
+
self._state.last_action_time = now
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _normalize_action(text: str) -> str:
|
|
162
|
+
"""Strip confidence and whitespace for action comparison.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
text: Action text to normalize.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Normalized text for deduplication comparison.
|
|
169
|
+
"""
|
|
170
|
+
lowered = text.lower().strip()
|
|
171
|
+
# Remove "(XX% sure)" or "(XX% confident)" suffix
|
|
172
|
+
lowered = re.sub(r"\(\d+%\s+(?:sure|confident)\)", "", lowered)
|
|
173
|
+
return re.sub(r"\s+", " ", lowered)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Processor state for unified event handling.
|
|
2
|
+
|
|
3
|
+
This module defines the internal state managed by EventProcessor.
|
|
4
|
+
Renderers should not modify this state directly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from soothe_sdk import Plan
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ProcessorState:
|
|
18
|
+
"""Internal state for EventProcessor.
|
|
19
|
+
|
|
20
|
+
This state is owned by the processor and should not be
|
|
21
|
+
modified directly by renderers. Renderers can read state
|
|
22
|
+
via processor properties.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Message deduplication - tracks seen message IDs
|
|
26
|
+
seen_message_ids: set[str] = field(default_factory=set)
|
|
27
|
+
|
|
28
|
+
# Streaming tool call arg accumulation (IG-053)
|
|
29
|
+
# Maps tool_call_id -> {'name': str, 'args_str': str, 'emitted': bool, 'is_main': bool}
|
|
30
|
+
pending_tool_calls: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
# Namespace -> display name mapping for subagents
|
|
33
|
+
name_map: dict[str, str] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
# Current plan state (updated on plan events)
|
|
36
|
+
current_plan: Plan | None = None
|
|
37
|
+
|
|
38
|
+
# Thread identifier from daemon
|
|
39
|
+
thread_id: str = ""
|
|
40
|
+
|
|
41
|
+
# Multi-step plan suppression flag (suppress step text, show final report)
|
|
42
|
+
multi_step_active: bool = False
|
|
43
|
+
|
|
44
|
+
# Internal context tracking (suppress internal LLM responses)
|
|
45
|
+
internal_context_active: bool = False
|
|
46
|
+
|
|
47
|
+
# Tool call timing for duration display (RFC-0020)
|
|
48
|
+
# Maps tool_call_id -> start_timestamp
|
|
49
|
+
tool_call_start_times: dict[str, float] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
# Deduplication for tool calls (prevents duplicate display)
|
|
52
|
+
emitted_tool_call_ids: set[str] = field(default_factory=set)
|
|
53
|
+
|
|
54
|
+
# Deduplication for tool results (prevents duplicate display)
|
|
55
|
+
emitted_tool_result_ids: set[str] = field(default_factory=set)
|
|
56
|
+
|
|
57
|
+
def reset_turn(self) -> None:
|
|
58
|
+
"""Reset per-turn state.
|
|
59
|
+
|
|
60
|
+
Called when a turn ends (status becomes idle/stopped).
|
|
61
|
+
Clears streaming buffers but preserves session state.
|
|
62
|
+
"""
|
|
63
|
+
self.pending_tool_calls.clear()
|
|
64
|
+
self.tool_call_start_times.clear()
|
|
65
|
+
self.emitted_tool_call_ids.clear()
|
|
66
|
+
self.emitted_tool_result_ids.clear()
|
|
67
|
+
|
|
68
|
+
def clear_session(self) -> None:
|
|
69
|
+
"""Clear all session state.
|
|
70
|
+
|
|
71
|
+
Called when thread changes. Resets everything for fresh session.
|
|
72
|
+
"""
|
|
73
|
+
self.seen_message_ids.clear()
|
|
74
|
+
self.pending_tool_calls.clear()
|
|
75
|
+
self.current_plan = None
|
|
76
|
+
self.multi_step_active = False
|
|
77
|
+
self.internal_context_active = False
|
|
78
|
+
self.tool_call_start_times.clear()
|
|
79
|
+
self.emitted_tool_call_ids.clear()
|
|
80
|
+
self.emitted_tool_result_ids.clear()
|