openhands-sdk 1.7.3__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 (180) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +650 -0
  4. openhands/sdk/agent/base.py +457 -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 +2 -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 +228 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +264 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +100 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +14 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/condenser/utils.py +149 -0
  29. openhands/sdk/context/prompts/__init__.py +6 -0
  30. openhands/sdk/context/prompts/prompt.py +114 -0
  31. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  32. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  33. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  34. openhands/sdk/context/skills/__init__.py +28 -0
  35. openhands/sdk/context/skills/exceptions.py +11 -0
  36. openhands/sdk/context/skills/skill.py +720 -0
  37. openhands/sdk/context/skills/trigger.py +36 -0
  38. openhands/sdk/context/skills/types.py +48 -0
  39. openhands/sdk/context/view.py +503 -0
  40. openhands/sdk/conversation/__init__.py +40 -0
  41. openhands/sdk/conversation/base.py +281 -0
  42. openhands/sdk/conversation/conversation.py +152 -0
  43. openhands/sdk/conversation/conversation_stats.py +85 -0
  44. openhands/sdk/conversation/event_store.py +157 -0
  45. openhands/sdk/conversation/events_list_base.py +17 -0
  46. openhands/sdk/conversation/exceptions.py +50 -0
  47. openhands/sdk/conversation/fifo_lock.py +133 -0
  48. openhands/sdk/conversation/impl/__init__.py +5 -0
  49. openhands/sdk/conversation/impl/local_conversation.py +665 -0
  50. openhands/sdk/conversation/impl/remote_conversation.py +956 -0
  51. openhands/sdk/conversation/persistence_const.py +9 -0
  52. openhands/sdk/conversation/response_utils.py +41 -0
  53. openhands/sdk/conversation/secret_registry.py +126 -0
  54. openhands/sdk/conversation/serialization_diff.py +0 -0
  55. openhands/sdk/conversation/state.py +392 -0
  56. openhands/sdk/conversation/stuck_detector.py +311 -0
  57. openhands/sdk/conversation/title_utils.py +191 -0
  58. openhands/sdk/conversation/types.py +45 -0
  59. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  60. openhands/sdk/conversation/visualizer/base.py +67 -0
  61. openhands/sdk/conversation/visualizer/default.py +373 -0
  62. openhands/sdk/critic/__init__.py +15 -0
  63. openhands/sdk/critic/base.py +38 -0
  64. openhands/sdk/critic/impl/__init__.py +12 -0
  65. openhands/sdk/critic/impl/agent_finished.py +83 -0
  66. openhands/sdk/critic/impl/empty_patch.py +49 -0
  67. openhands/sdk/critic/impl/pass_critic.py +42 -0
  68. openhands/sdk/event/__init__.py +42 -0
  69. openhands/sdk/event/base.py +149 -0
  70. openhands/sdk/event/condenser.py +82 -0
  71. openhands/sdk/event/conversation_error.py +25 -0
  72. openhands/sdk/event/conversation_state.py +104 -0
  73. openhands/sdk/event/llm_completion_log.py +39 -0
  74. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  75. openhands/sdk/event/llm_convertible/action.py +139 -0
  76. openhands/sdk/event/llm_convertible/message.py +142 -0
  77. openhands/sdk/event/llm_convertible/observation.py +141 -0
  78. openhands/sdk/event/llm_convertible/system.py +61 -0
  79. openhands/sdk/event/token.py +16 -0
  80. openhands/sdk/event/types.py +11 -0
  81. openhands/sdk/event/user_action.py +21 -0
  82. openhands/sdk/git/exceptions.py +43 -0
  83. openhands/sdk/git/git_changes.py +249 -0
  84. openhands/sdk/git/git_diff.py +129 -0
  85. openhands/sdk/git/models.py +21 -0
  86. openhands/sdk/git/utils.py +189 -0
  87. openhands/sdk/hooks/__init__.py +30 -0
  88. openhands/sdk/hooks/config.py +180 -0
  89. openhands/sdk/hooks/conversation_hooks.py +227 -0
  90. openhands/sdk/hooks/executor.py +155 -0
  91. openhands/sdk/hooks/manager.py +170 -0
  92. openhands/sdk/hooks/types.py +40 -0
  93. openhands/sdk/io/__init__.py +6 -0
  94. openhands/sdk/io/base.py +48 -0
  95. openhands/sdk/io/cache.py +85 -0
  96. openhands/sdk/io/local.py +119 -0
  97. openhands/sdk/io/memory.py +54 -0
  98. openhands/sdk/llm/__init__.py +45 -0
  99. openhands/sdk/llm/exceptions/__init__.py +45 -0
  100. openhands/sdk/llm/exceptions/classifier.py +50 -0
  101. openhands/sdk/llm/exceptions/mapping.py +54 -0
  102. openhands/sdk/llm/exceptions/types.py +101 -0
  103. openhands/sdk/llm/llm.py +1140 -0
  104. openhands/sdk/llm/llm_registry.py +122 -0
  105. openhands/sdk/llm/llm_response.py +59 -0
  106. openhands/sdk/llm/message.py +656 -0
  107. openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
  108. openhands/sdk/llm/mixins/non_native_fc.py +97 -0
  109. openhands/sdk/llm/options/__init__.py +1 -0
  110. openhands/sdk/llm/options/chat_options.py +93 -0
  111. openhands/sdk/llm/options/common.py +19 -0
  112. openhands/sdk/llm/options/responses_options.py +67 -0
  113. openhands/sdk/llm/router/__init__.py +10 -0
  114. openhands/sdk/llm/router/base.py +117 -0
  115. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  116. openhands/sdk/llm/router/impl/random.py +22 -0
  117. openhands/sdk/llm/streaming.py +9 -0
  118. openhands/sdk/llm/utils/metrics.py +312 -0
  119. openhands/sdk/llm/utils/model_features.py +192 -0
  120. openhands/sdk/llm/utils/model_info.py +90 -0
  121. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  122. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  123. openhands/sdk/llm/utils/telemetry.py +362 -0
  124. openhands/sdk/llm/utils/unverified_models.py +156 -0
  125. openhands/sdk/llm/utils/verified_models.py +65 -0
  126. openhands/sdk/logger/__init__.py +22 -0
  127. openhands/sdk/logger/logger.py +195 -0
  128. openhands/sdk/logger/rolling.py +113 -0
  129. openhands/sdk/mcp/__init__.py +24 -0
  130. openhands/sdk/mcp/client.py +76 -0
  131. openhands/sdk/mcp/definition.py +106 -0
  132. openhands/sdk/mcp/exceptions.py +19 -0
  133. openhands/sdk/mcp/tool.py +270 -0
  134. openhands/sdk/mcp/utils.py +83 -0
  135. openhands/sdk/observability/__init__.py +4 -0
  136. openhands/sdk/observability/laminar.py +166 -0
  137. openhands/sdk/observability/utils.py +20 -0
  138. openhands/sdk/py.typed +0 -0
  139. openhands/sdk/secret/__init__.py +19 -0
  140. openhands/sdk/secret/secrets.py +92 -0
  141. openhands/sdk/security/__init__.py +6 -0
  142. openhands/sdk/security/analyzer.py +111 -0
  143. openhands/sdk/security/confirmation_policy.py +61 -0
  144. openhands/sdk/security/llm_analyzer.py +29 -0
  145. openhands/sdk/security/risk.py +100 -0
  146. openhands/sdk/tool/__init__.py +34 -0
  147. openhands/sdk/tool/builtins/__init__.py +34 -0
  148. openhands/sdk/tool/builtins/finish.py +106 -0
  149. openhands/sdk/tool/builtins/think.py +117 -0
  150. openhands/sdk/tool/registry.py +184 -0
  151. openhands/sdk/tool/schema.py +286 -0
  152. openhands/sdk/tool/spec.py +39 -0
  153. openhands/sdk/tool/tool.py +481 -0
  154. openhands/sdk/utils/__init__.py +22 -0
  155. openhands/sdk/utils/async_executor.py +115 -0
  156. openhands/sdk/utils/async_utils.py +39 -0
  157. openhands/sdk/utils/cipher.py +68 -0
  158. openhands/sdk/utils/command.py +90 -0
  159. openhands/sdk/utils/deprecation.py +166 -0
  160. openhands/sdk/utils/github.py +44 -0
  161. openhands/sdk/utils/json.py +48 -0
  162. openhands/sdk/utils/models.py +570 -0
  163. openhands/sdk/utils/paging.py +63 -0
  164. openhands/sdk/utils/pydantic_diff.py +85 -0
  165. openhands/sdk/utils/pydantic_secrets.py +64 -0
  166. openhands/sdk/utils/truncate.py +117 -0
  167. openhands/sdk/utils/visualize.py +58 -0
  168. openhands/sdk/workspace/__init__.py +17 -0
  169. openhands/sdk/workspace/base.py +158 -0
  170. openhands/sdk/workspace/local.py +189 -0
  171. openhands/sdk/workspace/models.py +35 -0
  172. openhands/sdk/workspace/remote/__init__.py +8 -0
  173. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  174. openhands/sdk/workspace/remote/base.py +164 -0
  175. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  176. openhands/sdk/workspace/workspace.py +49 -0
  177. openhands_sdk-1.7.3.dist-info/METADATA +17 -0
  178. openhands_sdk-1.7.3.dist-info/RECORD +180 -0
  179. openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
  180. openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
@@ -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)
@@ -0,0 +1,152 @@
1
+ from collections.abc import Mapping
2
+ from pathlib import Path
3
+ from typing import TYPE_CHECKING, Self, overload
4
+
5
+ from openhands.sdk.agent.base import AgentBase
6
+ from openhands.sdk.conversation.base import BaseConversation
7
+ from openhands.sdk.conversation.types import (
8
+ ConversationCallbackType,
9
+ ConversationID,
10
+ ConversationTokenCallbackType,
11
+ StuckDetectionThresholds,
12
+ )
13
+ from openhands.sdk.conversation.visualizer import (
14
+ ConversationVisualizerBase,
15
+ DefaultConversationVisualizer,
16
+ )
17
+ from openhands.sdk.hooks import HookConfig
18
+ from openhands.sdk.logger import get_logger
19
+ from openhands.sdk.secret import SecretValue
20
+ from openhands.sdk.workspace import LocalWorkspace, RemoteWorkspace
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from openhands.sdk.conversation.impl.local_conversation import LocalConversation
25
+ from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation
26
+
27
+ logger = get_logger(__name__)
28
+
29
+
30
+ class Conversation:
31
+ """Factory class for creating conversation instances with OpenHands agents.
32
+
33
+ This factory automatically creates either a LocalConversation or RemoteConversation
34
+ based on the workspace type provided. LocalConversation runs the agent locally,
35
+ while RemoteConversation connects to a remote agent server.
36
+
37
+ Returns:
38
+ LocalConversation if workspace is local, RemoteConversation if workspace
39
+ is remote.
40
+
41
+ Example:
42
+ >>> from openhands.sdk import LLM, Agent, Conversation
43
+ >>> llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("key"))
44
+ >>> agent = Agent(llm=llm, tools=[])
45
+ >>> conversation = Conversation(agent=agent, workspace="./workspace")
46
+ >>> conversation.send_message("Hello!")
47
+ >>> conversation.run()
48
+ """
49
+
50
+ @overload
51
+ def __new__(
52
+ cls: type[Self],
53
+ agent: AgentBase,
54
+ *,
55
+ workspace: str | Path | LocalWorkspace = "workspace/project",
56
+ persistence_dir: str | Path | None = None,
57
+ conversation_id: ConversationID | None = None,
58
+ callbacks: list[ConversationCallbackType] | None = None,
59
+ token_callbacks: list[ConversationTokenCallbackType] | None = None,
60
+ hook_config: HookConfig | None = None,
61
+ max_iteration_per_run: int = 500,
62
+ stuck_detection: bool = True,
63
+ stuck_detection_thresholds: (
64
+ StuckDetectionThresholds | Mapping[str, int] | None
65
+ ) = None,
66
+ visualizer: (
67
+ type[ConversationVisualizerBase] | ConversationVisualizerBase | None
68
+ ) = DefaultConversationVisualizer,
69
+ secrets: dict[str, SecretValue] | dict[str, str] | None = None,
70
+ ) -> "LocalConversation": ...
71
+
72
+ @overload
73
+ def __new__(
74
+ cls: type[Self],
75
+ agent: AgentBase,
76
+ *,
77
+ workspace: RemoteWorkspace,
78
+ conversation_id: ConversationID | None = None,
79
+ callbacks: list[ConversationCallbackType] | None = None,
80
+ token_callbacks: list[ConversationTokenCallbackType] | None = None,
81
+ hook_config: HookConfig | None = None,
82
+ max_iteration_per_run: int = 500,
83
+ stuck_detection: bool = True,
84
+ stuck_detection_thresholds: (
85
+ StuckDetectionThresholds | Mapping[str, int] | None
86
+ ) = None,
87
+ visualizer: (
88
+ type[ConversationVisualizerBase] | ConversationVisualizerBase | None
89
+ ) = DefaultConversationVisualizer,
90
+ secrets: dict[str, SecretValue] | dict[str, str] | None = None,
91
+ ) -> "RemoteConversation": ...
92
+
93
+ def __new__(
94
+ cls: type[Self],
95
+ agent: AgentBase,
96
+ *,
97
+ workspace: str | Path | LocalWorkspace | RemoteWorkspace = "workspace/project",
98
+ persistence_dir: str | Path | None = None,
99
+ conversation_id: ConversationID | None = None,
100
+ callbacks: list[ConversationCallbackType] | None = None,
101
+ token_callbacks: list[ConversationTokenCallbackType] | None = None,
102
+ hook_config: HookConfig | None = None,
103
+ max_iteration_per_run: int = 500,
104
+ stuck_detection: bool = True,
105
+ stuck_detection_thresholds: (
106
+ StuckDetectionThresholds | Mapping[str, int] | None
107
+ ) = None,
108
+ visualizer: (
109
+ type[ConversationVisualizerBase] | ConversationVisualizerBase | None
110
+ ) = DefaultConversationVisualizer,
111
+ secrets: dict[str, SecretValue] | dict[str, str] | None = None,
112
+ ) -> BaseConversation:
113
+ from openhands.sdk.conversation.impl.local_conversation import LocalConversation
114
+ from openhands.sdk.conversation.impl.remote_conversation import (
115
+ RemoteConversation,
116
+ )
117
+
118
+ 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
121
+ if persistence_dir is not None:
122
+ raise ValueError(
123
+ "persistence_dir should not be set when using RemoteConversation"
124
+ )
125
+ return RemoteConversation(
126
+ agent=agent,
127
+ conversation_id=conversation_id,
128
+ callbacks=callbacks,
129
+ token_callbacks=token_callbacks,
130
+ hook_config=hook_config,
131
+ max_iteration_per_run=max_iteration_per_run,
132
+ stuck_detection=stuck_detection,
133
+ stuck_detection_thresholds=stuck_detection_thresholds,
134
+ visualizer=visualizer,
135
+ workspace=workspace,
136
+ secrets=secrets,
137
+ )
138
+
139
+ return LocalConversation(
140
+ agent=agent,
141
+ conversation_id=conversation_id,
142
+ callbacks=callbacks,
143
+ token_callbacks=token_callbacks,
144
+ hook_config=hook_config,
145
+ max_iteration_per_run=max_iteration_per_run,
146
+ stuck_detection=stuck_detection,
147
+ stuck_detection_thresholds=stuck_detection_thresholds,
148
+ visualizer=visualizer,
149
+ workspace=workspace,
150
+ persistence_dir=persistence_dir,
151
+ secrets=secrets,
152
+ )
@@ -0,0 +1,85 @@
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel, Field, PrivateAttr, model_serializer
4
+
5
+ from openhands.sdk.llm.llm_registry import RegistryEvent
6
+ from openhands.sdk.llm.utils.metrics import Metrics
7
+ from openhands.sdk.logger import get_logger
8
+
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ class ConversationStats(BaseModel):
14
+ """Track per-LLM usage metrics observed during conversations."""
15
+
16
+ usage_to_metrics: dict[str, Metrics] = Field(
17
+ default_factory=dict,
18
+ description="Active usage metrics tracked by the registry.",
19
+ )
20
+
21
+ _restored_usage_ids: set[str] = PrivateAttr(default_factory=set)
22
+
23
+ @model_serializer(mode="wrap")
24
+ def _serialize_with_context(self, serializer: Any, info: Any) -> dict[str, Any]:
25
+ """Serialize metrics based on context.
26
+
27
+ By default, preserves full metrics history including costs,
28
+ response_latencies, and token_usages lists for persistence.
29
+
30
+ When context={'use_snapshot': True} is passed, converts Metrics to
31
+ MetricsSnapshot format to minimize payload size for network transmission.
32
+
33
+ Args:
34
+ serializer: Pydantic's default serializer
35
+ info: Serialization info containing context
36
+
37
+ Returns:
38
+ Dictionary with metrics serialized based on context
39
+ """
40
+ # Get the default serialization
41
+ data = serializer(self)
42
+
43
+ # Check if we should use snapshot serialization
44
+ context = info.context if info else None
45
+ use_snapshot = context.get("use_snapshot", False) if context else False
46
+
47
+ if use_snapshot and "usage_to_metrics" in data:
48
+ # Replace each Metrics with its snapshot
49
+ usage_to_snapshots = {}
50
+ for usage_id, metrics in self.usage_to_metrics.items():
51
+ snapshot = metrics.get_snapshot()
52
+ usage_to_snapshots[usage_id] = snapshot.model_dump()
53
+
54
+ data["usage_to_metrics"] = usage_to_snapshots
55
+
56
+ return data
57
+
58
+ def get_combined_metrics(self) -> Metrics:
59
+ total_metrics = Metrics()
60
+ for metrics in self.usage_to_metrics.values():
61
+ total_metrics.merge(metrics)
62
+ return total_metrics
63
+
64
+ def get_metrics_for_usage(self, usage_id: str) -> Metrics:
65
+ if usage_id not in self.usage_to_metrics:
66
+ raise Exception(f"LLM usage does not exist {usage_id}")
67
+
68
+ return self.usage_to_metrics[usage_id]
69
+
70
+ def register_llm(self, event: RegistryEvent):
71
+ # Listen for LLM creations and track their metrics
72
+ llm = event.llm
73
+ usage_id = llm.usage_id
74
+
75
+ # Usage costs exist but have not been restored yet
76
+ if (
77
+ usage_id in self.usage_to_metrics
78
+ and usage_id not in self._restored_usage_ids
79
+ ):
80
+ llm.restore_metrics(self.usage_to_metrics[usage_id])
81
+ self._restored_usage_ids.add(usage_id)
82
+
83
+ # Usage is new, track its metrics
84
+ if usage_id not in self.usage_to_metrics and llm.metrics:
85
+ self.usage_to_metrics[usage_id] = llm.metrics
@@ -0,0 +1,157 @@
1
+ # state.py
2
+ import operator
3
+ from collections.abc import Iterator
4
+ from typing import SupportsIndex, overload
5
+
6
+ from openhands.sdk.conversation.events_list_base import EventsListBase
7
+ from openhands.sdk.conversation.persistence_const import (
8
+ EVENT_FILE_PATTERN,
9
+ EVENT_NAME_RE,
10
+ EVENTS_DIR,
11
+ )
12
+ from openhands.sdk.event import Event, EventID
13
+ from openhands.sdk.io import FileStore
14
+ from openhands.sdk.logger import get_logger
15
+
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class EventLog(EventsListBase):
21
+ _fs: FileStore
22
+ _dir: str
23
+ _length: int
24
+
25
+ def __init__(self, fs: FileStore, dir_path: str = EVENTS_DIR) -> None:
26
+ self._fs = fs
27
+ self._dir = dir_path
28
+ self._id_to_idx: dict[EventID, int] = {}
29
+ self._idx_to_id: dict[int, EventID] = {}
30
+ self._length = self._scan_and_build_index()
31
+
32
+ def get_index(self, event_id: EventID) -> int:
33
+ """Return the integer index for a given event_id."""
34
+ try:
35
+ return self._id_to_idx[event_id]
36
+ except KeyError:
37
+ raise KeyError(f"Unknown event_id: {event_id}")
38
+
39
+ def get_id(self, idx: int) -> EventID:
40
+ """Return the event_id for a given index."""
41
+ if idx < 0:
42
+ idx += self._length
43
+ if idx < 0 or idx >= self._length:
44
+ raise IndexError("Event index out of range")
45
+ return self._idx_to_id[idx]
46
+
47
+ @overload
48
+ def __getitem__(self, idx: int) -> Event: ...
49
+
50
+ @overload
51
+ def __getitem__(self, idx: slice) -> list[Event]: ...
52
+
53
+ def __getitem__(self, idx: SupportsIndex | slice) -> Event | list[Event]:
54
+ if isinstance(idx, slice):
55
+ start, stop, step = idx.indices(self._length)
56
+ return [self._get_single_item(i) for i in range(start, stop, step)]
57
+ # idx is int-like (SupportsIndex)
58
+ return self._get_single_item(idx)
59
+
60
+ def _get_single_item(self, idx: SupportsIndex) -> Event:
61
+ i = operator.index(idx)
62
+ if i < 0:
63
+ i += self._length
64
+ if i < 0 or i >= self._length:
65
+ raise IndexError("Event index out of range")
66
+ txt = self._fs.read(self._path(i))
67
+ if not txt:
68
+ raise FileNotFoundError(f"Missing event file: {self._path(i)}")
69
+ return Event.model_validate_json(txt)
70
+
71
+ def __iter__(self) -> Iterator[Event]:
72
+ for i in range(self._length):
73
+ txt = self._fs.read(self._path(i))
74
+ if not txt:
75
+ continue
76
+ evt = Event.model_validate_json(txt)
77
+ evt_id = evt.id
78
+ # only backfill mapping if missing
79
+ if i not in self._idx_to_id:
80
+ self._idx_to_id[i] = evt_id
81
+ self._id_to_idx.setdefault(evt_id, i)
82
+ yield evt
83
+
84
+ def append(self, event: Event) -> None:
85
+ evt_id = event.id
86
+ # Check for duplicate ID
87
+ if evt_id in self._id_to_idx:
88
+ existing_idx = self._id_to_idx[evt_id]
89
+ raise ValueError(
90
+ f"Event with ID '{evt_id}' already exists at index {existing_idx}"
91
+ )
92
+
93
+ path = self._path(self._length, event_id=evt_id)
94
+ self._fs.write(path, event.model_dump_json(exclude_none=True))
95
+ self._idx_to_id[self._length] = evt_id
96
+ self._id_to_idx[evt_id] = self._length
97
+ self._length += 1
98
+
99
+ def __len__(self) -> int:
100
+ return self._length
101
+
102
+ def _path(self, idx: int, *, event_id: EventID | None = None) -> str:
103
+ return f"{self._dir}/{
104
+ EVENT_FILE_PATTERN.format(
105
+ idx=idx, event_id=event_id or self._idx_to_id[idx]
106
+ )
107
+ }"
108
+
109
+ def _scan_and_build_index(self) -> int:
110
+ try:
111
+ paths = self._fs.list(self._dir)
112
+ except Exception:
113
+ self._id_to_idx.clear()
114
+ self._idx_to_id.clear()
115
+ return 0
116
+
117
+ by_idx: dict[int, EventID] = {}
118
+ for p in paths:
119
+ name = p.rsplit("/", 1)[-1]
120
+ m = EVENT_NAME_RE.match(name)
121
+ if m:
122
+ idx = int(m.group("idx"))
123
+ evt_id = m.group("event_id")
124
+ by_idx[idx] = evt_id
125
+ else:
126
+ logger.warning(f"Unrecognized event file name: {name}")
127
+
128
+ if not by_idx:
129
+ self._id_to_idx.clear()
130
+ self._idx_to_id.clear()
131
+ return 0
132
+
133
+ n = 0
134
+ while True:
135
+ if n not in by_idx:
136
+ if any(i > n for i in by_idx.keys()):
137
+ logger.warning(
138
+ "Event index gap detected: "
139
+ f"expect next index {n} but got {sorted(by_idx.keys())}"
140
+ )
141
+ break
142
+ n += 1
143
+
144
+ self._id_to_idx.clear()
145
+ self._idx_to_id.clear()
146
+ for i in range(n):
147
+ evt_id = by_idx[i]
148
+ self._idx_to_id[i] = evt_id
149
+ if evt_id in self._id_to_idx:
150
+ logger.warning(
151
+ f"Duplicate event ID '{evt_id}' found during scan. "
152
+ f"Keeping first occurrence at index {self._id_to_idx[evt_id]}, "
153
+ f"ignoring duplicate at index {i}"
154
+ )
155
+ else:
156
+ self._id_to_idx[evt_id] = i
157
+ return n
@@ -0,0 +1,17 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Sequence
3
+
4
+ from openhands.sdk.event import Event
5
+
6
+
7
+ class EventsListBase(Sequence[Event], ABC):
8
+ """Abstract base class for event lists that can be appended to.
9
+
10
+ This provides a common interface for both local EventLog and remote
11
+ RemoteEventsList implementations, avoiding circular imports in protocols.
12
+ """
13
+
14
+ @abstractmethod
15
+ def append(self, event: Event) -> None:
16
+ """Add a new event to the list."""
17
+ ...