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.
- openhands/sdk/__init__.py +111 -0
- openhands/sdk/agent/__init__.py +8 -0
- openhands/sdk/agent/agent.py +650 -0
- openhands/sdk/agent/base.py +457 -0
- openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
- openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
- openhands/sdk/agent/prompts/security_policy.j2 +22 -0
- openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
- openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
- openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
- openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
- openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
- openhands/sdk/agent/utils.py +228 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +264 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +100 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
- openhands/sdk/context/condenser/no_op_condenser.py +14 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/condenser/utils.py +149 -0
- openhands/sdk/context/prompts/__init__.py +6 -0
- openhands/sdk/context/prompts/prompt.py +114 -0
- openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
- openhands/sdk/context/skills/__init__.py +28 -0
- openhands/sdk/context/skills/exceptions.py +11 -0
- openhands/sdk/context/skills/skill.py +720 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +503 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +152 -0
- openhands/sdk/conversation/conversation_stats.py +85 -0
- openhands/sdk/conversation/event_store.py +157 -0
- openhands/sdk/conversation/events_list_base.py +17 -0
- openhands/sdk/conversation/exceptions.py +50 -0
- openhands/sdk/conversation/fifo_lock.py +133 -0
- openhands/sdk/conversation/impl/__init__.py +5 -0
- openhands/sdk/conversation/impl/local_conversation.py +665 -0
- openhands/sdk/conversation/impl/remote_conversation.py +956 -0
- openhands/sdk/conversation/persistence_const.py +9 -0
- openhands/sdk/conversation/response_utils.py +41 -0
- openhands/sdk/conversation/secret_registry.py +126 -0
- openhands/sdk/conversation/serialization_diff.py +0 -0
- openhands/sdk/conversation/state.py +392 -0
- openhands/sdk/conversation/stuck_detector.py +311 -0
- openhands/sdk/conversation/title_utils.py +191 -0
- openhands/sdk/conversation/types.py +45 -0
- openhands/sdk/conversation/visualizer/__init__.py +12 -0
- openhands/sdk/conversation/visualizer/base.py +67 -0
- openhands/sdk/conversation/visualizer/default.py +373 -0
- openhands/sdk/critic/__init__.py +15 -0
- openhands/sdk/critic/base.py +38 -0
- openhands/sdk/critic/impl/__init__.py +12 -0
- openhands/sdk/critic/impl/agent_finished.py +83 -0
- openhands/sdk/critic/impl/empty_patch.py +49 -0
- openhands/sdk/critic/impl/pass_critic.py +42 -0
- openhands/sdk/event/__init__.py +42 -0
- openhands/sdk/event/base.py +149 -0
- openhands/sdk/event/condenser.py +82 -0
- openhands/sdk/event/conversation_error.py +25 -0
- openhands/sdk/event/conversation_state.py +104 -0
- openhands/sdk/event/llm_completion_log.py +39 -0
- openhands/sdk/event/llm_convertible/__init__.py +20 -0
- openhands/sdk/event/llm_convertible/action.py +139 -0
- openhands/sdk/event/llm_convertible/message.py +142 -0
- openhands/sdk/event/llm_convertible/observation.py +141 -0
- openhands/sdk/event/llm_convertible/system.py +61 -0
- openhands/sdk/event/token.py +16 -0
- openhands/sdk/event/types.py +11 -0
- openhands/sdk/event/user_action.py +21 -0
- openhands/sdk/git/exceptions.py +43 -0
- openhands/sdk/git/git_changes.py +249 -0
- openhands/sdk/git/git_diff.py +129 -0
- openhands/sdk/git/models.py +21 -0
- openhands/sdk/git/utils.py +189 -0
- openhands/sdk/hooks/__init__.py +30 -0
- openhands/sdk/hooks/config.py +180 -0
- openhands/sdk/hooks/conversation_hooks.py +227 -0
- openhands/sdk/hooks/executor.py +155 -0
- openhands/sdk/hooks/manager.py +170 -0
- openhands/sdk/hooks/types.py +40 -0
- openhands/sdk/io/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/cache.py +85 -0
- openhands/sdk/io/local.py +119 -0
- openhands/sdk/io/memory.py +54 -0
- openhands/sdk/llm/__init__.py +45 -0
- openhands/sdk/llm/exceptions/__init__.py +45 -0
- openhands/sdk/llm/exceptions/classifier.py +50 -0
- openhands/sdk/llm/exceptions/mapping.py +54 -0
- openhands/sdk/llm/exceptions/types.py +101 -0
- openhands/sdk/llm/llm.py +1140 -0
- openhands/sdk/llm/llm_registry.py +122 -0
- openhands/sdk/llm/llm_response.py +59 -0
- openhands/sdk/llm/message.py +656 -0
- openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
- openhands/sdk/llm/mixins/non_native_fc.py +97 -0
- openhands/sdk/llm/options/__init__.py +1 -0
- openhands/sdk/llm/options/chat_options.py +93 -0
- openhands/sdk/llm/options/common.py +19 -0
- openhands/sdk/llm/options/responses_options.py +67 -0
- openhands/sdk/llm/router/__init__.py +10 -0
- openhands/sdk/llm/router/base.py +117 -0
- openhands/sdk/llm/router/impl/multimodal.py +76 -0
- openhands/sdk/llm/router/impl/random.py +22 -0
- openhands/sdk/llm/streaming.py +9 -0
- openhands/sdk/llm/utils/metrics.py +312 -0
- openhands/sdk/llm/utils/model_features.py +192 -0
- openhands/sdk/llm/utils/model_info.py +90 -0
- openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
- openhands/sdk/llm/utils/retry_mixin.py +128 -0
- openhands/sdk/llm/utils/telemetry.py +362 -0
- openhands/sdk/llm/utils/unverified_models.py +156 -0
- openhands/sdk/llm/utils/verified_models.py +65 -0
- openhands/sdk/logger/__init__.py +22 -0
- openhands/sdk/logger/logger.py +195 -0
- openhands/sdk/logger/rolling.py +113 -0
- openhands/sdk/mcp/__init__.py +24 -0
- openhands/sdk/mcp/client.py +76 -0
- openhands/sdk/mcp/definition.py +106 -0
- openhands/sdk/mcp/exceptions.py +19 -0
- openhands/sdk/mcp/tool.py +270 -0
- openhands/sdk/mcp/utils.py +83 -0
- openhands/sdk/observability/__init__.py +4 -0
- openhands/sdk/observability/laminar.py +166 -0
- openhands/sdk/observability/utils.py +20 -0
- openhands/sdk/py.typed +0 -0
- openhands/sdk/secret/__init__.py +19 -0
- openhands/sdk/secret/secrets.py +92 -0
- openhands/sdk/security/__init__.py +6 -0
- openhands/sdk/security/analyzer.py +111 -0
- openhands/sdk/security/confirmation_policy.py +61 -0
- openhands/sdk/security/llm_analyzer.py +29 -0
- openhands/sdk/security/risk.py +100 -0
- openhands/sdk/tool/__init__.py +34 -0
- openhands/sdk/tool/builtins/__init__.py +34 -0
- openhands/sdk/tool/builtins/finish.py +106 -0
- openhands/sdk/tool/builtins/think.py +117 -0
- openhands/sdk/tool/registry.py +184 -0
- openhands/sdk/tool/schema.py +286 -0
- openhands/sdk/tool/spec.py +39 -0
- openhands/sdk/tool/tool.py +481 -0
- openhands/sdk/utils/__init__.py +22 -0
- openhands/sdk/utils/async_executor.py +115 -0
- openhands/sdk/utils/async_utils.py +39 -0
- openhands/sdk/utils/cipher.py +68 -0
- openhands/sdk/utils/command.py +90 -0
- openhands/sdk/utils/deprecation.py +166 -0
- openhands/sdk/utils/github.py +44 -0
- openhands/sdk/utils/json.py +48 -0
- openhands/sdk/utils/models.py +570 -0
- openhands/sdk/utils/paging.py +63 -0
- openhands/sdk/utils/pydantic_diff.py +85 -0
- openhands/sdk/utils/pydantic_secrets.py +64 -0
- openhands/sdk/utils/truncate.py +117 -0
- openhands/sdk/utils/visualize.py +58 -0
- openhands/sdk/workspace/__init__.py +17 -0
- openhands/sdk/workspace/base.py +158 -0
- openhands/sdk/workspace/local.py +189 -0
- openhands/sdk/workspace/models.py +35 -0
- openhands/sdk/workspace/remote/__init__.py +8 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
- openhands/sdk/workspace/remote/base.py +164 -0
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
- openhands/sdk/workspace/workspace.py +49 -0
- openhands_sdk-1.7.3.dist-info/METADATA +17 -0
- openhands_sdk-1.7.3.dist-info/RECORD +180 -0
- openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
- 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,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
|