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,481 @@
1
+ import re
2
+ from abc import ABC, abstractmethod
3
+ from collections.abc import Sequence
4
+ from typing import (
5
+ TYPE_CHECKING,
6
+ Any,
7
+ ClassVar,
8
+ Protocol,
9
+ Self,
10
+ TypeVar,
11
+ )
12
+
13
+ from litellm import (
14
+ ChatCompletionToolParam,
15
+ ChatCompletionToolParamFunctionChunk,
16
+ )
17
+ from openai.types.responses import FunctionToolParam
18
+ from pydantic import (
19
+ BaseModel,
20
+ ConfigDict,
21
+ Field,
22
+ computed_field,
23
+ field_serializer,
24
+ field_validator,
25
+ )
26
+ from pydantic.json_schema import SkipJsonSchema
27
+
28
+ from openhands.sdk.security import risk
29
+ from openhands.sdk.tool.schema import Action, Observation, Schema
30
+ from openhands.sdk.utils.models import (
31
+ DiscriminatedUnionMixin,
32
+ get_known_concrete_subclasses,
33
+ kind_of,
34
+ )
35
+
36
+
37
+ if TYPE_CHECKING:
38
+ from openhands.sdk.conversation import LocalConversation
39
+
40
+
41
+ ActionT = TypeVar("ActionT", bound=Action)
42
+ ObservationT = TypeVar("ObservationT", bound=Observation)
43
+ _action_types_with_risk: dict[type, type] = {}
44
+
45
+
46
+ def _camel_to_snake(name: str) -> str:
47
+ """Convert CamelCase to snake_case.
48
+
49
+ Examples:
50
+ TerminalTool -> bash_tool
51
+ FileEditorTool -> file_editor_tool
52
+ XMLHttpRequest -> xml_http_request
53
+ """
54
+ # Insert underscore before uppercase letters (except the first one)
55
+ s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
56
+ # Insert underscore before uppercase letters that follow lowercase letters
57
+ return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
58
+
59
+
60
+ class ToolAnnotations(BaseModel):
61
+ """Annotations to provide hints about the tool's behavior.
62
+
63
+ Based on Model Context Protocol (MCP) spec:
64
+ https://github.com/modelcontextprotocol/modelcontextprotocol/blob/caf3424488b10b4a7b1f8cb634244a450a1f4400/schema/2025-06-18/schema.ts#L838
65
+ """
66
+
67
+ model_config: ClassVar[ConfigDict] = ConfigDict(
68
+ frozen=True,
69
+ # We need to define the title here to avoid conflict with MCP's ToolAnnotations
70
+ # when both are included in the same JSON schema for openapi.json
71
+ title="openhands.sdk.tool.tool.ToolAnnotations",
72
+ )
73
+
74
+ title: str | None = Field(
75
+ default=None, description="A human-readable title for the tool."
76
+ )
77
+ readOnlyHint: bool = Field(
78
+ default=False,
79
+ description="If true, the tool does not modify its environment. Default: false",
80
+ )
81
+ destructiveHint: bool = Field(
82
+ default=True,
83
+ description="If true, the tool may perform destructive updates to its environment. If false, the tool performs only additive updates. (This property is meaningful only when `readOnlyHint == false`) Default: true", # noqa: E501
84
+ )
85
+ idempotentHint: bool = Field(
86
+ default=False,
87
+ description="If true, calling the tool repeatedly with the same arguments will have no additional effect on the its environment. (This property is meaningful only when `readOnlyHint == false`) Default: false", # noqa: E501
88
+ )
89
+ openWorldHint: bool = Field(
90
+ default=True,
91
+ description="If true, this tool may interact with an 'open world' of external entities. If false, the tool's domain of interaction is closed. For example, the world of a web search tool is open, whereas that of a memory tool is not. Default: true", # noqa: E501
92
+ )
93
+
94
+
95
+ class ToolExecutor[ActionT, ObservationT](ABC):
96
+ """Executor function type for a Tool."""
97
+
98
+ @abstractmethod
99
+ def __call__(
100
+ self, action: ActionT, conversation: "LocalConversation | None" = None
101
+ ) -> ObservationT:
102
+ """Execute the tool with the given action and return an observation.
103
+
104
+ Args:
105
+ action: The action to execute, containing the parameters and context
106
+ needed for the tool operation.
107
+ conversation: The conversation context for the tool execution.
108
+ Note: This is typed as LocalConversation (not
109
+ BaseConversation) because all tool executions happen
110
+ within a LocalConversation context. Even when tools are
111
+ invoked via RemoteConversation, the remote agent server
112
+ creates a LocalConversation instance to handle the actual
113
+ tool execution. See https://github.com/OpenHands/agent-sdk/pull/925
114
+ for more details.
115
+
116
+ Returns:
117
+ An observation containing the results of the tool execution.
118
+ """
119
+
120
+ def close(self) -> None:
121
+ """Close the executor and clean up resources.
122
+
123
+ Default implementation does nothing. Subclasses should override
124
+ this method to perform cleanup (e.g., closing connections,
125
+ terminating processes, etc.).
126
+ """
127
+ pass
128
+
129
+
130
+ class ExecutableTool(Protocol):
131
+ """Protocol for tools that are guaranteed to have a non-None executor.
132
+
133
+ This eliminates the need for runtime None checks and type narrowing
134
+ when working with tools that are known to be executable.
135
+ """
136
+
137
+ name: str
138
+ executor: ToolExecutor[Any, Any] # Non-optional executor
139
+
140
+ def __call__(
141
+ self, action: Action, conversation: "LocalConversation | None" = None
142
+ ) -> Observation:
143
+ """Execute the tool with the given action."""
144
+ ...
145
+
146
+
147
+ class ToolDefinition[ActionT, ObservationT](DiscriminatedUnionMixin, ABC):
148
+ """Base class for all tool implementations.
149
+
150
+ This class serves as a base for the discriminated union of all tool types.
151
+ All tools must inherit from this class and implement the .create() method for
152
+ proper initialization with executors and parameters.
153
+
154
+ Features:
155
+ - Normalize input/output schemas (class or dict) into both model+schema.
156
+ - Validate inputs before execute.
157
+ - Coerce outputs only if an output model is defined; else return vanilla JSON.
158
+ - Export MCP tool description.
159
+
160
+ Examples:
161
+ Simple tool with no parameters:
162
+ class FinishTool(ToolDefinition[FinishAction, FinishObservation]):
163
+ @classmethod
164
+ def create(cls, conv_state=None, **params):
165
+ return [cls(name="finish", ..., executor=FinishExecutor())]
166
+
167
+ Complex tool with initialization parameters:
168
+ class TerminalTool(ToolDefinition[TerminalAction,
169
+ TerminalObservation]):
170
+ @classmethod
171
+ def create(cls, conv_state, **params):
172
+ executor = TerminalExecutor(
173
+ working_dir=conv_state.workspace.working_dir,
174
+ **params,
175
+ )
176
+ return [cls(name="terminal", ..., executor=executor)]
177
+ """
178
+
179
+ model_config: ClassVar[ConfigDict] = ConfigDict(
180
+ frozen=True, arbitrary_types_allowed=True
181
+ )
182
+
183
+ # Automatic tool naming - set by __init_subclass__
184
+ name: ClassVar[str] = ""
185
+
186
+ def __init_subclass__(cls, **kwargs):
187
+ """Automatically set name from class name when subclass is created."""
188
+ super().__init_subclass__(**kwargs)
189
+ # Only set automatically if not explicitly defined in the current class
190
+ if "name" not in cls.__dict__:
191
+ cls.name = _camel_to_snake(cls.__name__).removesuffix("_tool")
192
+
193
+ description: str
194
+ action_type: type[Action] = Field(repr=False)
195
+ observation_type: type[Observation] | None = Field(default=None, repr=False)
196
+
197
+ annotations: ToolAnnotations | None = None
198
+ meta: dict[str, Any] | None = None
199
+
200
+ # runtime-only; always hidden on dumps
201
+ executor: SkipJsonSchema[ToolExecutor | None] = Field(
202
+ default=None, repr=False, exclude=True
203
+ )
204
+
205
+ @classmethod
206
+ @abstractmethod
207
+ def create(cls, *args, **kwargs) -> Sequence[Self]:
208
+ """Create a sequence of Tool instances.
209
+
210
+ This method must be implemented by all subclasses to provide custom
211
+ initialization logic, typically initializing the executor with parameters
212
+ from conv_state and other optional parameters.
213
+
214
+ Args:
215
+ *args: Variable positional arguments (typically conv_state as first arg).
216
+ **kwargs: Optional parameters for tool initialization.
217
+
218
+ Returns:
219
+ A sequence of Tool instances. Even single tools are returned as a sequence
220
+ to provide a consistent interface and eliminate union return types.
221
+ """
222
+ raise NotImplementedError("ToolDefinition subclasses must implement .create()")
223
+
224
+ @computed_field(return_type=str, alias="title")
225
+ @property
226
+ def title(self) -> str:
227
+ if self.annotations and self.annotations.title:
228
+ return self.annotations.title
229
+ return self.name
230
+
231
+ @field_serializer("action_type")
232
+ def _ser_action_type(self, t: type[Action]) -> str:
233
+ # serialize as a plain kind string
234
+ return kind_of(t)
235
+
236
+ @field_serializer("observation_type")
237
+ def _ser_observation_type(self, t: type[Observation] | None) -> str | None:
238
+ return None if t is None else kind_of(t)
239
+
240
+ @field_validator("action_type", mode="before")
241
+ @classmethod
242
+ def _val_action_type(cls, v):
243
+ if isinstance(v, str):
244
+ return Action.resolve_kind(v)
245
+ assert isinstance(v, type) and issubclass(v, Action), (
246
+ f"action_type must be a subclass of Action, but got {type(v)}"
247
+ )
248
+ return v
249
+
250
+ @field_validator("observation_type", mode="before")
251
+ @classmethod
252
+ def _val_observation_type(cls, v):
253
+ if v is None:
254
+ return None
255
+ if isinstance(v, str):
256
+ v = Observation.resolve_kind(v)
257
+ assert isinstance(v, type) and issubclass(v, Observation), (
258
+ f"observation_type must be a subclass of Observation, but got {type(v)}"
259
+ )
260
+ return v
261
+
262
+ def set_executor(self, executor: ToolExecutor) -> Self:
263
+ """Create a new Tool instance with the given executor."""
264
+ return self.model_copy(update={"executor": executor})
265
+
266
+ def as_executable(self) -> ExecutableTool:
267
+ """Return this tool as an ExecutableTool, ensuring it has an executor.
268
+
269
+ This method eliminates the need for runtime None checks by guaranteeing
270
+ that the returned tool has a non-None executor.
271
+
272
+ Returns:
273
+ This tool instance, typed as ExecutableTool.
274
+
275
+ Raises:
276
+ NotImplementedError: If the tool has no executor.
277
+ """
278
+ if self.executor is None:
279
+ raise NotImplementedError(f"Tool '{self.name}' has no executor")
280
+ return self # type: ignore[return-value]
281
+
282
+ def action_from_arguments(self, arguments: dict[str, Any]) -> Action:
283
+ """Create an action from parsed arguments.
284
+
285
+ This method can be overridden by subclasses to provide custom logic
286
+ for creating actions from arguments (e.g., for MCP tools).
287
+
288
+ Args:
289
+ arguments: The parsed arguments from the tool call.
290
+
291
+ Returns:
292
+ The action instance created from the arguments.
293
+ """
294
+ return self.action_type.model_validate(arguments)
295
+
296
+ def __call__(
297
+ self, action: ActionT, conversation: "LocalConversation | None" = None
298
+ ) -> Observation:
299
+ """Validate input, execute, and coerce output.
300
+
301
+ We always return some Observation subclass, but not always the
302
+ generic ObservationT.
303
+ """
304
+ if self.executor is None:
305
+ raise NotImplementedError(f"Tool '{self.name}' has no executor")
306
+
307
+ # Execute
308
+ result = self.executor(action, conversation)
309
+
310
+ # Coerce output only if we declared a model; else wrap in base Observation
311
+ if self.observation_type:
312
+ if isinstance(result, self.observation_type):
313
+ return result
314
+ return self.observation_type.model_validate(result)
315
+ else:
316
+ # When no output schema is defined, wrap the result in Observation
317
+ if isinstance(result, Observation):
318
+ return result
319
+ elif isinstance(result, BaseModel):
320
+ return Observation.model_validate(result.model_dump())
321
+ elif isinstance(result, dict):
322
+ return Observation.model_validate(result)
323
+ raise TypeError(
324
+ "Output must be dict or BaseModel when no output schema is defined"
325
+ )
326
+
327
+ def to_mcp_tool(
328
+ self,
329
+ input_schema: dict[str, Any] | None = None,
330
+ output_schema: dict[str, Any] | None = None,
331
+ ) -> dict[str, Any]:
332
+ """Convert a Tool to an MCP tool definition.
333
+
334
+ Allow overriding input/output schemas (usually by subclasses).
335
+
336
+ Args:
337
+ input_schema: Optionally override the input schema.
338
+ output_schema: Optionally override the output schema.
339
+ """
340
+ out = {
341
+ "name": self.name,
342
+ "description": self.description,
343
+ "inputSchema": input_schema or self.action_type.to_mcp_schema(),
344
+ }
345
+ if self.annotations:
346
+ out["annotations"] = self.annotations
347
+ if self.meta is not None:
348
+ out["_meta"] = self.meta
349
+
350
+ derived_output = (
351
+ output_schema
352
+ if output_schema is not None
353
+ else (
354
+ self.observation_type.to_mcp_schema() if self.observation_type else None
355
+ )
356
+ )
357
+ if derived_output is not None:
358
+ out["outputSchema"] = derived_output
359
+ return out
360
+
361
+ def _get_tool_schema(
362
+ self,
363
+ add_security_risk_prediction: bool = False,
364
+ action_type: type[Schema] | None = None,
365
+ ) -> dict[str, Any]:
366
+ action_type = action_type or self.action_type
367
+ action_type_with_risk = create_action_type_with_risk(action_type)
368
+
369
+ add_security_risk_prediction = add_security_risk_prediction and (
370
+ self.annotations is None or (not self.annotations.readOnlyHint)
371
+ )
372
+ schema = (
373
+ action_type_with_risk.to_mcp_schema()
374
+ if add_security_risk_prediction
375
+ else action_type.to_mcp_schema()
376
+ )
377
+ return schema
378
+
379
+ def to_openai_tool(
380
+ self,
381
+ add_security_risk_prediction: bool = False,
382
+ action_type: type[Schema] | None = None,
383
+ ) -> ChatCompletionToolParam:
384
+ """Convert a Tool to an OpenAI tool.
385
+
386
+ Args:
387
+ add_security_risk_prediction: Whether to add a `security_risk` field
388
+ to the action schema for LLM to predict. This is useful for
389
+ tools that may have safety risks, so the LLM can reason about
390
+ the risk level before calling the tool.
391
+ action_type: Optionally override the action_type to use for the schema.
392
+ This is useful for MCPTool to use a dynamically created action type
393
+ based on the tool's input schema.
394
+ """
395
+ return ChatCompletionToolParam(
396
+ type="function",
397
+ function=ChatCompletionToolParamFunctionChunk(
398
+ name=self.name,
399
+ description=self.description,
400
+ parameters=self._get_tool_schema(
401
+ add_security_risk_prediction, action_type
402
+ ),
403
+ ),
404
+ )
405
+
406
+ def to_responses_tool(
407
+ self,
408
+ add_security_risk_prediction: bool = False,
409
+ action_type: type[Schema] | None = None,
410
+ ) -> FunctionToolParam:
411
+ """Convert a Tool to a Responses API function tool (LiteLLM typed).
412
+
413
+ For Responses API, function tools expect top-level keys:
414
+ { "type": "function", "name": ..., "description": ..., "parameters": ... }
415
+ """
416
+
417
+ return {
418
+ "type": "function",
419
+ "name": self.name,
420
+ "description": self.description,
421
+ "parameters": self._get_tool_schema(
422
+ add_security_risk_prediction, action_type
423
+ ),
424
+ "strict": False,
425
+ }
426
+
427
+ @classmethod
428
+ def resolve_kind(cls, kind: str) -> type:
429
+ """Resolve a kind string to its corresponding tool class.
430
+
431
+ Args:
432
+ kind: The name of the tool class to resolve
433
+
434
+ Returns:
435
+ The tool class corresponding to the kind
436
+
437
+ Raises:
438
+ ValueError: If the kind is unknown
439
+ """
440
+ for subclass in get_known_concrete_subclasses(cls):
441
+ if subclass.__name__ == kind:
442
+ return subclass
443
+
444
+ # Get all possible kinds for the error message
445
+ possible_kinds = [
446
+ subclass.__name__ for subclass in get_known_concrete_subclasses(cls)
447
+ ]
448
+ possible_kinds_str = (
449
+ ", ".join(sorted(possible_kinds)) if possible_kinds else "none"
450
+ )
451
+
452
+ error_msg = (
453
+ f"Unexpected kind '{kind}' for {cls.__name__}. "
454
+ f"Expected one of: {possible_kinds_str}. "
455
+ f"If you receive this error when trying to wrap a DiscriminatedUnion "
456
+ f"instance inside another pydantic model, you may need to use "
457
+ f"OpenHandsModel instead of BaseModel to make sure that an invalid "
458
+ f"schema has not been cached."
459
+ )
460
+ raise ValueError(error_msg)
461
+
462
+
463
+ def create_action_type_with_risk(action_type: type[Schema]) -> type[Schema]:
464
+ action_type_with_risk = _action_types_with_risk.get(action_type)
465
+ if action_type_with_risk:
466
+ return action_type_with_risk
467
+
468
+ action_type_with_risk = type(
469
+ f"{action_type.__name__}WithRisk",
470
+ (action_type,),
471
+ {
472
+ "security_risk": Field(
473
+ # We do NOT add default value to make it an required field
474
+ # default=risk.SecurityRisk.UNKNOWN
475
+ description="The LLM's assessment of the safety risk of this action.",
476
+ ),
477
+ "__annotations__": {"security_risk": risk.SecurityRisk},
478
+ },
479
+ )
480
+ _action_types_with_risk[action_type] = action_type_with_risk
481
+ return action_type_with_risk
@@ -0,0 +1,22 @@
1
+ """Utility functions for the OpenHands SDK."""
2
+
3
+ from .deprecation import (
4
+ deprecated,
5
+ warn_deprecated,
6
+ )
7
+ from .github import sanitize_openhands_mentions
8
+ from .truncate import (
9
+ DEFAULT_TEXT_CONTENT_LIMIT,
10
+ DEFAULT_TRUNCATE_NOTICE,
11
+ maybe_truncate,
12
+ )
13
+
14
+
15
+ __all__ = [
16
+ "DEFAULT_TEXT_CONTENT_LIMIT",
17
+ "DEFAULT_TRUNCATE_NOTICE",
18
+ "maybe_truncate",
19
+ "deprecated",
20
+ "warn_deprecated",
21
+ "sanitize_openhands_mentions",
22
+ ]
@@ -0,0 +1,115 @@
1
+ import atexit
2
+ import inspect
3
+ import threading
4
+ import weakref
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ import anyio
9
+ from anyio.from_thread import start_blocking_portal
10
+
11
+ from openhands.sdk.logger import get_logger
12
+
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class AsyncExecutor:
18
+ """
19
+ Thin wrapper around AnyIO's BlockingPortal to execute async code
20
+ from synchronous contexts with proper resource and timeout handling.
21
+ """
22
+
23
+ def __init__(self):
24
+ self._portal = None
25
+ self._portal_cm = None
26
+ self._lock = threading.Lock()
27
+ self._atexit_registered = False
28
+
29
+ def _ensure_portal(self):
30
+ with self._lock:
31
+ if self._portal is None:
32
+ self._portal_cm = start_blocking_portal()
33
+ self._portal = self._portal_cm.__enter__()
34
+ # Register atexit handler to ensure cleanup on interpreter shutdown
35
+ if not self._atexit_registered:
36
+ # Use weakref to avoid keeping the executor alive
37
+ weak_self = weakref.ref(self)
38
+
39
+ def cleanup():
40
+ executor = weak_self()
41
+ if executor is not None:
42
+ try:
43
+ executor.close()
44
+ except Exception:
45
+ pass
46
+
47
+ atexit.register(cleanup)
48
+ self._atexit_registered = True
49
+ return self._portal
50
+
51
+ def run_async(
52
+ self,
53
+ awaitable_or_fn: Callable[..., Any] | Any,
54
+ *args,
55
+ timeout: float | None = None,
56
+ **kwargs,
57
+ ) -> Any:
58
+ """
59
+ Run a coroutine or async function from sync code.
60
+
61
+ Args:
62
+ awaitable_or_fn: coroutine or async function
63
+ *args: positional arguments (only used if awaitable_or_fn is a function)
64
+ timeout: optional timeout in seconds
65
+ **kwargs: keyword arguments (only used if awaitable_or_fn is a function)
66
+ """
67
+ portal = self._ensure_portal()
68
+
69
+ # Construct coroutine
70
+ if inspect.iscoroutine(awaitable_or_fn):
71
+ coro = awaitable_or_fn
72
+ elif inspect.iscoroutinefunction(awaitable_or_fn):
73
+ coro = awaitable_or_fn(*args, **kwargs)
74
+ else:
75
+ raise TypeError("run_async expects a coroutine or async function")
76
+
77
+ # Apply timeout by wrapping in an async function with fail_after
78
+ if timeout is not None:
79
+
80
+ async def _with_timeout():
81
+ with anyio.fail_after(timeout):
82
+ return await coro
83
+
84
+ return portal.call(_with_timeout)
85
+ else:
86
+
87
+ async def _execute():
88
+ return await coro
89
+
90
+ return portal.call(_execute)
91
+
92
+ def close(self):
93
+ with self._lock:
94
+ portal_cm = self._portal_cm
95
+ self._portal_cm = None
96
+ self._portal = None
97
+
98
+ if portal_cm is not None:
99
+ try:
100
+ portal_cm.__exit__(None, None, None)
101
+ except Exception as e:
102
+ logger.warning(f"Error closing BlockingPortal: {e}")
103
+
104
+ def __enter__(self):
105
+ return self
106
+
107
+ def __exit__(self, exc_type, exc_val, exc_tb):
108
+ self.close()
109
+ return False
110
+
111
+ def __del__(self):
112
+ try:
113
+ self.close()
114
+ except Exception:
115
+ pass
@@ -0,0 +1,39 @@
1
+ """Async utilities for OpenHands SDK.
2
+
3
+ This module provides utilities for working with async callbacks in the context
4
+ of synchronous conversation handling.
5
+ """
6
+
7
+ import asyncio
8
+ from collections.abc import Callable, Coroutine
9
+ from typing import Any
10
+
11
+ from openhands.sdk.event.base import Event
12
+
13
+
14
+ AsyncConversationCallback = Callable[[Event], Coroutine[Any, Any, None]]
15
+
16
+
17
+ class AsyncCallbackWrapper:
18
+ """Wrapper that executes async callbacks in a different thread's event loop.
19
+
20
+ This class implements the ConversationCallbackType interface (synchronous)
21
+ but internally executes an async callback in an event loop running in a
22
+ different thread. This allows async callbacks to be used in synchronous
23
+ conversation contexts.
24
+ """
25
+
26
+ async_callback: AsyncConversationCallback
27
+ loop: asyncio.AbstractEventLoop
28
+
29
+ def __init__(
30
+ self,
31
+ async_callback: AsyncConversationCallback,
32
+ loop: asyncio.AbstractEventLoop,
33
+ ):
34
+ self.async_callback = async_callback
35
+ self.loop = loop
36
+
37
+ def __call__(self, event: Event):
38
+ if self.loop.is_running():
39
+ asyncio.run_coroutine_threadsafe(self.async_callback(event), self.loop)