openhands-sdk 1.7.3__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 (180) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +650 -0
  4. openhands/sdk/agent/base.py +457 -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 +2 -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 +228 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +264 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +100 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +14 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/condenser/utils.py +149 -0
  29. openhands/sdk/context/prompts/__init__.py +6 -0
  30. openhands/sdk/context/prompts/prompt.py +114 -0
  31. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  32. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  33. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  34. openhands/sdk/context/skills/__init__.py +28 -0
  35. openhands/sdk/context/skills/exceptions.py +11 -0
  36. openhands/sdk/context/skills/skill.py +720 -0
  37. openhands/sdk/context/skills/trigger.py +36 -0
  38. openhands/sdk/context/skills/types.py +48 -0
  39. openhands/sdk/context/view.py +503 -0
  40. openhands/sdk/conversation/__init__.py +40 -0
  41. openhands/sdk/conversation/base.py +281 -0
  42. openhands/sdk/conversation/conversation.py +152 -0
  43. openhands/sdk/conversation/conversation_stats.py +85 -0
  44. openhands/sdk/conversation/event_store.py +157 -0
  45. openhands/sdk/conversation/events_list_base.py +17 -0
  46. openhands/sdk/conversation/exceptions.py +50 -0
  47. openhands/sdk/conversation/fifo_lock.py +133 -0
  48. openhands/sdk/conversation/impl/__init__.py +5 -0
  49. openhands/sdk/conversation/impl/local_conversation.py +665 -0
  50. openhands/sdk/conversation/impl/remote_conversation.py +956 -0
  51. openhands/sdk/conversation/persistence_const.py +9 -0
  52. openhands/sdk/conversation/response_utils.py +41 -0
  53. openhands/sdk/conversation/secret_registry.py +126 -0
  54. openhands/sdk/conversation/serialization_diff.py +0 -0
  55. openhands/sdk/conversation/state.py +392 -0
  56. openhands/sdk/conversation/stuck_detector.py +311 -0
  57. openhands/sdk/conversation/title_utils.py +191 -0
  58. openhands/sdk/conversation/types.py +45 -0
  59. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  60. openhands/sdk/conversation/visualizer/base.py +67 -0
  61. openhands/sdk/conversation/visualizer/default.py +373 -0
  62. openhands/sdk/critic/__init__.py +15 -0
  63. openhands/sdk/critic/base.py +38 -0
  64. openhands/sdk/critic/impl/__init__.py +12 -0
  65. openhands/sdk/critic/impl/agent_finished.py +83 -0
  66. openhands/sdk/critic/impl/empty_patch.py +49 -0
  67. openhands/sdk/critic/impl/pass_critic.py +42 -0
  68. openhands/sdk/event/__init__.py +42 -0
  69. openhands/sdk/event/base.py +149 -0
  70. openhands/sdk/event/condenser.py +82 -0
  71. openhands/sdk/event/conversation_error.py +25 -0
  72. openhands/sdk/event/conversation_state.py +104 -0
  73. openhands/sdk/event/llm_completion_log.py +39 -0
  74. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  75. openhands/sdk/event/llm_convertible/action.py +139 -0
  76. openhands/sdk/event/llm_convertible/message.py +142 -0
  77. openhands/sdk/event/llm_convertible/observation.py +141 -0
  78. openhands/sdk/event/llm_convertible/system.py +61 -0
  79. openhands/sdk/event/token.py +16 -0
  80. openhands/sdk/event/types.py +11 -0
  81. openhands/sdk/event/user_action.py +21 -0
  82. openhands/sdk/git/exceptions.py +43 -0
  83. openhands/sdk/git/git_changes.py +249 -0
  84. openhands/sdk/git/git_diff.py +129 -0
  85. openhands/sdk/git/models.py +21 -0
  86. openhands/sdk/git/utils.py +189 -0
  87. openhands/sdk/hooks/__init__.py +30 -0
  88. openhands/sdk/hooks/config.py +180 -0
  89. openhands/sdk/hooks/conversation_hooks.py +227 -0
  90. openhands/sdk/hooks/executor.py +155 -0
  91. openhands/sdk/hooks/manager.py +170 -0
  92. openhands/sdk/hooks/types.py +40 -0
  93. openhands/sdk/io/__init__.py +6 -0
  94. openhands/sdk/io/base.py +48 -0
  95. openhands/sdk/io/cache.py +85 -0
  96. openhands/sdk/io/local.py +119 -0
  97. openhands/sdk/io/memory.py +54 -0
  98. openhands/sdk/llm/__init__.py +45 -0
  99. openhands/sdk/llm/exceptions/__init__.py +45 -0
  100. openhands/sdk/llm/exceptions/classifier.py +50 -0
  101. openhands/sdk/llm/exceptions/mapping.py +54 -0
  102. openhands/sdk/llm/exceptions/types.py +101 -0
  103. openhands/sdk/llm/llm.py +1140 -0
  104. openhands/sdk/llm/llm_registry.py +122 -0
  105. openhands/sdk/llm/llm_response.py +59 -0
  106. openhands/sdk/llm/message.py +656 -0
  107. openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
  108. openhands/sdk/llm/mixins/non_native_fc.py +97 -0
  109. openhands/sdk/llm/options/__init__.py +1 -0
  110. openhands/sdk/llm/options/chat_options.py +93 -0
  111. openhands/sdk/llm/options/common.py +19 -0
  112. openhands/sdk/llm/options/responses_options.py +67 -0
  113. openhands/sdk/llm/router/__init__.py +10 -0
  114. openhands/sdk/llm/router/base.py +117 -0
  115. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  116. openhands/sdk/llm/router/impl/random.py +22 -0
  117. openhands/sdk/llm/streaming.py +9 -0
  118. openhands/sdk/llm/utils/metrics.py +312 -0
  119. openhands/sdk/llm/utils/model_features.py +192 -0
  120. openhands/sdk/llm/utils/model_info.py +90 -0
  121. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  122. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  123. openhands/sdk/llm/utils/telemetry.py +362 -0
  124. openhands/sdk/llm/utils/unverified_models.py +156 -0
  125. openhands/sdk/llm/utils/verified_models.py +65 -0
  126. openhands/sdk/logger/__init__.py +22 -0
  127. openhands/sdk/logger/logger.py +195 -0
  128. openhands/sdk/logger/rolling.py +113 -0
  129. openhands/sdk/mcp/__init__.py +24 -0
  130. openhands/sdk/mcp/client.py +76 -0
  131. openhands/sdk/mcp/definition.py +106 -0
  132. openhands/sdk/mcp/exceptions.py +19 -0
  133. openhands/sdk/mcp/tool.py +270 -0
  134. openhands/sdk/mcp/utils.py +83 -0
  135. openhands/sdk/observability/__init__.py +4 -0
  136. openhands/sdk/observability/laminar.py +166 -0
  137. openhands/sdk/observability/utils.py +20 -0
  138. openhands/sdk/py.typed +0 -0
  139. openhands/sdk/secret/__init__.py +19 -0
  140. openhands/sdk/secret/secrets.py +92 -0
  141. openhands/sdk/security/__init__.py +6 -0
  142. openhands/sdk/security/analyzer.py +111 -0
  143. openhands/sdk/security/confirmation_policy.py +61 -0
  144. openhands/sdk/security/llm_analyzer.py +29 -0
  145. openhands/sdk/security/risk.py +100 -0
  146. openhands/sdk/tool/__init__.py +34 -0
  147. openhands/sdk/tool/builtins/__init__.py +34 -0
  148. openhands/sdk/tool/builtins/finish.py +106 -0
  149. openhands/sdk/tool/builtins/think.py +117 -0
  150. openhands/sdk/tool/registry.py +184 -0
  151. openhands/sdk/tool/schema.py +286 -0
  152. openhands/sdk/tool/spec.py +39 -0
  153. openhands/sdk/tool/tool.py +481 -0
  154. openhands/sdk/utils/__init__.py +22 -0
  155. openhands/sdk/utils/async_executor.py +115 -0
  156. openhands/sdk/utils/async_utils.py +39 -0
  157. openhands/sdk/utils/cipher.py +68 -0
  158. openhands/sdk/utils/command.py +90 -0
  159. openhands/sdk/utils/deprecation.py +166 -0
  160. openhands/sdk/utils/github.py +44 -0
  161. openhands/sdk/utils/json.py +48 -0
  162. openhands/sdk/utils/models.py +570 -0
  163. openhands/sdk/utils/paging.py +63 -0
  164. openhands/sdk/utils/pydantic_diff.py +85 -0
  165. openhands/sdk/utils/pydantic_secrets.py +64 -0
  166. openhands/sdk/utils/truncate.py +117 -0
  167. openhands/sdk/utils/visualize.py +58 -0
  168. openhands/sdk/workspace/__init__.py +17 -0
  169. openhands/sdk/workspace/base.py +158 -0
  170. openhands/sdk/workspace/local.py +189 -0
  171. openhands/sdk/workspace/models.py +35 -0
  172. openhands/sdk/workspace/remote/__init__.py +8 -0
  173. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  174. openhands/sdk/workspace/remote/base.py +164 -0
  175. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  176. openhands/sdk/workspace/workspace.py +49 -0
  177. openhands_sdk-1.7.3.dist-info/METADATA +17 -0
  178. openhands_sdk-1.7.3.dist-info/RECORD +180 -0
  179. openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
  180. openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,311 @@
1
+ from openhands.sdk.conversation.state import ConversationState
2
+ from openhands.sdk.conversation.types import StuckDetectionThresholds
3
+ from openhands.sdk.event import (
4
+ ActionEvent,
5
+ AgentErrorEvent,
6
+ CondensationSummaryEvent,
7
+ Event,
8
+ MessageEvent,
9
+ ObservationBaseEvent,
10
+ ObservationEvent,
11
+ )
12
+ from openhands.sdk.logger import get_logger
13
+
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class StuckDetector:
19
+ """Detects when an agent is stuck in repetitive or unproductive patterns.
20
+
21
+ This detector analyzes the conversation history to identify various stuck patterns:
22
+ 1. Repeating action-observation cycles
23
+ 2. Repeating action-error cycles
24
+ 3. Agent monologue (repeated messages without user input)
25
+ 4. Repeating alternating action-observation patterns
26
+ 5. Context window errors indicating memory issues
27
+ """
28
+
29
+ state: ConversationState
30
+ thresholds: StuckDetectionThresholds
31
+
32
+ def __init__(
33
+ self,
34
+ state: ConversationState,
35
+ thresholds: StuckDetectionThresholds | None = None,
36
+ ):
37
+ self.state = state
38
+ self.thresholds = thresholds or StuckDetectionThresholds()
39
+
40
+ @property
41
+ def action_observation_threshold(self) -> int:
42
+ return self.thresholds.action_observation
43
+
44
+ @property
45
+ def action_error_threshold(self) -> int:
46
+ return self.thresholds.action_error
47
+
48
+ @property
49
+ def monologue_threshold(self) -> int:
50
+ return self.thresholds.monologue
51
+
52
+ @property
53
+ def alternating_pattern_threshold(self) -> int:
54
+ return self.thresholds.alternating_pattern
55
+
56
+ def is_stuck(self) -> bool:
57
+ """Check if the agent is currently stuck."""
58
+ events = list(self.state.events)
59
+
60
+ # Only look at history after the last user message
61
+ last_user_msg_index = next(
62
+ (
63
+ i
64
+ for i in reversed(range(len(events)))
65
+ if isinstance(events[i], MessageEvent) and events[i].source == "user"
66
+ ),
67
+ -1, # Default to -1 if no user message found
68
+ )
69
+ if last_user_msg_index == -1:
70
+ logger.warning("No user message found in history, skipping stuck detection")
71
+ return False
72
+
73
+ events = events[last_user_msg_index + 1 :]
74
+
75
+ # Determine minimum events needed
76
+ min_threshold = min(
77
+ self.action_observation_threshold,
78
+ self.action_error_threshold,
79
+ self.monologue_threshold,
80
+ )
81
+ if len(events) < min_threshold:
82
+ return False
83
+
84
+ logger.debug(f"Checking for stuck patterns in {len(events)} events")
85
+ logger.debug(
86
+ f"Events after last user message: {[type(e).__name__ for e in events]}"
87
+ )
88
+
89
+ # Collect enough actions and observations for detection
90
+ max_needed = max(self.action_observation_threshold, self.action_error_threshold)
91
+ last_actions: list[Event] = []
92
+ last_observations: list[Event] = []
93
+
94
+ # Retrieve the last N actions and observations from the end of history
95
+ for event in reversed(events):
96
+ if isinstance(event, ActionEvent) and len(last_actions) < max_needed:
97
+ last_actions.append(event)
98
+ elif (
99
+ isinstance(event, ObservationBaseEvent)
100
+ and len(last_observations) < max_needed
101
+ ):
102
+ last_observations.append(event)
103
+ if len(last_actions) >= max_needed and len(last_observations) >= max_needed:
104
+ break
105
+
106
+ # Check all stuck patterns
107
+ # scenario 1: same action, same observation
108
+ if self._is_stuck_repeating_action_observation(last_actions, last_observations):
109
+ return True
110
+
111
+ # scenario 2: same action, errors
112
+ if self._is_stuck_repeating_action_error(last_actions, last_observations):
113
+ return True
114
+
115
+ # scenario 3: monologue
116
+ if self._is_stuck_monologue(events):
117
+ return True
118
+
119
+ # scenario 4: action, observation alternating pattern
120
+ if len(events) >= self.alternating_pattern_threshold:
121
+ if self._is_stuck_alternating_action_observation(events):
122
+ return True
123
+
124
+ # scenario 5: context window error loop
125
+ if len(events) >= 10:
126
+ if self._is_stuck_context_window_error(events):
127
+ return True
128
+
129
+ return False
130
+
131
+ def _is_stuck_repeating_action_observation(
132
+ self, last_actions: list[Event], last_observations: list[Event]
133
+ ) -> bool:
134
+ # scenario 1: same action, same observation
135
+ threshold = self.action_observation_threshold
136
+
137
+ # Check for a loop of identical action-observation pairs
138
+ if len(last_actions) >= threshold and len(last_observations) >= threshold:
139
+ logger.debug(
140
+ f"Found {len(last_actions)} actions and "
141
+ f"{len(last_observations)} observations, checking for equality"
142
+ )
143
+ actions_equal = all(
144
+ self._event_eq(last_actions[0], action)
145
+ for action in last_actions[:threshold]
146
+ )
147
+ observations_equal = all(
148
+ self._event_eq(last_observations[0], observation)
149
+ for observation in last_observations[:threshold]
150
+ )
151
+ logger.debug(
152
+ f"Actions equal: {actions_equal}, "
153
+ f"Observations equal: {observations_equal}"
154
+ )
155
+
156
+ if actions_equal and observations_equal:
157
+ logger.warning("Action, Observation loop detected")
158
+ return True
159
+ else:
160
+ logger.debug(
161
+ f"Not enough actions/observations: {len(last_actions)} actions,"
162
+ f" {len(last_observations)} observations"
163
+ )
164
+
165
+ return False
166
+
167
+ def _is_stuck_repeating_action_error(
168
+ self, last_actions: list[Event], last_observations: list[Event]
169
+ ) -> bool:
170
+ # scenario 2: same action, errors
171
+ threshold = self.action_error_threshold
172
+ if len(last_actions) < threshold or len(last_observations) < threshold:
173
+ return False
174
+
175
+ # are the last N actions the "same"?
176
+ if all(
177
+ self._event_eq(last_actions[0], action)
178
+ for action in last_actions[:threshold]
179
+ ):
180
+ # and the last N observations are all errors?
181
+ if all(
182
+ isinstance(obs, AgentErrorEvent)
183
+ for obs in last_observations[:threshold]
184
+ ):
185
+ logger.warning("Action, Error loop detected")
186
+ return True
187
+
188
+ # Check if observations are errors
189
+ return False
190
+
191
+ def _is_stuck_monologue(self, events: list[Event]) -> bool:
192
+ # scenario 3: monologue
193
+ # check for repeated MessageActions with source=AGENT
194
+ # see if the agent is engaged in a good old monologue, telling
195
+ # itself the same thing over and over
196
+ threshold = self.monologue_threshold
197
+ if len(events) < threshold:
198
+ return False
199
+
200
+ # Look for N consecutive agent messages without user interruption
201
+ agent_message_count = 0
202
+
203
+ for event in reversed(events):
204
+ if isinstance(event, MessageEvent):
205
+ if event.source == "agent":
206
+ agent_message_count += 1
207
+ elif event.source == "user":
208
+ break # User interrupted, not a monologue
209
+ elif isinstance(event, CondensationSummaryEvent):
210
+ # Condensation events don't break the monologue pattern
211
+ continue
212
+ else:
213
+ # Other events (actions/observations) don't count as monologue
214
+ break
215
+
216
+ return agent_message_count >= threshold
217
+
218
+ def _is_stuck_alternating_action_observation(self, events: list[Event]) -> bool:
219
+ # scenario 4: alternating action-observation loop
220
+ threshold = self.alternating_pattern_threshold
221
+
222
+ last_actions: list[Event] = []
223
+ last_observations: list[Event] = []
224
+
225
+ # collect most recent N actions and N observations
226
+ for event in reversed(events):
227
+ if isinstance(event, ActionEvent) and len(last_actions) < threshold:
228
+ last_actions.append(event)
229
+ elif (
230
+ isinstance(event, (ObservationEvent, AgentErrorEvent))
231
+ and len(last_observations) < threshold
232
+ ):
233
+ last_observations.append(event)
234
+
235
+ if len(last_actions) == threshold and len(last_observations) == threshold:
236
+ break
237
+
238
+ if len(last_actions) == threshold and len(last_observations) == threshold:
239
+ # Check alternating pattern: [A, B, A, B, A, B] where even/odd match
240
+ actions_equal = all(
241
+ self._event_eq(last_actions[i], last_actions[i + 2])
242
+ for i in range(threshold - 2)
243
+ )
244
+ observations_equal = all(
245
+ self._event_eq(last_observations[i], last_observations[i + 2])
246
+ for i in range(threshold - 2)
247
+ )
248
+
249
+ if actions_equal and observations_equal:
250
+ logger.warning("Alternating Action, Observation loop detected")
251
+ return True
252
+
253
+ return False
254
+
255
+ def _is_stuck_context_window_error(self, _events: list[Event]) -> bool:
256
+ """Detects if we're stuck in a loop of context window errors.
257
+
258
+ This happens when we repeatedly get context window errors and try to trim,
259
+ but the trimming doesn't work, causing us to get more context window errors.
260
+ The pattern is repeated AgentCondensationObservation events without any other
261
+ events between them.
262
+ """
263
+ # TODO: blocked by https://github.com/OpenHands/agent-sdk/issues/282
264
+ return False
265
+
266
+ def _event_eq(self, event1: Event, event2: Event) -> bool:
267
+ """
268
+ Compare two events for equality, ignoring irrelevant
269
+ details like ids, metrics.
270
+ """
271
+ # Must be same type
272
+ if type(event1) is not type(event2):
273
+ return False
274
+
275
+ # For ActionEvents, compare the action content, ignoring IDs
276
+ if isinstance(event1, ActionEvent) and isinstance(event2, ActionEvent):
277
+ return (
278
+ event1.source == event2.source
279
+ and event1.thought == event2.thought
280
+ and event1.action == event2.action
281
+ and event1.tool_name == event2.tool_name
282
+ # Ignore tool_call_id, llm_response_id, action_id as they vary
283
+ )
284
+
285
+ # For ObservationEvents, compare the observation content, ignoring IDs
286
+ if isinstance(event1, ObservationEvent) and isinstance(
287
+ event2, ObservationEvent
288
+ ):
289
+ return (
290
+ event1.source == event2.source
291
+ and event1.observation == event2.observation
292
+ and event1.tool_name == event2.tool_name
293
+ # Ignore action_id, tool_call_id as they vary
294
+ )
295
+
296
+ # For AgentErrorEvents, compare the error content
297
+ if isinstance(event1, AgentErrorEvent) and isinstance(event2, AgentErrorEvent):
298
+ return (
299
+ event1.source == event2.source and event1.error == event2.error
300
+ # Ignore action_id as it varies
301
+ )
302
+
303
+ # For MessageEvents, compare the message content
304
+ if isinstance(event1, MessageEvent) and isinstance(event2, MessageEvent):
305
+ return (
306
+ event1.source == event2.source
307
+ and event1.llm_message == event2.llm_message
308
+ )
309
+
310
+ # Default fallback
311
+ return event1 == event2
@@ -0,0 +1,191 @@
1
+ """Utility functions for generating conversation titles."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from openhands.sdk.event import MessageEvent
6
+ from openhands.sdk.event.base import Event
7
+ from openhands.sdk.llm import LLM, Message, TextContent
8
+ from openhands.sdk.logger import get_logger
9
+
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ categories = [
15
+ {"emoji": "💄", "name": "frontend", "description": "UI and style files"},
16
+ {"emoji": "👔", "name": "backend", "description": "Business logic"},
17
+ {"emoji": "✅", "name": "test", "description": "Tests"},
18
+ {"emoji": "👷", "name": "devops", "description": "CI build system"},
19
+ {"emoji": "🚀", "name": "deployment", "description": "Deploy stuff"},
20
+ {"emoji": "📦️", "name": "dependencies", "description": "Packages and dependencies"},
21
+ {"emoji": "🗃️", "name": "database", "description": "Database changes"},
22
+ {"emoji": "🔧", "name": "chores", "description": "Configuration and maintenance"},
23
+ {"emoji": "✨", "name": "features", "description": "New features"},
24
+ {"emoji": "🐛", "name": "bugfix", "description": "Bug fixes"},
25
+ {"emoji": "⚡️", "name": "performance", "description": "Performance improvements"},
26
+ {"emoji": "🔒️", "name": "security", "description": "Security fixes"},
27
+ {"emoji": "📝", "name": "documentation", "description": "Documentation"},
28
+ {"emoji": "♻️", "name": "refactor", "description": "Code refactoring"},
29
+ ]
30
+
31
+
32
+ def extract_first_user_message(events: Sequence[Event]) -> str | None:
33
+ """Extract the first user message from conversation events.
34
+
35
+ Args:
36
+ events: List of conversation events.
37
+
38
+ Returns:
39
+ The first user message text, or None if no user message is found.
40
+ """
41
+ for event in events:
42
+ if (
43
+ isinstance(event, MessageEvent)
44
+ and event.source == "user"
45
+ and event.llm_message.content
46
+ ):
47
+ # Extract text content from the message
48
+ text_parts = []
49
+ for content in event.llm_message.content:
50
+ if isinstance(content, TextContent):
51
+ text_parts.append(content.text)
52
+
53
+ if text_parts:
54
+ return " ".join(text_parts).strip()
55
+
56
+ return None
57
+
58
+
59
+ def generate_title_with_llm(message: str, llm: LLM, max_length: int = 50) -> str | None:
60
+ """Generate a conversation title using LLM.
61
+
62
+ Args:
63
+ message: The first user message to generate title from.
64
+ llm: The LLM to use for title generation.
65
+ max_length: Maximum length of the generated title.
66
+
67
+ Returns:
68
+ Generated title, or None if LLM fails or returns empty response.
69
+ """
70
+ # Truncate very long messages to avoid excessive token usage
71
+ if len(message) > 1000:
72
+ truncated_message = message[:1000] + "...(truncated)"
73
+ else:
74
+ truncated_message = message
75
+
76
+ emojis_descriptions = "\n- ".join(
77
+ f"{c['emoji']} {c['name']}: {c['description']}" for c in categories
78
+ )
79
+
80
+ try:
81
+ # Create messages for the LLM to generate a title
82
+ messages = [
83
+ Message(
84
+ role="system",
85
+ content=[
86
+ TextContent(
87
+ text=(
88
+ "You are a helpful assistant that generates concise, "
89
+ "descriptive titles for conversations with OpenHands. "
90
+ "OpenHands is a helpful AI agent that can interact "
91
+ "with a computer to solve tasks using bash terminal, "
92
+ "file editor, and browser. Given a user message "
93
+ "(which may be truncated), generate a concise, "
94
+ "descriptive title for the conversation. Return only "
95
+ "the title, with no additional text, quotes, or "
96
+ "explanations."
97
+ )
98
+ )
99
+ ],
100
+ ),
101
+ Message(
102
+ role="user",
103
+ content=[
104
+ TextContent(
105
+ text=(
106
+ f"Generate a title (maximum {max_length} characters) "
107
+ f"for a conversation that starts with this message:\n\n"
108
+ f"{truncated_message}."
109
+ "Also make sure to include ONE most relevant emoji at "
110
+ "the start of the title."
111
+ f" Choose the emoji from this list:{emojis_descriptions} "
112
+ )
113
+ )
114
+ ],
115
+ ),
116
+ ]
117
+
118
+ # Get completion from LLM
119
+ response = llm.completion(messages)
120
+
121
+ # Extract the title from the response
122
+ if response.message.content and isinstance(
123
+ response.message.content[0], TextContent
124
+ ):
125
+ title = response.message.content[0].text.strip()
126
+
127
+ # Ensure the title isn't too long
128
+ if len(title) > max_length:
129
+ title = title[: max_length - 3] + "..."
130
+
131
+ return title
132
+ else:
133
+ logger.warning("LLM returned empty response for title generation")
134
+ return None
135
+
136
+ except Exception as e:
137
+ logger.warning(f"Error generating conversation title with LLM: {e}")
138
+ return None
139
+
140
+
141
+ def generate_fallback_title(message: str, max_length: int = 50) -> str:
142
+ """Generate a fallback title by truncating the first user message.
143
+
144
+ Args:
145
+ message: The first user message.
146
+ max_length: Maximum length of the title.
147
+
148
+ Returns:
149
+ A truncated title.
150
+ """
151
+ title = message.strip()
152
+ if len(title) > max_length:
153
+ title = title[: max_length - 3] + "..."
154
+ return title
155
+
156
+
157
+ def generate_conversation_title(
158
+ events: Sequence[Event], llm: LLM | None = None, max_length: int = 50
159
+ ) -> str:
160
+ """Generate a title for a conversation based on the first user message.
161
+
162
+ This is the main utility function that orchestrates the title generation process:
163
+ 1. Extract the first user message from events
164
+ 2. Try to generate title using LLM
165
+ 3. Fall back to simple truncation if LLM fails
166
+
167
+ Args:
168
+ events: List of conversation events.
169
+ llm: Optional LLM to use for title generation.
170
+ max_length: Maximum length of the generated title.
171
+
172
+ Returns:
173
+ A generated title for the conversation.
174
+
175
+ Raises:
176
+ ValueError: If no user messages are found in the conversation events.
177
+ """
178
+ # Find the first user message in the events
179
+ first_user_message = extract_first_user_message(events)
180
+
181
+ if not first_user_message:
182
+ raise ValueError("No user messages found in conversation events")
183
+
184
+ # Try to generate title with LLM if provided
185
+ if llm:
186
+ llm_title = generate_title_with_llm(first_user_message, llm, max_length)
187
+ if llm_title:
188
+ return llm_title
189
+
190
+ # Fall back to simple truncation
191
+ return generate_fallback_title(first_user_message, max_length)
@@ -0,0 +1,45 @@
1
+ import uuid
2
+ from collections.abc import Callable
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from openhands.sdk.event.base import Event
7
+ from openhands.sdk.llm.streaming import TokenCallbackType
8
+
9
+
10
+ ConversationCallbackType = Callable[[Event], None]
11
+ """Type alias for event callback functions."""
12
+
13
+ ConversationTokenCallbackType = TokenCallbackType
14
+ """Callback type invoked for streaming LLM deltas."""
15
+
16
+ ConversationID = uuid.UUID
17
+ """Type alias for conversation IDs."""
18
+
19
+
20
+ class StuckDetectionThresholds(BaseModel):
21
+ """Configuration for stuck detection thresholds.
22
+
23
+ Attributes:
24
+ action_observation: Number of repetitions before triggering
25
+ action-observation loop detection
26
+ action_error: Number of repetitions before triggering
27
+ action-error loop detection
28
+ monologue: Number of consecutive agent messages before triggering
29
+ monologue detection
30
+ alternating_pattern: Number of repetitions before triggering
31
+ alternating pattern detection
32
+ """
33
+
34
+ action_observation: int = Field(
35
+ default=4, ge=1, description="Threshold for action-observation loop detection"
36
+ )
37
+ action_error: int = Field(
38
+ default=3, ge=1, description="Threshold for action-error loop detection"
39
+ )
40
+ monologue: int = Field(
41
+ default=3, ge=1, description="Threshold for agent monologue detection"
42
+ )
43
+ alternating_pattern: int = Field(
44
+ default=6, ge=1, description="Threshold for alternating pattern detection"
45
+ )
@@ -0,0 +1,12 @@
1
+ from openhands.sdk.conversation.visualizer.base import (
2
+ ConversationVisualizerBase,
3
+ )
4
+ from openhands.sdk.conversation.visualizer.default import (
5
+ DefaultConversationVisualizer,
6
+ )
7
+
8
+
9
+ __all__ = [
10
+ "ConversationVisualizerBase",
11
+ "DefaultConversationVisualizer",
12
+ ]
@@ -0,0 +1,67 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import TYPE_CHECKING, final
3
+
4
+ from openhands.sdk.event.base import Event
5
+
6
+
7
+ if TYPE_CHECKING:
8
+ from openhands.sdk.conversation.base import ConversationStateProtocol
9
+ from openhands.sdk.conversation.conversation_stats import ConversationStats
10
+
11
+
12
+ class ConversationVisualizerBase(ABC):
13
+ """Base class for conversation visualizers.
14
+
15
+ This abstract base class defines the interface that all conversation visualizers
16
+ must implement. Visualizers can be created before the Conversation is initialized
17
+ and will be configured with the conversation state automatically.
18
+
19
+ The typical usage pattern:
20
+ 1. Create a visualizer instance:
21
+ `viz = MyVisualizer()`
22
+ 2. Pass it to Conversation: `conv = Conversation(agent, visualizer=viz)`
23
+ 3. Conversation automatically calls `viz.initialize(state)` to attach the state
24
+
25
+ You can also pass the uninstantiated class if you don't need extra args
26
+ for initialization, and Conversation will create it:
27
+ `conv = Conversation(agent, visualizer=MyVisualizer)`
28
+ Conversation will then calls `MyVisualizer()` followed by `initialize(state)`
29
+ """
30
+
31
+ _state: "ConversationStateProtocol | None"
32
+
33
+ def __init__(self):
34
+ """Initialize the visualizer base."""
35
+ self._state = None
36
+
37
+ @final
38
+ def initialize(self, state: "ConversationStateProtocol") -> None:
39
+ """Initialize the visualizer with conversation state.
40
+
41
+ This method is called by Conversation after the state is created,
42
+ allowing the visualizer to access conversation stats and other
43
+ state information.
44
+
45
+ Subclasses should not override this method, to ensure the state is set.
46
+
47
+ Args:
48
+ state: The conversation state object
49
+ """
50
+ self._state = state
51
+
52
+ @property
53
+ def conversation_stats(self) -> "ConversationStats | None":
54
+ """Get conversation stats from the state."""
55
+ return self._state.stats if self._state else None
56
+
57
+ @abstractmethod
58
+ def on_event(self, event: Event) -> None:
59
+ """Handle a conversation event.
60
+
61
+ This method is called for each event in the conversation and should
62
+ implement the visualization logic.
63
+
64
+ Args:
65
+ event: The event to visualize
66
+ """
67
+ pass