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.
- openhands/sdk/agent/agent.py +90 -16
- openhands/sdk/agent/base.py +33 -46
- openhands/sdk/context/condenser/base.py +36 -3
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +65 -24
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +2 -1
- openhands/sdk/context/skills/skill.py +2 -25
- openhands/sdk/context/view.py +108 -122
- openhands/sdk/conversation/__init__.py +2 -0
- openhands/sdk/conversation/conversation.py +18 -3
- openhands/sdk/conversation/exceptions.py +18 -0
- openhands/sdk/conversation/impl/local_conversation.py +211 -36
- openhands/sdk/conversation/impl/remote_conversation.py +151 -12
- openhands/sdk/conversation/stuck_detector.py +18 -9
- openhands/sdk/critic/impl/api/critic.py +10 -7
- openhands/sdk/event/condenser.py +52 -2
- openhands/sdk/git/cached_repo.py +19 -0
- openhands/sdk/hooks/__init__.py +2 -0
- openhands/sdk/hooks/config.py +44 -4
- openhands/sdk/hooks/executor.py +2 -1
- openhands/sdk/llm/__init__.py +16 -0
- openhands/sdk/llm/auth/__init__.py +28 -0
- openhands/sdk/llm/auth/credentials.py +157 -0
- openhands/sdk/llm/auth/openai.py +762 -0
- openhands/sdk/llm/llm.py +222 -33
- openhands/sdk/llm/message.py +65 -27
- openhands/sdk/llm/options/chat_options.py +2 -1
- openhands/sdk/llm/options/responses_options.py +8 -7
- openhands/sdk/llm/utils/model_features.py +2 -0
- openhands/sdk/mcp/client.py +53 -6
- openhands/sdk/mcp/tool.py +24 -21
- openhands/sdk/mcp/utils.py +31 -23
- openhands/sdk/plugin/__init__.py +12 -1
- openhands/sdk/plugin/fetch.py +118 -14
- openhands/sdk/plugin/loader.py +111 -0
- openhands/sdk/plugin/plugin.py +155 -13
- openhands/sdk/plugin/types.py +163 -1
- openhands/sdk/secret/secrets.py +13 -1
- openhands/sdk/utils/__init__.py +2 -0
- openhands/sdk/utils/async_utils.py +36 -1
- openhands/sdk/utils/command.py +28 -1
- openhands/sdk/workspace/remote/base.py +8 -3
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +40 -7
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/METADATA +1 -1
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/RECORD +47 -43
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/WHEEL +1 -1
- {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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
|
|
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
|
|
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
|
|
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
|
"""
|