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
@@ -106,20 +106,61 @@ class Agent(AgentBase):
106
106
  # TODO(openhands): we should add test to test this init_state will actually
107
107
  # modify state in-place
108
108
 
109
- llm_convertible_messages = [
110
- event for event in state.events if isinstance(event, LLMConvertibleEvent)
111
- ]
112
- if len(llm_convertible_messages) == 0:
113
- # Prepare system message
114
- event = SystemPromptEvent(
115
- source="agent",
116
- system_prompt=TextContent(text=self.system_message),
117
- # Tools are stored as ToolDefinition objects and converted to
118
- # OpenAI format with security_risk parameter during LLM completion.
119
- # See make_llm_completion() in agent/utils.py for details.
120
- tools=list(self.tools_map.values()),
109
+ # Defensive check: Analyze state to detect unexpected initialization scenarios
110
+ # These checks help diagnose issues related to lazy loading and event ordering
111
+ # See: https://github.com/OpenHands/software-agent-sdk/issues/1785
112
+ events = list(state.events)
113
+ has_system_prompt = any(isinstance(e, SystemPromptEvent) for e in events)
114
+ has_user_message = any(
115
+ isinstance(e, MessageEvent) and e.source == "user" for e in events
116
+ )
117
+ has_any_llm_event = any(isinstance(e, LLMConvertibleEvent) for e in events)
118
+
119
+ # Log state for debugging initialization order issues
120
+ logger.debug(
121
+ f"init_state called: conversation_id={state.id}, "
122
+ f"event_count={len(events)}, "
123
+ f"has_system_prompt={has_system_prompt}, "
124
+ f"has_user_message={has_user_message}, "
125
+ f"has_any_llm_event={has_any_llm_event}"
126
+ )
127
+
128
+ if has_system_prompt:
129
+ # SystemPromptEvent already exists - this is unexpected during normal flow
130
+ # but could happen in persistence/resume scenarios
131
+ logger.warning(
132
+ f"init_state called but SystemPromptEvent already exists. "
133
+ f"conversation_id={state.id}, event_count={len(events)}. "
134
+ f"This may indicate double initialization or a resume scenario."
121
135
  )
122
- on_event(event)
136
+ return
137
+
138
+ # Assert: If there are user messages but no system prompt, something is wrong
139
+ # The system prompt should always be added before any user messages
140
+ if has_user_message:
141
+ event_types = [type(e).__name__ for e in events]
142
+ logger.error(
143
+ f"init_state: User message exists without SystemPromptEvent! "
144
+ f"conversation_id={state.id}, events={event_types}"
145
+ )
146
+ assert not has_user_message, (
147
+ f"Unexpected state: User message exists before SystemPromptEvent. "
148
+ f"conversation_id={state.id}, event_count={len(events)}, "
149
+ f"event_types={event_types}. "
150
+ f"This indicates an initialization order bug - init_state should be "
151
+ f"called before any user messages are added to the conversation."
152
+ )
153
+
154
+ # Prepare system message
155
+ event = SystemPromptEvent(
156
+ source="agent",
157
+ system_prompt=TextContent(text=self.system_message),
158
+ # Tools are stored as ToolDefinition objects and converted to
159
+ # OpenAI format with security_risk parameter during LLM completion.
160
+ # See make_llm_completion() in agent/utils.py for details.
161
+ tools=list(self.tools_map.values()),
162
+ )
163
+ on_event(event)
123
164
 
124
165
  def _should_evaluate_with_critic(self, action: Action | None) -> bool:
125
166
  """Determine if critic should evaluate based on action type and mode."""
@@ -345,28 +345,28 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
345
345
  def verify(
346
346
  self,
347
347
  persisted: AgentBase,
348
- events: Sequence[Any] | None = None,
348
+ events: Sequence[Any] | None = None, # noqa: ARG002
349
349
  ) -> AgentBase:
350
350
  """Verify that we can resume this agent from persisted state.
351
351
 
352
- This PR's goal is to *not* reconcile configuration between persisted and
353
- runtime Agent instances. Instead, we verify compatibility requirements
354
- and then continue with the runtime-provided Agent.
352
+ We do not merge configuration between persisted and runtime Agent
353
+ instances. Instead, we verify compatibility requirements and then
354
+ continue with the runtime-provided Agent.
355
355
 
356
356
  Compatibility requirements:
357
357
  - Agent class/type must match.
358
- - Tools:
359
- - If events are provided, only tools that were actually used in history
360
- must exist in runtime.
361
- - If events are not provided, tool names must match exactly.
358
+ - Tools must match exactly (same tool names).
362
359
 
363
- All other configuration (LLM, agent_context, condenser, system prompts,
364
- etc.) can be freely changed between sessions.
360
+ Tools are part of the system prompt and cannot be changed mid-conversation.
361
+ To use different tools, start a new conversation or use conversation forking
362
+ (see https://github.com/OpenHands/OpenHands/issues/8560).
363
+
364
+ All other configuration (LLM, agent_context, condenser, etc.) can be
365
+ freely changed between sessions.
365
366
 
366
367
  Args:
367
368
  persisted: The agent loaded from persisted state.
368
- events: Optional event sequence to scan for used tools if tool names
369
- don't match.
369
+ events: Unused, kept for API compatibility.
370
370
 
371
371
  Returns:
372
372
  This runtime agent (self) if verification passes.
@@ -381,52 +381,39 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
381
381
  f"{self.__class__.__name__}."
382
382
  )
383
383
 
384
+ # Collect explicit tool names
384
385
  runtime_names = {tool.name for tool in self.tools}
385
386
  persisted_names = {tool.name for tool in persisted.tools}
386
387
 
387
- if runtime_names == persisted_names:
388
- return self
389
-
390
- if events is not None:
391
- from openhands.sdk.event import ActionEvent
392
-
393
- used_tools = {
394
- event.tool_name
395
- for event in events
396
- if isinstance(event, ActionEvent) and event.tool_name
397
- }
388
+ # Add builtin tool names from include_default_tools
389
+ # These are runtime names like 'finish', 'think'
390
+ for tool_class_name in self.include_default_tools:
391
+ tool_class = BUILT_IN_TOOL_CLASSES.get(tool_class_name)
392
+ if tool_class is not None:
393
+ runtime_names.add(tool_class.name)
398
394
 
399
- # Add builtin tool names from include_default_tools
400
- # These are runtime names like 'finish', 'think'
401
- for tool_class_name in self.include_default_tools:
402
- tool_class = BUILT_IN_TOOL_CLASSES.get(tool_class_name)
403
- if tool_class is not None:
404
- runtime_names.add(tool_class.name)
405
-
406
- # Only require tools that were actually used in history.
407
- missing_used_tools = used_tools - runtime_names
408
- if missing_used_tools:
409
- raise ValueError(
410
- "Cannot resume conversation: tools that were used in history "
411
- f"are missing from runtime: {sorted(missing_used_tools)}. "
412
- f"Available tools: {sorted(runtime_names)}"
413
- )
395
+ for tool_class_name in persisted.include_default_tools:
396
+ tool_class = BUILT_IN_TOOL_CLASSES.get(tool_class_name)
397
+ if tool_class is not None:
398
+ persisted_names.add(tool_class.name)
414
399
 
400
+ if runtime_names == persisted_names:
415
401
  return self
416
402
 
417
- # No events provided: strict tool name matching.
403
+ # Tools don't match - this is not allowed
418
404
  missing_in_runtime = persisted_names - runtime_names
419
- missing_in_persisted = runtime_names - persisted_names
405
+ added_in_runtime = runtime_names - persisted_names
420
406
 
421
407
  details: list[str] = []
422
408
  if missing_in_runtime:
423
- details.append(f"Missing in runtime: {sorted(missing_in_runtime)}")
424
- if missing_in_persisted:
425
- details.append(f"Missing in persisted: {sorted(missing_in_persisted)}")
409
+ details.append(f"removed: {sorted(missing_in_runtime)}")
410
+ if added_in_runtime:
411
+ details.append(f"added: {sorted(added_in_runtime)}")
426
412
 
427
- suffix = f" ({'; '.join(details)})" if details else ""
428
413
  raise ValueError(
429
- "Tools don't match between runtime and persisted agents." + suffix
414
+ f"Cannot resume conversation: tools cannot be changed mid-conversation "
415
+ f"({'; '.join(details)}). "
416
+ f"To use different tools, start a new conversation."
430
417
  )
431
418
 
432
419
  def model_dump_succint(self, **kwargs):
@@ -17,7 +17,6 @@ from openhands.sdk.context.prompts import render_template
17
17
  from openhands.sdk.context.view import View
18
18
  from openhands.sdk.event.base import LLMConvertibleEvent
19
19
  from openhands.sdk.event.condenser import Condensation
20
- from openhands.sdk.event.llm_convertible import MessageEvent
21
20
  from openhands.sdk.llm import LLM, Message, TextContent
22
21
  from openhands.sdk.observability.laminar import observe
23
22
 
@@ -117,25 +116,8 @@ class LLMSummarizingCondenser(RollingCondenser):
117
116
  if Reason.REQUEST in reasons:
118
117
  return CondensationRequirement.HARD
119
118
 
120
- def _get_summary_event_content(self, view: View) -> str:
121
- """Extract the text content from the summary event in the view, if any.
122
-
123
- If there is no summary event or it does not contain text content, returns an
124
- empty string.
125
- """
126
- summary_event_content: str = ""
127
-
128
- summary_event = view.summary_event
129
- if isinstance(summary_event, MessageEvent):
130
- message_content = summary_event.llm_message.content[0]
131
- if isinstance(message_content, TextContent):
132
- summary_event_content = message_content.text
133
-
134
- return summary_event_content
135
-
136
119
  def _generate_condensation(
137
120
  self,
138
- summary_event_content: str,
139
121
  forgotten_events: Sequence[LLMConvertibleEvent],
140
122
  summary_offset: int,
141
123
  ) -> Condensation:
@@ -143,7 +125,6 @@ class LLMSummarizingCondenser(RollingCondenser):
143
125
  events.
144
126
 
145
127
  Args:
146
- summary_event_content: The content of the previous summary event.
147
128
  forgotten_events: The list of events to be summarized.
148
129
  summary_offset: The index where the summary event should be inserted.
149
130
 
@@ -161,7 +142,6 @@ class LLMSummarizingCondenser(RollingCondenser):
161
142
  prompt = render_template(
162
143
  os.path.join(os.path.dirname(__file__), "prompts"),
163
144
  "summarizing_prompt.j2",
164
- previous_summary=summary_event_content,
165
145
  events=event_strings,
166
146
  )
167
147
 
@@ -269,10 +249,7 @@ class LLMSummarizingCondenser(RollingCondenser):
269
249
  "events. Consider adjusting keep_first or max_size parameters."
270
250
  )
271
251
 
272
- summary_event_content = self._get_summary_event_content(view)
273
-
274
252
  return self._generate_condensation(
275
- summary_event_content=summary_event_content,
276
253
  forgotten_events=forgotten_events,
277
254
  summary_offset=summary_offset,
278
255
  )
@@ -1,5 +1,5 @@
1
1
  You are maintaining a context-aware state summary for an interactive agent.
2
- You will be given a list of events corresponding to actions taken by the agent, and the most recent previous summary if one exists.
2
+ You will be given a list of events corresponding to actions taken by the agent, which will include previous summaries.
3
3
  If the events being summarized contain ANY task-tracking, you MUST include a TASK_TRACKING section to maintain continuity.
4
4
  When referencing tasks make sure to preserve exact task IDs and statuses.
5
5
 
@@ -46,10 +46,6 @@ COMPLETED: 15 haikus written for results [T,H,T,H,T,H,T,T,H,T,H,T,H,T,H]
46
46
  PENDING: 5 more haikus needed
47
47
  CURRENT_STATE: Last flip: Heads, Haiku count: 15/20
48
48
 
49
- <PREVIOUS SUMMARY>
50
- {{ previous_summary }}
51
- </PREVIOUS SUMMARY>
52
-
53
49
  {% for event in events %}
54
50
  <EVENT>
55
51
  {{ event }}
@@ -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
  ]