openhands-sdk 1.9.1__py3-none-any.whl → 1.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. openhands/sdk/agent/agent.py +90 -16
  2. openhands/sdk/agent/base.py +33 -46
  3. openhands/sdk/context/condenser/base.py +36 -3
  4. openhands/sdk/context/condenser/llm_summarizing_condenser.py +65 -24
  5. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
  6. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +2 -1
  7. openhands/sdk/context/skills/skill.py +2 -25
  8. openhands/sdk/context/view.py +108 -122
  9. openhands/sdk/conversation/__init__.py +2 -0
  10. openhands/sdk/conversation/conversation.py +18 -3
  11. openhands/sdk/conversation/exceptions.py +18 -0
  12. openhands/sdk/conversation/impl/local_conversation.py +211 -36
  13. openhands/sdk/conversation/impl/remote_conversation.py +151 -12
  14. openhands/sdk/conversation/stuck_detector.py +18 -9
  15. openhands/sdk/critic/impl/api/critic.py +10 -7
  16. openhands/sdk/event/condenser.py +52 -2
  17. openhands/sdk/git/cached_repo.py +19 -0
  18. openhands/sdk/hooks/__init__.py +2 -0
  19. openhands/sdk/hooks/config.py +44 -4
  20. openhands/sdk/hooks/executor.py +2 -1
  21. openhands/sdk/llm/__init__.py +16 -0
  22. openhands/sdk/llm/auth/__init__.py +28 -0
  23. openhands/sdk/llm/auth/credentials.py +157 -0
  24. openhands/sdk/llm/auth/openai.py +762 -0
  25. openhands/sdk/llm/llm.py +222 -33
  26. openhands/sdk/llm/message.py +65 -27
  27. openhands/sdk/llm/options/chat_options.py +2 -1
  28. openhands/sdk/llm/options/responses_options.py +8 -7
  29. openhands/sdk/llm/utils/model_features.py +2 -0
  30. openhands/sdk/mcp/client.py +53 -6
  31. openhands/sdk/mcp/tool.py +24 -21
  32. openhands/sdk/mcp/utils.py +31 -23
  33. openhands/sdk/plugin/__init__.py +12 -1
  34. openhands/sdk/plugin/fetch.py +118 -14
  35. openhands/sdk/plugin/loader.py +111 -0
  36. openhands/sdk/plugin/plugin.py +155 -13
  37. openhands/sdk/plugin/types.py +163 -1
  38. openhands/sdk/secret/secrets.py +13 -1
  39. openhands/sdk/utils/__init__.py +2 -0
  40. openhands/sdk/utils/async_utils.py +36 -1
  41. openhands/sdk/utils/command.py +28 -1
  42. openhands/sdk/workspace/remote/base.py +8 -3
  43. openhands/sdk/workspace/remote/remote_workspace_mixin.py +40 -7
  44. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/METADATA +1 -1
  45. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/RECORD +47 -43
  46. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/WHEEL +1 -1
  47. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/top_level.txt +0 -0
@@ -11,7 +11,6 @@ from pydantic import BaseModel, computed_field
11
11
  from openhands.sdk.event import (
12
12
  Condensation,
13
13
  CondensationRequest,
14
- CondensationSummaryEvent,
15
14
  LLMConvertibleEvent,
16
15
  )
17
16
  from openhands.sdk.event.base import Event, EventID
@@ -86,32 +85,6 @@ class View(BaseModel):
86
85
  def __len__(self) -> int:
87
86
  return len(self.events)
88
87
 
89
- @property
90
- def most_recent_condensation(self) -> Condensation | None:
91
- """Return the most recent condensation, or None if no condensations exist."""
92
- return self.condensations[-1] if self.condensations else None
93
-
94
- @property
95
- def summary_event_index(self) -> int | None:
96
- """Return the index of the summary event, or None if no summary exists."""
97
- recent_condensation = self.most_recent_condensation
98
- if (
99
- recent_condensation is not None
100
- and recent_condensation.summary is not None
101
- and recent_condensation.summary_offset is not None
102
- ):
103
- return recent_condensation.summary_offset
104
- return None
105
-
106
- @property
107
- def summary_event(self) -> CondensationSummaryEvent | None:
108
- """Return the summary event, or None if no summary exists."""
109
- if self.summary_event_index is not None:
110
- event = self.events[self.summary_event_index]
111
- if isinstance(event, CondensationSummaryEvent):
112
- return event
113
- return None
114
-
115
88
  @computed_field # type: ignore[prop-decorator]
116
89
  @cached_property
117
90
  def manipulation_indices(self) -> list[int]:
@@ -291,46 +264,53 @@ class View(BaseModel):
291
264
 
292
265
  @staticmethod
293
266
  def _enforce_batch_atomicity(
294
- events: Sequence[Event],
295
- removed_event_ids: set[EventID],
296
- ) -> set[EventID]:
297
- """Ensure that if any ActionEvent in a batch is removed, all ActionEvents
298
- in that batch are removed.
267
+ view_events: Sequence[LLMConvertibleEvent],
268
+ all_events: Sequence[Event],
269
+ ) -> list[LLMConvertibleEvent]:
270
+ """Ensure that if any ActionEvent in a batch is removed from the view,
271
+ all ActionEvents in that batch are removed.
299
272
 
300
273
  This prevents partial batches from being sent to the LLM, which can cause
301
274
  API errors when thinking blocks are separated from their tool calls.
302
275
 
303
276
  Args:
304
- events: The original list of events
305
- removed_event_ids: Set of event IDs that are being removed
277
+ view_events: The list of events that are being kept in the view
278
+ all_events: The complete original list of all events
306
279
 
307
280
  Returns:
308
- Updated set of event IDs that should be removed (including all
309
- ActionEvents in batches where any ActionEvent was removed)
281
+ Filtered list of view events with batch atomicity enforced
282
+ (removing all ActionEvents from batches where any ActionEvent
283
+ was already removed)
310
284
  """
311
- action_batch = ActionBatch.from_events(events)
285
+ action_batch = ActionBatch.from_events(all_events)
312
286
 
313
287
  if not action_batch.batches:
314
- return removed_event_ids
288
+ return list(view_events)
289
+
290
+ # Get set of event IDs currently in the view
291
+ view_event_ids = {event.id for event in view_events}
315
292
 
316
- updated_removed_ids = set(removed_event_ids)
293
+ # Track which event IDs should be removed due to batch atomicity
294
+ ids_to_remove: set[EventID] = set()
317
295
 
318
296
  for llm_response_id, batch_event_ids in action_batch.batches.items():
319
- # Check if any ActionEvent in this batch is being removed
320
- if any(event_id in removed_event_ids for event_id in batch_event_ids):
297
+ # Check if any ActionEvent in this batch is missing from view
298
+ if any(event_id not in view_event_ids for event_id in batch_event_ids):
321
299
  # If so, remove all ActionEvents in this batch
322
- updated_removed_ids.update(batch_event_ids)
300
+ ids_to_remove.update(batch_event_ids)
323
301
  logger.debug(
324
302
  f"Enforcing batch atomicity: removing entire batch "
325
303
  f"with llm_response_id={llm_response_id} "
326
304
  f"({len(batch_event_ids)} events)"
327
305
  )
328
306
 
329
- return updated_removed_ids
307
+ # Filter out events that need to be removed
308
+ return [event for event in view_events if event.id not in ids_to_remove]
330
309
 
331
310
  @staticmethod
332
- def filter_unmatched_tool_calls(
333
- events: list[LLMConvertibleEvent],
311
+ def _filter_unmatched_tool_calls(
312
+ view_events: Sequence[LLMConvertibleEvent],
313
+ all_events: Sequence[Event],
334
314
  ) -> list[LLMConvertibleEvent]:
335
315
  """Filter out unmatched tool call events.
336
316
 
@@ -338,49 +318,58 @@ class View(BaseModel):
338
318
  but don't have matching pairs. Also enforces batch atomicity - if any
339
319
  ActionEvent in a batch is filtered out, all ActionEvents in that batch
340
320
  are also filtered out.
321
+
322
+ Args:
323
+ view_events: The list of events to filter
324
+ all_events: The complete original list of all events
325
+
326
+ Returns:
327
+ Filtered list of events with unmatched tool calls removed
341
328
  """
342
- action_tool_call_ids = View._get_action_tool_call_ids(events)
343
- observation_tool_call_ids = View._get_observation_tool_call_ids(events)
329
+ action_tool_call_ids = View._get_action_tool_call_ids(view_events)
330
+ observation_tool_call_ids = View._get_observation_tool_call_ids(view_events)
344
331
 
345
332
  # Build batch info for batch atomicity enforcement
346
- action_batch = ActionBatch.from_events(events)
333
+ batch = ActionBatch.from_events(all_events)
347
334
 
348
- # First pass: identify which events would NOT be kept based on matching
349
- removed_event_ids: set[EventID] = set()
350
- for event in events:
351
- if not View._should_keep_event(
335
+ # First pass: filter out events that don't match based on tool call pairing
336
+ kept_events = [
337
+ event
338
+ for event in view_events
339
+ if View._should_keep_event(
352
340
  event, action_tool_call_ids, observation_tool_call_ids
353
- ):
354
- removed_event_ids.add(event.id)
341
+ )
342
+ ]
355
343
 
356
344
  # Second pass: enforce batch atomicity for ActionEvents
357
345
  # If any ActionEvent in a batch is removed, all ActionEvents in that
358
346
  # batch should also be removed
359
- removed_event_ids = View._enforce_batch_atomicity(events, removed_event_ids)
347
+ kept_events = View._enforce_batch_atomicity(kept_events, all_events)
360
348
 
361
349
  # Third pass: also remove ObservationEvents whose ActionEvents were removed
362
350
  # due to batch atomicity
363
- tool_call_ids_to_remove: set[ToolCallID] = set()
364
- for action_id in removed_event_ids:
365
- if action_id in action_batch.action_id_to_tool_call_id:
366
- tool_call_ids_to_remove.add(
367
- action_batch.action_id_to_tool_call_id[action_id]
368
- )
369
-
370
- # Filter out removed events
371
- result = []
372
- for event in events:
373
- if event.id in removed_event_ids:
374
- continue
375
- if isinstance(event, ObservationBaseEvent):
376
- if event.tool_call_id in tool_call_ids_to_remove:
377
- continue
378
- result.append(event)
351
+ # Find which action IDs are now missing after batch atomicity enforcement
352
+ kept_event_ids = {event.id for event in kept_events}
353
+ tool_call_ids_to_remove: set[ToolCallID] = {
354
+ tool_call_id
355
+ for action_id, tool_call_id in batch.action_id_to_tool_call_id.items()
356
+ if action_id not in kept_event_ids
357
+ }
358
+
359
+ # Filter out ObservationEvents whose ActionEvents were removed
360
+ result = [
361
+ event
362
+ for event in kept_events
363
+ if not (
364
+ isinstance(event, ObservationBaseEvent)
365
+ and event.tool_call_id in tool_call_ids_to_remove
366
+ )
367
+ ]
379
368
 
380
369
  return result
381
370
 
382
371
  @staticmethod
383
- def _get_action_tool_call_ids(events: list[LLMConvertibleEvent]) -> set[ToolCallID]:
372
+ def _get_action_tool_call_ids(events: Sequence[Event]) -> set[ToolCallID]:
384
373
  """Extract tool_call_ids from ActionEvents."""
385
374
  tool_call_ids = set()
386
375
  for event in events:
@@ -390,7 +379,7 @@ class View(BaseModel):
390
379
 
391
380
  @staticmethod
392
381
  def _get_observation_tool_call_ids(
393
- events: list[LLMConvertibleEvent],
382
+ events: Sequence[Event],
394
383
  ) -> set[ToolCallID]:
395
384
  """Extract tool_call_ids from ObservationEvents."""
396
385
  tool_call_ids = set()
@@ -404,7 +393,7 @@ class View(BaseModel):
404
393
 
405
394
  @staticmethod
406
395
  def _should_keep_event(
407
- event: LLMConvertibleEvent,
396
+ event: Event,
408
397
  action_tool_call_ids: set[ToolCallID],
409
398
  observation_tool_call_ids: set[ToolCallID],
410
399
  ) -> bool:
@@ -434,67 +423,64 @@ class View(BaseModel):
434
423
  return idx
435
424
  return threshold
436
425
 
426
+ @staticmethod
427
+ def unhandled_condensation_request_exists(
428
+ events: Sequence[Event],
429
+ ) -> bool:
430
+ """Check if there is an unhandled condensation request in the list of events.
431
+
432
+ An unhandled condensation request is defined as a CondensationRequest event
433
+ that appears after the most recent Condensation event in the list.
434
+ """
435
+ for event in reversed(events):
436
+ if isinstance(event, Condensation):
437
+ return False
438
+ if isinstance(event, CondensationRequest):
439
+ return True
440
+ return False
441
+
437
442
  @staticmethod
438
443
  def from_events(events: Sequence[Event]) -> View:
439
444
  """Create a view from a list of events, respecting the semantics of any
440
445
  condensation events.
441
446
  """
442
- forgotten_event_ids: set[EventID] = set()
447
+ output: list[LLMConvertibleEvent] = []
443
448
  condensations: list[Condensation] = []
449
+
450
+ # Generate the LLMConvertibleEvent objects the agent can send to the LLM by
451
+ # removing un-sendable events and applying condensations in order.
444
452
  for event in events:
453
+ # By the time we come across a Condensation event, the output list should
454
+ # already reflect the events seen by the agent up to that point. We can
455
+ # therefore apply the condensation semantics directly to the output list.
445
456
  if isinstance(event, Condensation):
446
457
  condensations.append(event)
447
- forgotten_event_ids.update(event.forgotten_event_ids)
448
- # Make sure we also forget the condensation action itself
449
- forgotten_event_ids.add(event.id)
450
- if isinstance(event, CondensationRequest):
451
- forgotten_event_ids.add(event.id)
452
-
453
- # Enforce batch atomicity: if any event in a multi-action batch is forgotten,
454
- # forget all events in that batch to prevent partial batches with thinking
455
- # blocks separated from their tool calls
456
- forgotten_event_ids = View._enforce_batch_atomicity(events, forgotten_event_ids)
457
-
458
- kept_events = [
459
- event
460
- for event in events
461
- if event.id not in forgotten_event_ids
462
- and isinstance(event, LLMConvertibleEvent)
463
- ]
458
+ output = event.apply(output)
464
459
 
465
- # If we have a summary, insert it at the specified offset.
466
- summary: str | None = None
467
- summary_offset: int | None = None
460
+ elif isinstance(event, LLMConvertibleEvent):
461
+ output.append(event)
468
462
 
469
- # The relevant summary is always in the last condensation event (i.e., the most
470
- # recent one).
471
- for event in reversed(events):
472
- if isinstance(event, Condensation):
473
- if event.summary is not None and event.summary_offset is not None:
474
- summary = event.summary
475
- summary_offset = event.summary_offset
476
- break
477
-
478
- if summary is not None and summary_offset is not None:
479
- logger.debug(f"Inserting summary at offset {summary_offset}")
480
-
481
- _new_summary_event = CondensationSummaryEvent(summary=summary)
482
- kept_events.insert(summary_offset, _new_summary_event)
483
-
484
- # Check for an unhandled condensation request -- these are events closer to the
485
- # end of the list than any condensation action.
486
- unhandled_condensation_request = False
487
-
488
- for event in reversed(events):
489
- if isinstance(event, Condensation):
490
- break
463
+ # If the event isn't related to condensation and isn't LLMConvertible, it
464
+ # should not be in the resulting view. Examples include certain internal
465
+ # events used for state tracking that the LLM does not need to see -- see,
466
+ # for example, ConversationStateUpdateEvent, PauseEvent, and (relevant here)
467
+ # CondensationRequest.
468
+ else:
469
+ logger.debug(
470
+ f"Skipping non-LLMConvertibleEvent of type {type(event)} "
471
+ f"in View.from_events"
472
+ )
491
473
 
492
- if isinstance(event, CondensationRequest):
493
- unhandled_condensation_request = True
494
- break
474
+ # Enforce batch atomicity: if any event in a multi-action batch is removed,
475
+ # remove all events in that batch to prevent partial batches with thinking
476
+ # blocks separated from their tool calls
477
+ output = View._enforce_batch_atomicity(output, events)
478
+ output = View._filter_unmatched_tool_calls(output, events)
495
479
 
496
480
  return View(
497
- events=View.filter_unmatched_tool_calls(kept_events),
498
- unhandled_condensation_request=unhandled_condensation_request,
481
+ events=output,
482
+ unhandled_condensation_request=View.unhandled_condensation_request_exists(
483
+ events
484
+ ),
499
485
  condensations=condensations,
500
486
  )
@@ -2,6 +2,7 @@ from openhands.sdk.conversation.base import BaseConversation
2
2
  from openhands.sdk.conversation.conversation import Conversation
3
3
  from openhands.sdk.conversation.event_store import EventLog
4
4
  from openhands.sdk.conversation.events_list_base import EventsListBase
5
+ from openhands.sdk.conversation.exceptions import WebSocketConnectionError
5
6
  from openhands.sdk.conversation.impl.local_conversation import LocalConversation
6
7
  from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation
7
8
  from openhands.sdk.conversation.response_utils import get_agent_final_response
@@ -37,4 +38,5 @@ __all__ = [
37
38
  "RemoteConversation",
38
39
  "EventsListBase",
39
40
  "get_agent_final_response",
41
+ "WebSocketConnectionError",
40
42
  ]
@@ -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,
@@ -67,6 +74,7 @@ class Conversation:
67
74
  type[ConversationVisualizerBase] | ConversationVisualizerBase | None
68
75
  ) = DefaultConversationVisualizer,
69
76
  secrets: dict[str, SecretValue] | dict[str, str] | None = None,
77
+ delete_on_close: bool = False,
70
78
  ) -> "LocalConversation": ...
71
79
 
72
80
  @overload
@@ -75,6 +83,7 @@ class Conversation:
75
83
  agent: AgentBase,
76
84
  *,
77
85
  workspace: RemoteWorkspace,
86
+ plugins: list[PluginSource] | None = None,
78
87
  conversation_id: ConversationID | None = None,
79
88
  callbacks: list[ConversationCallbackType] | None = None,
80
89
  token_callbacks: list[ConversationTokenCallbackType] | None = None,
@@ -88,6 +97,7 @@ class Conversation:
88
97
  type[ConversationVisualizerBase] | ConversationVisualizerBase | None
89
98
  ) = DefaultConversationVisualizer,
90
99
  secrets: dict[str, SecretValue] | dict[str, str] | None = None,
100
+ delete_on_close: bool = False,
91
101
  ) -> "RemoteConversation": ...
92
102
 
93
103
  def __new__(
@@ -95,6 +105,7 @@ class Conversation:
95
105
  agent: AgentBase,
96
106
  *,
97
107
  workspace: str | Path | LocalWorkspace | RemoteWorkspace = "workspace/project",
108
+ plugins: list[PluginSource] | None = None,
98
109
  persistence_dir: str | Path | None = None,
99
110
  conversation_id: ConversationID | None = None,
100
111
  callbacks: list[ConversationCallbackType] | None = None,
@@ -109,6 +120,7 @@ class Conversation:
109
120
  type[ConversationVisualizerBase] | ConversationVisualizerBase | None
110
121
  ) = DefaultConversationVisualizer,
111
122
  secrets: dict[str, SecretValue] | dict[str, str] | None = None,
123
+ delete_on_close: bool = False,
112
124
  ) -> BaseConversation:
113
125
  from openhands.sdk.conversation.impl.local_conversation import LocalConversation
114
126
  from openhands.sdk.conversation.impl.remote_conversation import (
@@ -116,14 +128,14 @@ class Conversation:
116
128
  )
117
129
 
118
130
  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
131
+ # For RemoteConversation, persistence_dir should not be used.
121
132
  if persistence_dir is not None:
122
133
  raise ValueError(
123
134
  "persistence_dir should not be set when using RemoteConversation"
124
135
  )
125
136
  return RemoteConversation(
126
137
  agent=agent,
138
+ plugins=plugins,
127
139
  conversation_id=conversation_id,
128
140
  callbacks=callbacks,
129
141
  token_callbacks=token_callbacks,
@@ -134,10 +146,12 @@ class Conversation:
134
146
  visualizer=visualizer,
135
147
  workspace=workspace,
136
148
  secrets=secrets,
149
+ delete_on_close=delete_on_close,
137
150
  )
138
151
 
139
152
  return LocalConversation(
140
153
  agent=agent,
154
+ plugins=plugins,
141
155
  conversation_id=conversation_id,
142
156
  callbacks=callbacks,
143
157
  token_callbacks=token_callbacks,
@@ -149,4 +163,5 @@ class Conversation:
149
163
  workspace=workspace,
150
164
  persistence_dir=persistence_dir,
151
165
  secrets=secrets,
166
+ delete_on_close=delete_on_close,
152
167
  )
@@ -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