-
Notifications
You must be signed in to change notification settings - Fork 193
Expand file tree
/
Copy pathdefault.py
More file actions
385 lines (315 loc) · 12.1 KB
/
default.py
File metadata and controls
385 lines (315 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
import logging
import re
from collections.abc import Callable
from pydantic import BaseModel
from rich.console import Console, Group
from rich.rule import Rule
from rich.text import Text
from openhands.sdk.conversation.visualizer.base import (
ConversationVisualizerBase,
)
from openhands.sdk.event import (
ACPToolCallEvent,
ActionEvent,
AgentErrorEvent,
ConversationStateUpdateEvent,
MessageEvent,
ObservationEvent,
PauseEvent,
SystemPromptEvent,
UserRejectObservation,
)
from openhands.sdk.event.base import Event
from openhands.sdk.event.condenser import Condensation, CondensationRequest
from openhands.sdk.logger import flush_stdin
logger = logging.getLogger(__name__)
# These are external inputs
_OBSERVATION_COLOR = "yellow"
_MESSAGE_USER_COLOR = "gold3"
_PAUSE_COLOR = "bright_yellow"
# These are internal system stuff
_SYSTEM_COLOR = "magenta"
_THOUGHT_COLOR = "bright_black"
_ERROR_COLOR = "red"
# These are agent actions
_ACTION_COLOR = "blue"
_MESSAGE_ASSISTANT_COLOR = _ACTION_COLOR
DEFAULT_HIGHLIGHT_REGEX = {
r"^Reasoning:": f"bold {_THOUGHT_COLOR}",
r"^Thought:": f"bold {_THOUGHT_COLOR}",
r"^Action:": f"bold {_ACTION_COLOR}",
r"^Arguments:": f"bold {_ACTION_COLOR}",
r"^Tool:": f"bold {_OBSERVATION_COLOR}",
r"^Result:": f"bold {_OBSERVATION_COLOR}",
r"^Rejection Reason:": f"bold {_ERROR_COLOR}",
# Markdown-style
r"\*\*(.*?)\*\*": "bold",
r"\*(.*?)\*": "italic",
}
class EventVisualizationConfig(BaseModel):
"""Configuration for how to visualize an event type."""
title: str | Callable[[Event], str]
"""The title to display for this event. Can be a string or callable."""
color: str | Callable[[Event], str]
"""The Rich color to use for the title and rule. Can be a string or callable."""
show_metrics: bool = False
"""Whether to show the metrics subtitle."""
indent_content: bool = False
"""Whether to indent the content."""
skip: bool = False
"""If True, skip visualization of this event type entirely."""
model_config = {"arbitrary_types_allowed": True}
def indent_content(content: Text, spaces: int = 4) -> Text:
"""Indent content for visual hierarchy while preserving all formatting."""
prefix = " " * spaces
lines = content.split("\n")
indented = Text()
for i, line in enumerate(lines):
if i > 0:
indented.append("\n")
indented.append(prefix)
indented.append(line)
return indented
def section_header(title: str, color: str) -> Rule:
"""Create a semantic divider with title."""
return Rule(
f"[{color} bold]{title}[/{color} bold]",
style=color,
characters="─",
align="left",
)
def build_event_block(
content: Text,
title: str,
title_color: str,
subtitle: str | None = None,
indent: bool = False,
) -> Group:
"""Build a complete event block with header, content, and optional subtitle."""
parts = []
# Header with rule
parts.append(section_header(title, title_color))
parts.append(Text()) # Blank line after header
# Content (optionally indented)
if indent:
parts.append(indent_content(content))
else:
parts.append(content)
# Subtitle (metrics) if provided
if subtitle:
parts.append(Text()) # Blank line before subtitle
subtitle_text = Text.from_markup(subtitle)
subtitle_text.stylize("dim")
parts.append(subtitle_text)
parts.append(Text()) # Blank line after block
return Group(*parts)
def _get_action_title(event: Event) -> str:
"""Get title for ActionEvent based on whether action is None."""
if isinstance(event, ActionEvent):
return "Agent Action (Not Executed)" if event.action is None else "Agent Action"
return "Action"
def _get_message_title(event: Event) -> str:
"""Get title for MessageEvent based on role."""
if isinstance(event, MessageEvent) and event.llm_message:
return (
"Message from User"
if event.llm_message.role == "user"
else "Message from Agent"
)
return "Message"
def _get_message_color(event: Event) -> str:
"""Get color for MessageEvent based on role."""
if isinstance(event, MessageEvent) and event.llm_message:
return (
_MESSAGE_USER_COLOR
if event.llm_message.role == "user"
else _MESSAGE_ASSISTANT_COLOR
)
return "white"
# Event type to visualization configuration mapping
# This replaces the large isinstance chain with a cleaner lookup approach
EVENT_VISUALIZATION_CONFIG: dict[type[Event], EventVisualizationConfig] = {
ACPToolCallEvent: EventVisualizationConfig(
title="ACP Tool Call",
color=_ACTION_COLOR,
),
SystemPromptEvent: EventVisualizationConfig(
title="System Prompt",
color=_SYSTEM_COLOR,
),
ActionEvent: EventVisualizationConfig(
title=_get_action_title,
color=_ACTION_COLOR,
show_metrics=True,
),
ObservationEvent: EventVisualizationConfig(
title="Observation",
color=_OBSERVATION_COLOR,
),
UserRejectObservation: EventVisualizationConfig(
title="User Rejected Action",
color=_ERROR_COLOR,
),
MessageEvent: EventVisualizationConfig(
title=_get_message_title,
color=_get_message_color,
show_metrics=True,
),
AgentErrorEvent: EventVisualizationConfig(
title="Agent Error",
color=_ERROR_COLOR,
show_metrics=True,
),
PauseEvent: EventVisualizationConfig(
title="User Paused",
color=_PAUSE_COLOR,
),
Condensation: EventVisualizationConfig(
title="Condensation",
color="white",
show_metrics=True,
),
CondensationRequest: EventVisualizationConfig(
title="Condensation Request",
color=_SYSTEM_COLOR,
),
ConversationStateUpdateEvent: EventVisualizationConfig(
title="Conversation State Update",
color=_SYSTEM_COLOR,
skip=True,
),
}
class DefaultConversationVisualizer(ConversationVisualizerBase):
"""Handles visualization of conversation events with Rich formatting.
Provides Rich-formatted output with semantic dividers and complete content display.
"""
_console: Console
_skip_user_messages: bool
_highlight_patterns: dict[str, str]
def __init__(
self,
highlight_regex: dict[str, str] | None = DEFAULT_HIGHLIGHT_REGEX,
skip_user_messages: bool = False,
):
"""Initialize the visualizer.
Args:
highlight_regex: Dictionary mapping regex patterns to Rich color styles
for highlighting keywords in the visualizer.
For example: {"Reasoning:": "bold blue",
"Thought:": "bold green"}
skip_user_messages: If True, skip displaying user messages. Useful for
scenarios where user input is not relevant to show.
"""
super().__init__()
self._console = Console()
self._skip_user_messages = skip_user_messages
self._highlight_patterns = highlight_regex or {}
def on_event(self, event: Event) -> None:
"""Main event handler that displays events with Rich formatting."""
# Flush any pending terminal query responses before rendering.
# This prevents ANSI escape codes from accumulating in stdin
# and corrupting subsequent input() calls.
# See: https://github.com/OpenHands/software-agent-sdk/issues/2244
flush_stdin()
output = self._create_event_block(event)
if output:
self._console.print(output)
def _apply_highlighting(self, text: Text) -> Text:
"""Apply regex-based highlighting to text content.
Args:
text: The Rich Text object to highlight
Returns:
A new Text object with highlighting applied
"""
if not self._highlight_patterns:
return text
# Create a copy to avoid modifying the original
highlighted = text.copy()
# Apply each pattern using Rich's built-in highlight_regex method
for pattern, style in self._highlight_patterns.items():
pattern_compiled = re.compile(pattern, re.MULTILINE)
highlighted.highlight_regex(pattern_compiled, style)
return highlighted
def _create_event_block(self, event: Event) -> Group | None:
"""Create a Rich event block for the event with full detail."""
# Look up visualization config for this event type
config = EVENT_VISUALIZATION_CONFIG.get(type(event))
if not config:
# Warn about unknown event types and skip
logger.warning(
"Event type %s is not registered in EVENT_VISUALIZATION_CONFIG. "
"Skipping visualization.",
event.__class__.__name__,
)
return None
# Check if this event type should be skipped
if config.skip:
return None
# Check if we should skip user messages based on runtime configuration
if (
self._skip_user_messages
and isinstance(event, MessageEvent)
and event.llm_message
and event.llm_message.role == "user"
):
return None
# Use the event's visualize property for content
content = event.visualize
if not content.plain.strip():
return None
# Apply highlighting if configured
if self._highlight_patterns:
content = self._apply_highlighting(content)
# Resolve title (may be a string or callable)
title = config.title(event) if callable(config.title) else config.title
# Resolve color (may be a string or callable)
title_color = config.color(event) if callable(config.color) else config.color
# Build subtitle if needed
subtitle = self._format_metrics_subtitle() if config.show_metrics else None
return build_event_block(
content=content,
title=title,
title_color=title_color,
subtitle=subtitle,
)
def _format_metrics_subtitle(self) -> str | None:
"""Format LLM metrics as a visually appealing subtitle string with icons,
colors, and k/m abbreviations using conversation stats."""
stats = self.conversation_stats
if not stats:
return None
combined_metrics = stats.get_combined_metrics()
if not combined_metrics or not combined_metrics.accumulated_token_usage:
return None
usage = combined_metrics.accumulated_token_usage
cost = combined_metrics.accumulated_cost or 0.0
# helper: 1234 -> "1.2K", 1200000 -> "1.2M"
def abbr(n: int | float) -> str:
n = int(n or 0)
if n >= 1_000_000_000:
val, suffix = n / 1_000_000_000, "B"
elif n >= 1_000_000:
val, suffix = n / 1_000_000, "M"
elif n >= 1_000:
val, suffix = n / 1_000, "K"
else:
return str(n)
return f"{val:.2f}".rstrip("0").rstrip(".") + suffix
input_tokens = abbr(usage.prompt_tokens or 0)
output_tokens = abbr(usage.completion_tokens or 0)
# Cache hit rate (prompt + cache)
prompt = usage.prompt_tokens or 0
cache_read = usage.cache_read_tokens or 0
cache_rate = f"{(cache_read / prompt * 100):.2f}%" if prompt > 0 else "N/A"
reasoning_tokens = usage.reasoning_tokens or 0
# Cost
cost_str = f"{cost:.4f}" if cost > 0 else "0.00"
# Build with fixed color scheme
parts: list[str] = []
parts.append(f"[cyan]↑ input {input_tokens}[/cyan]")
parts.append(f"[magenta]cache hit {cache_rate}[/magenta]")
if reasoning_tokens > 0:
parts.append(f"[yellow] reasoning {abbr(reasoning_tokens)}[/yellow]")
parts.append(f"[blue]↓ output {output_tokens}[/blue]")
parts.append(f"[green]$ {cost_str}[/green]")
return "Tokens: " + " • ".join(parts)