openhands-sdk 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +607 -0
  4. openhands/sdk/agent/base.py +454 -0
  5. openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
  6. openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
  7. openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
  8. openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
  9. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +3 -0
  10. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
  11. openhands/sdk/agent/prompts/security_policy.j2 +22 -0
  12. openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
  13. openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
  14. openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
  15. openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
  16. openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
  17. openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
  18. openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
  19. openhands/sdk/agent/utils.py +223 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +240 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +95 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +89 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +13 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +55 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/prompts/__init__.py +6 -0
  29. openhands/sdk/context/prompts/prompt.py +114 -0
  30. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  31. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  32. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  33. openhands/sdk/context/skills/__init__.py +28 -0
  34. openhands/sdk/context/skills/exceptions.py +11 -0
  35. openhands/sdk/context/skills/skill.py +630 -0
  36. openhands/sdk/context/skills/trigger.py +36 -0
  37. openhands/sdk/context/skills/types.py +48 -0
  38. openhands/sdk/context/view.py +306 -0
  39. openhands/sdk/conversation/__init__.py +40 -0
  40. openhands/sdk/conversation/base.py +281 -0
  41. openhands/sdk/conversation/conversation.py +146 -0
  42. openhands/sdk/conversation/conversation_stats.py +85 -0
  43. openhands/sdk/conversation/event_store.py +157 -0
  44. openhands/sdk/conversation/events_list_base.py +17 -0
  45. openhands/sdk/conversation/exceptions.py +50 -0
  46. openhands/sdk/conversation/fifo_lock.py +133 -0
  47. openhands/sdk/conversation/impl/__init__.py +5 -0
  48. openhands/sdk/conversation/impl/local_conversation.py +620 -0
  49. openhands/sdk/conversation/impl/remote_conversation.py +883 -0
  50. openhands/sdk/conversation/persistence_const.py +9 -0
  51. openhands/sdk/conversation/response_utils.py +41 -0
  52. openhands/sdk/conversation/secret_registry.py +126 -0
  53. openhands/sdk/conversation/serialization_diff.py +0 -0
  54. openhands/sdk/conversation/state.py +352 -0
  55. openhands/sdk/conversation/stuck_detector.py +311 -0
  56. openhands/sdk/conversation/title_utils.py +191 -0
  57. openhands/sdk/conversation/types.py +45 -0
  58. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  59. openhands/sdk/conversation/visualizer/base.py +67 -0
  60. openhands/sdk/conversation/visualizer/default.py +373 -0
  61. openhands/sdk/critic/__init__.py +15 -0
  62. openhands/sdk/critic/base.py +38 -0
  63. openhands/sdk/critic/impl/__init__.py +12 -0
  64. openhands/sdk/critic/impl/agent_finished.py +83 -0
  65. openhands/sdk/critic/impl/empty_patch.py +49 -0
  66. openhands/sdk/critic/impl/pass_critic.py +42 -0
  67. openhands/sdk/event/__init__.py +42 -0
  68. openhands/sdk/event/base.py +149 -0
  69. openhands/sdk/event/condenser.py +82 -0
  70. openhands/sdk/event/conversation_error.py +25 -0
  71. openhands/sdk/event/conversation_state.py +104 -0
  72. openhands/sdk/event/llm_completion_log.py +39 -0
  73. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  74. openhands/sdk/event/llm_convertible/action.py +139 -0
  75. openhands/sdk/event/llm_convertible/message.py +142 -0
  76. openhands/sdk/event/llm_convertible/observation.py +141 -0
  77. openhands/sdk/event/llm_convertible/system.py +61 -0
  78. openhands/sdk/event/token.py +16 -0
  79. openhands/sdk/event/types.py +11 -0
  80. openhands/sdk/event/user_action.py +21 -0
  81. openhands/sdk/git/exceptions.py +43 -0
  82. openhands/sdk/git/git_changes.py +249 -0
  83. openhands/sdk/git/git_diff.py +129 -0
  84. openhands/sdk/git/models.py +21 -0
  85. openhands/sdk/git/utils.py +189 -0
  86. openhands/sdk/io/__init__.py +6 -0
  87. openhands/sdk/io/base.py +48 -0
  88. openhands/sdk/io/local.py +82 -0
  89. openhands/sdk/io/memory.py +54 -0
  90. openhands/sdk/llm/__init__.py +45 -0
  91. openhands/sdk/llm/exceptions/__init__.py +45 -0
  92. openhands/sdk/llm/exceptions/classifier.py +50 -0
  93. openhands/sdk/llm/exceptions/mapping.py +54 -0
  94. openhands/sdk/llm/exceptions/types.py +101 -0
  95. openhands/sdk/llm/llm.py +1140 -0
  96. openhands/sdk/llm/llm_registry.py +122 -0
  97. openhands/sdk/llm/llm_response.py +59 -0
  98. openhands/sdk/llm/message.py +656 -0
  99. openhands/sdk/llm/mixins/fn_call_converter.py +1243 -0
  100. openhands/sdk/llm/mixins/non_native_fc.py +93 -0
  101. openhands/sdk/llm/options/__init__.py +1 -0
  102. openhands/sdk/llm/options/chat_options.py +93 -0
  103. openhands/sdk/llm/options/common.py +19 -0
  104. openhands/sdk/llm/options/responses_options.py +67 -0
  105. openhands/sdk/llm/router/__init__.py +10 -0
  106. openhands/sdk/llm/router/base.py +117 -0
  107. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  108. openhands/sdk/llm/router/impl/random.py +22 -0
  109. openhands/sdk/llm/streaming.py +9 -0
  110. openhands/sdk/llm/utils/metrics.py +312 -0
  111. openhands/sdk/llm/utils/model_features.py +191 -0
  112. openhands/sdk/llm/utils/model_info.py +90 -0
  113. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  114. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  115. openhands/sdk/llm/utils/telemetry.py +362 -0
  116. openhands/sdk/llm/utils/unverified_models.py +156 -0
  117. openhands/sdk/llm/utils/verified_models.py +66 -0
  118. openhands/sdk/logger/__init__.py +22 -0
  119. openhands/sdk/logger/logger.py +195 -0
  120. openhands/sdk/logger/rolling.py +113 -0
  121. openhands/sdk/mcp/__init__.py +24 -0
  122. openhands/sdk/mcp/client.py +76 -0
  123. openhands/sdk/mcp/definition.py +106 -0
  124. openhands/sdk/mcp/exceptions.py +19 -0
  125. openhands/sdk/mcp/tool.py +270 -0
  126. openhands/sdk/mcp/utils.py +83 -0
  127. openhands/sdk/observability/__init__.py +4 -0
  128. openhands/sdk/observability/laminar.py +166 -0
  129. openhands/sdk/observability/utils.py +20 -0
  130. openhands/sdk/py.typed +0 -0
  131. openhands/sdk/secret/__init__.py +19 -0
  132. openhands/sdk/secret/secrets.py +92 -0
  133. openhands/sdk/security/__init__.py +6 -0
  134. openhands/sdk/security/analyzer.py +111 -0
  135. openhands/sdk/security/confirmation_policy.py +61 -0
  136. openhands/sdk/security/llm_analyzer.py +29 -0
  137. openhands/sdk/security/risk.py +100 -0
  138. openhands/sdk/tool/__init__.py +34 -0
  139. openhands/sdk/tool/builtins/__init__.py +34 -0
  140. openhands/sdk/tool/builtins/finish.py +106 -0
  141. openhands/sdk/tool/builtins/think.py +117 -0
  142. openhands/sdk/tool/registry.py +161 -0
  143. openhands/sdk/tool/schema.py +276 -0
  144. openhands/sdk/tool/spec.py +39 -0
  145. openhands/sdk/tool/tool.py +481 -0
  146. openhands/sdk/utils/__init__.py +22 -0
  147. openhands/sdk/utils/async_executor.py +115 -0
  148. openhands/sdk/utils/async_utils.py +39 -0
  149. openhands/sdk/utils/cipher.py +68 -0
  150. openhands/sdk/utils/command.py +90 -0
  151. openhands/sdk/utils/deprecation.py +166 -0
  152. openhands/sdk/utils/github.py +44 -0
  153. openhands/sdk/utils/json.py +48 -0
  154. openhands/sdk/utils/models.py +570 -0
  155. openhands/sdk/utils/paging.py +63 -0
  156. openhands/sdk/utils/pydantic_diff.py +85 -0
  157. openhands/sdk/utils/pydantic_secrets.py +64 -0
  158. openhands/sdk/utils/truncate.py +117 -0
  159. openhands/sdk/utils/visualize.py +58 -0
  160. openhands/sdk/workspace/__init__.py +17 -0
  161. openhands/sdk/workspace/base.py +158 -0
  162. openhands/sdk/workspace/local.py +189 -0
  163. openhands/sdk/workspace/models.py +35 -0
  164. openhands/sdk/workspace/remote/__init__.py +8 -0
  165. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  166. openhands/sdk/workspace/remote/base.py +164 -0
  167. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  168. openhands/sdk/workspace/workspace.py +49 -0
  169. openhands_sdk-1.7.0.dist-info/METADATA +17 -0
  170. openhands_sdk-1.7.0.dist-info/RECORD +172 -0
  171. openhands_sdk-1.7.0.dist-info/WHEEL +5 -0
  172. openhands_sdk-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,373 @@
1
+ import logging
2
+ import re
3
+ from collections.abc import Callable
4
+
5
+ from pydantic import BaseModel
6
+ from rich.console import Console, Group
7
+ from rich.rule import Rule
8
+ from rich.text import Text
9
+
10
+ from openhands.sdk.conversation.visualizer.base import (
11
+ ConversationVisualizerBase,
12
+ )
13
+ from openhands.sdk.event import (
14
+ ActionEvent,
15
+ AgentErrorEvent,
16
+ ConversationStateUpdateEvent,
17
+ MessageEvent,
18
+ ObservationEvent,
19
+ PauseEvent,
20
+ SystemPromptEvent,
21
+ UserRejectObservation,
22
+ )
23
+ from openhands.sdk.event.base import Event
24
+ from openhands.sdk.event.condenser import Condensation, CondensationRequest
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ # These are external inputs
31
+ _OBSERVATION_COLOR = "yellow"
32
+ _MESSAGE_USER_COLOR = "gold3"
33
+ _PAUSE_COLOR = "bright_yellow"
34
+ # These are internal system stuff
35
+ _SYSTEM_COLOR = "magenta"
36
+ _THOUGHT_COLOR = "bright_black"
37
+ _ERROR_COLOR = "red"
38
+ # These are agent actions
39
+ _ACTION_COLOR = "blue"
40
+ _MESSAGE_ASSISTANT_COLOR = _ACTION_COLOR
41
+
42
+ DEFAULT_HIGHLIGHT_REGEX = {
43
+ r"^Reasoning:": f"bold {_THOUGHT_COLOR}",
44
+ r"^Thought:": f"bold {_THOUGHT_COLOR}",
45
+ r"^Action:": f"bold {_ACTION_COLOR}",
46
+ r"^Arguments:": f"bold {_ACTION_COLOR}",
47
+ r"^Tool:": f"bold {_OBSERVATION_COLOR}",
48
+ r"^Result:": f"bold {_OBSERVATION_COLOR}",
49
+ r"^Rejection Reason:": f"bold {_ERROR_COLOR}",
50
+ # Markdown-style
51
+ r"\*\*(.*?)\*\*": "bold",
52
+ r"\*(.*?)\*": "italic",
53
+ }
54
+
55
+
56
+ class EventVisualizationConfig(BaseModel):
57
+ """Configuration for how to visualize an event type."""
58
+
59
+ title: str | Callable[[Event], str]
60
+ """The title to display for this event. Can be a string or callable."""
61
+
62
+ color: str | Callable[[Event], str]
63
+ """The Rich color to use for the title and rule. Can be a string or callable."""
64
+
65
+ show_metrics: bool = False
66
+ """Whether to show the metrics subtitle."""
67
+
68
+ indent_content: bool = False
69
+ """Whether to indent the content."""
70
+
71
+ skip: bool = False
72
+ """If True, skip visualization of this event type entirely."""
73
+
74
+ model_config = {"arbitrary_types_allowed": True}
75
+
76
+
77
+ def indent_content(content: Text, spaces: int = 4) -> Text:
78
+ """Indent content for visual hierarchy while preserving all formatting."""
79
+ prefix = " " * spaces
80
+ lines = content.split("\n")
81
+
82
+ indented = Text()
83
+ for i, line in enumerate(lines):
84
+ if i > 0:
85
+ indented.append("\n")
86
+ indented.append(prefix)
87
+ indented.append(line)
88
+
89
+ return indented
90
+
91
+
92
+ def section_header(title: str, color: str) -> Rule:
93
+ """Create a semantic divider with title."""
94
+ return Rule(
95
+ f"[{color} bold]{title}[/{color} bold]",
96
+ style=color,
97
+ characters="─",
98
+ align="left",
99
+ )
100
+
101
+
102
+ def build_event_block(
103
+ content: Text,
104
+ title: str,
105
+ title_color: str,
106
+ subtitle: str | None = None,
107
+ indent: bool = False,
108
+ ) -> Group:
109
+ """Build a complete event block with header, content, and optional subtitle."""
110
+ parts = []
111
+
112
+ # Header with rule
113
+ parts.append(section_header(title, title_color))
114
+ parts.append(Text()) # Blank line after header
115
+
116
+ # Content (optionally indented)
117
+ if indent:
118
+ parts.append(indent_content(content))
119
+ else:
120
+ parts.append(content)
121
+
122
+ # Subtitle (metrics) if provided
123
+ if subtitle:
124
+ parts.append(Text()) # Blank line before subtitle
125
+ subtitle_text = Text.from_markup(subtitle)
126
+ subtitle_text.stylize("dim")
127
+ parts.append(subtitle_text)
128
+
129
+ parts.append(Text()) # Blank line after block
130
+
131
+ return Group(*parts)
132
+
133
+
134
+ def _get_action_title(event: Event) -> str:
135
+ """Get title for ActionEvent based on whether action is None."""
136
+ if isinstance(event, ActionEvent):
137
+ return "Agent Action (Not Executed)" if event.action is None else "Agent Action"
138
+ return "Action"
139
+
140
+
141
+ def _get_message_title(event: Event) -> str:
142
+ """Get title for MessageEvent based on role."""
143
+ if isinstance(event, MessageEvent) and event.llm_message:
144
+ return (
145
+ "Message from User"
146
+ if event.llm_message.role == "user"
147
+ else "Message from Agent"
148
+ )
149
+ return "Message"
150
+
151
+
152
+ def _get_message_color(event: Event) -> str:
153
+ """Get color for MessageEvent based on role."""
154
+ if isinstance(event, MessageEvent) and event.llm_message:
155
+ return (
156
+ _MESSAGE_USER_COLOR
157
+ if event.llm_message.role == "user"
158
+ else _MESSAGE_ASSISTANT_COLOR
159
+ )
160
+ return "white"
161
+
162
+
163
+ # Event type to visualization configuration mapping
164
+ # This replaces the large isinstance chain with a cleaner lookup approach
165
+ EVENT_VISUALIZATION_CONFIG: dict[type[Event], EventVisualizationConfig] = {
166
+ SystemPromptEvent: EventVisualizationConfig(
167
+ title="System Prompt",
168
+ color=_SYSTEM_COLOR,
169
+ ),
170
+ ActionEvent: EventVisualizationConfig(
171
+ title=_get_action_title,
172
+ color=_ACTION_COLOR,
173
+ show_metrics=True,
174
+ ),
175
+ ObservationEvent: EventVisualizationConfig(
176
+ title="Observation",
177
+ color=_OBSERVATION_COLOR,
178
+ ),
179
+ UserRejectObservation: EventVisualizationConfig(
180
+ title="User Rejected Action",
181
+ color=_ERROR_COLOR,
182
+ ),
183
+ MessageEvent: EventVisualizationConfig(
184
+ title=_get_message_title,
185
+ color=_get_message_color,
186
+ show_metrics=True,
187
+ ),
188
+ AgentErrorEvent: EventVisualizationConfig(
189
+ title="Agent Error",
190
+ color=_ERROR_COLOR,
191
+ show_metrics=True,
192
+ ),
193
+ PauseEvent: EventVisualizationConfig(
194
+ title="User Paused",
195
+ color=_PAUSE_COLOR,
196
+ ),
197
+ Condensation: EventVisualizationConfig(
198
+ title="Condensation",
199
+ color="white",
200
+ show_metrics=True,
201
+ ),
202
+ CondensationRequest: EventVisualizationConfig(
203
+ title="Condensation Request",
204
+ color=_SYSTEM_COLOR,
205
+ ),
206
+ ConversationStateUpdateEvent: EventVisualizationConfig(
207
+ title="Conversation State Update",
208
+ color=_SYSTEM_COLOR,
209
+ skip=True,
210
+ ),
211
+ }
212
+
213
+
214
+ class DefaultConversationVisualizer(ConversationVisualizerBase):
215
+ """Handles visualization of conversation events with Rich formatting.
216
+
217
+ Provides Rich-formatted output with semantic dividers and complete content display.
218
+ """
219
+
220
+ _console: Console
221
+ _skip_user_messages: bool
222
+ _highlight_patterns: dict[str, str]
223
+
224
+ def __init__(
225
+ self,
226
+ highlight_regex: dict[str, str] | None = DEFAULT_HIGHLIGHT_REGEX,
227
+ skip_user_messages: bool = False,
228
+ ):
229
+ """Initialize the visualizer.
230
+
231
+ Args:
232
+ highlight_regex: Dictionary mapping regex patterns to Rich color styles
233
+ for highlighting keywords in the visualizer.
234
+ For example: {"Reasoning:": "bold blue",
235
+ "Thought:": "bold green"}
236
+ skip_user_messages: If True, skip displaying user messages. Useful for
237
+ scenarios where user input is not relevant to show.
238
+ """
239
+ super().__init__()
240
+ self._console = Console()
241
+ self._skip_user_messages = skip_user_messages
242
+ self._highlight_patterns = highlight_regex or {}
243
+
244
+ def on_event(self, event: Event) -> None:
245
+ """Main event handler that displays events with Rich formatting."""
246
+ output = self._create_event_block(event)
247
+ if output:
248
+ self._console.print(output)
249
+
250
+ def _apply_highlighting(self, text: Text) -> Text:
251
+ """Apply regex-based highlighting to text content.
252
+
253
+ Args:
254
+ text: The Rich Text object to highlight
255
+
256
+ Returns:
257
+ A new Text object with highlighting applied
258
+ """
259
+ if not self._highlight_patterns:
260
+ return text
261
+
262
+ # Create a copy to avoid modifying the original
263
+ highlighted = text.copy()
264
+
265
+ # Apply each pattern using Rich's built-in highlight_regex method
266
+ for pattern, style in self._highlight_patterns.items():
267
+ pattern_compiled = re.compile(pattern, re.MULTILINE)
268
+ highlighted.highlight_regex(pattern_compiled, style)
269
+
270
+ return highlighted
271
+
272
+ def _create_event_block(self, event: Event) -> Group | None:
273
+ """Create a Rich event block for the event with full detail."""
274
+ # Look up visualization config for this event type
275
+ config = EVENT_VISUALIZATION_CONFIG.get(type(event))
276
+
277
+ if not config:
278
+ # Warn about unknown event types and skip
279
+ logger.warning(
280
+ "Event type %s is not registered in EVENT_VISUALIZATION_CONFIG. "
281
+ "Skipping visualization.",
282
+ event.__class__.__name__,
283
+ )
284
+ return None
285
+
286
+ # Check if this event type should be skipped
287
+ if config.skip:
288
+ return None
289
+
290
+ # Check if we should skip user messages based on runtime configuration
291
+ if (
292
+ self._skip_user_messages
293
+ and isinstance(event, MessageEvent)
294
+ and event.llm_message
295
+ and event.llm_message.role == "user"
296
+ ):
297
+ return None
298
+
299
+ # Use the event's visualize property for content
300
+ content = event.visualize
301
+
302
+ if not content.plain.strip():
303
+ return None
304
+
305
+ # Apply highlighting if configured
306
+ if self._highlight_patterns:
307
+ content = self._apply_highlighting(content)
308
+
309
+ # Resolve title (may be a string or callable)
310
+ title = config.title(event) if callable(config.title) else config.title
311
+
312
+ # Resolve color (may be a string or callable)
313
+ title_color = config.color(event) if callable(config.color) else config.color
314
+
315
+ # Build subtitle if needed
316
+ subtitle = self._format_metrics_subtitle() if config.show_metrics else None
317
+
318
+ return build_event_block(
319
+ content=content,
320
+ title=title,
321
+ title_color=title_color,
322
+ subtitle=subtitle,
323
+ )
324
+
325
+ def _format_metrics_subtitle(self) -> str | None:
326
+ """Format LLM metrics as a visually appealing subtitle string with icons,
327
+ colors, and k/m abbreviations using conversation stats."""
328
+ stats = self.conversation_stats
329
+ if not stats:
330
+ return None
331
+
332
+ combined_metrics = stats.get_combined_metrics()
333
+ if not combined_metrics or not combined_metrics.accumulated_token_usage:
334
+ return None
335
+
336
+ usage = combined_metrics.accumulated_token_usage
337
+ cost = combined_metrics.accumulated_cost or 0.0
338
+
339
+ # helper: 1234 -> "1.2K", 1200000 -> "1.2M"
340
+ def abbr(n: int | float) -> str:
341
+ n = int(n or 0)
342
+ if n >= 1_000_000_000:
343
+ val, suffix = n / 1_000_000_000, "B"
344
+ elif n >= 1_000_000:
345
+ val, suffix = n / 1_000_000, "M"
346
+ elif n >= 1_000:
347
+ val, suffix = n / 1_000, "K"
348
+ else:
349
+ return str(n)
350
+ return f"{val:.2f}".rstrip("0").rstrip(".") + suffix
351
+
352
+ input_tokens = abbr(usage.prompt_tokens or 0)
353
+ output_tokens = abbr(usage.completion_tokens or 0)
354
+
355
+ # Cache hit rate (prompt + cache)
356
+ prompt = usage.prompt_tokens or 0
357
+ cache_read = usage.cache_read_tokens or 0
358
+ cache_rate = f"{(cache_read / prompt * 100):.2f}%" if prompt > 0 else "N/A"
359
+ reasoning_tokens = usage.reasoning_tokens or 0
360
+
361
+ # Cost
362
+ cost_str = f"{cost:.4f}" if cost > 0 else "0.00"
363
+
364
+ # Build with fixed color scheme
365
+ parts: list[str] = []
366
+ parts.append(f"[cyan]↑ input {input_tokens}[/cyan]")
367
+ parts.append(f"[magenta]cache hit {cache_rate}[/magenta]")
368
+ if reasoning_tokens > 0:
369
+ parts.append(f"[yellow] reasoning {abbr(reasoning_tokens)}[/yellow]")
370
+ parts.append(f"[blue]↓ output {output_tokens}[/blue]")
371
+ parts.append(f"[green]$ {cost_str}[/green]")
372
+
373
+ return "Tokens: " + " • ".join(parts)
@@ -0,0 +1,15 @@
1
+ from openhands.sdk.critic.base import CriticBase, CriticResult
2
+ from openhands.sdk.critic.impl import (
3
+ AgentFinishedCritic,
4
+ EmptyPatchCritic,
5
+ PassCritic,
6
+ )
7
+
8
+
9
+ __all__ = [
10
+ "CriticBase",
11
+ "CriticResult",
12
+ "AgentFinishedCritic",
13
+ "EmptyPatchCritic",
14
+ "PassCritic",
15
+ ]
@@ -0,0 +1,38 @@
1
+ import abc
2
+ from collections.abc import Sequence
3
+ from typing import ClassVar
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from openhands.sdk.event import LLMConvertibleEvent
8
+ from openhands.sdk.utils.models import DiscriminatedUnionMixin
9
+
10
+
11
+ class CriticResult(BaseModel):
12
+ """A critic result is a score and a message."""
13
+
14
+ THRESHOLD: ClassVar[float] = 0.5
15
+
16
+ score: float = Field(
17
+ description="A predicted probability of success between 0 and 1.",
18
+ ge=0.0,
19
+ le=1.0,
20
+ )
21
+ message: str | None = Field(description="An optional message explaining the score.")
22
+
23
+ @property
24
+ def success(self) -> bool:
25
+ """Whether the agent is successful."""
26
+ return self.score >= CriticResult.THRESHOLD
27
+
28
+
29
+ class CriticBase(DiscriminatedUnionMixin, abc.ABC):
30
+ """A critic is a function that takes in a list of events,
31
+ optional git patch, and returns a score about the quality of agent's action.
32
+ """
33
+
34
+ @abc.abstractmethod
35
+ def evaluate(
36
+ self, events: Sequence[LLMConvertibleEvent], git_patch: str | None = None
37
+ ) -> CriticResult:
38
+ pass
@@ -0,0 +1,12 @@
1
+ """Critic implementations module."""
2
+
3
+ from openhands.sdk.critic.impl.agent_finished import AgentFinishedCritic
4
+ from openhands.sdk.critic.impl.empty_patch import EmptyPatchCritic
5
+ from openhands.sdk.critic.impl.pass_critic import PassCritic
6
+
7
+
8
+ __all__ = [
9
+ "AgentFinishedCritic",
10
+ "EmptyPatchCritic",
11
+ "PassCritic",
12
+ ]
@@ -0,0 +1,83 @@
1
+ """
2
+ AgentFinishedCritic implementation.
3
+
4
+ This critic evaluates whether an agent properly finished a task by checking:
5
+ 1. The agent's last action was a FinishAction (proper completion)
6
+ 2. The generated git patch is non-empty (actual changes were made)
7
+ """
8
+
9
+ from collections.abc import Sequence
10
+
11
+ from openhands.sdk.critic.base import CriticBase, CriticResult
12
+ from openhands.sdk.event import ActionEvent, LLMConvertibleEvent
13
+ from openhands.sdk.logger import get_logger
14
+ from openhands.sdk.tool.builtins.finish import FinishAction
15
+
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class AgentFinishedCritic(CriticBase):
21
+ """
22
+ Critic that evaluates whether an agent properly finished a task.
23
+
24
+ This critic checks two main criteria:
25
+ 1. The agent's last action was a FinishAction (proper completion)
26
+ 2. The generated git patch is non-empty (actual changes were made)
27
+ """
28
+
29
+ def evaluate(
30
+ self, events: Sequence[LLMConvertibleEvent], git_patch: str | None = None
31
+ ) -> CriticResult:
32
+ """
33
+ Evaluate if an agent properly finished with a non-empty git patch.
34
+
35
+ Args:
36
+ events: List of events from the agent's execution
37
+ git_patch: Optional git patch generated by the agent
38
+
39
+ Returns:
40
+ CriticResult with score 1.0 if successful, 0.0 otherwise
41
+ """
42
+ reasons = []
43
+
44
+ # Check if git patch is non-empty
45
+ if not git_patch or not git_patch.strip():
46
+ reasons.append("Empty git patch")
47
+ logger.debug("AgentFinishedCritic: Empty git patch")
48
+ return CriticResult(
49
+ score=0.0,
50
+ message="Agent did not produce a non-empty git patch. "
51
+ + "; ".join(reasons),
52
+ )
53
+
54
+ # Check if agent properly finished with FinishAction
55
+ if not self._has_finish_action(events):
56
+ reasons.append("No FinishAction found")
57
+ logger.debug("AgentFinishedCritic: No FinishAction")
58
+ return CriticResult(
59
+ score=0.0,
60
+ message="Agent did not finish properly. " + "; ".join(reasons),
61
+ )
62
+
63
+ logger.debug("AgentFinishedCritic: Successfully completed")
64
+ return CriticResult(
65
+ score=1.0,
66
+ message="Agent completed with FinishAction and non-empty patch",
67
+ )
68
+
69
+ def _has_finish_action(self, events: Sequence[LLMConvertibleEvent]) -> bool:
70
+ """Check if the last action was a FinishAction."""
71
+ if not events:
72
+ return False
73
+
74
+ # Look for the last ActionEvent in the history
75
+ for event in reversed(events):
76
+ if isinstance(event, ActionEvent):
77
+ # Check if this is a FinishAction
78
+ if event.action and isinstance(event.action, FinishAction):
79
+ return True
80
+ # If we find any other action type, the agent didn't finish
81
+ return False
82
+
83
+ return False
@@ -0,0 +1,49 @@
1
+ """
2
+ EmptyPatchCritic implementation.
3
+
4
+ This critic only evaluates whether a git patch is non-empty.
5
+ Unlike AgentFinishedCritic, it does not check for proper agent completion.
6
+ """
7
+
8
+ from collections.abc import Sequence
9
+
10
+ from openhands.sdk.critic.base import CriticBase, CriticResult
11
+ from openhands.sdk.event import LLMConvertibleEvent
12
+ from openhands.sdk.logger import get_logger
13
+
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class EmptyPatchCritic(CriticBase):
19
+ """
20
+ Critic that only evaluates whether a git patch is non-empty.
21
+
22
+ This critic checks only one criterion:
23
+ - The generated git patch is non-empty (actual changes were made)
24
+
25
+ Unlike AgentFinishedCritic, this critic does not check for proper
26
+ agent completion with FinishAction.
27
+ """
28
+
29
+ def evaluate(
30
+ self,
31
+ events: Sequence[LLMConvertibleEvent], # noqa: ARG002
32
+ git_patch: str | None = None,
33
+ ) -> CriticResult:
34
+ """
35
+ Evaluate if a git patch is non-empty.
36
+
37
+ Args:
38
+ events: List of events from the agent's execution (not used)
39
+ git_patch: Optional git patch generated by the agent
40
+
41
+ Returns:
42
+ CriticResult with score 1.0 if patch is non-empty, 0.0 otherwise
43
+ """
44
+ if not git_patch or not git_patch.strip():
45
+ logger.debug("EmptyPatchCritic: Empty git patch")
46
+ return CriticResult(score=0.0, message="Git patch is empty or missing")
47
+
48
+ logger.debug("EmptyPatchCritic: Non-empty git patch found")
49
+ return CriticResult(score=1.0, message="Git patch is non-empty")
@@ -0,0 +1,42 @@
1
+ """
2
+ PassCritic implementation.
3
+
4
+ This critic always returns success, useful when no evaluation is needed
5
+ or when all instances should be considered successful.
6
+ """
7
+
8
+ from collections.abc import Sequence
9
+
10
+ from openhands.sdk.critic.base import CriticBase, CriticResult
11
+ from openhands.sdk.event import LLMConvertibleEvent
12
+ from openhands.sdk.logger import get_logger
13
+
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class PassCritic(CriticBase):
19
+ """
20
+ Critic that always returns success.
21
+
22
+ This critic can be used when no evaluation is needed or when
23
+ all instances should be considered successful regardless of their output.
24
+ """
25
+
26
+ def evaluate(
27
+ self,
28
+ events: Sequence[LLMConvertibleEvent], # noqa: ARG002
29
+ git_patch: str | None = None, # noqa: ARG002
30
+ ) -> CriticResult:
31
+ """
32
+ Always evaluate as successful.
33
+
34
+ Args:
35
+ events: List of events from the agent's execution (not used)
36
+ git_patch: Optional git patch generated by the agent (not used)
37
+
38
+ Returns:
39
+ CriticResult with score 1.0 (always successful)
40
+ """
41
+ logger.debug("PassCritic: Always returns success")
42
+ return CriticResult(score=1.0, message="PassCritic always succeeds")
@@ -0,0 +1,42 @@
1
+ from openhands.sdk.event.base import Event, LLMConvertibleEvent
2
+ from openhands.sdk.event.condenser import (
3
+ Condensation,
4
+ CondensationRequest,
5
+ CondensationSummaryEvent,
6
+ )
7
+ from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent
8
+ from openhands.sdk.event.llm_completion_log import LLMCompletionLogEvent
9
+ from openhands.sdk.event.llm_convertible import (
10
+ ActionEvent,
11
+ AgentErrorEvent,
12
+ MessageEvent,
13
+ ObservationBaseEvent,
14
+ ObservationEvent,
15
+ SystemPromptEvent,
16
+ UserRejectObservation,
17
+ )
18
+ from openhands.sdk.event.token import TokenEvent
19
+ from openhands.sdk.event.types import EventID, ToolCallID
20
+ from openhands.sdk.event.user_action import PauseEvent
21
+
22
+
23
+ __all__ = [
24
+ "Event",
25
+ "LLMConvertibleEvent",
26
+ "SystemPromptEvent",
27
+ "ActionEvent",
28
+ "TokenEvent",
29
+ "ObservationEvent",
30
+ "ObservationBaseEvent",
31
+ "MessageEvent",
32
+ "AgentErrorEvent",
33
+ "UserRejectObservation",
34
+ "PauseEvent",
35
+ "Condensation",
36
+ "CondensationRequest",
37
+ "CondensationSummaryEvent",
38
+ "ConversationStateUpdateEvent",
39
+ "LLMCompletionLogEvent",
40
+ "EventID",
41
+ "ToolCallID",
42
+ ]