openhands-sdk 1.7.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 (172) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +607 -0
  4. openhands/sdk/agent/base.py +454 -0
  5. openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
  6. openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
  7. openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
  8. openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
  9. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +3 -0
  10. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
  11. openhands/sdk/agent/prompts/security_policy.j2 +22 -0
  12. openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
  13. openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
  14. openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
  15. openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
  16. openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
  17. openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
  18. openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
  19. openhands/sdk/agent/utils.py +223 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +240 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +95 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +89 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +13 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +55 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/prompts/__init__.py +6 -0
  29. openhands/sdk/context/prompts/prompt.py +114 -0
  30. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  31. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  32. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  33. openhands/sdk/context/skills/__init__.py +28 -0
  34. openhands/sdk/context/skills/exceptions.py +11 -0
  35. openhands/sdk/context/skills/skill.py +630 -0
  36. openhands/sdk/context/skills/trigger.py +36 -0
  37. openhands/sdk/context/skills/types.py +48 -0
  38. openhands/sdk/context/view.py +306 -0
  39. openhands/sdk/conversation/__init__.py +40 -0
  40. openhands/sdk/conversation/base.py +281 -0
  41. openhands/sdk/conversation/conversation.py +146 -0
  42. openhands/sdk/conversation/conversation_stats.py +85 -0
  43. openhands/sdk/conversation/event_store.py +157 -0
  44. openhands/sdk/conversation/events_list_base.py +17 -0
  45. openhands/sdk/conversation/exceptions.py +50 -0
  46. openhands/sdk/conversation/fifo_lock.py +133 -0
  47. openhands/sdk/conversation/impl/__init__.py +5 -0
  48. openhands/sdk/conversation/impl/local_conversation.py +620 -0
  49. openhands/sdk/conversation/impl/remote_conversation.py +883 -0
  50. openhands/sdk/conversation/persistence_const.py +9 -0
  51. openhands/sdk/conversation/response_utils.py +41 -0
  52. openhands/sdk/conversation/secret_registry.py +126 -0
  53. openhands/sdk/conversation/serialization_diff.py +0 -0
  54. openhands/sdk/conversation/state.py +352 -0
  55. openhands/sdk/conversation/stuck_detector.py +311 -0
  56. openhands/sdk/conversation/title_utils.py +191 -0
  57. openhands/sdk/conversation/types.py +45 -0
  58. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  59. openhands/sdk/conversation/visualizer/base.py +67 -0
  60. openhands/sdk/conversation/visualizer/default.py +373 -0
  61. openhands/sdk/critic/__init__.py +15 -0
  62. openhands/sdk/critic/base.py +38 -0
  63. openhands/sdk/critic/impl/__init__.py +12 -0
  64. openhands/sdk/critic/impl/agent_finished.py +83 -0
  65. openhands/sdk/critic/impl/empty_patch.py +49 -0
  66. openhands/sdk/critic/impl/pass_critic.py +42 -0
  67. openhands/sdk/event/__init__.py +42 -0
  68. openhands/sdk/event/base.py +149 -0
  69. openhands/sdk/event/condenser.py +82 -0
  70. openhands/sdk/event/conversation_error.py +25 -0
  71. openhands/sdk/event/conversation_state.py +104 -0
  72. openhands/sdk/event/llm_completion_log.py +39 -0
  73. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  74. openhands/sdk/event/llm_convertible/action.py +139 -0
  75. openhands/sdk/event/llm_convertible/message.py +142 -0
  76. openhands/sdk/event/llm_convertible/observation.py +141 -0
  77. openhands/sdk/event/llm_convertible/system.py +61 -0
  78. openhands/sdk/event/token.py +16 -0
  79. openhands/sdk/event/types.py +11 -0
  80. openhands/sdk/event/user_action.py +21 -0
  81. openhands/sdk/git/exceptions.py +43 -0
  82. openhands/sdk/git/git_changes.py +249 -0
  83. openhands/sdk/git/git_diff.py +129 -0
  84. openhands/sdk/git/models.py +21 -0
  85. openhands/sdk/git/utils.py +189 -0
  86. openhands/sdk/io/__init__.py +6 -0
  87. openhands/sdk/io/base.py +48 -0
  88. openhands/sdk/io/local.py +82 -0
  89. openhands/sdk/io/memory.py +54 -0
  90. openhands/sdk/llm/__init__.py +45 -0
  91. openhands/sdk/llm/exceptions/__init__.py +45 -0
  92. openhands/sdk/llm/exceptions/classifier.py +50 -0
  93. openhands/sdk/llm/exceptions/mapping.py +54 -0
  94. openhands/sdk/llm/exceptions/types.py +101 -0
  95. openhands/sdk/llm/llm.py +1140 -0
  96. openhands/sdk/llm/llm_registry.py +122 -0
  97. openhands/sdk/llm/llm_response.py +59 -0
  98. openhands/sdk/llm/message.py +656 -0
  99. openhands/sdk/llm/mixins/fn_call_converter.py +1243 -0
  100. openhands/sdk/llm/mixins/non_native_fc.py +93 -0
  101. openhands/sdk/llm/options/__init__.py +1 -0
  102. openhands/sdk/llm/options/chat_options.py +93 -0
  103. openhands/sdk/llm/options/common.py +19 -0
  104. openhands/sdk/llm/options/responses_options.py +67 -0
  105. openhands/sdk/llm/router/__init__.py +10 -0
  106. openhands/sdk/llm/router/base.py +117 -0
  107. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  108. openhands/sdk/llm/router/impl/random.py +22 -0
  109. openhands/sdk/llm/streaming.py +9 -0
  110. openhands/sdk/llm/utils/metrics.py +312 -0
  111. openhands/sdk/llm/utils/model_features.py +191 -0
  112. openhands/sdk/llm/utils/model_info.py +90 -0
  113. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  114. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  115. openhands/sdk/llm/utils/telemetry.py +362 -0
  116. openhands/sdk/llm/utils/unverified_models.py +156 -0
  117. openhands/sdk/llm/utils/verified_models.py +66 -0
  118. openhands/sdk/logger/__init__.py +22 -0
  119. openhands/sdk/logger/logger.py +195 -0
  120. openhands/sdk/logger/rolling.py +113 -0
  121. openhands/sdk/mcp/__init__.py +24 -0
  122. openhands/sdk/mcp/client.py +76 -0
  123. openhands/sdk/mcp/definition.py +106 -0
  124. openhands/sdk/mcp/exceptions.py +19 -0
  125. openhands/sdk/mcp/tool.py +270 -0
  126. openhands/sdk/mcp/utils.py +83 -0
  127. openhands/sdk/observability/__init__.py +4 -0
  128. openhands/sdk/observability/laminar.py +166 -0
  129. openhands/sdk/observability/utils.py +20 -0
  130. openhands/sdk/py.typed +0 -0
  131. openhands/sdk/secret/__init__.py +19 -0
  132. openhands/sdk/secret/secrets.py +92 -0
  133. openhands/sdk/security/__init__.py +6 -0
  134. openhands/sdk/security/analyzer.py +111 -0
  135. openhands/sdk/security/confirmation_policy.py +61 -0
  136. openhands/sdk/security/llm_analyzer.py +29 -0
  137. openhands/sdk/security/risk.py +100 -0
  138. openhands/sdk/tool/__init__.py +34 -0
  139. openhands/sdk/tool/builtins/__init__.py +34 -0
  140. openhands/sdk/tool/builtins/finish.py +106 -0
  141. openhands/sdk/tool/builtins/think.py +117 -0
  142. openhands/sdk/tool/registry.py +161 -0
  143. openhands/sdk/tool/schema.py +276 -0
  144. openhands/sdk/tool/spec.py +39 -0
  145. openhands/sdk/tool/tool.py +481 -0
  146. openhands/sdk/utils/__init__.py +22 -0
  147. openhands/sdk/utils/async_executor.py +115 -0
  148. openhands/sdk/utils/async_utils.py +39 -0
  149. openhands/sdk/utils/cipher.py +68 -0
  150. openhands/sdk/utils/command.py +90 -0
  151. openhands/sdk/utils/deprecation.py +166 -0
  152. openhands/sdk/utils/github.py +44 -0
  153. openhands/sdk/utils/json.py +48 -0
  154. openhands/sdk/utils/models.py +570 -0
  155. openhands/sdk/utils/paging.py +63 -0
  156. openhands/sdk/utils/pydantic_diff.py +85 -0
  157. openhands/sdk/utils/pydantic_secrets.py +64 -0
  158. openhands/sdk/utils/truncate.py +117 -0
  159. openhands/sdk/utils/visualize.py +58 -0
  160. openhands/sdk/workspace/__init__.py +17 -0
  161. openhands/sdk/workspace/base.py +158 -0
  162. openhands/sdk/workspace/local.py +189 -0
  163. openhands/sdk/workspace/models.py +35 -0
  164. openhands/sdk/workspace/remote/__init__.py +8 -0
  165. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  166. openhands/sdk/workspace/remote/base.py +164 -0
  167. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  168. openhands/sdk/workspace/workspace.py +49 -0
  169. openhands_sdk-1.7.0.dist-info/METADATA +17 -0
  170. openhands_sdk-1.7.0.dist-info/RECORD +172 -0
  171. openhands_sdk-1.7.0.dist-info/WHEEL +5 -0
  172. openhands_sdk-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,48 @@
1
+ from datetime import UTC, datetime
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class InputMetadata(BaseModel):
7
+ """Metadata for task skill inputs."""
8
+
9
+ name: str = Field(description="Name of the input parameter")
10
+ description: str = Field(description="Description of the input parameter")
11
+
12
+
13
+ class SkillKnowledge(BaseModel):
14
+ """Represents knowledge from a triggered skill."""
15
+
16
+ name: str = Field(description="The name of the skill that was triggered")
17
+ trigger: str = Field(description="The word that triggered this skill")
18
+ content: str = Field(description="The actual content/knowledge from the skill")
19
+
20
+
21
+ class SkillResponse(BaseModel):
22
+ """Response model for skills endpoint.
23
+
24
+ Note: This model only includes basic metadata that can be determined
25
+ without parsing skill content. Use the separate content API
26
+ to get detailed skill information.
27
+ """
28
+
29
+ name: str = Field(description="The name of the skill")
30
+ path: str = Field(description="The path or identifier of the skill")
31
+ created_at: datetime = Field(
32
+ default_factory=lambda: datetime.now(UTC),
33
+ description="Timestamp when the skill was created",
34
+ )
35
+
36
+
37
+ class SkillContentResponse(BaseModel):
38
+ """Response model for individual skill content endpoint."""
39
+
40
+ content: str = Field(description="The full content of the skill")
41
+ path: str = Field(description="The path or identifier of the skill")
42
+ triggers: list[str] = Field(
43
+ description="List of triggers associated with the skill"
44
+ )
45
+ git_provider: str | None = Field(
46
+ None,
47
+ description="Git provider if the skill is sourced from a Git repository",
48
+ )
@@ -0,0 +1,306 @@
1
+ from collections.abc import Sequence
2
+ from logging import getLogger
3
+ from typing import overload
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from openhands.sdk.event import (
8
+ Condensation,
9
+ CondensationRequest,
10
+ CondensationSummaryEvent,
11
+ LLMConvertibleEvent,
12
+ )
13
+ from openhands.sdk.event.base import Event, EventID
14
+ from openhands.sdk.event.llm_convertible import (
15
+ ActionEvent,
16
+ ObservationBaseEvent,
17
+ )
18
+ from openhands.sdk.event.types import ToolCallID
19
+
20
+
21
+ logger = getLogger(__name__)
22
+
23
+
24
+ class View(BaseModel):
25
+ """Linearly ordered view of events.
26
+
27
+ Produced by a condenser to indicate the included events are ready to process as LLM
28
+ input. Also contains fields with information from the condensation process to aid
29
+ in deciding whether further condensation is needed.
30
+ """
31
+
32
+ events: list[LLMConvertibleEvent]
33
+
34
+ unhandled_condensation_request: bool = False
35
+ """Whether there is an unhandled condensation request in the view."""
36
+
37
+ condensations: list[Condensation] = []
38
+ """A list of condensations that were processed to produce the view."""
39
+
40
+ def __len__(self) -> int:
41
+ return len(self.events)
42
+
43
+ @property
44
+ def most_recent_condensation(self) -> Condensation | None:
45
+ """Return the most recent condensation, or None if no condensations exist."""
46
+ return self.condensations[-1] if self.condensations else None
47
+
48
+ @property
49
+ def summary_event_index(self) -> int | None:
50
+ """Return the index of the summary event, or None if no summary exists."""
51
+ recent_condensation = self.most_recent_condensation
52
+ if (
53
+ recent_condensation is not None
54
+ and recent_condensation.summary is not None
55
+ and recent_condensation.summary_offset is not None
56
+ ):
57
+ return recent_condensation.summary_offset
58
+ return None
59
+
60
+ @property
61
+ def summary_event(self) -> CondensationSummaryEvent | None:
62
+ """Return the summary event, or None if no summary exists."""
63
+ if self.summary_event_index is not None:
64
+ event = self.events[self.summary_event_index]
65
+ if isinstance(event, CondensationSummaryEvent):
66
+ return event
67
+ return None
68
+
69
+ # To preserve list-like indexing, we ideally support slicing and position-based
70
+ # indexing. The only challenge with that is switching the return type based on the
71
+ # input type -- we can mark the different signatures for MyPy with `@overload`
72
+ # decorators.
73
+
74
+ @overload
75
+ def __getitem__(self, key: slice) -> list[LLMConvertibleEvent]: ...
76
+
77
+ @overload
78
+ def __getitem__(self, key: int) -> LLMConvertibleEvent: ...
79
+
80
+ def __getitem__(
81
+ self, key: int | slice
82
+ ) -> LLMConvertibleEvent | list[LLMConvertibleEvent]:
83
+ if isinstance(key, slice):
84
+ start, stop, step = key.indices(len(self))
85
+ return [self[i] for i in range(start, stop, step)]
86
+ elif isinstance(key, int):
87
+ return self.events[key]
88
+ else:
89
+ raise ValueError(f"Invalid key type: {type(key)}")
90
+
91
+ @staticmethod
92
+ def _build_action_batches(
93
+ events: Sequence[Event],
94
+ ) -> tuple[
95
+ dict[EventID, list[EventID]], dict[EventID, EventID], dict[EventID, ToolCallID]
96
+ ]:
97
+ """Build a map of llm_response_id -> list of ActionEvent IDs.
98
+
99
+ Returns:
100
+ A tuple of:
101
+ - batches: dict mapping llm_response_id to list of ActionEvent IDs
102
+ - action_id_to_response_id: dict mapping ActionEvent ID to llm_response_id
103
+ - action_id_to_tool_call_id: dict mapping ActionEvent ID to tool_call_id
104
+ """
105
+ batches: dict[EventID, list[EventID]] = {}
106
+ action_id_to_response_id: dict[EventID, EventID] = {}
107
+ action_id_to_tool_call_id: dict[EventID, ToolCallID] = {}
108
+
109
+ for event in events:
110
+ if isinstance(event, ActionEvent):
111
+ llm_response_id = event.llm_response_id
112
+ if llm_response_id not in batches:
113
+ batches[llm_response_id] = []
114
+ batches[llm_response_id].append(event.id)
115
+ action_id_to_response_id[event.id] = llm_response_id
116
+ action_id_to_tool_call_id[event.id] = event.tool_call_id
117
+
118
+ return batches, action_id_to_response_id, action_id_to_tool_call_id
119
+
120
+ @staticmethod
121
+ def _enforce_batch_atomicity(
122
+ events: Sequence[Event],
123
+ removed_event_ids: set[EventID],
124
+ ) -> set[EventID]:
125
+ """Ensure that if any ActionEvent in a batch is removed, all ActionEvents
126
+ in that batch are removed.
127
+
128
+ This prevents partial batches from being sent to the LLM, which can cause
129
+ API errors when thinking blocks are separated from their tool calls.
130
+
131
+ Args:
132
+ events: The original list of events
133
+ removed_event_ids: Set of event IDs that are being removed
134
+
135
+ Returns:
136
+ Updated set of event IDs that should be removed (including all
137
+ ActionEvents in batches where any ActionEvent was removed)
138
+ """
139
+ batches, action_id_to_response_id, _ = View._build_action_batches(events)
140
+
141
+ if not batches:
142
+ return removed_event_ids
143
+
144
+ updated_removed_ids = set(removed_event_ids)
145
+
146
+ for llm_response_id, batch_event_ids in batches.items():
147
+ # Check if any ActionEvent in this batch is being removed
148
+ if any(event_id in removed_event_ids for event_id in batch_event_ids):
149
+ # If so, remove all ActionEvents in this batch
150
+ updated_removed_ids.update(batch_event_ids)
151
+ logger.debug(
152
+ f"Enforcing batch atomicity: removing entire batch "
153
+ f"with llm_response_id={llm_response_id} "
154
+ f"({len(batch_event_ids)} events)"
155
+ )
156
+
157
+ return updated_removed_ids
158
+
159
+ @staticmethod
160
+ def filter_unmatched_tool_calls(
161
+ events: list[LLMConvertibleEvent],
162
+ ) -> list[LLMConvertibleEvent]:
163
+ """Filter out unmatched tool call events.
164
+
165
+ Removes ActionEvents and ObservationEvents that have tool_call_ids
166
+ but don't have matching pairs. Also enforces batch atomicity - if any
167
+ ActionEvent in a batch is filtered out, all ActionEvents in that batch
168
+ are also filtered out.
169
+ """
170
+ action_tool_call_ids = View._get_action_tool_call_ids(events)
171
+ observation_tool_call_ids = View._get_observation_tool_call_ids(events)
172
+
173
+ # Build batch info for batch atomicity enforcement
174
+ _, _, action_id_to_tool_call_id = View._build_action_batches(events)
175
+
176
+ # First pass: identify which events would NOT be kept based on matching
177
+ removed_event_ids: set[EventID] = set()
178
+ for event in events:
179
+ if not View._should_keep_event(
180
+ event, action_tool_call_ids, observation_tool_call_ids
181
+ ):
182
+ removed_event_ids.add(event.id)
183
+
184
+ # Second pass: enforce batch atomicity for ActionEvents
185
+ # If any ActionEvent in a batch is removed, all ActionEvents in that
186
+ # batch should also be removed
187
+ removed_event_ids = View._enforce_batch_atomicity(events, removed_event_ids)
188
+
189
+ # Third pass: also remove ObservationEvents whose ActionEvents were removed
190
+ # due to batch atomicity
191
+ tool_call_ids_to_remove: set[ToolCallID] = set()
192
+ for action_id in removed_event_ids:
193
+ if action_id in action_id_to_tool_call_id:
194
+ tool_call_ids_to_remove.add(action_id_to_tool_call_id[action_id])
195
+
196
+ # Filter out removed events
197
+ result = []
198
+ for event in events:
199
+ if event.id in removed_event_ids:
200
+ continue
201
+ if isinstance(event, ObservationBaseEvent):
202
+ if event.tool_call_id in tool_call_ids_to_remove:
203
+ continue
204
+ result.append(event)
205
+
206
+ return result
207
+
208
+ @staticmethod
209
+ def _get_action_tool_call_ids(events: list[LLMConvertibleEvent]) -> set[ToolCallID]:
210
+ """Extract tool_call_ids from ActionEvents."""
211
+ tool_call_ids = set()
212
+ for event in events:
213
+ if isinstance(event, ActionEvent) and event.tool_call_id is not None:
214
+ tool_call_ids.add(event.tool_call_id)
215
+ return tool_call_ids
216
+
217
+ @staticmethod
218
+ def _get_observation_tool_call_ids(
219
+ events: list[LLMConvertibleEvent],
220
+ ) -> set[ToolCallID]:
221
+ """Extract tool_call_ids from ObservationEvents."""
222
+ tool_call_ids = set()
223
+ for event in events:
224
+ if (
225
+ isinstance(event, ObservationBaseEvent)
226
+ and event.tool_call_id is not None
227
+ ):
228
+ tool_call_ids.add(event.tool_call_id)
229
+ return tool_call_ids
230
+
231
+ @staticmethod
232
+ def _should_keep_event(
233
+ event: LLMConvertibleEvent,
234
+ action_tool_call_ids: set[ToolCallID],
235
+ observation_tool_call_ids: set[ToolCallID],
236
+ ) -> bool:
237
+ """Determine if an event should be kept based on tool call matching."""
238
+ if isinstance(event, ObservationBaseEvent):
239
+ return event.tool_call_id in action_tool_call_ids
240
+ elif isinstance(event, ActionEvent):
241
+ return event.tool_call_id in observation_tool_call_ids
242
+ else:
243
+ return True
244
+
245
+ @staticmethod
246
+ def from_events(events: Sequence[Event]) -> "View":
247
+ """Create a view from a list of events, respecting the semantics of any
248
+ condensation events.
249
+ """
250
+ forgotten_event_ids: set[EventID] = set()
251
+ condensations: list[Condensation] = []
252
+ for event in events:
253
+ if isinstance(event, Condensation):
254
+ condensations.append(event)
255
+ forgotten_event_ids.update(event.forgotten_event_ids)
256
+ # Make sure we also forget the condensation action itself
257
+ forgotten_event_ids.add(event.id)
258
+ if isinstance(event, CondensationRequest):
259
+ forgotten_event_ids.add(event.id)
260
+
261
+ # Enforce batch atomicity: if any event in a multi-action batch is forgotten,
262
+ # forget all events in that batch to prevent partial batches with thinking
263
+ # blocks separated from their tool calls
264
+ forgotten_event_ids = View._enforce_batch_atomicity(events, forgotten_event_ids)
265
+
266
+ kept_events = [
267
+ event
268
+ for event in events
269
+ if event.id not in forgotten_event_ids
270
+ and isinstance(event, LLMConvertibleEvent)
271
+ ]
272
+
273
+ # If we have a summary, insert it at the specified offset.
274
+ summary: str | None = None
275
+ summary_offset: int | None = None
276
+
277
+ # The relevant summary is always in the last condensation event (i.e., the most
278
+ # recent one).
279
+ for event in reversed(events):
280
+ if isinstance(event, Condensation):
281
+ if event.summary is not None and event.summary_offset is not None:
282
+ summary = event.summary
283
+ summary_offset = event.summary_offset
284
+ break
285
+
286
+ if summary is not None and summary_offset is not None:
287
+ logger.debug(f"Inserting summary at offset {summary_offset}")
288
+
289
+ _new_summary_event = CondensationSummaryEvent(summary=summary)
290
+ kept_events.insert(summary_offset, _new_summary_event)
291
+
292
+ # Check for an unhandled condensation request -- these are events closer to the
293
+ # end of the list than any condensation action.
294
+ unhandled_condensation_request = False
295
+ for event in reversed(events):
296
+ if isinstance(event, Condensation):
297
+ break
298
+ if isinstance(event, CondensationRequest):
299
+ unhandled_condensation_request = True
300
+ break
301
+
302
+ return View(
303
+ events=View.filter_unmatched_tool_calls(kept_events),
304
+ unhandled_condensation_request=unhandled_condensation_request,
305
+ condensations=condensations,
306
+ )
@@ -0,0 +1,40 @@
1
+ from openhands.sdk.conversation.base import BaseConversation
2
+ from openhands.sdk.conversation.conversation import Conversation
3
+ from openhands.sdk.conversation.event_store import EventLog
4
+ from openhands.sdk.conversation.events_list_base import EventsListBase
5
+ from openhands.sdk.conversation.impl.local_conversation import LocalConversation
6
+ from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation
7
+ from openhands.sdk.conversation.response_utils import get_agent_final_response
8
+ from openhands.sdk.conversation.secret_registry import SecretRegistry
9
+ from openhands.sdk.conversation.state import (
10
+ ConversationExecutionStatus,
11
+ ConversationState,
12
+ )
13
+ from openhands.sdk.conversation.stuck_detector import StuckDetector
14
+ from openhands.sdk.conversation.types import (
15
+ ConversationCallbackType,
16
+ ConversationTokenCallbackType,
17
+ )
18
+ from openhands.sdk.conversation.visualizer import (
19
+ ConversationVisualizerBase,
20
+ DefaultConversationVisualizer,
21
+ )
22
+
23
+
24
+ __all__ = [
25
+ "Conversation",
26
+ "BaseConversation",
27
+ "ConversationState",
28
+ "ConversationExecutionStatus",
29
+ "ConversationCallbackType",
30
+ "ConversationTokenCallbackType",
31
+ "DefaultConversationVisualizer",
32
+ "ConversationVisualizerBase",
33
+ "SecretRegistry",
34
+ "StuckDetector",
35
+ "EventLog",
36
+ "LocalConversation",
37
+ "RemoteConversation",
38
+ "EventsListBase",
39
+ "get_agent_final_response",
40
+ ]
@@ -0,0 +1,281 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Iterable, Mapping
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Protocol, TypeVar, cast
5
+
6
+ from openhands.sdk.conversation.conversation_stats import ConversationStats
7
+ from openhands.sdk.conversation.events_list_base import EventsListBase
8
+ from openhands.sdk.conversation.secret_registry import SecretValue
9
+ from openhands.sdk.conversation.types import (
10
+ ConversationCallbackType,
11
+ ConversationID,
12
+ ConversationTokenCallbackType,
13
+ )
14
+ from openhands.sdk.llm.llm import LLM
15
+ from openhands.sdk.llm.message import Message
16
+ from openhands.sdk.observability.laminar import (
17
+ end_active_span,
18
+ should_enable_observability,
19
+ start_active_span,
20
+ )
21
+ from openhands.sdk.security.analyzer import SecurityAnalyzerBase
22
+ from openhands.sdk.security.confirmation_policy import (
23
+ ConfirmationPolicyBase,
24
+ NeverConfirm,
25
+ )
26
+ from openhands.sdk.workspace.base import BaseWorkspace
27
+
28
+
29
+ if TYPE_CHECKING:
30
+ from openhands.sdk.agent.base import AgentBase
31
+ from openhands.sdk.conversation.state import ConversationExecutionStatus
32
+
33
+
34
+ CallbackType = TypeVar(
35
+ "CallbackType",
36
+ ConversationCallbackType,
37
+ ConversationTokenCallbackType,
38
+ )
39
+
40
+
41
+ class ConversationStateProtocol(Protocol):
42
+ """Protocol defining the interface for conversation state objects."""
43
+
44
+ @property
45
+ def id(self) -> ConversationID:
46
+ """The conversation ID."""
47
+ ...
48
+
49
+ @property
50
+ def events(self) -> EventsListBase:
51
+ """Access to the events list."""
52
+ ...
53
+
54
+ @property
55
+ def execution_status(self) -> "ConversationExecutionStatus":
56
+ """The current conversation execution status."""
57
+ ...
58
+
59
+ @property
60
+ def confirmation_policy(self) -> ConfirmationPolicyBase:
61
+ """The confirmation policy."""
62
+ ...
63
+
64
+ @property
65
+ def security_analyzer(self) -> SecurityAnalyzerBase | None:
66
+ """The security analyzer."""
67
+ ...
68
+
69
+ @property
70
+ def activated_knowledge_skills(self) -> list[str]:
71
+ """List of activated knowledge skills."""
72
+ ...
73
+
74
+ @property
75
+ def workspace(self) -> BaseWorkspace:
76
+ """The workspace for agent operations and tool execution."""
77
+ ...
78
+
79
+ @property
80
+ def persistence_dir(self) -> str | None:
81
+ """The persistence directory from the FileStore.
82
+
83
+ If None, it means the conversation is not being persisted.
84
+ """
85
+ ...
86
+
87
+ @property
88
+ def agent(self) -> "AgentBase":
89
+ """The agent running in the conversation."""
90
+ ...
91
+
92
+ @property
93
+ def stats(self) -> ConversationStats:
94
+ """The conversation statistics."""
95
+ ...
96
+
97
+
98
+ class BaseConversation(ABC):
99
+ """Abstract base class for conversation implementations.
100
+
101
+ This class defines the interface that all conversation implementations must follow.
102
+ Conversations manage the interaction between users and agents, handling message
103
+ exchange, execution control, and state management.
104
+ """
105
+
106
+ def __init__(self) -> None:
107
+ """Initialize the base conversation with span tracking."""
108
+ self._span_ended = False
109
+
110
+ def _start_observability_span(self, session_id: str) -> None:
111
+ """Start an observability span if observability is enabled.
112
+
113
+ Args:
114
+ session_id: The session ID to associate with the span
115
+ """
116
+ if should_enable_observability():
117
+ start_active_span("conversation", session_id=session_id)
118
+
119
+ def _end_observability_span(self) -> None:
120
+ """End the observability span if it hasn't been ended already."""
121
+ if not self._span_ended and should_enable_observability():
122
+ end_active_span()
123
+ self._span_ended = True
124
+
125
+ @property
126
+ @abstractmethod
127
+ def id(self) -> ConversationID: ...
128
+
129
+ @property
130
+ @abstractmethod
131
+ def state(self) -> ConversationStateProtocol: ...
132
+
133
+ @property
134
+ @abstractmethod
135
+ def conversation_stats(self) -> ConversationStats: ...
136
+
137
+ @abstractmethod
138
+ def send_message(self, message: str | Message, sender: str | None = None) -> None:
139
+ """Send a message to the agent.
140
+
141
+ Args:
142
+ message: Either a string (which will be converted to a user message)
143
+ or a Message object
144
+ sender: Optional identifier of the sender. Can be used to track
145
+ message origin in multi-agent scenarios. For example, when
146
+ one agent delegates to another, the sender can be set to
147
+ identify which agent is sending the message.
148
+ """
149
+ ...
150
+
151
+ @abstractmethod
152
+ def run(self) -> None:
153
+ """Execute the agent to process messages and perform actions.
154
+
155
+ This method runs the agent until it finishes processing the current
156
+ message or reaches the maximum iteration limit.
157
+ """
158
+ ...
159
+
160
+ @abstractmethod
161
+ def set_confirmation_policy(self, policy: ConfirmationPolicyBase) -> None:
162
+ """Set the confirmation policy for the conversation."""
163
+ ...
164
+
165
+ @property
166
+ def confirmation_policy_active(self) -> bool:
167
+ return not isinstance(self.state.confirmation_policy, NeverConfirm)
168
+
169
+ @property
170
+ def is_confirmation_mode_active(self) -> bool:
171
+ """Check if confirmation mode is active.
172
+
173
+ Returns True if BOTH conditions are met:
174
+ 1. The conversation state has a security analyzer set (not None)
175
+ 2. The confirmation policy is active
176
+
177
+ """
178
+ return (
179
+ self.state.security_analyzer is not None and self.confirmation_policy_active
180
+ )
181
+
182
+ @abstractmethod
183
+ def reject_pending_actions(
184
+ self, reason: str = "User rejected the action"
185
+ ) -> None: ...
186
+
187
+ @abstractmethod
188
+ def pause(self) -> None: ...
189
+
190
+ @abstractmethod
191
+ def update_secrets(self, secrets: Mapping[str, SecretValue]) -> None: ...
192
+
193
+ @abstractmethod
194
+ def close(self) -> None: ...
195
+
196
+ @abstractmethod
197
+ def generate_title(self, llm: LLM | None = None, max_length: int = 50) -> str:
198
+ """Generate a title for the conversation based on the first user message.
199
+
200
+ Args:
201
+ llm: Optional LLM to use for title generation. If not provided,
202
+ uses the agent's LLM.
203
+ max_length: Maximum length of the generated title.
204
+
205
+ Returns:
206
+ A generated title for the conversation.
207
+
208
+ Raises:
209
+ ValueError: If no user messages are found in the conversation.
210
+ """
211
+ ...
212
+
213
+ @staticmethod
214
+ def get_persistence_dir(
215
+ persistence_base_dir: str | Path, conversation_id: ConversationID
216
+ ) -> str:
217
+ """Get the persistence directory for the conversation.
218
+
219
+ Args:
220
+ persistence_base_dir: Base directory for persistence. Can be a string
221
+ path or Path object.
222
+ conversation_id: Unique conversation ID.
223
+
224
+ Returns:
225
+ String path to the conversation-specific persistence directory.
226
+ Always returns a normalized string path even if a Path was provided.
227
+ """
228
+ return str(Path(persistence_base_dir) / conversation_id.hex)
229
+
230
+ @abstractmethod
231
+ def ask_agent(self, question: str) -> str:
232
+ """Ask the agent a simple, stateless question and get a direct LLM response.
233
+
234
+ This bypasses the normal conversation flow and does **not** modify, persist,
235
+ or become part of the conversation state. The request is not remembered by
236
+ the main agent, no events are recorded, and execution status is untouched.
237
+ It is also thread-safe and may be called while `conversation.run()` is
238
+ executing in another thread.
239
+
240
+ Args:
241
+ question: A simple string question to ask the agent
242
+
243
+ Returns:
244
+ A string response from the agent
245
+ """
246
+ ...
247
+
248
+ @abstractmethod
249
+ def condense(self) -> None:
250
+ """Force condensation of the conversation history.
251
+
252
+ This method uses the existing condensation request pattern to trigger
253
+ condensation. It adds a CondensationRequest event to the conversation
254
+ and forces the agent to take a single step to process it.
255
+
256
+ The condensation will be applied immediately and will modify the conversation
257
+ state by adding a condensation event to the history.
258
+
259
+ Raises:
260
+ ValueError: If no condenser is configured or the condenser doesn't
261
+ handle condensation requests.
262
+ """
263
+ ...
264
+
265
+ @staticmethod
266
+ def compose_callbacks(callbacks: Iterable[CallbackType]) -> CallbackType:
267
+ """Compose multiple callbacks into a single callback function.
268
+
269
+ Args:
270
+ callbacks: An iterable of callback functions
271
+
272
+ Returns:
273
+ A single callback function that calls all provided callbacks
274
+ """
275
+
276
+ def composed(event) -> None:
277
+ for cb in callbacks:
278
+ if cb:
279
+ cb(event)
280
+
281
+ return cast(CallbackType, composed)