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,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)
|