openhands-sdk 1.9.1__py3-none-any.whl → 1.11.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 (47) hide show
  1. openhands/sdk/agent/agent.py +90 -16
  2. openhands/sdk/agent/base.py +33 -46
  3. openhands/sdk/context/condenser/base.py +36 -3
  4. openhands/sdk/context/condenser/llm_summarizing_condenser.py +65 -24
  5. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
  6. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +2 -1
  7. openhands/sdk/context/skills/skill.py +2 -25
  8. openhands/sdk/context/view.py +108 -122
  9. openhands/sdk/conversation/__init__.py +2 -0
  10. openhands/sdk/conversation/conversation.py +18 -3
  11. openhands/sdk/conversation/exceptions.py +18 -0
  12. openhands/sdk/conversation/impl/local_conversation.py +211 -36
  13. openhands/sdk/conversation/impl/remote_conversation.py +151 -12
  14. openhands/sdk/conversation/stuck_detector.py +18 -9
  15. openhands/sdk/critic/impl/api/critic.py +10 -7
  16. openhands/sdk/event/condenser.py +52 -2
  17. openhands/sdk/git/cached_repo.py +19 -0
  18. openhands/sdk/hooks/__init__.py +2 -0
  19. openhands/sdk/hooks/config.py +44 -4
  20. openhands/sdk/hooks/executor.py +2 -1
  21. openhands/sdk/llm/__init__.py +16 -0
  22. openhands/sdk/llm/auth/__init__.py +28 -0
  23. openhands/sdk/llm/auth/credentials.py +157 -0
  24. openhands/sdk/llm/auth/openai.py +762 -0
  25. openhands/sdk/llm/llm.py +222 -33
  26. openhands/sdk/llm/message.py +65 -27
  27. openhands/sdk/llm/options/chat_options.py +2 -1
  28. openhands/sdk/llm/options/responses_options.py +8 -7
  29. openhands/sdk/llm/utils/model_features.py +2 -0
  30. openhands/sdk/mcp/client.py +53 -6
  31. openhands/sdk/mcp/tool.py +24 -21
  32. openhands/sdk/mcp/utils.py +31 -23
  33. openhands/sdk/plugin/__init__.py +12 -1
  34. openhands/sdk/plugin/fetch.py +118 -14
  35. openhands/sdk/plugin/loader.py +111 -0
  36. openhands/sdk/plugin/plugin.py +155 -13
  37. openhands/sdk/plugin/types.py +163 -1
  38. openhands/sdk/secret/secrets.py +13 -1
  39. openhands/sdk/utils/__init__.py +2 -0
  40. openhands/sdk/utils/async_utils.py +36 -1
  41. openhands/sdk/utils/command.py +28 -1
  42. openhands/sdk/workspace/remote/base.py +8 -3
  43. openhands/sdk/workspace/remote/remote_workspace_mixin.py +40 -7
  44. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/METADATA +1 -1
  45. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/RECORD +47 -43
  46. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/WHEEL +1 -1
  47. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/top_level.txt +0 -0
@@ -36,6 +36,12 @@ from openhands.sdk.llm import LLM, Message, TextContent
36
36
  from openhands.sdk.llm.llm_registry import LLMRegistry
37
37
  from openhands.sdk.logger import get_logger
38
38
  from openhands.sdk.observability.laminar import observe
39
+ from openhands.sdk.plugin import (
40
+ Plugin,
41
+ PluginSource,
42
+ ResolvedPluginSource,
43
+ fetch_plugin_with_resolution,
44
+ )
39
45
  from openhands.sdk.security.analyzer import SecurityAnalyzerBase
40
46
  from openhands.sdk.security.confirmation_policy import (
41
47
  ConfirmationPolicyBase,
@@ -59,11 +65,18 @@ class LocalConversation(BaseConversation):
59
65
  llm_registry: LLMRegistry
60
66
  _cleanup_initiated: bool
61
67
  _hook_processor: HookEventProcessor | None
68
+ delete_on_close: bool = True
69
+ # Plugin lazy loading state
70
+ _plugin_specs: list[PluginSource] | None
71
+ _resolved_plugins: list[ResolvedPluginSource] | None
72
+ _plugins_loaded: bool
73
+ _pending_hook_config: HookConfig | None # Hook config to combine with plugin hooks
62
74
 
63
75
  def __init__(
64
76
  self,
65
77
  agent: AgentBase,
66
78
  workspace: str | Path | LocalWorkspace,
79
+ plugins: list[PluginSource] | None = None,
67
80
  persistence_dir: str | Path | None = None,
68
81
  conversation_id: ConversationID | None = None,
69
82
  callbacks: list[ConversationCallbackType] | None = None,
@@ -78,15 +91,22 @@ class LocalConversation(BaseConversation):
78
91
  type[ConversationVisualizerBase] | ConversationVisualizerBase | None
79
92
  ) = DefaultConversationVisualizer,
80
93
  secrets: Mapping[str, SecretValue] | None = None,
94
+ delete_on_close: bool = True,
81
95
  cipher: Cipher | None = None,
82
96
  **_: object,
83
97
  ):
84
98
  """Initialize the conversation.
85
99
 
86
100
  Args:
87
- agent: The agent to use for the conversation
101
+ agent: The agent to use for the conversation.
88
102
  workspace: Working directory for agent operations and tool execution.
89
103
  Can be a string path, Path object, or LocalWorkspace instance.
104
+ plugins: Optional list of plugins to load. Each plugin is specified
105
+ with a source (github:owner/repo, git URL, or local path),
106
+ optional ref (branch/tag/commit), and optional repo_path for
107
+ monorepos. Plugins are loaded in order with these merge
108
+ semantics: skills override by name (last wins), MCP config
109
+ override by key (last wins), hooks concatenate (all run).
90
110
  persistence_dir: Directory for persisting conversation state and events.
91
111
  Can be a string path or Path object.
92
112
  conversation_id: Optional ID for the conversation. If provided, will
@@ -94,7 +114,8 @@ class LocalConversation(BaseConversation):
94
114
  suffix their persistent filestore with this ID.
95
115
  callbacks: Optional list of callback functions to handle events
96
116
  token_callbacks: Optional list of callbacks invoked for streaming deltas
97
- hook_config: Optional hook configuration to auto-wire session hooks
117
+ hook_config: Optional hook configuration to auto-wire session hooks.
118
+ If plugins are loaded, their hooks are combined with this config.
98
119
  max_iteration_per_run: Maximum number of iterations per run
99
120
  visualizer: Visualization configuration. Can be:
100
121
  - ConversationVisualizerBase subclass: Class to instantiate
@@ -117,6 +138,14 @@ class LocalConversation(BaseConversation):
117
138
  # initialized instances during interpreter shutdown.
118
139
  self._cleanup_initiated = False
119
140
 
141
+ # Store plugin specs for lazy loading (no IO in constructor)
142
+ # Plugins will be loaded on first run() or send_message() call
143
+ self._plugin_specs = plugins
144
+ self._resolved_plugins = None
145
+ self._plugins_loaded = False
146
+ self._pending_hook_config = hook_config # Will be combined with plugin hooks
147
+ self._agent_ready = False # Agent initialized lazily after plugins loaded
148
+
120
149
  self.agent = agent
121
150
  if isinstance(workspace, (str, Path)):
122
151
  # LocalWorkspace accepts both str and Path via BeforeValidator
@@ -172,18 +201,13 @@ class LocalConversation(BaseConversation):
172
201
 
173
202
  # Compose the base callback chain (visualizer -> user callbacks -> default)
174
203
  base_callback = BaseConversation.compose_callbacks(composed_list)
204
+ self._base_callback = base_callback # Store for _ensure_plugins_loaded
175
205
 
176
- # If hooks configured, wrap with hook processor that forwards to base chain
206
+ # Defer all hook setup to _ensure_plugins_loaded() for consistency
207
+ # This runs on first run()/send_message() call and handles both
208
+ # explicit hooks and plugin hooks in one place
177
209
  self._hook_processor = None
178
- if hook_config is not None:
179
- self._hook_processor, self._on_event = create_hook_callback(
180
- hook_config=hook_config,
181
- working_dir=str(self.workspace.working_dir),
182
- session_id=str(desired_id),
183
- original_callback=base_callback,
184
- )
185
- else:
186
- self._on_event = base_callback
210
+ self._on_event = base_callback
187
211
  self._on_token = (
188
212
  BaseConversation.compose_callbacks(token_callbacks)
189
213
  if token_callbacks
@@ -208,18 +232,9 @@ class LocalConversation(BaseConversation):
208
232
  else:
209
233
  self._stuck_detector = None
210
234
 
211
- if self._hook_processor is not None:
212
- self._hook_processor.set_conversation_state(self._state)
213
- self._hook_processor.run_session_start()
214
-
215
- with self._state:
216
- self.agent.init_state(self._state, on_event=self._on_event)
217
-
218
- # Register existing llms in agent
235
+ # Agent initialization is deferred to _ensure_agent_ready() for lazy loading
236
+ # This ensures plugins are loaded before agent initialization
219
237
  self.llm_registry = LLMRegistry()
220
- self.llm_registry.subscribe(self._state.stats.register_llm)
221
- for llm in list(self.agent.get_all_llms()):
222
- self.llm_registry.add(llm)
223
238
 
224
239
  # Initialize secrets if provided
225
240
  if secrets:
@@ -229,6 +244,7 @@ class LocalConversation(BaseConversation):
229
244
 
230
245
  atexit.register(self.close)
231
246
  self._start_observability_span(str(desired_id))
247
+ self.delete_on_close = delete_on_close
232
248
 
233
249
  @property
234
250
  def id(self) -> ConversationID:
@@ -255,6 +271,154 @@ class LocalConversation(BaseConversation):
255
271
  """Get the stuck detector instance if enabled."""
256
272
  return self._stuck_detector
257
273
 
274
+ @property
275
+ def resolved_plugins(self) -> list[ResolvedPluginSource] | None:
276
+ """Get the resolved plugin sources after plugins are loaded.
277
+
278
+ Returns None if plugins haven't been loaded yet, or if no plugins
279
+ were specified. Use this for persistence to ensure conversation
280
+ resume uses the exact same plugin versions.
281
+ """
282
+ return self._resolved_plugins
283
+
284
+ def _ensure_plugins_loaded(self) -> None:
285
+ """Lazy load plugins and set up hooks on first use.
286
+
287
+ This method is called automatically before run() and send_message().
288
+ It handles both plugin loading and hook initialization in one place
289
+ for consistency.
290
+
291
+ The method:
292
+ 1. Fetches plugins from their sources (network IO for remote sources)
293
+ 2. Resolves refs to commit SHAs for deterministic resume
294
+ 3. Loads plugin contents (skills, MCP config, hooks)
295
+ 4. Merges plugin contents into the agent
296
+ 5. Sets up hook processor with combined hooks (explicit + plugin)
297
+ 6. Runs session_start hooks
298
+ """
299
+ if self._plugins_loaded:
300
+ return
301
+
302
+ all_plugin_hooks: list[HookConfig] = []
303
+
304
+ # Load plugins if specified
305
+ if self._plugin_specs:
306
+ logger.info(f"Loading {len(self._plugin_specs)} plugin(s)...")
307
+ self._resolved_plugins = []
308
+
309
+ # Start with agent's existing context and MCP config
310
+ merged_context = self.agent.agent_context
311
+ merged_mcp = dict(self.agent.mcp_config) if self.agent.mcp_config else {}
312
+
313
+ for spec in self._plugin_specs:
314
+ # Fetch plugin and get resolved commit SHA
315
+ path, resolved_ref = fetch_plugin_with_resolution(
316
+ source=spec.source,
317
+ ref=spec.ref,
318
+ repo_path=spec.repo_path,
319
+ )
320
+
321
+ # Store resolved ref for persistence
322
+ resolved = ResolvedPluginSource.from_plugin_source(spec, resolved_ref)
323
+ self._resolved_plugins.append(resolved)
324
+
325
+ # Load the plugin
326
+ plugin = Plugin.load(path)
327
+ logger.debug(
328
+ f"Loaded plugin '{plugin.manifest.name}' from {spec.source}"
329
+ + (f" @ {resolved_ref[:8]}" if resolved_ref else "")
330
+ )
331
+
332
+ # Merge plugin contents
333
+ merged_context = plugin.add_skills_to(merged_context)
334
+ merged_mcp = plugin.add_mcp_config_to(merged_mcp)
335
+
336
+ # Collect hooks
337
+ if plugin.hooks and not plugin.hooks.is_empty():
338
+ all_plugin_hooks.append(plugin.hooks)
339
+
340
+ # Update agent with merged content
341
+ self.agent = self.agent.model_copy(
342
+ update={
343
+ "agent_context": merged_context,
344
+ "mcp_config": merged_mcp,
345
+ }
346
+ )
347
+
348
+ # Also update the agent in _state so API responses reflect loaded plugins
349
+ with self._state:
350
+ self._state.agent = self.agent
351
+
352
+ logger.info(f"Loaded {len(self._plugin_specs)} plugin(s) via Conversation")
353
+
354
+ # Combine explicit hook_config with plugin hooks
355
+ # Explicit hooks run first (before plugin hooks)
356
+ final_hook_config = self._pending_hook_config
357
+ if all_plugin_hooks:
358
+ plugin_hooks = HookConfig.merge(all_plugin_hooks)
359
+ if plugin_hooks is not None:
360
+ if final_hook_config is not None:
361
+ final_hook_config = HookConfig.merge(
362
+ [final_hook_config, plugin_hooks]
363
+ )
364
+ else:
365
+ final_hook_config = plugin_hooks
366
+
367
+ # Set up hook processor with the combined config
368
+ if final_hook_config is not None:
369
+ self._hook_processor, self._on_event = create_hook_callback(
370
+ hook_config=final_hook_config,
371
+ working_dir=str(self.workspace.working_dir),
372
+ session_id=str(self._state.id),
373
+ original_callback=self._base_callback,
374
+ )
375
+ self._hook_processor.set_conversation_state(self._state)
376
+ self._hook_processor.run_session_start()
377
+
378
+ self._plugins_loaded = True
379
+
380
+ def _ensure_agent_ready(self) -> None:
381
+ """Ensure agent is fully initialized with plugins loaded.
382
+
383
+ This method combines plugin loading and agent initialization to ensure
384
+ the agent is initialized exactly once with complete configuration.
385
+
386
+ Called lazily on first send_message() or run() to:
387
+ 1. Load plugins (if specified)
388
+ 2. Initialize agent with complete plugin config and hooks
389
+ 3. Register LLMs in the registry
390
+
391
+ This preserves the design principle that constructors should not perform
392
+ I/O or error-prone operations, while eliminating double initialization.
393
+
394
+ Thread-safe: Uses state lock to prevent concurrent initialization.
395
+ """
396
+ # Fast path: if already initialized, skip lock acquisition entirely.
397
+ # This is crucial for concurrent send_message() calls during run(),
398
+ # which holds the state lock during agent.step(). Without this check,
399
+ # send_message() would block waiting for the lock even though no
400
+ # initialization is needed.
401
+ if self._agent_ready:
402
+ return
403
+
404
+ with self._state:
405
+ # Re-check after acquiring lock in case another thread initialized
406
+ if self._agent_ready:
407
+ return
408
+
409
+ # Load plugins first (merges skills, MCP config, hooks)
410
+ self._ensure_plugins_loaded()
411
+
412
+ # Initialize agent with complete configuration
413
+ self.agent.init_state(self._state, on_event=self._on_event)
414
+
415
+ # Register LLMs in the registry (still holding lock)
416
+ self.llm_registry.subscribe(self._state.stats.register_llm)
417
+ for llm in list(self.agent.get_all_llms()):
418
+ self.llm_registry.add(llm)
419
+
420
+ self._agent_ready = True
421
+
258
422
  @observe(name="conversation.send_message")
259
423
  def send_message(self, message: str | Message, sender: str | None = None) -> None:
260
424
  """Send a message to the agent.
@@ -267,6 +431,9 @@ class LocalConversation(BaseConversation):
267
431
  one agent delegates to another, the sender can be set to
268
432
  identify which agent is sending the message.
269
433
  """
434
+ # Ensure agent is fully initialized (loads plugins and initializes agent)
435
+ self._ensure_agent_ready()
436
+
270
437
  # Convert string to Message if needed
271
438
  if isinstance(message, str):
272
439
  message = Message(role="user", content=[TextContent(text=message)])
@@ -325,6 +492,8 @@ class LocalConversation(BaseConversation):
325
492
 
326
493
  Can be paused between steps
327
494
  """
495
+ # Ensure agent is fully initialized (loads plugins and initializes agent)
496
+ self._ensure_agent_ready()
328
497
 
329
498
  with self._state:
330
499
  if self._state.execution_status in [
@@ -542,20 +711,23 @@ class LocalConversation(BaseConversation):
542
711
  except AttributeError:
543
712
  # Object may be partially constructed; span fields may be missing.
544
713
  pass
545
- try:
546
- tools_map = self.agent.tools_map
547
- except (AttributeError, RuntimeError):
548
- # Agent not initialized or partially constructed
549
- return
550
- for tool in tools_map.values():
714
+ if self.delete_on_close:
551
715
  try:
552
- executable_tool = tool.as_executable()
553
- executable_tool.executor.close()
554
- except NotImplementedError:
555
- # Tool has no executor, skip it without erroring
556
- continue
557
- except Exception as e:
558
- logger.warning(f"Error closing executor for tool '{tool.name}': {e}")
716
+ tools_map = self.agent.tools_map
717
+ except (AttributeError, RuntimeError):
718
+ # Agent not initialized or partially constructed
719
+ return
720
+ for tool in tools_map.values():
721
+ try:
722
+ executable_tool = tool.as_executable()
723
+ executable_tool.executor.close()
724
+ except NotImplementedError:
725
+ # Tool has no executor, skip it without erroring
726
+ continue
727
+ except Exception as e:
728
+ logger.warning(
729
+ f"Error closing executor for tool '{tool.name}': {e}"
730
+ )
559
731
 
560
732
  def ask_agent(self, question: str) -> str:
561
733
  """Ask the agent a simple, stateless question and get a direct LLM response.
@@ -572,6 +744,9 @@ class LocalConversation(BaseConversation):
572
744
  Returns:
573
745
  A string response from the agent
574
746
  """
747
+ # Ensure agent is initialized (needs tools_map)
748
+ self._ensure_agent_ready()
749
+
575
750
  # Import here to avoid circular imports
576
751
  from openhands.sdk.agent.utils import make_llm_completion, prepare_llm_messages
577
752
 
@@ -16,7 +16,10 @@ from openhands.sdk.agent.base import AgentBase
16
16
  from openhands.sdk.conversation.base import BaseConversation, ConversationStateProtocol
17
17
  from openhands.sdk.conversation.conversation_stats import ConversationStats
18
18
  from openhands.sdk.conversation.events_list_base import EventsListBase
19
- from openhands.sdk.conversation.exceptions import ConversationRunError
19
+ from openhands.sdk.conversation.exceptions import (
20
+ ConversationRunError,
21
+ WebSocketConnectionError,
22
+ )
20
23
  from openhands.sdk.conversation.secret_registry import SecretValue
21
24
  from openhands.sdk.conversation.state import ConversationExecutionStatus
22
25
  from openhands.sdk.conversation.types import (
@@ -95,6 +98,7 @@ class WebSocketCallbackClient:
95
98
  api_key: str | None
96
99
  _thread: threading.Thread | None
97
100
  _stop: threading.Event
101
+ _ready: threading.Event
98
102
 
99
103
  def __init__(
100
104
  self,
@@ -109,6 +113,7 @@ class WebSocketCallbackClient:
109
113
  self.api_key = api_key
110
114
  self._thread = None
111
115
  self._stop = threading.Event()
116
+ self._ready = threading.Event()
112
117
 
113
118
  def start(self) -> None:
114
119
  if self._thread:
@@ -124,6 +129,38 @@ class WebSocketCallbackClient:
124
129
  self._thread.join(timeout=5)
125
130
  self._thread = None
126
131
 
132
+ def wait_until_ready(self, timeout: float | None = None) -> bool:
133
+ """Wait for WebSocket subscription to complete.
134
+
135
+ The server sends a ConversationStateUpdateEvent immediately after
136
+ subscription completes. This method blocks until that event is received,
137
+ the client is stopped, or the timeout expires.
138
+
139
+ Args:
140
+ timeout: Maximum time to wait in seconds. None means wait forever.
141
+
142
+ Returns:
143
+ True if the WebSocket is ready, False if stopped or timeout expired.
144
+ """
145
+ deadline = None if timeout is None else time.monotonic() + timeout
146
+ while True:
147
+ # Calculate remaining timeout
148
+ if deadline is not None:
149
+ remaining = deadline - time.monotonic()
150
+ if remaining <= 0:
151
+ return False
152
+ wait_timeout = min(0.05, remaining)
153
+ else:
154
+ wait_timeout = 0.05
155
+
156
+ # Wait efficiently using Event.wait() instead of sleep
157
+ if self._ready.wait(timeout=wait_timeout):
158
+ return True
159
+
160
+ # Check if stopped
161
+ if self._stop.is_set():
162
+ return False
163
+
127
164
  def _run(self) -> None:
128
165
  try:
129
166
  asyncio.run(self._client_loop())
@@ -154,6 +191,15 @@ class WebSocketCallbackClient:
154
191
  break
155
192
  try:
156
193
  event = Event.model_validate(json.loads(message))
194
+
195
+ # Set ready on first ConversationStateUpdateEvent
196
+ # The server sends this immediately after subscription
197
+ if (
198
+ isinstance(event, ConversationStateUpdateEvent)
199
+ and not self._ready.is_set()
200
+ ):
201
+ self._ready.set()
202
+
157
203
  self.callback(event)
158
204
  except Exception:
159
205
  logger.exception(
@@ -219,6 +265,73 @@ class RemoteEventsList(EventsListBase):
219
265
  self._cached_event_ids.update(e.id for e in events)
220
266
  logger.debug(f"Full sync completed, {len(events)} events cached")
221
267
 
268
+ def reconcile(self) -> int:
269
+ """Reconcile local cache with server by fetching and merging events.
270
+
271
+ This method fetches all events from the server and merges them with
272
+ the local cache, deduplicating by event ID. This ensures no events
273
+ are missed due to race conditions between REST sync and WebSocket
274
+ subscription.
275
+
276
+ Returns:
277
+ Number of new events added during reconciliation.
278
+ """
279
+ logger.debug(
280
+ f"Performing reconciliation sync for conversation {self._conversation_id}"
281
+ )
282
+
283
+ events = []
284
+ page_id = None
285
+
286
+ while True:
287
+ params = {"limit": 100}
288
+ if page_id:
289
+ params["page_id"] = page_id
290
+
291
+ try:
292
+ resp = _send_request(
293
+ self._client,
294
+ "GET",
295
+ f"/api/conversations/{self._conversation_id}/events/search",
296
+ params=params,
297
+ )
298
+ data = resp.json()
299
+ except Exception as e:
300
+ logger.warning(f"Failed to fetch events during reconciliation: {e}")
301
+ break # Return partial results rather than failing completely
302
+
303
+ events.extend([Event.model_validate(item) for item in data["items"]])
304
+
305
+ if not data.get("next_page_id"):
306
+ break
307
+ page_id = data["next_page_id"]
308
+
309
+ # Merge events into cache, acquiring lock once for all events
310
+ added_count = 0
311
+ with self._lock:
312
+ for event in events:
313
+ if event.id not in self._cached_event_ids:
314
+ self._add_event_unsafe(event)
315
+ added_count += 1
316
+
317
+ logger.debug(
318
+ f"Reconciliation completed, {added_count} new events added "
319
+ f"(total: {len(self._cached_events)})"
320
+ )
321
+ return added_count
322
+
323
+ def _add_event_unsafe(self, event: Event) -> None:
324
+ """Add event to cache without acquiring lock (caller must hold lock)."""
325
+ # Use bisect with key function for O(log N) insertion
326
+ # This ensures events are always ordered correctly even if
327
+ # WebSocket delivers them out of order
328
+ insert_pos = bisect.bisect_right(
329
+ self._cached_events, event.timestamp, key=lambda e: e.timestamp
330
+ )
331
+ self._cached_events.insert(insert_pos, event)
332
+ self._cached_event_ids.add(event.id)
333
+ logger.debug(f"Added event {event.id} to local cache at position {insert_pos}")
334
+
222
335
  def add_event(self, event: Event) -> None:
223
336
  """Add a new event to the local cache (called by WebSocket callback).
224
337
 
@@ -228,17 +341,7 @@ class RemoteEventsList(EventsListBase):
228
341
  with self._lock:
229
342
  # Check if event already exists to avoid duplicates
230
343
  if event.id not in self._cached_event_ids:
231
- # Use bisect with key function for O(log N) insertion
232
- # This ensures events are always ordered correctly even if
233
- # WebSocket delivers them out of order
234
- insert_pos = bisect.bisect_right(
235
- self._cached_events, event.timestamp, key=lambda e: e.timestamp
236
- )
237
- self._cached_events.insert(insert_pos, event)
238
- self._cached_event_ids.add(event.id)
239
- logger.debug(
240
- f"Added event {event.id} to local cache at position {insert_pos}"
241
- )
344
+ self._add_event_unsafe(event)
242
345
 
243
346
  def append(self, event: Event) -> None:
244
347
  """Add a new event to the list (for compatibility with EventLog interface)."""
@@ -452,11 +555,13 @@ class RemoteConversation(BaseConversation):
452
555
  _client: httpx.Client
453
556
  _hook_processor: HookEventProcessor | None
454
557
  _cleanup_initiated: bool
558
+ delete_on_close: bool = False
455
559
 
456
560
  def __init__(
457
561
  self,
458
562
  agent: AgentBase,
459
563
  workspace: RemoteWorkspace,
564
+ plugins: list | None = None,
460
565
  conversation_id: ConversationID | None = None,
461
566
  callbacks: list[ConversationCallbackType] | None = None,
462
567
  max_iteration_per_run: int = 500,
@@ -469,6 +574,7 @@ class RemoteConversation(BaseConversation):
469
574
  type[ConversationVisualizerBase] | ConversationVisualizerBase | None
470
575
  ) = DefaultConversationVisualizer,
471
576
  secrets: Mapping[str, SecretValue] | None = None,
577
+ delete_on_close: bool = False,
472
578
  **_: object,
473
579
  ) -> None:
474
580
  """Remote conversation proxy that talks to an agent server.
@@ -476,6 +582,8 @@ class RemoteConversation(BaseConversation):
476
582
  Args:
477
583
  agent: Agent configuration (will be sent to the server)
478
584
  workspace: The working directory for agent operations and tool execution.
585
+ plugins: Optional list of plugins to load on the server. Each plugin
586
+ is a PluginSource specifying source, ref, and repo_path.
479
587
  conversation_id: Optional existing conversation id to attach to
480
588
  callbacks: Optional callbacks to receive events (not yet streamed)
481
589
  max_iteration_per_run: Max iterations configured on server
@@ -537,6 +645,8 @@ class RemoteConversation(BaseConversation):
537
645
  ).model_dump(),
538
646
  # Include tool module qualnames for dynamic registration on server
539
647
  "tool_module_qualnames": tool_qualnames,
648
+ # Include plugins to load on server
649
+ "plugins": [p.model_dump() for p in plugins] if plugins else None,
540
650
  }
541
651
  if stuck_detection_thresholds is not None:
542
652
  # Convert to StuckDetectionThresholds if dict, then serialize
@@ -610,6 +720,27 @@ class RemoteConversation(BaseConversation):
610
720
  )
611
721
  self._ws_client.start()
612
722
 
723
+ # Wait for WebSocket subscription to complete before allowing operations.
724
+ # This ensures events emitted during send_message() are not missed.
725
+ # The server sends a ConversationStateUpdateEvent after subscription.
726
+ ws_timeout = 30.0
727
+ if not self._ws_client.wait_until_ready(timeout=ws_timeout):
728
+ try:
729
+ self._ws_client.stop()
730
+ except Exception:
731
+ pass
732
+ finally:
733
+ self._ws_client = None
734
+ raise WebSocketConnectionError(
735
+ conversation_id=self._id,
736
+ timeout=ws_timeout,
737
+ )
738
+
739
+ # Reconcile events after WebSocket is ready to catch any events that
740
+ # were emitted between the initial REST sync and WebSocket subscription.
741
+ # This is the "reconciliation" part of the subscription handshake.
742
+ self._state.events.reconcile()
743
+
613
744
  # Initialize secrets if provided
614
745
  if secrets:
615
746
  # Convert dict[str, str] to dict[str, SecretValue]
@@ -636,6 +767,7 @@ class RemoteConversation(BaseConversation):
636
767
  )
637
768
  self._hook_processor = HookEventProcessor(hook_manager=hook_manager)
638
769
  self._hook_processor.run_session_start()
770
+ self.delete_on_close = delete_on_close
639
771
 
640
772
  def _create_llm_completion_log_callback(self) -> ConversationCallbackType:
641
773
  """Create a callback that writes LLM completion logs to client filesystem."""
@@ -1005,6 +1137,13 @@ class RemoteConversation(BaseConversation):
1005
1137
  pass
1006
1138
 
1007
1139
  self._end_observability_span()
1140
+ if self.delete_on_close:
1141
+ try:
1142
+ # trigger server-side delete_conversation to release resources
1143
+ # like tmux sessions
1144
+ _send_request(self._client, "DELETE", f"/api/conversations/{self.id}")
1145
+ except Exception:
1146
+ pass
1008
1147
 
1009
1148
  def __del__(self) -> None:
1010
1149
  try:
@@ -15,6 +15,12 @@ from openhands.sdk.logger import get_logger
15
15
  logger = get_logger(__name__)
16
16
 
17
17
 
18
+ # Maximum recent events to scan for stuck detection.
19
+ # This window should be large enough to capture repetitive patterns
20
+ # (4 repeats × 2 events per cycle = 8 events minimum, plus buffer for user messages)
21
+ MAX_EVENTS_TO_SCAN_FOR_STUCK_DETECTION: int = 20
22
+
23
+
18
24
  class StuckDetector:
19
25
  """Detects when an agent is stuck in repetitive or unproductive patterns.
20
26
 
@@ -54,8 +60,14 @@ class StuckDetector:
54
60
  return self.thresholds.alternating_pattern
55
61
 
56
62
  def is_stuck(self) -> bool:
57
- """Check if the agent is currently stuck."""
58
- events = list(self.state.events)
63
+ """Check if the agent is currently stuck.
64
+
65
+ Note: To avoid materializing potentially large file-backed event histories,
66
+ only the last MAX_EVENTS_TO_SCAN_FOR_STUCK_DETECTION events are analyzed.
67
+ If a user message exists within this window, only events after it are checked.
68
+ Otherwise, all events in the window are analyzed.
69
+ """
70
+ events = list(self.state.events[-MAX_EVENTS_TO_SCAN_FOR_STUCK_DETECTION:])
59
71
 
60
72
  # Only look at history after the last user message
61
73
  last_user_msg_index = next(
@@ -66,11 +78,8 @@ class StuckDetector:
66
78
  ),
67
79
  -1, # Default to -1 if no user message found
68
80
  )
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 :]
81
+ if last_user_msg_index != -1:
82
+ events = events[last_user_msg_index + 1 :]
74
83
 
75
84
  # Determine minimum events needed
76
85
  min_threshold = min(
@@ -253,10 +262,10 @@ class StuckDetector:
253
262
  return False
254
263
 
255
264
  def _is_stuck_context_window_error(self, _events: list[Event]) -> bool:
256
- """Detects if we're stuck in a loop of context window errors.
265
+ """Detects if we are stuck in a loop of context window errors.
257
266
 
258
267
  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.
268
+ but the trimming does not work, causing us to get more context window errors.
260
269
  The pattern is repeated AgentCondensationObservation events without any other
261
270
  events between them.
262
271
  """