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