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,270 @@
1
+ """Utility functions for MCP integration."""
2
+
3
+ import re
4
+ from collections.abc import Sequence
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+
8
+ if TYPE_CHECKING:
9
+ from openhands.sdk.conversation import LocalConversation
10
+
11
+ import mcp.types
12
+ from litellm import ChatCompletionToolParam
13
+ from pydantic import Field, ValidationError
14
+
15
+ from openhands.sdk.logger import get_logger
16
+ from openhands.sdk.mcp.client import MCPClient
17
+ from openhands.sdk.mcp.definition import MCPToolAction, MCPToolObservation
18
+ from openhands.sdk.observability.laminar import observe
19
+ from openhands.sdk.tool import (
20
+ Action,
21
+ Observation,
22
+ ToolAnnotations,
23
+ ToolDefinition,
24
+ ToolExecutor,
25
+ )
26
+ from openhands.sdk.tool.schema import Schema
27
+ from openhands.sdk.utils.models import DiscriminatedUnionMixin
28
+
29
+
30
+ logger = get_logger(__name__)
31
+
32
+
33
+ # NOTE: We don't define MCPToolAction because it
34
+ # will be a pydantic BaseModel dynamically created from the MCP tool schema.
35
+ # It will be available as "tool.action_type".
36
+
37
+
38
+ def to_camel_case(s: str) -> str:
39
+ parts = re.split(r"[_\-\s]+", s)
40
+ return "".join(word.capitalize() for word in parts if word)
41
+
42
+
43
+ class MCPToolExecutor(ToolExecutor):
44
+ """Executor for MCP tools."""
45
+
46
+ tool_name: str
47
+ client: MCPClient
48
+
49
+ def __init__(self, tool_name: str, client: MCPClient):
50
+ self.tool_name = tool_name
51
+ self.client = client
52
+
53
+ @observe(name="MCPToolExecutor.call_tool", span_type="TOOL")
54
+ async def call_tool(self, action: MCPToolAction) -> MCPToolObservation:
55
+ async with self.client:
56
+ assert self.client.is_connected(), "MCP client is not connected."
57
+ try:
58
+ logger.debug(
59
+ f"Calling MCP tool {self.tool_name} "
60
+ f"with args: {action.model_dump()}"
61
+ )
62
+ result: mcp.types.CallToolResult = await self.client.call_tool_mcp(
63
+ name=self.tool_name, arguments=action.to_mcp_arguments()
64
+ )
65
+ return MCPToolObservation.from_call_tool_result(
66
+ tool_name=self.tool_name, result=result
67
+ )
68
+ except Exception as e:
69
+ error_msg = f"Error calling MCP tool {self.tool_name}: {str(e)}"
70
+ logger.error(error_msg, exc_info=True)
71
+ return MCPToolObservation.from_text(
72
+ text=error_msg,
73
+ is_error=True,
74
+ tool_name=self.tool_name,
75
+ )
76
+
77
+ def __call__(
78
+ self,
79
+ action: MCPToolAction,
80
+ conversation: "LocalConversation | None" = None, # noqa: ARG002
81
+ ) -> MCPToolObservation:
82
+ """Execute an MCP tool call."""
83
+ return self.client.call_async_from_sync(
84
+ self.call_tool, action=action, timeout=300
85
+ )
86
+
87
+
88
+ _mcp_dynamic_action_type: dict[str, type[Schema]] = {}
89
+
90
+
91
+ def _create_mcp_action_type(action_type: mcp.types.Tool) -> type[Schema]:
92
+ """Dynamically create a Pydantic model for MCP tool action from schema.
93
+
94
+ We create from "Schema" instead of:
95
+ - "MCPToolAction" because MCPToolAction has a "data" field that
96
+ wraps all dynamic fields, which we don't want here.
97
+ - "Action" because Action inherits from DiscriminatedUnionMixin,
98
+ which includes `kind` field that is not needed here.
99
+
100
+ .from_mcp_schema simply defines a new Pydantic model class
101
+ that inherits from the given base class.
102
+ We may want to use the returned class to convert fields definitions
103
+ to openai tool schema.
104
+ """
105
+
106
+ # Tool.name should be unique, so we can cache the created types.
107
+ mcp_action_type = _mcp_dynamic_action_type.get(action_type.name)
108
+ if mcp_action_type:
109
+ return mcp_action_type
110
+
111
+ model_name = f"MCP{to_camel_case(action_type.name)}Action"
112
+ mcp_action_type = Schema.from_mcp_schema(model_name, action_type.inputSchema)
113
+ _mcp_dynamic_action_type[action_type.name] = mcp_action_type
114
+ return mcp_action_type
115
+
116
+
117
+ class MCPToolDefinition(ToolDefinition[MCPToolAction, MCPToolObservation]):
118
+ """MCP Tool that wraps an MCP client and provides tool functionality."""
119
+
120
+ mcp_tool: mcp.types.Tool = Field(description="The MCP tool definition.")
121
+
122
+ @property
123
+ def name(self) -> str: # type: ignore[override]
124
+ """Return the MCP tool name instead of the class name."""
125
+ return self.mcp_tool.name
126
+
127
+ def __call__(
128
+ self,
129
+ action: Action,
130
+ conversation: "LocalConversation | None" = None, # noqa: ARG002
131
+ ) -> Observation:
132
+ """Execute the tool action using the MCP client.
133
+
134
+ We dynamically create a new MCPToolAction class with
135
+ the tool's input schema to validate the action.
136
+
137
+ Args:
138
+ action: The action to execute.
139
+
140
+ Returns:
141
+ The observation result from executing the action.
142
+ """
143
+ if not isinstance(action, MCPToolAction):
144
+ raise ValueError(
145
+ f"MCPTool can only execute MCPToolAction actions, got {type(action)}",
146
+ )
147
+ assert self.name == self.mcp_tool.name
148
+ mcp_action_type = _create_mcp_action_type(self.mcp_tool)
149
+ try:
150
+ mcp_action_type.model_validate(action.data)
151
+ except ValidationError as e:
152
+ # Surface validation errors as an observation instead of crashing
153
+ error_msg = f"Validation error for MCP tool '{self.name}' args: {e}"
154
+ logger.error(error_msg, exc_info=True)
155
+ return MCPToolObservation.from_text(
156
+ text=error_msg,
157
+ is_error=True,
158
+ tool_name=self.name,
159
+ )
160
+
161
+ return super().__call__(action, conversation)
162
+
163
+ def action_from_arguments(self, arguments: dict[str, Any]) -> MCPToolAction:
164
+ """Create an MCPToolAction from parsed arguments with early validation.
165
+
166
+ We validate the raw arguments against the MCP tool's input schema here so
167
+ Agent._get_action_event can catch ValidationError and surface an
168
+ AgentErrorEvent back to the model instead of crashing later during tool
169
+ execution. On success, we return MCPToolAction with sanitized arguments.
170
+
171
+ Args:
172
+ arguments: The parsed arguments from the tool call.
173
+
174
+ Returns:
175
+ The MCPToolAction instance with data populated from the arguments.
176
+
177
+ Raises:
178
+ ValidationError: If the arguments do not conform to the tool schema.
179
+ """
180
+ # Drop None-valued keys before validation to avoid type errors
181
+ # on optional fields
182
+ prefiltered_args = {k: v for k, v in (arguments or {}).items() if v is not None}
183
+ # Validate against the dynamically created action type (from MCP schema)
184
+ mcp_action_type = _create_mcp_action_type(self.mcp_tool)
185
+ validated = mcp_action_type.model_validate(prefiltered_args)
186
+ # Use exclude_none to avoid injecting nulls back to the call
187
+ # Exclude DiscriminatedUnionMixin fields (e.g., 'kind') as they're
188
+ # internal to OpenHands and not part of the MCP tool schema
189
+ exclude_fields = set(DiscriminatedUnionMixin.model_fields.keys())
190
+ sanitized = validated.model_dump(exclude_none=True, exclude=exclude_fields)
191
+ return MCPToolAction(data=sanitized)
192
+
193
+ @classmethod
194
+ def create(
195
+ cls,
196
+ mcp_tool: mcp.types.Tool,
197
+ mcp_client: MCPClient,
198
+ ) -> Sequence["MCPToolDefinition"]:
199
+ try:
200
+ annotations = (
201
+ ToolAnnotations.model_validate(
202
+ mcp_tool.annotations.model_dump(exclude_none=True)
203
+ )
204
+ if mcp_tool.annotations
205
+ else None
206
+ )
207
+
208
+ tool_instance = cls(
209
+ description=mcp_tool.description or "No description provided",
210
+ action_type=MCPToolAction,
211
+ observation_type=MCPToolObservation,
212
+ annotations=annotations,
213
+ meta=mcp_tool.meta,
214
+ executor=MCPToolExecutor(tool_name=mcp_tool.name, client=mcp_client),
215
+ # pass-through fields (enabled by **extra in Tool.create)
216
+ mcp_tool=mcp_tool,
217
+ )
218
+ return [tool_instance]
219
+ except ValidationError as e:
220
+ logger.error(
221
+ f"Validation error creating MCPTool for {mcp_tool.name}: "
222
+ f"{e.json(indent=2)}",
223
+ exc_info=True,
224
+ )
225
+ raise e
226
+
227
+ def to_mcp_tool(
228
+ self,
229
+ input_schema: dict[str, Any] | None = None,
230
+ output_schema: dict[str, Any] | None = None,
231
+ ) -> dict[str, Any]:
232
+ if input_schema is not None or output_schema is not None:
233
+ raise ValueError("MCPTool.to_mcp_tool does not support overriding schemas")
234
+
235
+ return super().to_mcp_tool(
236
+ input_schema=self.mcp_tool.inputSchema,
237
+ output_schema=self.observation_type.to_mcp_schema()
238
+ if self.observation_type
239
+ else None,
240
+ )
241
+
242
+ def to_openai_tool(
243
+ self,
244
+ add_security_risk_prediction: bool = False,
245
+ action_type: type[Schema] | None = None,
246
+ ) -> ChatCompletionToolParam:
247
+ """Convert a Tool to an OpenAI tool.
248
+
249
+ For MCP, we dynamically create the action_type (type: Schema)
250
+ from the MCP tool input schema, and pass it to the parent method.
251
+ It will use the .model_fields from this pydantic model to
252
+ generate the OpenAI-compatible tool schema.
253
+
254
+ Args:
255
+ add_security_risk_prediction: Whether to add a `security_risk` field
256
+ to the action schema for LLM to predict. This is useful for
257
+ tools that may have safety risks, so the LLM can reason about
258
+ the risk level before calling the tool.
259
+ """
260
+ if action_type is not None:
261
+ raise ValueError(
262
+ "MCPTool.to_openai_tool does not support overriding action_type"
263
+ )
264
+
265
+ assert self.name == self.mcp_tool.name
266
+ mcp_action_type = _create_mcp_action_type(self.mcp_tool)
267
+ return super().to_openai_tool(
268
+ add_security_risk_prediction=add_security_risk_prediction,
269
+ action_type=mcp_action_type,
270
+ )
@@ -0,0 +1,83 @@
1
+ """Utility functions for MCP integration."""
2
+
3
+ import logging
4
+
5
+ import mcp.types
6
+ from fastmcp.client.logging import LogMessage
7
+ from fastmcp.mcp_config import MCPConfig
8
+
9
+ from openhands.sdk.logger import get_logger
10
+ from openhands.sdk.mcp.client import MCPClient
11
+ from openhands.sdk.mcp.exceptions import MCPTimeoutError
12
+ from openhands.sdk.mcp.tool import MCPToolDefinition
13
+ from openhands.sdk.tool.tool import ToolDefinition
14
+
15
+
16
+ logger = get_logger(__name__)
17
+ LOGGING_LEVEL_MAP = logging.getLevelNamesMapping()
18
+
19
+
20
+ async def log_handler(message: LogMessage):
21
+ """
22
+ Handles incoming logs from the MCP server and forwards them
23
+ to the standard Python logging system.
24
+ """
25
+ msg = message.data.get("msg")
26
+ extra = message.data.get("extra")
27
+
28
+ # Convert the MCP log level to a Python log level
29
+ level = LOGGING_LEVEL_MAP.get(message.level.upper(), logging.INFO)
30
+
31
+ # Log the message using the standard logging library
32
+ logger.log(level, msg, extra=extra)
33
+
34
+
35
+ async def _list_tools(client: MCPClient) -> list[ToolDefinition]:
36
+ """List tools from an MCP client."""
37
+ tools: list[ToolDefinition] = []
38
+
39
+ async with client:
40
+ assert client.is_connected(), "MCP client is not connected."
41
+ mcp_type_tools: list[mcp.types.Tool] = await client.list_tools()
42
+ for mcp_tool in mcp_type_tools:
43
+ tool_sequence = MCPToolDefinition.create(
44
+ mcp_tool=mcp_tool, mcp_client=client
45
+ )
46
+ tools.extend(tool_sequence) # Flatten sequence into list
47
+ assert not client.is_connected(), (
48
+ "MCP client should be disconnected after listing tools."
49
+ )
50
+ return tools
51
+
52
+
53
+ def create_mcp_tools(
54
+ config: dict | MCPConfig,
55
+ timeout: float = 30.0,
56
+ ) -> list[MCPToolDefinition]:
57
+ """Create MCP tools from MCP configuration."""
58
+ tools: list[MCPToolDefinition] = []
59
+ if isinstance(config, dict):
60
+ config = MCPConfig.model_validate(config)
61
+ client = MCPClient(config, log_handler=log_handler)
62
+
63
+ try:
64
+ tools = client.call_async_from_sync(_list_tools, timeout=timeout, client=client)
65
+ except TimeoutError as e:
66
+ # Extract server names from config for better error message
67
+ server_names = (
68
+ list(config.mcpServers.keys()) if config.mcpServers else ["unknown"]
69
+ )
70
+ error_msg = (
71
+ f"MCP tool listing timed out after {timeout} seconds.\n"
72
+ f"MCP servers configured: {', '.join(server_names)}\n\n"
73
+ "Possible solutions:\n"
74
+ " 1. Increase the timeout value (default is 30 seconds)\n"
75
+ " 2. Check if the MCP server is running and responding\n"
76
+ " 3. Verify network connectivity to the MCP server\n"
77
+ )
78
+ raise MCPTimeoutError(
79
+ error_msg, timeout=timeout, config=config.model_dump()
80
+ ) from e
81
+
82
+ logger.info(f"Created {len(tools)} MCP tools: {[t.name for t in tools]}")
83
+ return tools
@@ -0,0 +1,4 @@
1
+ from openhands.sdk.observability.laminar import maybe_init_laminar, observe
2
+
3
+
4
+ __all__ = ["maybe_init_laminar", "observe"]
@@ -0,0 +1,166 @@
1
+ from collections.abc import Callable
2
+ from typing import (
3
+ Any,
4
+ Literal,
5
+ )
6
+
7
+ import litellm
8
+ from lmnr import (
9
+ Instruments,
10
+ Laminar,
11
+ LaminarLiteLLMCallback,
12
+ observe as laminar_observe,
13
+ )
14
+ from opentelemetry import trace
15
+
16
+ from openhands.sdk.logger import get_logger
17
+ from openhands.sdk.observability.utils import get_env
18
+
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ def maybe_init_laminar():
24
+ """Initialize Laminar if the environment variables are set.
25
+
26
+ Example configuration:
27
+ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4317/v1/traces
28
+
29
+ # comma separated, key=value url-encoded pairs
30
+ OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer%20<KEY>,X-Key=<CUSTOM_VALUE>"
31
+
32
+ # grpc is assumed if not specified
33
+ OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf # or grpc/protobuf
34
+ # or
35
+ OTEL_EXPORTER=otlp_http # or otlp_grpc
36
+ """
37
+ if should_enable_observability():
38
+ if _is_otel_backend_laminar():
39
+ Laminar.initialize()
40
+ else:
41
+ # Do not enable browser session replays for non-laminar backends
42
+ Laminar.initialize(
43
+ disabled_instruments=[
44
+ Instruments.BROWSER_USE_SESSION,
45
+ Instruments.PATCHRIGHT,
46
+ Instruments.PLAYWRIGHT,
47
+ ],
48
+ )
49
+ litellm.callbacks.append(LaminarLiteLLMCallback())
50
+ else:
51
+ logger.debug(
52
+ "Observability/OTEL environment variables are not set. "
53
+ "Skipping Laminar initialization."
54
+ )
55
+
56
+
57
+ def observe[**P, R](
58
+ *,
59
+ name: str | None = None,
60
+ session_id: str | None = None,
61
+ user_id: str | None = None,
62
+ ignore_input: bool = False,
63
+ ignore_output: bool = False,
64
+ span_type: Literal["DEFAULT", "LLM", "TOOL"] = "DEFAULT",
65
+ ignore_inputs: list[str] | None = None,
66
+ input_formatter: Callable[P, str] | None = None,
67
+ output_formatter: Callable[[R], str] | None = None,
68
+ metadata: dict[str, Any] | None = None,
69
+ tags: list[str] | None = None,
70
+ preserve_global_context: bool = False,
71
+ **kwargs: dict[str, Any],
72
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
73
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
74
+ return laminar_observe(
75
+ name=name,
76
+ session_id=session_id,
77
+ user_id=user_id,
78
+ ignore_input=ignore_input,
79
+ ignore_output=ignore_output,
80
+ span_type=span_type,
81
+ ignore_inputs=ignore_inputs,
82
+ input_formatter=input_formatter,
83
+ output_formatter=output_formatter,
84
+ metadata=metadata,
85
+ tags=tags,
86
+ preserve_global_context=preserve_global_context,
87
+ **kwargs,
88
+ )(func)
89
+
90
+ return decorator
91
+
92
+
93
+ def should_enable_observability():
94
+ keys = [
95
+ "LMNR_PROJECT_API_KEY",
96
+ "OTEL_ENDPOINT",
97
+ "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
98
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
99
+ ]
100
+ if any(get_env(key) for key in keys):
101
+ return True
102
+ if Laminar.is_initialized():
103
+ return True
104
+ return False
105
+
106
+
107
+ def _is_otel_backend_laminar():
108
+ """Simple heuristic to check if the OTEL backend is Laminar.
109
+ Caveat: This will still be True if another backend uses the same
110
+ authentication scheme, and the user uses LMNR_PROJECT_API_KEY
111
+ instead of OTEL_HEADERS to authenticate.
112
+ """
113
+ key = get_env("LMNR_PROJECT_API_KEY")
114
+ return key is not None and key != ""
115
+
116
+
117
+ class SpanManager:
118
+ """Manages a stack of active spans and their associated tokens."""
119
+
120
+ def __init__(self):
121
+ self._stack: list[trace.Span] = []
122
+
123
+ def start_active_span(self, name: str, session_id: str | None = None) -> None:
124
+ """Start a new active span and push it to the stack."""
125
+ span = Laminar.start_active_span(name)
126
+ if session_id:
127
+ Laminar.set_trace_session_id(session_id)
128
+ self._stack.append(span)
129
+
130
+ def end_active_span(self) -> None:
131
+ """End the most recent active span by popping it from the stack."""
132
+ if not self._stack:
133
+ logger.warning("Attempted to end active span, but stack is empty")
134
+ return
135
+
136
+ try:
137
+ span = self._stack.pop()
138
+ if span and span.is_recording():
139
+ span.end()
140
+ except IndexError:
141
+ logger.warning("Attempted to end active span, but stack is empty")
142
+ return
143
+
144
+
145
+ _span_manager: SpanManager | None = None
146
+
147
+
148
+ def _get_span_manager() -> SpanManager:
149
+ global _span_manager
150
+ if _span_manager is None:
151
+ _span_manager = SpanManager()
152
+ return _span_manager
153
+
154
+
155
+ def start_active_span(name: str, session_id: str | None = None) -> None:
156
+ """Start a new active span using the global span manager."""
157
+ _get_span_manager().start_active_span(name, session_id)
158
+
159
+
160
+ def end_active_span() -> None:
161
+ """End the most recent active span using the global span manager."""
162
+ try:
163
+ _get_span_manager().end_active_span()
164
+ except Exception:
165
+ logger.debug("Error ending active span")
166
+ pass
@@ -0,0 +1,20 @@
1
+ import os
2
+
3
+ from dotenv import dotenv_values
4
+
5
+ from openhands.sdk.event import ActionEvent
6
+
7
+
8
+ def get_env(key: str) -> str | None:
9
+ """Get an environment variable from the environment or the dotenv file."""
10
+ return os.getenv(key) or dotenv_values().get(key)
11
+
12
+
13
+ def extract_action_name(action_event: ActionEvent) -> str:
14
+ try:
15
+ if action_event.action is not None and hasattr(action_event.action, "kind"):
16
+ return action_event.action.kind
17
+ else:
18
+ return action_event.tool_name
19
+ except Exception:
20
+ return "agent.execute_action"
openhands/sdk/py.typed ADDED
File without changes
@@ -0,0 +1,19 @@
1
+ """Secret management module for handling sensitive data.
2
+
3
+ This module provides classes and types for managing secrets in OpenHands.
4
+ """
5
+
6
+ from openhands.sdk.secret.secrets import (
7
+ LookupSecret,
8
+ SecretSource,
9
+ SecretValue,
10
+ StaticSecret,
11
+ )
12
+
13
+
14
+ __all__ = [
15
+ "SecretSource",
16
+ "StaticSecret",
17
+ "LookupSecret",
18
+ "SecretValue",
19
+ ]
@@ -0,0 +1,92 @@
1
+ """Secret sources and types for handling sensitive data."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ import httpx
6
+ from pydantic import Field, SecretStr, field_serializer, field_validator
7
+
8
+ from openhands.sdk.utils.models import DiscriminatedUnionMixin
9
+ from openhands.sdk.utils.pydantic_secrets import serialize_secret, validate_secret
10
+
11
+
12
+ class SecretSource(DiscriminatedUnionMixin, ABC):
13
+ """Source for a named secret which may be obtained dynamically"""
14
+
15
+ description: str | None = Field(
16
+ default=None,
17
+ description="Optional description for this secret",
18
+ )
19
+
20
+ @abstractmethod
21
+ def get_value(self) -> str | None:
22
+ """Get the value of a secret in plain text"""
23
+
24
+
25
+ class StaticSecret(SecretSource):
26
+ """A secret stored locally"""
27
+
28
+ value: SecretStr
29
+
30
+ def get_value(self):
31
+ return self.value.get_secret_value()
32
+
33
+ @field_validator("value")
34
+ @classmethod
35
+ def _validate_secrets(cls, v: SecretStr | None, info):
36
+ return validate_secret(v, info)
37
+
38
+ @field_serializer("value", when_used="always")
39
+ def _serialize_secrets(self, v: SecretStr | None, info):
40
+ return serialize_secret(v, info)
41
+
42
+
43
+ class LookupSecret(SecretSource):
44
+ """A secret looked up from some external url"""
45
+
46
+ url: str
47
+ headers: dict[str, str] = Field(default_factory=dict)
48
+
49
+ def get_value(self):
50
+ response = httpx.get(self.url, headers=self.headers, timeout=30.0)
51
+ response.raise_for_status()
52
+ return response.text
53
+
54
+ @field_validator("headers")
55
+ @classmethod
56
+ def _validate_secrets(cls, headers: dict[str, str], info):
57
+ result = {}
58
+ for key, value in headers.items():
59
+ if _is_secret_header(key):
60
+ secret_value = validate_secret(SecretStr(value), info)
61
+ assert secret_value is not None
62
+ result[key] = secret_value.get_secret_value()
63
+ else:
64
+ result[key] = value
65
+ return result
66
+
67
+ @field_serializer("headers", when_used="always")
68
+ def _serialize_secrets(self, headers: dict[str, str], info):
69
+ result = {}
70
+ for key, value in headers.items():
71
+ if _is_secret_header(key):
72
+ secret_value = serialize_secret(SecretStr(value), info)
73
+ assert secret_value is not None
74
+ result[key] = secret_value
75
+ else:
76
+ result[key] = value
77
+ return result
78
+
79
+
80
+ _SECRET_HEADERS = ["AUTHORIZATION", "KEY", "SECRET"]
81
+
82
+
83
+ def _is_secret_header(key: str):
84
+ key = key.upper()
85
+ for secret in _SECRET_HEADERS:
86
+ if secret in key:
87
+ return True
88
+ return False
89
+
90
+
91
+ # Type alias for secret values - can be a plain string or a SecretSource
92
+ SecretValue = str | SecretSource
@@ -0,0 +1,6 @@
1
+ from openhands.sdk.security.risk import SecurityRisk
2
+
3
+
4
+ __all__ = [
5
+ "SecurityRisk",
6
+ ]