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,620 @@
1
+ import atexit
2
+ import uuid
3
+ from collections.abc import Mapping
4
+ from pathlib import Path
5
+
6
+ from openhands.sdk.agent.base import AgentBase
7
+ from openhands.sdk.context.prompts.prompt import render_template
8
+ from openhands.sdk.conversation.base import BaseConversation
9
+ from openhands.sdk.conversation.exceptions import ConversationRunError
10
+ from openhands.sdk.conversation.secret_registry import SecretValue
11
+ from openhands.sdk.conversation.state import (
12
+ ConversationExecutionStatus,
13
+ ConversationState,
14
+ )
15
+ from openhands.sdk.conversation.stuck_detector import StuckDetector
16
+ from openhands.sdk.conversation.title_utils import generate_conversation_title
17
+ from openhands.sdk.conversation.types import (
18
+ ConversationCallbackType,
19
+ ConversationID,
20
+ ConversationTokenCallbackType,
21
+ StuckDetectionThresholds,
22
+ )
23
+ from openhands.sdk.conversation.visualizer import (
24
+ ConversationVisualizerBase,
25
+ DefaultConversationVisualizer,
26
+ )
27
+ from openhands.sdk.event import (
28
+ CondensationRequest,
29
+ MessageEvent,
30
+ PauseEvent,
31
+ UserRejectObservation,
32
+ )
33
+ from openhands.sdk.event.conversation_error import ConversationErrorEvent
34
+ from openhands.sdk.llm import LLM, Message, TextContent
35
+ from openhands.sdk.llm.llm_registry import LLMRegistry
36
+ from openhands.sdk.logger import get_logger
37
+ from openhands.sdk.observability.laminar import observe
38
+ from openhands.sdk.security.analyzer import SecurityAnalyzerBase
39
+ from openhands.sdk.security.confirmation_policy import (
40
+ ConfirmationPolicyBase,
41
+ )
42
+ from openhands.sdk.workspace import LocalWorkspace
43
+
44
+
45
+ logger = get_logger(__name__)
46
+
47
+
48
+ class LocalConversation(BaseConversation):
49
+ agent: AgentBase
50
+ workspace: LocalWorkspace
51
+ _state: ConversationState
52
+ _visualizer: ConversationVisualizerBase | None
53
+ _on_event: ConversationCallbackType
54
+ _on_token: ConversationTokenCallbackType | None
55
+ max_iteration_per_run: int
56
+ _stuck_detector: StuckDetector | None
57
+ llm_registry: LLMRegistry
58
+ _cleanup_initiated: bool
59
+
60
+ def __init__(
61
+ self,
62
+ agent: AgentBase,
63
+ workspace: str | Path | LocalWorkspace,
64
+ persistence_dir: str | Path | None = None,
65
+ conversation_id: ConversationID | None = None,
66
+ callbacks: list[ConversationCallbackType] | None = None,
67
+ token_callbacks: list[ConversationTokenCallbackType] | None = None,
68
+ max_iteration_per_run: int = 500,
69
+ stuck_detection: bool = True,
70
+ stuck_detection_thresholds: (
71
+ StuckDetectionThresholds | Mapping[str, int] | None
72
+ ) = None,
73
+ visualizer: (
74
+ type[ConversationVisualizerBase] | ConversationVisualizerBase | None
75
+ ) = DefaultConversationVisualizer,
76
+ secrets: Mapping[str, SecretValue] | None = None,
77
+ **_: object,
78
+ ):
79
+ """Initialize the conversation.
80
+
81
+ Args:
82
+ agent: The agent to use for the conversation
83
+ workspace: Working directory for agent operations and tool execution.
84
+ Can be a string path, Path object, or LocalWorkspace instance.
85
+ persistence_dir: Directory for persisting conversation state and events.
86
+ Can be a string path or Path object.
87
+ conversation_id: Optional ID for the conversation. If provided, will
88
+ be used to identify the conversation. The user might want to
89
+ suffix their persistent filestore with this ID.
90
+ callbacks: Optional list of callback functions to handle events
91
+ token_callbacks: Optional list of callbacks invoked for streaming deltas
92
+ max_iteration_per_run: Maximum number of iterations per run
93
+ visualizer: Visualization configuration. Can be:
94
+ - ConversationVisualizerBase subclass: Class to instantiate
95
+ (default: ConversationVisualizer)
96
+ - ConversationVisualizerBase instance: Use custom visualizer
97
+ - None: No visualization
98
+ stuck_detection: Whether to enable stuck detection
99
+ stuck_detection_thresholds: Optional configuration for stuck detection
100
+ thresholds. Can be a StuckDetectionThresholds instance or
101
+ a dict with keys: 'action_observation', 'action_error',
102
+ 'monologue', 'alternating_pattern'. Values are integers
103
+ representing the number of repetitions before triggering.
104
+ """
105
+ super().__init__() # Initialize with span tracking
106
+ # Mark cleanup as initiated as early as possible to avoid races or partially
107
+ # initialized instances during interpreter shutdown.
108
+ self._cleanup_initiated = False
109
+
110
+ self.agent = agent
111
+ if isinstance(workspace, (str, Path)):
112
+ # LocalWorkspace accepts both str and Path via BeforeValidator
113
+ workspace = LocalWorkspace(working_dir=workspace)
114
+ assert isinstance(workspace, LocalWorkspace), (
115
+ "workspace must be a LocalWorkspace instance"
116
+ )
117
+ self.workspace = workspace
118
+ ws_path = Path(self.workspace.working_dir)
119
+ if not ws_path.exists():
120
+ ws_path.mkdir(parents=True, exist_ok=True)
121
+
122
+ # Create-or-resume: factory inspects BASE_STATE to decide
123
+ desired_id = conversation_id or uuid.uuid4()
124
+ self._state = ConversationState.create(
125
+ id=desired_id,
126
+ agent=agent,
127
+ workspace=self.workspace,
128
+ persistence_dir=self.get_persistence_dir(persistence_dir, desired_id)
129
+ if persistence_dir
130
+ else None,
131
+ max_iterations=max_iteration_per_run,
132
+ stuck_detection=stuck_detection,
133
+ )
134
+
135
+ # Default callback: persist every event to state
136
+ def _default_callback(e):
137
+ self._state.events.append(e)
138
+
139
+ composed_list = (callbacks if callbacks else []) + [_default_callback]
140
+ # Handle visualization configuration
141
+ if isinstance(visualizer, ConversationVisualizerBase):
142
+ # Use custom visualizer instance
143
+ self._visualizer = visualizer
144
+ # Initialize the visualizer with conversation state
145
+ self._visualizer.initialize(self._state)
146
+ composed_list = [self._visualizer.on_event] + composed_list
147
+ # visualizer should happen first for visibility
148
+ elif isinstance(visualizer, type) and issubclass(
149
+ visualizer, ConversationVisualizerBase
150
+ ):
151
+ # Instantiate the visualizer class with appropriate parameters
152
+ self._visualizer = visualizer()
153
+ # Initialize with state
154
+ self._visualizer.initialize(self._state)
155
+ composed_list = [self._visualizer.on_event] + composed_list
156
+ # visualizer should happen first for visibility
157
+ else:
158
+ # No visualization (visualizer is None)
159
+ self._visualizer = None
160
+
161
+ self._on_event = BaseConversation.compose_callbacks(composed_list)
162
+ self._on_token = (
163
+ BaseConversation.compose_callbacks(token_callbacks)
164
+ if token_callbacks
165
+ else None
166
+ )
167
+
168
+ self.max_iteration_per_run = max_iteration_per_run
169
+
170
+ # Initialize stuck detector
171
+ if stuck_detection:
172
+ # Convert dict to StuckDetectionThresholds if needed
173
+ if isinstance(stuck_detection_thresholds, Mapping):
174
+ threshold_config = StuckDetectionThresholds(
175
+ **stuck_detection_thresholds
176
+ )
177
+ else:
178
+ threshold_config = stuck_detection_thresholds
179
+ self._stuck_detector = StuckDetector(
180
+ self._state,
181
+ thresholds=threshold_config,
182
+ )
183
+ else:
184
+ self._stuck_detector = None
185
+
186
+ with self._state:
187
+ self.agent.init_state(self._state, on_event=self._on_event)
188
+
189
+ # Register existing llms in agent
190
+ self.llm_registry = LLMRegistry()
191
+ self.llm_registry.subscribe(self._state.stats.register_llm)
192
+ for llm in list(self.agent.get_all_llms()):
193
+ self.llm_registry.add(llm)
194
+
195
+ # Initialize secrets if provided
196
+ if secrets:
197
+ # Convert dict[str, str] to dict[str, SecretValue]
198
+ secret_values: dict[str, SecretValue] = {k: v for k, v in secrets.items()}
199
+ self.update_secrets(secret_values)
200
+
201
+ atexit.register(self.close)
202
+ self._start_observability_span(str(desired_id))
203
+
204
+ @property
205
+ def id(self) -> ConversationID:
206
+ """Get the unique ID of the conversation."""
207
+ return self._state.id
208
+
209
+ @property
210
+ def state(self) -> ConversationState:
211
+ """Get the conversation state.
212
+
213
+ It returns a protocol that has a subset of ConversationState methods
214
+ and properties. We will have the ability to access the same properties
215
+ of ConversationState on a remote conversation object.
216
+ But we won't be able to access methods that mutate the state.
217
+ """
218
+ return self._state
219
+
220
+ @property
221
+ def conversation_stats(self):
222
+ return self._state.stats
223
+
224
+ @property
225
+ def stuck_detector(self) -> StuckDetector | None:
226
+ """Get the stuck detector instance if enabled."""
227
+ return self._stuck_detector
228
+
229
+ @observe(name="conversation.send_message")
230
+ def send_message(self, message: str | Message, sender: str | None = None) -> None:
231
+ """Send a message to the agent.
232
+
233
+ Args:
234
+ message: Either a string (which will be converted to a user message)
235
+ or a Message object
236
+ sender: Optional identifier of the sender. Can be used to track
237
+ message origin in multi-agent scenarios. For example, when
238
+ one agent delegates to another, the sender can be set to
239
+ identify which agent is sending the message.
240
+ """
241
+ # Convert string to Message if needed
242
+ if isinstance(message, str):
243
+ message = Message(role="user", content=[TextContent(text=message)])
244
+
245
+ assert message.role == "user", (
246
+ "Only user messages are allowed to be sent to the agent."
247
+ )
248
+ with self._state:
249
+ if self._state.execution_status == ConversationExecutionStatus.FINISHED:
250
+ self._state.execution_status = (
251
+ ConversationExecutionStatus.IDLE
252
+ ) # now we have a new message
253
+
254
+ # TODO: We should add test cases for all these scenarios
255
+ activated_skill_names: list[str] = []
256
+ extended_content: list[TextContent] = []
257
+
258
+ # Handle per-turn user message (i.e., knowledge agent trigger)
259
+ if self.agent.agent_context:
260
+ ctx = self.agent.agent_context.get_user_message_suffix(
261
+ user_message=message,
262
+ # We skip skills that were already activated
263
+ skip_skill_names=self._state.activated_knowledge_skills,
264
+ )
265
+ # TODO(calvin): we need to update
266
+ # self._state.activated_knowledge_skills
267
+ # so condenser can work
268
+ if ctx:
269
+ content, activated_skill_names = ctx
270
+ logger.debug(
271
+ f"Got augmented user message content: {content}, "
272
+ f"activated skills: {activated_skill_names}"
273
+ )
274
+ extended_content.append(content)
275
+ self._state.activated_knowledge_skills.extend(activated_skill_names)
276
+
277
+ user_msg_event = MessageEvent(
278
+ source="user",
279
+ llm_message=message,
280
+ activated_skills=activated_skill_names,
281
+ extended_content=extended_content,
282
+ sender=sender,
283
+ )
284
+ self._on_event(user_msg_event)
285
+
286
+ @observe(name="conversation.run")
287
+ def run(self) -> None:
288
+ """Runs the conversation until the agent finishes.
289
+
290
+ In confirmation mode:
291
+ - First call: creates actions but doesn't execute them, stops and waits
292
+ - Second call: executes pending actions (implicit confirmation)
293
+
294
+ In normal mode:
295
+ - Creates and executes actions immediately
296
+
297
+ Can be paused between steps
298
+ """
299
+
300
+ with self._state:
301
+ if self._state.execution_status in [
302
+ ConversationExecutionStatus.IDLE,
303
+ ConversationExecutionStatus.PAUSED,
304
+ ConversationExecutionStatus.ERROR,
305
+ ]:
306
+ self._state.execution_status = ConversationExecutionStatus.RUNNING
307
+
308
+ iteration = 0
309
+ try:
310
+ while True:
311
+ logger.debug(f"Conversation run iteration {iteration}")
312
+ with self._state:
313
+ # Pause attempts to acquire the state lock
314
+ # Before value can be modified step can be taken
315
+ # Ensure step conditions are checked when lock is already acquired
316
+ if self._state.execution_status in [
317
+ ConversationExecutionStatus.FINISHED,
318
+ ConversationExecutionStatus.PAUSED,
319
+ ConversationExecutionStatus.STUCK,
320
+ ]:
321
+ break
322
+
323
+ # Check for stuck patterns if enabled
324
+ if self._stuck_detector:
325
+ is_stuck = self._stuck_detector.is_stuck()
326
+
327
+ if is_stuck:
328
+ logger.warning("Stuck pattern detected.")
329
+ self._state.execution_status = (
330
+ ConversationExecutionStatus.STUCK
331
+ )
332
+ continue
333
+
334
+ # clear the flag before calling agent.step() (user approved)
335
+ if (
336
+ self._state.execution_status
337
+ == ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
338
+ ):
339
+ self._state.execution_status = (
340
+ ConversationExecutionStatus.RUNNING
341
+ )
342
+
343
+ self.agent.step(
344
+ self, on_event=self._on_event, on_token=self._on_token
345
+ )
346
+ iteration += 1
347
+
348
+ # Check for non-finished terminal conditions
349
+ # Note: We intentionally do NOT check for FINISHED status here.
350
+ # This allows concurrent user messages to be processed:
351
+ # 1. Agent finishes and sets status to FINISHED
352
+ # 2. User sends message concurrently via send_message()
353
+ # 3. send_message() waits for FIFO lock, then sets status to IDLE
354
+ # 4. Run loop continues to next iteration and processes the message
355
+ # 5. Without this design, concurrent messages would be lost
356
+ if (
357
+ self.state.execution_status
358
+ == ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
359
+ or iteration >= self.max_iteration_per_run
360
+ ):
361
+ break
362
+ except Exception as e:
363
+ self._state.execution_status = ConversationExecutionStatus.ERROR
364
+
365
+ # Add an error event
366
+ self._on_event(
367
+ ConversationErrorEvent(
368
+ source="environment",
369
+ code=e.__class__.__name__,
370
+ detail=str(e),
371
+ )
372
+ )
373
+
374
+ # Re-raise with conversation id and persistence dir for better UX
375
+ raise ConversationRunError(
376
+ self._state.id, e, persistence_dir=self._state.persistence_dir
377
+ ) from e
378
+
379
+ def set_confirmation_policy(self, policy: ConfirmationPolicyBase) -> None:
380
+ """Set the confirmation policy and store it in conversation state."""
381
+ with self._state:
382
+ self._state.confirmation_policy = policy
383
+ logger.info(f"Confirmation policy set to: {policy}")
384
+
385
+ def reject_pending_actions(self, reason: str = "User rejected the action") -> None:
386
+ """Reject all pending actions from the agent.
387
+
388
+ This is a non-invasive method to reject actions between run() calls.
389
+ Also clears the agent_waiting_for_confirmation flag.
390
+ """
391
+ pending_actions = ConversationState.get_unmatched_actions(self._state.events)
392
+
393
+ with self._state:
394
+ # Always clear the agent_waiting_for_confirmation flag
395
+ if (
396
+ self._state.execution_status
397
+ == ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
398
+ ):
399
+ self._state.execution_status = ConversationExecutionStatus.IDLE
400
+
401
+ if not pending_actions:
402
+ logger.warning("No pending actions to reject")
403
+ return
404
+
405
+ for action_event in pending_actions:
406
+ # Create rejection observation
407
+ rejection_event = UserRejectObservation(
408
+ action_id=action_event.id,
409
+ tool_name=action_event.tool_name,
410
+ tool_call_id=action_event.tool_call_id,
411
+ rejection_reason=reason,
412
+ )
413
+ self._on_event(rejection_event)
414
+ logger.info(f"Rejected pending action: {action_event} - {reason}")
415
+
416
+ def pause(self) -> None:
417
+ """Pause agent execution.
418
+
419
+ This method can be called from any thread to request that the agent
420
+ pause execution. The pause will take effect at the next iteration
421
+ of the run loop (between agent steps).
422
+
423
+ Note: If called during an LLM completion, the pause will not take
424
+ effect until the current LLM call completes.
425
+ """
426
+
427
+ if self._state.execution_status == ConversationExecutionStatus.PAUSED:
428
+ return
429
+
430
+ with self._state:
431
+ # Only pause when running or idle
432
+ if (
433
+ self._state.execution_status == ConversationExecutionStatus.IDLE
434
+ or self._state.execution_status == ConversationExecutionStatus.RUNNING
435
+ ):
436
+ self._state.execution_status = ConversationExecutionStatus.PAUSED
437
+ pause_event = PauseEvent()
438
+ self._on_event(pause_event)
439
+ logger.info("Agent execution pause requested")
440
+
441
+ def update_secrets(self, secrets: Mapping[str, SecretValue]) -> None:
442
+ """Add secrets to the conversation.
443
+
444
+ Args:
445
+ secrets: Dictionary mapping secret keys to values or no-arg callables.
446
+ SecretValue = str | Callable[[], str]. Callables are invoked lazily
447
+ when a command references the secret key.
448
+ """
449
+
450
+ secret_registry = self._state.secret_registry
451
+ secret_registry.update_secrets(secrets)
452
+ logger.info(f"Added {len(secrets)} secrets to conversation")
453
+
454
+ def set_security_analyzer(self, analyzer: SecurityAnalyzerBase | None) -> None:
455
+ """Set the security analyzer for the conversation."""
456
+ with self._state:
457
+ self._state.security_analyzer = analyzer
458
+
459
+ def close(self) -> None:
460
+ """Close the conversation and clean up all tool executors."""
461
+ if self._cleanup_initiated:
462
+ return
463
+ self._cleanup_initiated = True
464
+ logger.debug("Closing conversation and cleaning up tool executors")
465
+ try:
466
+ self._end_observability_span()
467
+ except AttributeError:
468
+ # Object may be partially constructed; span fields may be missing.
469
+ pass
470
+ for tool in self.agent.tools_map.values():
471
+ try:
472
+ executable_tool = tool.as_executable()
473
+ executable_tool.executor.close()
474
+ except NotImplementedError:
475
+ # Tool has no executor, skip it without erroring
476
+ continue
477
+ except Exception as e:
478
+ logger.warning(f"Error closing executor for tool '{tool.name}': {e}")
479
+
480
+ def ask_agent(self, question: str) -> str:
481
+ """Ask the agent a simple, stateless question and get a direct LLM response.
482
+
483
+ This bypasses the normal conversation flow and does **not** modify, persist,
484
+ or become part of the conversation state. The request is not remembered by
485
+ the main agent, no events are recorded, and execution status is untouched.
486
+ It is also thread-safe and may be called while `conversation.run()` is
487
+ executing in another thread.
488
+
489
+ Args:
490
+ question: A simple string question to ask the agent
491
+
492
+ Returns:
493
+ A string response from the agent
494
+ """
495
+ # Import here to avoid circular imports
496
+ from openhands.sdk.agent.utils import make_llm_completion, prepare_llm_messages
497
+
498
+ template_dir = (
499
+ Path(__file__).parent.parent.parent / "context" / "prompts" / "templates"
500
+ )
501
+
502
+ question_text = render_template(
503
+ str(template_dir), "ask_agent_template.j2", question=question
504
+ )
505
+
506
+ # Create a user message with the context-aware question
507
+ user_message = Message(
508
+ role="user",
509
+ content=[TextContent(text=question_text)],
510
+ )
511
+
512
+ messages = prepare_llm_messages(
513
+ self.state.events, additional_messages=[user_message]
514
+ )
515
+
516
+ # Get or create the specialized ask-agent LLM
517
+ try:
518
+ question_llm = self.llm_registry.get("ask-agent-llm")
519
+ except KeyError:
520
+ question_llm = self.agent.llm.model_copy(
521
+ update={
522
+ "usage_id": "ask-agent-llm",
523
+ },
524
+ deep=True,
525
+ )
526
+ self.llm_registry.add(question_llm)
527
+
528
+ # Pass agent tools so LLM can understand tool_calls in conversation history
529
+ response = make_llm_completion(
530
+ question_llm, messages, tools=list(self.agent.tools_map.values())
531
+ )
532
+
533
+ message = response.message
534
+
535
+ # Extract the text content from the LLMResponse message
536
+ if message.content and len(message.content) > 0:
537
+ # Look for the first TextContent in the response
538
+ for content in response.message.content:
539
+ if isinstance(content, TextContent):
540
+ return content.text
541
+
542
+ raise Exception("Failed to generate summary")
543
+
544
+ @observe(name="conversation.generate_title", ignore_inputs=["llm"])
545
+ def generate_title(self, llm: LLM | None = None, max_length: int = 50) -> str:
546
+ """Generate a title for the conversation based on the first user message.
547
+
548
+ Args:
549
+ llm: Optional LLM to use for title generation. If not provided,
550
+ uses self.agent.llm.
551
+ max_length: Maximum length of the generated title.
552
+
553
+ Returns:
554
+ A generated title for the conversation.
555
+
556
+ Raises:
557
+ ValueError: If no user messages are found in the conversation.
558
+ """
559
+ # Use provided LLM or fall back to agent's LLM
560
+ llm_to_use = llm or self.agent.llm
561
+
562
+ return generate_conversation_title(
563
+ events=self._state.events, llm=llm_to_use, max_length=max_length
564
+ )
565
+
566
+ def condense(self) -> None:
567
+ """Synchronously force condense the conversation history.
568
+
569
+ If the agent is currently running, `condense()` will wait for the
570
+ ongoing step to finish before proceeding.
571
+
572
+ Raises ValueError if no compatible condenser exists.
573
+ """
574
+
575
+ # Check if condenser is configured and handles condensation requests
576
+ if (
577
+ self.agent.condenser is None
578
+ or not self.agent.condenser.handles_condensation_requests()
579
+ ):
580
+ condenser_info = (
581
+ "No condenser configured"
582
+ if self.agent.condenser is None
583
+ else (
584
+ f"Condenser {type(self.agent.condenser).__name__} does not handle "
585
+ "condensation requests"
586
+ )
587
+ )
588
+ raise ValueError(
589
+ f"Cannot condense conversation: {condenser_info}. "
590
+ "To enable manual condensation, configure an "
591
+ "LLMSummarizingCondenser:\n\n"
592
+ "from openhands.sdk.context.condenser import LLMSummarizingCondenser\n"
593
+ "agent = Agent(\n"
594
+ " llm=your_llm,\n"
595
+ " condenser=LLMSummarizingCondenser(\n"
596
+ " llm=your_llm,\n"
597
+ " max_size=120,\n"
598
+ " keep_first=4\n"
599
+ " )\n"
600
+ ")"
601
+ )
602
+
603
+ # Add a condensation request event
604
+ condensation_request = CondensationRequest()
605
+ self._on_event(condensation_request)
606
+
607
+ # Force the agent to take a single step to process the condensation request
608
+ # This will trigger the condenser if it handles condensation requests
609
+ with self._state:
610
+ # Take a single step to process the condensation request
611
+ self.agent.step(self, on_event=self._on_event, on_token=self._on_token)
612
+
613
+ logger.info("Condensation request processed")
614
+
615
+ def __del__(self) -> None:
616
+ """Ensure cleanup happens when conversation is destroyed."""
617
+ try:
618
+ self.close()
619
+ except Exception as e:
620
+ logger.warning(f"Error during conversation cleanup: {e}", exc_info=True)