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,188 @@
|
|
|
1
|
+
"""Shared suppression state for multi-step/agentic loop execution (IG-143).
|
|
2
|
+
|
|
3
|
+
This module provides reusable state tracking and logic for suppressing
|
|
4
|
+
intermediate output during multi-step plan execution and agentic loops.
|
|
5
|
+
Both CLI and TUI renderers use this to prevent redundant intermediate output.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from soothe_sdk.events import DEFAULT_AGENT_LOOP_MAX_ITERATIONS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SuppressionState:
|
|
17
|
+
"""Shared state for IG-143 multi-step/agentic suppression.
|
|
18
|
+
|
|
19
|
+
This tracks execution state to suppress intermediate LLM responses,
|
|
20
|
+
tool calls, and tool results during multi-step execution, showing
|
|
21
|
+
only essential progress events and the final aggregated report.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
# In renderer state:
|
|
25
|
+
suppression: SuppressionState = field(default_factory=SuppressionState)
|
|
26
|
+
|
|
27
|
+
# Check suppression:
|
|
28
|
+
if suppression.should_suppress_output():
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
# Track from events:
|
|
32
|
+
suppression.track_from_event(event_type, data)
|
|
33
|
+
|
|
34
|
+
# Get final report:
|
|
35
|
+
if suppression.should_emit_final_report(event_type, final_stdout):
|
|
36
|
+
text = suppression.get_final_response(final_stdout)
|
|
37
|
+
# ... emit text ...
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Multi-step plan suppression flag
|
|
41
|
+
multi_step_active: bool = False
|
|
42
|
+
|
|
43
|
+
# Agentic loop (max_iterations>1): keep suppressing until loop.completed
|
|
44
|
+
# even if status idle/on_turn_end cleared multi_step_active first
|
|
45
|
+
agentic_stdout_suppressed: bool = False
|
|
46
|
+
|
|
47
|
+
# Track if final stdout already emitted (prevent duplicate emission)
|
|
48
|
+
agentic_final_stdout_emitted: bool = False
|
|
49
|
+
|
|
50
|
+
# Accumulated response text for final report
|
|
51
|
+
full_response: list[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
def should_suppress_output(self) -> bool:
|
|
54
|
+
"""Check if output should be suppressed.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
True if multi-step or agentic loop is active and suppressing.
|
|
58
|
+
"""
|
|
59
|
+
return self.multi_step_active or (
|
|
60
|
+
self.agentic_stdout_suppressed and not self.agentic_final_stdout_emitted
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def should_emit_final_report(
|
|
64
|
+
self,
|
|
65
|
+
event_type: str,
|
|
66
|
+
final_stdout: str,
|
|
67
|
+
) -> bool:
|
|
68
|
+
"""Check if final report should be emitted on loop completion.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
event_type: Event type string.
|
|
72
|
+
final_stdout: Final stdout message from event data.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
True if final report should be emitted.
|
|
76
|
+
"""
|
|
77
|
+
if event_type != "soothe.cognition.agent_loop.completed":
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
if not final_stdout:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
if self.agentic_final_stdout_emitted:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
# Mark flag when emitting
|
|
87
|
+
should_emit = self.multi_step_active or self.agentic_stdout_suppressed
|
|
88
|
+
if should_emit:
|
|
89
|
+
self.agentic_final_stdout_emitted = True
|
|
90
|
+
|
|
91
|
+
return should_emit
|
|
92
|
+
|
|
93
|
+
def track_from_event(self, event_type: str, data: dict) -> str:
|
|
94
|
+
"""Track suppression state from progress event.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
event_type: Event type string.
|
|
98
|
+
data: Event payload dict.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Final stdout message if present (for emission after tracking).
|
|
102
|
+
"""
|
|
103
|
+
# Track suppression state from agentic loop start.
|
|
104
|
+
# Suppress intermediate output only when max_iterations > 1 (multi-step mode).
|
|
105
|
+
# In single-step mode (max_iterations == 1), stdout flows normally and final_stdout
|
|
106
|
+
# should NOT be emitted separately to avoid duplication (IG-143 follow-up).
|
|
107
|
+
if event_type == "soothe.cognition.agent_loop.started":
|
|
108
|
+
max_iterations = data.get("max_iterations", DEFAULT_AGENT_LOOP_MAX_ITERATIONS)
|
|
109
|
+
if max_iterations > 1:
|
|
110
|
+
self.multi_step_active = True
|
|
111
|
+
self.agentic_stdout_suppressed = True
|
|
112
|
+
self.agentic_final_stdout_emitted = False
|
|
113
|
+
|
|
114
|
+
# Backup suppression: suppress after iteration 1+ if loop.started was filtered
|
|
115
|
+
if event_type == "soothe.cognition.agent_loop.reasoning":
|
|
116
|
+
try:
|
|
117
|
+
iteration = int(data.get("iteration", 0))
|
|
118
|
+
except (TypeError, ValueError):
|
|
119
|
+
iteration = 0
|
|
120
|
+
if iteration >= 1 and not self.agentic_final_stdout_emitted:
|
|
121
|
+
self.agentic_stdout_suppressed = True
|
|
122
|
+
|
|
123
|
+
# Extract final stdout message from loop completion
|
|
124
|
+
payload = dict(data)
|
|
125
|
+
final_stdout = (payload.pop("final_stdout_message", None) or "").strip()
|
|
126
|
+
|
|
127
|
+
# Note: agentic_final_stdout_emitted flag is set in should_emit_final_report()
|
|
128
|
+
# after checking the condition, not here (order matters for rendering logic)
|
|
129
|
+
|
|
130
|
+
return final_stdout
|
|
131
|
+
|
|
132
|
+
def track_from_plan(self, num_steps: int) -> None:
|
|
133
|
+
"""Track suppression state from plan creation.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
num_steps: Number of steps in the plan.
|
|
137
|
+
"""
|
|
138
|
+
self.multi_step_active = num_steps > 1
|
|
139
|
+
# max_iterations==1 does not arm agentic_stdout_suppressed in loop.started;
|
|
140
|
+
# multi-step plans still clear multi_step_active on on_turn_end before
|
|
141
|
+
# loop.completed (test-case1).
|
|
142
|
+
if num_steps > 1:
|
|
143
|
+
self.agentic_stdout_suppressed = True
|
|
144
|
+
self.agentic_final_stdout_emitted = False
|
|
145
|
+
|
|
146
|
+
def get_final_response(self, final_stdout: str | None = None) -> str:
|
|
147
|
+
"""Get accumulated final response text.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
final_stdout: Optional final stdout message to append.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Aggregated response text.
|
|
154
|
+
"""
|
|
155
|
+
if final_stdout:
|
|
156
|
+
stripped = final_stdout.strip()
|
|
157
|
+
if stripped:
|
|
158
|
+
self.full_response.append(stripped)
|
|
159
|
+
|
|
160
|
+
# Join accumulated response
|
|
161
|
+
return "".join(self.full_response)
|
|
162
|
+
|
|
163
|
+
def accumulate_text(self, text: str) -> None:
|
|
164
|
+
"""Accumulate text for final response.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
text: Text chunk to accumulate.
|
|
168
|
+
"""
|
|
169
|
+
self.full_response.append(text)
|
|
170
|
+
|
|
171
|
+
def reset_turn(self) -> None:
|
|
172
|
+
"""Reset state for next turn.
|
|
173
|
+
|
|
174
|
+
Called on turn end (status becomes idle/stopped).
|
|
175
|
+
Clears suppression flags and accumulated response.
|
|
176
|
+
"""
|
|
177
|
+
self.multi_step_active = False
|
|
178
|
+
self.full_response = []
|
|
179
|
+
|
|
180
|
+
def reset_session(self) -> None:
|
|
181
|
+
"""Reset all session state.
|
|
182
|
+
|
|
183
|
+
Called when thread changes.
|
|
184
|
+
"""
|
|
185
|
+
self.multi_step_active = False
|
|
186
|
+
self.agentic_stdout_suppressed = False
|
|
187
|
+
self.agentic_final_stdout_emitted = False
|
|
188
|
+
self.full_response = []
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Tool-specific formatters for semantic result summarization.
|
|
2
|
+
|
|
3
|
+
This package provides tool-specific formatters that transform raw tool outputs
|
|
4
|
+
into concise, semantic summaries following RFC-0020 event display architecture.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from soothe_cli.shared.tool_formatters.base import BaseFormatter
|
|
10
|
+
from soothe_cli.shared.tool_formatters.execution import ExecutionFormatter
|
|
11
|
+
from soothe_cli.shared.tool_formatters.fallback import FallbackFormatter
|
|
12
|
+
from soothe_cli.shared.tool_formatters.file_ops import FileOpsFormatter
|
|
13
|
+
from soothe_cli.shared.tool_formatters.goal_formatter import GoalFormatter
|
|
14
|
+
from soothe_cli.shared.tool_formatters.media import MediaFormatter
|
|
15
|
+
from soothe_cli.shared.tool_formatters.structured import StructuredFormatter
|
|
16
|
+
from soothe_cli.shared.tool_formatters.web import WebFormatter
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BaseFormatter",
|
|
20
|
+
"ExecutionFormatter",
|
|
21
|
+
"FallbackFormatter",
|
|
22
|
+
"FileOpsFormatter",
|
|
23
|
+
"GoalFormatter",
|
|
24
|
+
"MediaFormatter",
|
|
25
|
+
"StructuredFormatter",
|
|
26
|
+
"WebFormatter",
|
|
27
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Base formatter interface for tool output summarization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from soothe_cli.shared.tool_output_formatter import ToolBrief
|
|
9
|
+
|
|
10
|
+
# Byte size thresholds
|
|
11
|
+
_KB = 1024
|
|
12
|
+
_MB = _KB * 1024
|
|
13
|
+
_GB = _MB * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseFormatter(ABC):
|
|
17
|
+
"""Abstract base class for tool-specific formatters.
|
|
18
|
+
|
|
19
|
+
Each formatter implements semantic summarization for a category of tools
|
|
20
|
+
(file operations, execution, media, goals, etc.).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def format(self, tool_name: str, result: Any) -> ToolBrief:
|
|
25
|
+
"""Format tool result into semantic summary.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
tool_name: Name of the tool (e.g., "read_file", "run_command").
|
|
29
|
+
result: Tool result (can be str, dict, ToolOutput, or other).
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
ToolBrief with semantic summary.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
NotImplementedError: If not implemented by subclass.
|
|
36
|
+
"""
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _format_size(size_bytes: int) -> str:
|
|
41
|
+
"""Format byte size as human-readable string.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
size_bytes: Size in bytes.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Human-readable size string (e.g., "2.3 KB", "1.5 MB").
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
>>> BaseFormatter._format_size(1024)
|
|
51
|
+
'1.0 KB'
|
|
52
|
+
>>> BaseFormatter._format_size(1536)
|
|
53
|
+
'1.5 KB'
|
|
54
|
+
>>> BaseFormatter._format_size(1048576)
|
|
55
|
+
'1.0 MB'
|
|
56
|
+
"""
|
|
57
|
+
if size_bytes < _KB:
|
|
58
|
+
return f"{size_bytes} B"
|
|
59
|
+
if size_bytes < _MB:
|
|
60
|
+
size_kb = size_bytes / _KB
|
|
61
|
+
return f"{size_kb:.1f} KB"
|
|
62
|
+
if size_bytes < _GB:
|
|
63
|
+
size_mb = size_bytes / _MB
|
|
64
|
+
return f"{size_mb:.1f} MB"
|
|
65
|
+
size_gb = size_bytes / _GB
|
|
66
|
+
return f"{size_gb:.1f} GB"
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _count_lines(text: str) -> int:
|
|
70
|
+
r"""Count number of lines in text.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
text: Text to count lines in.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Number of lines (minimum 1 for non-empty text, 0 for empty text).
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
>>> BaseFormatter._count_lines("Hello\nWorld\n")
|
|
80
|
+
2
|
|
81
|
+
>>> BaseFormatter._count_lines("")
|
|
82
|
+
0
|
|
83
|
+
>>> BaseFormatter._count_lines("Single line")
|
|
84
|
+
1
|
|
85
|
+
"""
|
|
86
|
+
if not text or not text.strip():
|
|
87
|
+
return 0
|
|
88
|
+
return text.count("\n") + 1 if text.strip() else 0
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _truncate_text(text: str, max_length: int = 80) -> str:
|
|
92
|
+
"""Truncate text to maximum length with ellipsis.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
text: Text to truncate.
|
|
96
|
+
max_length: Maximum length (default 80).
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Truncated text with "..." if longer than max_length.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
>>> BaseFormatter._truncate_text("Short text", max_length=10)
|
|
103
|
+
'Short text'
|
|
104
|
+
>>> BaseFormatter._truncate_text("Very long text that needs truncation", max_length=20)
|
|
105
|
+
'Very long text tha...'
|
|
106
|
+
"""
|
|
107
|
+
if len(text) <= max_length:
|
|
108
|
+
return text
|
|
109
|
+
return text[: max_length - 3] + "..."
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Formatter for execution tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from soothe_cli.shared.tool_formatters.base import BaseFormatter
|
|
8
|
+
from soothe_cli.shared.tool_output_formatter import ToolBrief
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExecutionFormatter(BaseFormatter):
|
|
12
|
+
"""Formatter for execution tools.
|
|
13
|
+
|
|
14
|
+
Handles: run_command, run_python, run_background, kill_process
|
|
15
|
+
|
|
16
|
+
Provides semantic summaries with success/failure status, PIDs, and error messages.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def format(self, tool_name: str, result: Any) -> ToolBrief:
|
|
20
|
+
"""Format execution tool result.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
tool_name: Name of the execution tool.
|
|
24
|
+
result: Tool result (string or dict depending on tool).
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
ToolBrief with execution summary.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ValueError: If tool_name is not a recognized execution tool.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
>>> formatter = ExecutionFormatter()
|
|
34
|
+
>>> brief = formatter.format("run_command", "output text")
|
|
35
|
+
>>> brief.to_display()
|
|
36
|
+
'✓ Done'
|
|
37
|
+
"""
|
|
38
|
+
# Normalize tool name
|
|
39
|
+
normalized = tool_name.lower().replace("-", "_").replace(" ", "_")
|
|
40
|
+
|
|
41
|
+
# Route to specific formatter
|
|
42
|
+
if normalized == "run_command":
|
|
43
|
+
return self._format_run_command(result)
|
|
44
|
+
if normalized == "run_python":
|
|
45
|
+
return self._format_run_python(result)
|
|
46
|
+
if normalized == "run_background":
|
|
47
|
+
return self._format_run_background(result)
|
|
48
|
+
if normalized == "kill_process":
|
|
49
|
+
return self._format_kill_process(result)
|
|
50
|
+
msg = f"Unknown execution tool: {tool_name}"
|
|
51
|
+
raise ValueError(msg)
|
|
52
|
+
|
|
53
|
+
def _format_run_command(self, result: str) -> ToolBrief:
|
|
54
|
+
"""Format run_command result.
|
|
55
|
+
|
|
56
|
+
Shows "Done" for success or "Failed: {reason}" for errors.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
result: Command output or error string.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
ToolBrief with execution status.
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
>>> brief = formatter._format_run_command("command output")
|
|
66
|
+
>>> brief.summary
|
|
67
|
+
'Done'
|
|
68
|
+
>>> brief = formatter._format_run_command("Error: command not found")
|
|
69
|
+
>>> brief.summary
|
|
70
|
+
'Failed'
|
|
71
|
+
>>> brief.detail
|
|
72
|
+
'command not found'
|
|
73
|
+
"""
|
|
74
|
+
# Check for error indicators
|
|
75
|
+
if result.startswith("Error:"):
|
|
76
|
+
error_msg = result[6:].strip() # Remove "Error:" prefix
|
|
77
|
+
return ToolBrief(
|
|
78
|
+
icon="✗",
|
|
79
|
+
summary="Failed",
|
|
80
|
+
detail=self._truncate_text(error_msg, 80),
|
|
81
|
+
metrics={"error": True},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Check for other error patterns
|
|
85
|
+
error_indicators = ["failed:", "exception:", "traceback", "command not found"]
|
|
86
|
+
result_lower = result.lower()
|
|
87
|
+
if any(indicator in result_lower for indicator in error_indicators):
|
|
88
|
+
# Extract first line as error message
|
|
89
|
+
first_line = result.partition("\n")[0].strip()
|
|
90
|
+
return ToolBrief(
|
|
91
|
+
icon="✗",
|
|
92
|
+
summary="Failed",
|
|
93
|
+
detail=self._truncate_text(first_line, 80),
|
|
94
|
+
metrics={"error": True},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Success
|
|
98
|
+
output_size = len(result)
|
|
99
|
+
detail = None
|
|
100
|
+
|
|
101
|
+
# Show output size if there's substantial output
|
|
102
|
+
if output_size > 0:
|
|
103
|
+
output_chars = f"{output_size} chars"
|
|
104
|
+
detail = f"{output_chars} output"
|
|
105
|
+
else:
|
|
106
|
+
detail = "no output"
|
|
107
|
+
|
|
108
|
+
return ToolBrief(
|
|
109
|
+
icon="✓",
|
|
110
|
+
summary="Done",
|
|
111
|
+
detail=detail,
|
|
112
|
+
metrics={"output_size": output_size},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _format_run_python(self, result: dict[str, Any]) -> ToolBrief:
|
|
116
|
+
"""Format run_python result.
|
|
117
|
+
|
|
118
|
+
Shows execution status with return value type.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
result: Dict with 'success', 'output', 'result', 'error' fields.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
ToolBrief with execution status.
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
>>> brief = formatter._format_run_python({"success": True, "result": 42})
|
|
128
|
+
>>> brief.summary
|
|
129
|
+
'Executed'
|
|
130
|
+
>>> brief.detail
|
|
131
|
+
'returned: int'
|
|
132
|
+
"""
|
|
133
|
+
# Handle dict result
|
|
134
|
+
if isinstance(result, dict):
|
|
135
|
+
success = result.get("success", True)
|
|
136
|
+
error = result.get("error")
|
|
137
|
+
return_value = result.get("result")
|
|
138
|
+
|
|
139
|
+
if not success or error:
|
|
140
|
+
error_msg = str(error) if error else "Execution failed"
|
|
141
|
+
return ToolBrief(
|
|
142
|
+
icon="✗",
|
|
143
|
+
summary="Execution failed",
|
|
144
|
+
detail=self._truncate_text(error_msg, 80),
|
|
145
|
+
metrics={"error": True},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Success
|
|
149
|
+
detail = None
|
|
150
|
+
if return_value is not None:
|
|
151
|
+
return_type = type(return_value).__name__
|
|
152
|
+
detail = f"returned: {return_type}"
|
|
153
|
+
else:
|
|
154
|
+
output = result.get("output", "")
|
|
155
|
+
detail = f"{len(output)} chars output" if output else "no output"
|
|
156
|
+
|
|
157
|
+
return ToolBrief(
|
|
158
|
+
icon="✓",
|
|
159
|
+
summary="Executed",
|
|
160
|
+
detail=detail,
|
|
161
|
+
metrics={"has_return": return_value is not None},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Handle string result (fallback)
|
|
165
|
+
if isinstance(result, str):
|
|
166
|
+
if "error" in result.lower() or "failed" in result.lower():
|
|
167
|
+
return ToolBrief(
|
|
168
|
+
icon="✗",
|
|
169
|
+
summary="Execution failed",
|
|
170
|
+
detail=self._truncate_text(result, 80),
|
|
171
|
+
metrics={"error": True},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return ToolBrief(
|
|
175
|
+
icon="✓",
|
|
176
|
+
summary="Executed",
|
|
177
|
+
detail=f"{len(result)} chars output",
|
|
178
|
+
metrics={},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Unknown type
|
|
182
|
+
return ToolBrief(
|
|
183
|
+
icon="✓",
|
|
184
|
+
summary="Executed",
|
|
185
|
+
detail=None,
|
|
186
|
+
metrics={},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _format_run_background(self, result: dict[str, Any]) -> ToolBrief:
|
|
190
|
+
"""Format run_background result.
|
|
191
|
+
|
|
192
|
+
Shows PID of started background process.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
result: Dict with 'pid', 'status', 'message' fields.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
ToolBrief with PID.
|
|
199
|
+
|
|
200
|
+
Example:
|
|
201
|
+
>>> brief = formatter._format_run_background({"pid": 12345, "status": "running"})
|
|
202
|
+
>>> brief.summary
|
|
203
|
+
'Started PID 12345'
|
|
204
|
+
"""
|
|
205
|
+
# Handle dict result
|
|
206
|
+
if isinstance(result, dict):
|
|
207
|
+
pid = result.get("pid")
|
|
208
|
+
status = result.get("status")
|
|
209
|
+
message = result.get("message")
|
|
210
|
+
|
|
211
|
+
if status == "error" or not pid:
|
|
212
|
+
error_msg = message or "Failed to start background process"
|
|
213
|
+
return ToolBrief(
|
|
214
|
+
icon="✗",
|
|
215
|
+
summary="Start failed",
|
|
216
|
+
detail=self._truncate_text(error_msg, 80),
|
|
217
|
+
metrics={"error": True},
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Success
|
|
221
|
+
return ToolBrief(
|
|
222
|
+
icon="✓",
|
|
223
|
+
summary=f"Started PID {pid}",
|
|
224
|
+
detail=None,
|
|
225
|
+
metrics={"pid": pid, "status": status},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Handle string result (fallback)
|
|
229
|
+
if isinstance(result, str):
|
|
230
|
+
if "error" in result.lower() or "failed" in result.lower():
|
|
231
|
+
return ToolBrief(
|
|
232
|
+
icon="✗",
|
|
233
|
+
summary="Start failed",
|
|
234
|
+
detail=self._truncate_text(result, 80),
|
|
235
|
+
metrics={"error": True},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return ToolBrief(
|
|
239
|
+
icon="✓",
|
|
240
|
+
summary="Started",
|
|
241
|
+
detail=self._truncate_text(result, 80),
|
|
242
|
+
metrics={},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Unknown type
|
|
246
|
+
return ToolBrief(
|
|
247
|
+
icon="✓",
|
|
248
|
+
summary="Started",
|
|
249
|
+
detail=None,
|
|
250
|
+
metrics={},
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _format_kill_process(self, result: str) -> ToolBrief:
|
|
254
|
+
"""Format kill_process result.
|
|
255
|
+
|
|
256
|
+
Shows termination status with PID.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
result: Success message or error string.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
ToolBrief with termination status.
|
|
263
|
+
|
|
264
|
+
Example:
|
|
265
|
+
>>> brief = formatter._format_kill_process("Process 12345 terminated")
|
|
266
|
+
>>> brief.summary
|
|
267
|
+
'Terminated PID 12345'
|
|
268
|
+
"""
|
|
269
|
+
# Check for error
|
|
270
|
+
if "error" in result.lower() or "failed" in result.lower() or "not found" in result.lower():
|
|
271
|
+
return ToolBrief(
|
|
272
|
+
icon="✗",
|
|
273
|
+
summary="Termination failed",
|
|
274
|
+
detail=self._truncate_text(result, 80),
|
|
275
|
+
metrics={"error": True},
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Try to extract PID from result
|
|
279
|
+
import re
|
|
280
|
+
|
|
281
|
+
pid_match = re.search(r"PID\s+(\d+)", result)
|
|
282
|
+
if pid_match:
|
|
283
|
+
pid = pid_match.group(1)
|
|
284
|
+
return ToolBrief(
|
|
285
|
+
icon="✓",
|
|
286
|
+
summary=f"Terminated PID {pid}",
|
|
287
|
+
detail=None,
|
|
288
|
+
metrics={"pid": int(pid)},
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Generic success
|
|
292
|
+
return ToolBrief(
|
|
293
|
+
icon="✓",
|
|
294
|
+
summary="Terminated",
|
|
295
|
+
detail=None,
|
|
296
|
+
metrics={},
|
|
297
|
+
)
|