openhands-sdk 1.9.1__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.
- openhands/sdk/agent/agent.py +54 -13
- openhands/sdk/agent/base.py +32 -45
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +0 -23
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
- openhands/sdk/context/view.py +108 -122
- openhands/sdk/conversation/__init__.py +2 -0
- openhands/sdk/conversation/conversation.py +13 -3
- openhands/sdk/conversation/exceptions.py +18 -0
- openhands/sdk/conversation/impl/local_conversation.py +192 -23
- openhands/sdk/conversation/impl/remote_conversation.py +141 -12
- 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/llm.py +47 -13
- openhands/sdk/llm/message.py +65 -27
- openhands/sdk/llm/options/chat_options.py +2 -1
- 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/utils/__init__.py +2 -0
- openhands/sdk/utils/async_utils.py +36 -1
- openhands/sdk/utils/command.py +28 -1
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.10.0.dist-info}/METADATA +1 -1
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.10.0.dist-info}/RECORD +34 -33
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.10.0.dist-info}/WHEEL +1 -1
- {openhands_sdk-1.9.1.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(
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)."""
|
|
@@ -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
|
-
|
|
53
|
-
message.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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]
|