openhands-sdk 1.9.0__py3-none-any.whl → 1.10.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 (34) hide show
  1. openhands/sdk/agent/agent.py +54 -13
  2. openhands/sdk/agent/base.py +32 -45
  3. openhands/sdk/context/condenser/llm_summarizing_condenser.py +0 -23
  4. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
  5. openhands/sdk/context/view.py +108 -122
  6. openhands/sdk/conversation/__init__.py +2 -0
  7. openhands/sdk/conversation/conversation.py +13 -3
  8. openhands/sdk/conversation/exceptions.py +18 -0
  9. openhands/sdk/conversation/impl/local_conversation.py +192 -23
  10. openhands/sdk/conversation/impl/remote_conversation.py +141 -12
  11. openhands/sdk/critic/impl/api/critic.py +10 -7
  12. openhands/sdk/event/condenser.py +52 -2
  13. openhands/sdk/git/cached_repo.py +19 -0
  14. openhands/sdk/hooks/__init__.py +2 -0
  15. openhands/sdk/hooks/config.py +44 -4
  16. openhands/sdk/hooks/executor.py +2 -1
  17. openhands/sdk/llm/llm.py +47 -13
  18. openhands/sdk/llm/message.py +65 -27
  19. openhands/sdk/llm/options/chat_options.py +2 -1
  20. openhands/sdk/mcp/client.py +53 -6
  21. openhands/sdk/mcp/tool.py +24 -21
  22. openhands/sdk/mcp/utils.py +31 -23
  23. openhands/sdk/plugin/__init__.py +12 -1
  24. openhands/sdk/plugin/fetch.py +118 -14
  25. openhands/sdk/plugin/loader.py +111 -0
  26. openhands/sdk/plugin/plugin.py +155 -13
  27. openhands/sdk/plugin/types.py +163 -1
  28. openhands/sdk/utils/__init__.py +2 -0
  29. openhands/sdk/utils/async_utils.py +36 -1
  30. openhands/sdk/utils/command.py +28 -1
  31. {openhands_sdk-1.9.0.dist-info → openhands_sdk-1.10.0.dist-info}/METADATA +1 -1
  32. {openhands_sdk-1.9.0.dist-info → openhands_sdk-1.10.0.dist-info}/RECORD +34 -33
  33. {openhands_sdk-1.9.0.dist-info → openhands_sdk-1.10.0.dist-info}/WHEEL +1 -1
  34. {openhands_sdk-1.9.0.dist-info → openhands_sdk-1.10.0.dist-info}/top_level.txt +0 -0
@@ -16,6 +16,7 @@ from openhands.sdk.conversation.visualizer import (
16
16
  )
17
17
  from openhands.sdk.hooks import HookConfig
18
18
  from openhands.sdk.logger import get_logger
19
+ from openhands.sdk.plugin import PluginSource
19
20
  from openhands.sdk.secret import SecretValue
20
21
  from openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace
21
22
 
@@ -40,9 +41,14 @@ class Conversation:
40
41
 
41
42
  Example:
42
43
  >>> from openhands.sdk import LLM, Agent, Conversation
44
+ >>> from openhands.sdk.plugin import PluginSource
43
45
  >>> llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("key"))
44
46
  >>> agent = Agent(llm=llm, tools=[])
45
- >>> conversation = Conversation(agent=agent, workspace="./workspace")
47
+ >>> conversation = Conversation(
48
+ ... agent=agent,
49
+ ... workspace="./workspace",
50
+ ... plugins=[PluginSource(source="github:org/security-plugin", ref="v1.0")],
51
+ ... )
46
52
  >>> conversation.send_message("Hello!")
47
53
  >>> conversation.run()
48
54
  """
@@ -53,6 +59,7 @@ class Conversation:
53
59
  agent: AgentBase,
54
60
  *,
55
61
  workspace: str | Path | LocalWorkspace = "workspace/project",
62
+ plugins: list[PluginSource] | None = None,
56
63
  persistence_dir: str | Path | None = None,
57
64
  conversation_id: ConversationID | None = None,
58
65
  callbacks: list[ConversationCallbackType] | None = None,
@@ -75,6 +82,7 @@ class Conversation:
75
82
  agent: AgentBase,
76
83
  *,
77
84
  workspace: RemoteWorkspace,
85
+ plugins: list[PluginSource] | None = None,
78
86
  conversation_id: ConversationID | None = None,
79
87
  callbacks: list[ConversationCallbackType] | None = None,
80
88
  token_callbacks: list[ConversationTokenCallbackType] | None = None,
@@ -95,6 +103,7 @@ class Conversation:
95
103
  agent: AgentBase,
96
104
  *,
97
105
  workspace: str | Path | LocalWorkspace | RemoteWorkspace = "workspace/project",
106
+ plugins: list[PluginSource] | None = None,
98
107
  persistence_dir: str | Path | None = None,
99
108
  conversation_id: ConversationID | None = None,
100
109
  callbacks: list[ConversationCallbackType] | None = None,
@@ -116,14 +125,14 @@ class Conversation:
116
125
  )
117
126
 
118
127
  if isinstance(workspace, RemoteWorkspace):
119
- # For RemoteConversation, persistence_dir should not be used
120
- # Only check if it was explicitly set to something other than the default
128
+ # For RemoteConversation, persistence_dir should not be used.
121
129
  if persistence_dir is not None:
122
130
  raise ValueError(
123
131
  "persistence_dir should not be set when using RemoteConversation"
124
132
  )
125
133
  return RemoteConversation(
126
134
  agent=agent,
135
+ plugins=plugins,
127
136
  conversation_id=conversation_id,
128
137
  callbacks=callbacks,
129
138
  token_callbacks=token_callbacks,
@@ -138,6 +147,7 @@ class Conversation:
138
147
 
139
148
  return LocalConversation(
140
149
  agent=agent,
150
+ plugins=plugins,
141
151
  conversation_id=conversation_id,
142
152
  callbacks=callbacks,
143
153
  token_callbacks=token_callbacks,
@@ -4,6 +4,24 @@ from openhands.sdk.conversation.types import ConversationID
4
4
  ISSUE_URL = "https://github.com/OpenHands/software-agent-sdk/issues/new"
5
5
 
6
6
 
7
+ class WebSocketConnectionError(RuntimeError):
8
+ """Raised when WebSocket connection fails to establish within the timeout."""
9
+
10
+ def __init__(
11
+ self,
12
+ conversation_id: ConversationID,
13
+ timeout: float,
14
+ message: str | None = None,
15
+ ) -> None:
16
+ self.conversation_id = conversation_id
17
+ self.timeout = timeout
18
+ default_msg = (
19
+ f"WebSocket subscription did not complete within {timeout} seconds "
20
+ f"for conversation {conversation_id}. Events may be missed."
21
+ )
22
+ super().__init__(message or default_msg)
23
+
24
+
7
25
  class ConversationRunError(RuntimeError):
8
26
  """Raised when a conversation run fails.
9
27
 
@@ -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,17 @@ class LocalConversation(BaseConversation):
59
65
  llm_registry: LLMRegistry
60
66
  _cleanup_initiated: bool
61
67
  _hook_processor: HookEventProcessor | None
68
+ # Plugin lazy loading state
69
+ _plugin_specs: list[PluginSource] | None
70
+ _resolved_plugins: list[ResolvedPluginSource] | None
71
+ _plugins_loaded: bool
72
+ _pending_hook_config: HookConfig | None # Hook config to combine with plugin hooks
62
73
 
63
74
  def __init__(
64
75
  self,
65
76
  agent: AgentBase,
66
77
  workspace: str | Path | LocalWorkspace,
78
+ plugins: list[PluginSource] | None = None,
67
79
  persistence_dir: str | Path | None = None,
68
80
  conversation_id: ConversationID | None = None,
69
81
  callbacks: list[ConversationCallbackType] | None = None,
@@ -84,9 +96,15 @@ class LocalConversation(BaseConversation):
84
96
  """Initialize the conversation.
85
97
 
86
98
  Args:
87
- agent: The agent to use for the conversation
99
+ agent: The agent to use for the conversation.
88
100
  workspace: Working directory for agent operations and tool execution.
89
101
  Can be a string path, Path object, or LocalWorkspace instance.
102
+ plugins: Optional list of plugins to load. Each plugin is specified
103
+ with a source (github:owner/repo, git URL, or local path),
104
+ optional ref (branch/tag/commit), and optional repo_path for
105
+ monorepos. Plugins are loaded in order with these merge
106
+ semantics: skills override by name (last wins), MCP config
107
+ override by key (last wins), hooks concatenate (all run).
90
108
  persistence_dir: Directory for persisting conversation state and events.
91
109
  Can be a string path or Path object.
92
110
  conversation_id: Optional ID for the conversation. If provided, will
@@ -94,7 +112,8 @@ class LocalConversation(BaseConversation):
94
112
  suffix their persistent filestore with this ID.
95
113
  callbacks: Optional list of callback functions to handle events
96
114
  token_callbacks: Optional list of callbacks invoked for streaming deltas
97
- hook_config: Optional hook configuration to auto-wire session hooks
115
+ hook_config: Optional hook configuration to auto-wire session hooks.
116
+ If plugins are loaded, their hooks are combined with this config.
98
117
  max_iteration_per_run: Maximum number of iterations per run
99
118
  visualizer: Visualization configuration. Can be:
100
119
  - ConversationVisualizerBase subclass: Class to instantiate
@@ -117,6 +136,14 @@ class LocalConversation(BaseConversation):
117
136
  # initialized instances during interpreter shutdown.
118
137
  self._cleanup_initiated = False
119
138
 
139
+ # Store plugin specs for lazy loading (no IO in constructor)
140
+ # Plugins will be loaded on first run() or send_message() call
141
+ self._plugin_specs = plugins
142
+ self._resolved_plugins = None
143
+ self._plugins_loaded = False
144
+ self._pending_hook_config = hook_config # Will be combined with plugin hooks
145
+ self._agent_ready = False # Agent initialized lazily after plugins loaded
146
+
120
147
  self.agent = agent
121
148
  if isinstance(workspace, (str, Path)):
122
149
  # LocalWorkspace accepts both str and Path via BeforeValidator
@@ -172,18 +199,13 @@ class LocalConversation(BaseConversation):
172
199
 
173
200
  # Compose the base callback chain (visualizer -> user callbacks -> default)
174
201
  base_callback = BaseConversation.compose_callbacks(composed_list)
202
+ self._base_callback = base_callback # Store for _ensure_plugins_loaded
175
203
 
176
- # If hooks configured, wrap with hook processor that forwards to base chain
204
+ # Defer all hook setup to _ensure_plugins_loaded() for consistency
205
+ # This runs on first run()/send_message() call and handles both
206
+ # explicit hooks and plugin hooks in one place
177
207
  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
208
+ self._on_event = base_callback
187
209
  self._on_token = (
188
210
  BaseConversation.compose_callbacks(token_callbacks)
189
211
  if token_callbacks
@@ -208,18 +230,9 @@ class LocalConversation(BaseConversation):
208
230
  else:
209
231
  self._stuck_detector = None
210
232
 
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
233
+ # Agent initialization is deferred to _ensure_agent_ready() for lazy loading
234
+ # This ensures plugins are loaded before agent initialization
219
235
  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
236
 
224
237
  # Initialize secrets if provided
225
238
  if secrets:
@@ -255,6 +268,154 @@ class LocalConversation(BaseConversation):
255
268
  """Get the stuck detector instance if enabled."""
256
269
  return self._stuck_detector
257
270
 
271
+ @property
272
+ def resolved_plugins(self) -> list[ResolvedPluginSource] | None:
273
+ """Get the resolved plugin sources after plugins are loaded.
274
+
275
+ Returns None if plugins haven't been loaded yet, or if no plugins
276
+ were specified. Use this for persistence to ensure conversation
277
+ resume uses the exact same plugin versions.
278
+ """
279
+ return self._resolved_plugins
280
+
281
+ def _ensure_plugins_loaded(self) -> None:
282
+ """Lazy load plugins and set up hooks on first use.
283
+
284
+ This method is called automatically before run() and send_message().
285
+ It handles both plugin loading and hook initialization in one place
286
+ for consistency.
287
+
288
+ The method:
289
+ 1. Fetches plugins from their sources (network IO for remote sources)
290
+ 2. Resolves refs to commit SHAs for deterministic resume
291
+ 3. Loads plugin contents (skills, MCP config, hooks)
292
+ 4. Merges plugin contents into the agent
293
+ 5. Sets up hook processor with combined hooks (explicit + plugin)
294
+ 6. Runs session_start hooks
295
+ """
296
+ if self._plugins_loaded:
297
+ return
298
+
299
+ all_plugin_hooks: list[HookConfig] = []
300
+
301
+ # Load plugins if specified
302
+ if self._plugin_specs:
303
+ logger.info(f"Loading {len(self._plugin_specs)} plugin(s)...")
304
+ self._resolved_plugins = []
305
+
306
+ # Start with agent's existing context and MCP config
307
+ merged_context = self.agent.agent_context
308
+ merged_mcp = dict(self.agent.mcp_config) if self.agent.mcp_config else {}
309
+
310
+ for spec in self._plugin_specs:
311
+ # Fetch plugin and get resolved commit SHA
312
+ path, resolved_ref = fetch_plugin_with_resolution(
313
+ source=spec.source,
314
+ ref=spec.ref,
315
+ repo_path=spec.repo_path,
316
+ )
317
+
318
+ # Store resolved ref for persistence
319
+ resolved = ResolvedPluginSource.from_plugin_source(spec, resolved_ref)
320
+ self._resolved_plugins.append(resolved)
321
+
322
+ # Load the plugin
323
+ plugin = Plugin.load(path)
324
+ logger.debug(
325
+ f"Loaded plugin '{plugin.manifest.name}' from {spec.source}"
326
+ + (f" @ {resolved_ref[:8]}" if resolved_ref else "")
327
+ )
328
+
329
+ # Merge plugin contents
330
+ merged_context = plugin.add_skills_to(merged_context)
331
+ merged_mcp = plugin.add_mcp_config_to(merged_mcp)
332
+
333
+ # Collect hooks
334
+ if plugin.hooks and not plugin.hooks.is_empty():
335
+ all_plugin_hooks.append(plugin.hooks)
336
+
337
+ # Update agent with merged content
338
+ self.agent = self.agent.model_copy(
339
+ update={
340
+ "agent_context": merged_context,
341
+ "mcp_config": merged_mcp,
342
+ }
343
+ )
344
+
345
+ # Also update the agent in _state so API responses reflect loaded plugins
346
+ with self._state:
347
+ self._state.agent = self.agent
348
+
349
+ logger.info(f"Loaded {len(self._plugin_specs)} plugin(s) via Conversation")
350
+
351
+ # Combine explicit hook_config with plugin hooks
352
+ # Explicit hooks run first (before plugin hooks)
353
+ final_hook_config = self._pending_hook_config
354
+ if all_plugin_hooks:
355
+ plugin_hooks = HookConfig.merge(all_plugin_hooks)
356
+ if plugin_hooks is not None:
357
+ if final_hook_config is not None:
358
+ final_hook_config = HookConfig.merge(
359
+ [final_hook_config, plugin_hooks]
360
+ )
361
+ else:
362
+ final_hook_config = plugin_hooks
363
+
364
+ # Set up hook processor with the combined config
365
+ if final_hook_config is not None:
366
+ self._hook_processor, self._on_event = create_hook_callback(
367
+ hook_config=final_hook_config,
368
+ working_dir=str(self.workspace.working_dir),
369
+ session_id=str(self._state.id),
370
+ original_callback=self._base_callback,
371
+ )
372
+ self._hook_processor.set_conversation_state(self._state)
373
+ self._hook_processor.run_session_start()
374
+
375
+ self._plugins_loaded = True
376
+
377
+ def _ensure_agent_ready(self) -> None:
378
+ """Ensure agent is fully initialized with plugins loaded.
379
+
380
+ This method combines plugin loading and agent initialization to ensure
381
+ the agent is initialized exactly once with complete configuration.
382
+
383
+ Called lazily on first send_message() or run() to:
384
+ 1. Load plugins (if specified)
385
+ 2. Initialize agent with complete plugin config and hooks
386
+ 3. Register LLMs in the registry
387
+
388
+ This preserves the design principle that constructors should not perform
389
+ I/O or error-prone operations, while eliminating double initialization.
390
+
391
+ Thread-safe: Uses state lock to prevent concurrent initialization.
392
+ """
393
+ # Fast path: if already initialized, skip lock acquisition entirely.
394
+ # This is crucial for concurrent send_message() calls during run(),
395
+ # which holds the state lock during agent.step(). Without this check,
396
+ # send_message() would block waiting for the lock even though no
397
+ # initialization is needed.
398
+ if self._agent_ready:
399
+ return
400
+
401
+ with self._state:
402
+ # Re-check after acquiring lock in case another thread initialized
403
+ if self._agent_ready:
404
+ return
405
+
406
+ # Load plugins first (merges skills, MCP config, hooks)
407
+ self._ensure_plugins_loaded()
408
+
409
+ # Initialize agent with complete configuration
410
+ self.agent.init_state(self._state, on_event=self._on_event)
411
+
412
+ # Register LLMs in the registry (still holding lock)
413
+ self.llm_registry.subscribe(self._state.stats.register_llm)
414
+ for llm in list(self.agent.get_all_llms()):
415
+ self.llm_registry.add(llm)
416
+
417
+ self._agent_ready = True
418
+
258
419
  @observe(name="conversation.send_message")
259
420
  def send_message(self, message: str | Message, sender: str | None = None) -> None:
260
421
  """Send a message to the agent.
@@ -267,6 +428,9 @@ class LocalConversation(BaseConversation):
267
428
  one agent delegates to another, the sender can be set to
268
429
  identify which agent is sending the message.
269
430
  """
431
+ # Ensure agent is fully initialized (loads plugins and initializes agent)
432
+ self._ensure_agent_ready()
433
+
270
434
  # Convert string to Message if needed
271
435
  if isinstance(message, str):
272
436
  message = Message(role="user", content=[TextContent(text=message)])
@@ -325,6 +489,8 @@ class LocalConversation(BaseConversation):
325
489
 
326
490
  Can be paused between steps
327
491
  """
492
+ # Ensure agent is fully initialized (loads plugins and initializes agent)
493
+ self._ensure_agent_ready()
328
494
 
329
495
  with self._state:
330
496
  if self._state.execution_status in [
@@ -572,6 +738,9 @@ class LocalConversation(BaseConversation):
572
738
  Returns:
573
739
  A string response from the agent
574
740
  """
741
+ # Ensure agent is initialized (needs tools_map)
742
+ self._ensure_agent_ready()
743
+
575
744
  # Import here to avoid circular imports
576
745
  from openhands.sdk.agent.utils import make_llm_completion, prepare_llm_messages
577
746
 
@@ -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)."""
@@ -457,6 +560,7 @@ class RemoteConversation(BaseConversation):
457
560
  self,
458
561
  agent: AgentBase,
459
562
  workspace: RemoteWorkspace,
563
+ plugins: list | None = None,
460
564
  conversation_id: ConversationID | None = None,
461
565
  callbacks: list[ConversationCallbackType] | None = None,
462
566
  max_iteration_per_run: int = 500,
@@ -476,6 +580,8 @@ class RemoteConversation(BaseConversation):
476
580
  Args:
477
581
  agent: Agent configuration (will be sent to the server)
478
582
  workspace: The working directory for agent operations and tool execution.
583
+ plugins: Optional list of plugins to load on the server. Each plugin
584
+ is a PluginSource specifying source, ref, and repo_path.
479
585
  conversation_id: Optional existing conversation id to attach to
480
586
  callbacks: Optional callbacks to receive events (not yet streamed)
481
587
  max_iteration_per_run: Max iterations configured on server
@@ -537,6 +643,8 @@ class RemoteConversation(BaseConversation):
537
643
  ).model_dump(),
538
644
  # Include tool module qualnames for dynamic registration on server
539
645
  "tool_module_qualnames": tool_qualnames,
646
+ # Include plugins to load on server
647
+ "plugins": [p.model_dump() for p in plugins] if plugins else None,
540
648
  }
541
649
  if stuck_detection_thresholds is not None:
542
650
  # Convert to StuckDetectionThresholds if dict, then serialize
@@ -610,6 +718,27 @@ class RemoteConversation(BaseConversation):
610
718
  )
611
719
  self._ws_client.start()
612
720
 
721
+ # Wait for WebSocket subscription to complete before allowing operations.
722
+ # This ensures events emitted during send_message() are not missed.
723
+ # The server sends a ConversationStateUpdateEvent after subscription.
724
+ ws_timeout = 30.0
725
+ if not self._ws_client.wait_until_ready(timeout=ws_timeout):
726
+ try:
727
+ self._ws_client.stop()
728
+ except Exception:
729
+ pass
730
+ finally:
731
+ self._ws_client = None
732
+ raise WebSocketConnectionError(
733
+ conversation_id=self._id,
734
+ timeout=ws_timeout,
735
+ )
736
+
737
+ # Reconcile events after WebSocket is ready to catch any events that
738
+ # were emitted between the initial REST sync and WebSocket subscription.
739
+ # This is the "reconciliation" part of the subscription handshake.
740
+ self._state.events.reconcile()
741
+
613
742
  # Initialize secrets if provided
614
743
  if secrets:
615
744
  # Convert dict[str, str] to dict[str, SecretValue]
@@ -49,13 +49,16 @@ class APIBasedCritic(CriticBase, CriticClient):
49
49
  messages = LLMConvertibleEvent.events_to_messages(llm_convertible_events)
50
50
 
51
51
  # Serialize messages to dicts for API
52
- for message in messages:
53
- message.cache_enabled = False
54
- message.vision_enabled = False # Critic does not support vision currently
55
- message.function_calling_enabled = True
56
- message.force_string_serializer = False
57
- message.send_reasoning_content = False
58
- formatted_messages = [message.to_chat_dict() for message in messages]
52
+ formatted_messages = [
53
+ message.to_chat_dict(
54
+ cache_enabled=False,
55
+ vision_enabled=False, # Critic does not support vision currently
56
+ function_calling_enabled=True,
57
+ force_string_serializer=False,
58
+ send_reasoning_content=False,
59
+ )
60
+ for message in messages
61
+ ]
59
62
 
60
63
  # Convert ToolDefinition objects to ChatCompletionToolParam format
61
64
  tools_for_api = [tool.to_openai_tool() for tool in tools]