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.
- openhands/sdk/__init__.py +111 -0
- openhands/sdk/agent/__init__.py +8 -0
- openhands/sdk/agent/agent.py +650 -0
- openhands/sdk/agent/base.py +457 -0
- openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
- openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
- openhands/sdk/agent/prompts/security_policy.j2 +22 -0
- openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
- openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
- openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
- openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
- openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
- openhands/sdk/agent/utils.py +228 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +264 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +100 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
- openhands/sdk/context/condenser/no_op_condenser.py +14 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/condenser/utils.py +149 -0
- openhands/sdk/context/prompts/__init__.py +6 -0
- openhands/sdk/context/prompts/prompt.py +114 -0
- openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
- openhands/sdk/context/skills/__init__.py +28 -0
- openhands/sdk/context/skills/exceptions.py +11 -0
- openhands/sdk/context/skills/skill.py +720 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +503 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +152 -0
- openhands/sdk/conversation/conversation_stats.py +85 -0
- openhands/sdk/conversation/event_store.py +157 -0
- openhands/sdk/conversation/events_list_base.py +17 -0
- openhands/sdk/conversation/exceptions.py +50 -0
- openhands/sdk/conversation/fifo_lock.py +133 -0
- openhands/sdk/conversation/impl/__init__.py +5 -0
- openhands/sdk/conversation/impl/local_conversation.py +665 -0
- openhands/sdk/conversation/impl/remote_conversation.py +956 -0
- openhands/sdk/conversation/persistence_const.py +9 -0
- openhands/sdk/conversation/response_utils.py +41 -0
- openhands/sdk/conversation/secret_registry.py +126 -0
- openhands/sdk/conversation/serialization_diff.py +0 -0
- openhands/sdk/conversation/state.py +392 -0
- openhands/sdk/conversation/stuck_detector.py +311 -0
- openhands/sdk/conversation/title_utils.py +191 -0
- openhands/sdk/conversation/types.py +45 -0
- openhands/sdk/conversation/visualizer/__init__.py +12 -0
- openhands/sdk/conversation/visualizer/base.py +67 -0
- openhands/sdk/conversation/visualizer/default.py +373 -0
- openhands/sdk/critic/__init__.py +15 -0
- openhands/sdk/critic/base.py +38 -0
- openhands/sdk/critic/impl/__init__.py +12 -0
- openhands/sdk/critic/impl/agent_finished.py +83 -0
- openhands/sdk/critic/impl/empty_patch.py +49 -0
- openhands/sdk/critic/impl/pass_critic.py +42 -0
- openhands/sdk/event/__init__.py +42 -0
- openhands/sdk/event/base.py +149 -0
- openhands/sdk/event/condenser.py +82 -0
- openhands/sdk/event/conversation_error.py +25 -0
- openhands/sdk/event/conversation_state.py +104 -0
- openhands/sdk/event/llm_completion_log.py +39 -0
- openhands/sdk/event/llm_convertible/__init__.py +20 -0
- openhands/sdk/event/llm_convertible/action.py +139 -0
- openhands/sdk/event/llm_convertible/message.py +142 -0
- openhands/sdk/event/llm_convertible/observation.py +141 -0
- openhands/sdk/event/llm_convertible/system.py +61 -0
- openhands/sdk/event/token.py +16 -0
- openhands/sdk/event/types.py +11 -0
- openhands/sdk/event/user_action.py +21 -0
- openhands/sdk/git/exceptions.py +43 -0
- openhands/sdk/git/git_changes.py +249 -0
- openhands/sdk/git/git_diff.py +129 -0
- openhands/sdk/git/models.py +21 -0
- openhands/sdk/git/utils.py +189 -0
- openhands/sdk/hooks/__init__.py +30 -0
- openhands/sdk/hooks/config.py +180 -0
- openhands/sdk/hooks/conversation_hooks.py +227 -0
- openhands/sdk/hooks/executor.py +155 -0
- openhands/sdk/hooks/manager.py +170 -0
- openhands/sdk/hooks/types.py +40 -0
- openhands/sdk/io/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/cache.py +85 -0
- openhands/sdk/io/local.py +119 -0
- openhands/sdk/io/memory.py +54 -0
- openhands/sdk/llm/__init__.py +45 -0
- openhands/sdk/llm/exceptions/__init__.py +45 -0
- openhands/sdk/llm/exceptions/classifier.py +50 -0
- openhands/sdk/llm/exceptions/mapping.py +54 -0
- openhands/sdk/llm/exceptions/types.py +101 -0
- openhands/sdk/llm/llm.py +1140 -0
- openhands/sdk/llm/llm_registry.py +122 -0
- openhands/sdk/llm/llm_response.py +59 -0
- openhands/sdk/llm/message.py +656 -0
- openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
- openhands/sdk/llm/mixins/non_native_fc.py +97 -0
- openhands/sdk/llm/options/__init__.py +1 -0
- openhands/sdk/llm/options/chat_options.py +93 -0
- openhands/sdk/llm/options/common.py +19 -0
- openhands/sdk/llm/options/responses_options.py +67 -0
- openhands/sdk/llm/router/__init__.py +10 -0
- openhands/sdk/llm/router/base.py +117 -0
- openhands/sdk/llm/router/impl/multimodal.py +76 -0
- openhands/sdk/llm/router/impl/random.py +22 -0
- openhands/sdk/llm/streaming.py +9 -0
- openhands/sdk/llm/utils/metrics.py +312 -0
- openhands/sdk/llm/utils/model_features.py +192 -0
- openhands/sdk/llm/utils/model_info.py +90 -0
- openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
- openhands/sdk/llm/utils/retry_mixin.py +128 -0
- openhands/sdk/llm/utils/telemetry.py +362 -0
- openhands/sdk/llm/utils/unverified_models.py +156 -0
- openhands/sdk/llm/utils/verified_models.py +65 -0
- openhands/sdk/logger/__init__.py +22 -0
- openhands/sdk/logger/logger.py +195 -0
- openhands/sdk/logger/rolling.py +113 -0
- openhands/sdk/mcp/__init__.py +24 -0
- openhands/sdk/mcp/client.py +76 -0
- openhands/sdk/mcp/definition.py +106 -0
- openhands/sdk/mcp/exceptions.py +19 -0
- openhands/sdk/mcp/tool.py +270 -0
- openhands/sdk/mcp/utils.py +83 -0
- openhands/sdk/observability/__init__.py +4 -0
- openhands/sdk/observability/laminar.py +166 -0
- openhands/sdk/observability/utils.py +20 -0
- openhands/sdk/py.typed +0 -0
- openhands/sdk/secret/__init__.py +19 -0
- openhands/sdk/secret/secrets.py +92 -0
- openhands/sdk/security/__init__.py +6 -0
- openhands/sdk/security/analyzer.py +111 -0
- openhands/sdk/security/confirmation_policy.py +61 -0
- openhands/sdk/security/llm_analyzer.py +29 -0
- openhands/sdk/security/risk.py +100 -0
- openhands/sdk/tool/__init__.py +34 -0
- openhands/sdk/tool/builtins/__init__.py +34 -0
- openhands/sdk/tool/builtins/finish.py +106 -0
- openhands/sdk/tool/builtins/think.py +117 -0
- openhands/sdk/tool/registry.py +184 -0
- openhands/sdk/tool/schema.py +286 -0
- openhands/sdk/tool/spec.py +39 -0
- openhands/sdk/tool/tool.py +481 -0
- openhands/sdk/utils/__init__.py +22 -0
- openhands/sdk/utils/async_executor.py +115 -0
- openhands/sdk/utils/async_utils.py +39 -0
- openhands/sdk/utils/cipher.py +68 -0
- openhands/sdk/utils/command.py +90 -0
- openhands/sdk/utils/deprecation.py +166 -0
- openhands/sdk/utils/github.py +44 -0
- openhands/sdk/utils/json.py +48 -0
- openhands/sdk/utils/models.py +570 -0
- openhands/sdk/utils/paging.py +63 -0
- openhands/sdk/utils/pydantic_diff.py +85 -0
- openhands/sdk/utils/pydantic_secrets.py +64 -0
- openhands/sdk/utils/truncate.py +117 -0
- openhands/sdk/utils/visualize.py +58 -0
- openhands/sdk/workspace/__init__.py +17 -0
- openhands/sdk/workspace/base.py +158 -0
- openhands/sdk/workspace/local.py +189 -0
- openhands/sdk/workspace/models.py +35 -0
- openhands/sdk/workspace/remote/__init__.py +8 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
- openhands/sdk/workspace/remote/base.py +164 -0
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
- openhands/sdk/workspace/workspace.py +49 -0
- openhands_sdk-1.7.3.dist-info/METADATA +17 -0
- openhands_sdk-1.7.3.dist-info/RECORD +180 -0
- openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
- 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
|