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,184 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Callable, Sequence
|
|
3
|
+
from threading import RLock
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from openhands.sdk.logger import get_logger
|
|
7
|
+
from openhands.sdk.tool.spec import Tool
|
|
8
|
+
from openhands.sdk.tool.tool import ToolDefinition
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from openhands.sdk.conversation.state import ConversationState
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
# A resolver produces ToolDefinition instances for given params.
|
|
17
|
+
Resolver = Callable[[dict[str, Any], "ConversationState"], Sequence[ToolDefinition]]
|
|
18
|
+
"""A resolver produces ToolDefinition instances for given params.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
params: Arbitrary parameters passed to the resolver. These are typically
|
|
22
|
+
used to configure the ToolDefinition instances that are created.
|
|
23
|
+
conversation: Optional conversation state to get directories from.
|
|
24
|
+
Returns: A sequence of ToolDefinition instances. Most of the time this will be a
|
|
25
|
+
single-item
|
|
26
|
+
sequence, but in some cases a ToolDefinition.create may produce multiple tools
|
|
27
|
+
(e.g., BrowserToolSet).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_LOCK = RLock()
|
|
31
|
+
_REG: dict[str, Resolver] = {}
|
|
32
|
+
_MODULE_QUALNAMES: dict[str, str] = {} # Maps tool name to module qualname
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolver_from_instance(name: str, tool: ToolDefinition) -> Resolver:
|
|
36
|
+
if tool.executor is None:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"Unable to register tool: "
|
|
39
|
+
f"ToolDefinition instance '{name}' must have a non-None .executor"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def _resolve(
|
|
43
|
+
params: dict[str, Any], _conv_state: "ConversationState"
|
|
44
|
+
) -> Sequence[ToolDefinition]:
|
|
45
|
+
if params:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"ToolDefinition '{name}' is a fixed instance; params not supported"
|
|
48
|
+
)
|
|
49
|
+
return [tool]
|
|
50
|
+
|
|
51
|
+
return _resolve
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resolver_from_callable(
|
|
55
|
+
name: str, factory: Callable[..., Sequence[ToolDefinition]]
|
|
56
|
+
) -> Resolver:
|
|
57
|
+
def _resolve(
|
|
58
|
+
params: dict[str, Any], conv_state: "ConversationState"
|
|
59
|
+
) -> Sequence[ToolDefinition]:
|
|
60
|
+
try:
|
|
61
|
+
# Try to call with conv_state parameter first
|
|
62
|
+
created = factory(conv_state=conv_state, **params)
|
|
63
|
+
except TypeError as exc:
|
|
64
|
+
raise TypeError(
|
|
65
|
+
f"Unable to resolve tool '{name}': factory could not be called with "
|
|
66
|
+
f"params {params}."
|
|
67
|
+
) from exc
|
|
68
|
+
if not isinstance(created, Sequence) or not all(
|
|
69
|
+
isinstance(t, ToolDefinition) for t in created
|
|
70
|
+
):
|
|
71
|
+
raise TypeError(
|
|
72
|
+
f"Factory '{name}' must return Sequence[ToolDefinition], "
|
|
73
|
+
f"got {type(created)}"
|
|
74
|
+
)
|
|
75
|
+
return created
|
|
76
|
+
|
|
77
|
+
return _resolve
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_abstract_method(cls: type, name: str) -> bool:
|
|
81
|
+
try:
|
|
82
|
+
attr = inspect.getattr_static(cls, name)
|
|
83
|
+
except AttributeError:
|
|
84
|
+
return False
|
|
85
|
+
# Unwrap classmethod/staticmethod
|
|
86
|
+
if isinstance(attr, (classmethod, staticmethod)):
|
|
87
|
+
attr = attr.__func__
|
|
88
|
+
return getattr(attr, "__isabstractmethod__", False)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolver_from_subclass(_name: str, cls: type[ToolDefinition]) -> Resolver:
|
|
92
|
+
create = getattr(cls, "create", None)
|
|
93
|
+
|
|
94
|
+
if create is None or not callable(create) or _is_abstract_method(cls, "create"):
|
|
95
|
+
raise TypeError(
|
|
96
|
+
"Unable to register tool: "
|
|
97
|
+
f"ToolDefinition subclass '{cls.__name__}' must define .create(**params)"
|
|
98
|
+
f" as a concrete classmethod"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _resolve(
|
|
102
|
+
params: dict[str, Any], conv_state: "ConversationState"
|
|
103
|
+
) -> Sequence[ToolDefinition]:
|
|
104
|
+
created = create(conv_state=conv_state, **params)
|
|
105
|
+
if not isinstance(created, Sequence) or not all(
|
|
106
|
+
isinstance(t, ToolDefinition) for t in created
|
|
107
|
+
):
|
|
108
|
+
raise TypeError(
|
|
109
|
+
f"ToolDefinition subclass '{cls.__name__}' create() must return "
|
|
110
|
+
f"Sequence[ToolDefinition], "
|
|
111
|
+
f"got {type(created)}"
|
|
112
|
+
)
|
|
113
|
+
# Optional sanity: permit tools without executor; they'll fail at .call()
|
|
114
|
+
return created
|
|
115
|
+
|
|
116
|
+
return _resolve
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def register_tool(
|
|
120
|
+
name: str,
|
|
121
|
+
factory: ToolDefinition
|
|
122
|
+
| type[ToolDefinition]
|
|
123
|
+
| Callable[..., Sequence[ToolDefinition]],
|
|
124
|
+
) -> None:
|
|
125
|
+
if not isinstance(name, str) or not name.strip():
|
|
126
|
+
raise ValueError("ToolDefinition name must be a non-empty string")
|
|
127
|
+
|
|
128
|
+
if isinstance(factory, ToolDefinition):
|
|
129
|
+
resolver = _resolver_from_instance(name, factory)
|
|
130
|
+
elif isinstance(factory, type) and issubclass(factory, ToolDefinition):
|
|
131
|
+
resolver = _resolver_from_subclass(name, factory)
|
|
132
|
+
elif callable(factory):
|
|
133
|
+
resolver = _resolver_from_callable(name, factory)
|
|
134
|
+
else:
|
|
135
|
+
raise TypeError(
|
|
136
|
+
"register_tool(...) only accepts: (1) a ToolDefinition instance with "
|
|
137
|
+
".executor, (2) a ToolDefinition subclass with .create(**params), or "
|
|
138
|
+
"(3) a callable factory returning a Sequence[ToolDefinition]"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Track the module qualname for this tool
|
|
142
|
+
module_qualname = None
|
|
143
|
+
if isinstance(factory, type):
|
|
144
|
+
module_qualname = factory.__module__
|
|
145
|
+
elif callable(factory):
|
|
146
|
+
module_qualname = getattr(factory, "__module__", None)
|
|
147
|
+
elif isinstance(factory, ToolDefinition):
|
|
148
|
+
module_qualname = factory.__class__.__module__
|
|
149
|
+
|
|
150
|
+
with _LOCK:
|
|
151
|
+
# TODO: throw exception when registering duplicate name tools
|
|
152
|
+
if name in _REG:
|
|
153
|
+
logger.warning(f"Duplicate tool name registerd {name}")
|
|
154
|
+
_REG[name] = resolver
|
|
155
|
+
if module_qualname:
|
|
156
|
+
_MODULE_QUALNAMES[name] = module_qualname
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_tool(
|
|
160
|
+
tool_spec: Tool, conv_state: "ConversationState"
|
|
161
|
+
) -> Sequence[ToolDefinition]:
|
|
162
|
+
with _LOCK:
|
|
163
|
+
resolver = _REG.get(tool_spec.name)
|
|
164
|
+
|
|
165
|
+
if resolver is None:
|
|
166
|
+
raise KeyError(f"ToolDefinition '{tool_spec.name}' is not registered")
|
|
167
|
+
|
|
168
|
+
return resolver(tool_spec.params, conv_state)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def list_registered_tools() -> list[str]:
|
|
172
|
+
with _LOCK:
|
|
173
|
+
return list(_REG.keys())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_tool_module_qualnames() -> dict[str, str]:
|
|
177
|
+
"""Get a mapping of tool names to their module qualnames.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
A dictionary mapping tool names to module qualnames (e.g.,
|
|
181
|
+
{"glob": "openhands.tools.glob.definition"}).
|
|
182
|
+
"""
|
|
183
|
+
with _LOCK:
|
|
184
|
+
return dict(_MODULE_QUALNAMES)
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import ConfigDict, Field, create_model
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from openhands.sdk.llm import ImageContent, TextContent
|
|
9
|
+
from openhands.sdk.llm.message import content_to_str
|
|
10
|
+
from openhands.sdk.utils.models import (
|
|
11
|
+
DiscriminatedUnionMixin,
|
|
12
|
+
)
|
|
13
|
+
from openhands.sdk.utils.visualize import display_dict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from typing import Self
|
|
18
|
+
|
|
19
|
+
S = TypeVar("S", bound="Schema")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def py_type(spec: dict[str, Any]) -> Any:
|
|
23
|
+
"""Map JSON schema types to Python types."""
|
|
24
|
+
t = spec.get("type")
|
|
25
|
+
|
|
26
|
+
# Normalize union types like ["string", "null"] to a single representative type.
|
|
27
|
+
# MCP schemas often mark optional fields this way; we keep the non-null type.
|
|
28
|
+
if isinstance(t, (list, tuple, set)):
|
|
29
|
+
types = list(t)
|
|
30
|
+
non_null = [tp for tp in types if tp != "null"]
|
|
31
|
+
if len(non_null) == 1:
|
|
32
|
+
t = non_null[0]
|
|
33
|
+
else:
|
|
34
|
+
return Any
|
|
35
|
+
if t == "array":
|
|
36
|
+
items = spec.get("items", {})
|
|
37
|
+
inner = py_type(items) if isinstance(items, dict) else Any
|
|
38
|
+
return list[inner] # type: ignore[index]
|
|
39
|
+
if t == "object":
|
|
40
|
+
return dict[str, Any]
|
|
41
|
+
_map = {
|
|
42
|
+
"string": str,
|
|
43
|
+
"integer": int,
|
|
44
|
+
"number": float,
|
|
45
|
+
"boolean": bool,
|
|
46
|
+
}
|
|
47
|
+
if t in _map:
|
|
48
|
+
return _map[t]
|
|
49
|
+
return Any
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _process_schema_node(node, defs):
|
|
53
|
+
"""Recursively process a schema node to simplify and resolve $ref.
|
|
54
|
+
|
|
55
|
+
https://www.reddit.com/r/mcp/comments/1kjo9gt/toolinputschema_conversion_from_pydanticmodel/
|
|
56
|
+
https://gist.github.com/leandromoreira/3de4819e4e4df9422d87f1d3e7465c16
|
|
57
|
+
"""
|
|
58
|
+
# Handle $ref references
|
|
59
|
+
if "$ref" in node:
|
|
60
|
+
ref_path = node["$ref"]
|
|
61
|
+
if ref_path.startswith("#/$defs/"):
|
|
62
|
+
ref_name = ref_path.split("/")[-1]
|
|
63
|
+
if ref_name in defs:
|
|
64
|
+
# Process the referenced definition
|
|
65
|
+
return _process_schema_node(defs[ref_name], defs)
|
|
66
|
+
|
|
67
|
+
# Start with a new schema object
|
|
68
|
+
result = {}
|
|
69
|
+
|
|
70
|
+
# Copy the basic properties
|
|
71
|
+
if "type" in node:
|
|
72
|
+
result["type"] = node["type"]
|
|
73
|
+
|
|
74
|
+
# Handle anyOf (often used for optional fields with None)
|
|
75
|
+
if "anyOf" in node:
|
|
76
|
+
non_null_types = [t for t in node["anyOf"] if t.get("type") != "null"]
|
|
77
|
+
if non_null_types:
|
|
78
|
+
# Process the first non-null type
|
|
79
|
+
processed = _process_schema_node(non_null_types[0], defs)
|
|
80
|
+
result.update(processed)
|
|
81
|
+
|
|
82
|
+
# Handle description
|
|
83
|
+
if "description" in node:
|
|
84
|
+
result["description"] = node["description"]
|
|
85
|
+
|
|
86
|
+
# Handle object properties recursively
|
|
87
|
+
if node.get("type") == "object" and "properties" in node:
|
|
88
|
+
result["type"] = "object"
|
|
89
|
+
result["properties"] = {}
|
|
90
|
+
|
|
91
|
+
# Process each property
|
|
92
|
+
for prop_name, prop_schema in node["properties"].items():
|
|
93
|
+
result["properties"][prop_name] = _process_schema_node(prop_schema, defs)
|
|
94
|
+
|
|
95
|
+
# Add required fields if present
|
|
96
|
+
if "required" in node:
|
|
97
|
+
result["required"] = node["required"]
|
|
98
|
+
|
|
99
|
+
# Handle arrays
|
|
100
|
+
if node.get("type") == "array" and "items" in node:
|
|
101
|
+
result["type"] = "array"
|
|
102
|
+
result["items"] = _process_schema_node(node["items"], defs)
|
|
103
|
+
|
|
104
|
+
# Handle enum
|
|
105
|
+
if "enum" in node:
|
|
106
|
+
result["enum"] = node["enum"]
|
|
107
|
+
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Schema(DiscriminatedUnionMixin):
|
|
112
|
+
"""Base schema for input action / output observation."""
|
|
113
|
+
|
|
114
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", frozen=True)
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def to_mcp_schema(cls) -> dict[str, Any]:
|
|
118
|
+
"""Convert to JSON schema format compatible with MCP."""
|
|
119
|
+
full_schema = cls.model_json_schema()
|
|
120
|
+
# This will get rid of all "anyOf" in the schema,
|
|
121
|
+
# so it is fully compatible with MCP tool schema
|
|
122
|
+
result = _process_schema_node(full_schema, full_schema.get("$defs", {}))
|
|
123
|
+
|
|
124
|
+
# Remove 'kind' from properties if present (discriminator field, not for LLM)
|
|
125
|
+
EXCLUDE_FIELDS = DiscriminatedUnionMixin.model_fields.keys()
|
|
126
|
+
for f in EXCLUDE_FIELDS:
|
|
127
|
+
if "properties" in result and f in result["properties"]:
|
|
128
|
+
result["properties"].pop(f)
|
|
129
|
+
# Also remove from required if present
|
|
130
|
+
if "required" in result and f in result["required"]:
|
|
131
|
+
result["required"].remove(f)
|
|
132
|
+
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_mcp_schema(
|
|
137
|
+
cls: type[S], model_name: str, schema: dict[str, Any]
|
|
138
|
+
) -> type["S"]:
|
|
139
|
+
"""Create a Schema subclass from an MCP/JSON Schema object.
|
|
140
|
+
|
|
141
|
+
For non-required fields, we annotate as `T | None`
|
|
142
|
+
so explicit nulls are allowed.
|
|
143
|
+
"""
|
|
144
|
+
assert isinstance(schema, dict), "Schema must be a dict"
|
|
145
|
+
assert schema.get("type") == "object", "Only object schemas are supported"
|
|
146
|
+
|
|
147
|
+
props: dict[str, Any] = schema.get("properties", {}) or {}
|
|
148
|
+
required = set(schema.get("required", []) or [])
|
|
149
|
+
|
|
150
|
+
fields: dict[str, tuple] = {}
|
|
151
|
+
for fname, spec in props.items():
|
|
152
|
+
spec = spec if isinstance(spec, dict) else {}
|
|
153
|
+
tp = py_type(spec)
|
|
154
|
+
|
|
155
|
+
# Add description if present
|
|
156
|
+
desc: str | None = spec.get("description")
|
|
157
|
+
|
|
158
|
+
# Required → bare type, ellipsis sentinel
|
|
159
|
+
# Optional → make nullable via `| None`, default None
|
|
160
|
+
if fname in required:
|
|
161
|
+
anno = tp
|
|
162
|
+
default = ...
|
|
163
|
+
else:
|
|
164
|
+
anno = tp | None # allow explicit null in addition to omission
|
|
165
|
+
default = None
|
|
166
|
+
|
|
167
|
+
fields[fname] = (
|
|
168
|
+
anno,
|
|
169
|
+
Field(default=default, description=desc)
|
|
170
|
+
if desc
|
|
171
|
+
else Field(default=default),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return create_model(model_name, __base__=cls, **fields) # type: ignore[return-value]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Action(Schema, ABC):
|
|
178
|
+
"""Base schema for input action."""
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def visualize(self) -> Text:
|
|
182
|
+
"""Return Rich Text representation of this action.
|
|
183
|
+
|
|
184
|
+
This method can be overridden by subclasses to customize visualization.
|
|
185
|
+
The base implementation displays all action fields systematically.
|
|
186
|
+
"""
|
|
187
|
+
content = Text()
|
|
188
|
+
|
|
189
|
+
# Display action name
|
|
190
|
+
action_name = self.__class__.__name__
|
|
191
|
+
content.append("Action: ", style="bold")
|
|
192
|
+
content.append(action_name)
|
|
193
|
+
content.append("\n\n")
|
|
194
|
+
|
|
195
|
+
# Display all action fields systematically
|
|
196
|
+
content.append("Arguments:", style="bold")
|
|
197
|
+
action_fields = self.model_dump()
|
|
198
|
+
content.append(display_dict(action_fields))
|
|
199
|
+
|
|
200
|
+
return content
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class Observation(Schema, ABC):
|
|
204
|
+
"""Base schema for output observation."""
|
|
205
|
+
|
|
206
|
+
ERROR_MESSAGE_HEADER: ClassVar[str] = "[An error occurred during execution.]\n"
|
|
207
|
+
|
|
208
|
+
content: list[TextContent | ImageContent] = Field(
|
|
209
|
+
default_factory=list,
|
|
210
|
+
description=(
|
|
211
|
+
"Content returned from the tool as a list of "
|
|
212
|
+
"TextContent/ImageContent objects. "
|
|
213
|
+
"When there is an error, it should be written in this field."
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
is_error: bool = Field(
|
|
217
|
+
default=False, description="Whether the observation indicates an error"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def from_text(
|
|
222
|
+
cls,
|
|
223
|
+
text: str,
|
|
224
|
+
is_error: bool = False,
|
|
225
|
+
**kwargs: Any,
|
|
226
|
+
) -> "Self":
|
|
227
|
+
"""Utility to create an Observation from a simple text string.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
text: The text content to include in the observation.
|
|
231
|
+
is_error: Whether this observation represents an error.
|
|
232
|
+
**kwargs: Additional fields for the observation subclass.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
An Observation instance with the text wrapped in a TextContent.
|
|
236
|
+
"""
|
|
237
|
+
return cls(content=[TextContent(text=text)], is_error=is_error, **kwargs)
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def text(self) -> str:
|
|
241
|
+
"""Extract all text content from the observation.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Concatenated text from all TextContent items in content.
|
|
245
|
+
"""
|
|
246
|
+
return "".join(
|
|
247
|
+
item.text for item in self.content if isinstance(item, TextContent)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def to_llm_content(self) -> Sequence[TextContent | ImageContent]:
|
|
252
|
+
"""
|
|
253
|
+
Default content formatting for converting observation to LLM readable content.
|
|
254
|
+
Subclasses can override to provide richer content (e.g., images, diffs).
|
|
255
|
+
"""
|
|
256
|
+
llm_content: list[TextContent | ImageContent] = []
|
|
257
|
+
|
|
258
|
+
# If is_error is true, prepend error message
|
|
259
|
+
if self.is_error:
|
|
260
|
+
llm_content.append(TextContent(text=self.ERROR_MESSAGE_HEADER))
|
|
261
|
+
|
|
262
|
+
# Add content (now always a list)
|
|
263
|
+
llm_content.extend(self.content)
|
|
264
|
+
|
|
265
|
+
return llm_content
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def visualize(self) -> Text:
|
|
269
|
+
"""Return Rich Text representation of this observation.
|
|
270
|
+
|
|
271
|
+
Subclasses can override for custom visualization; by default we show the
|
|
272
|
+
same text that would be sent to the LLM.
|
|
273
|
+
"""
|
|
274
|
+
text = Text()
|
|
275
|
+
|
|
276
|
+
if self.is_error:
|
|
277
|
+
text.append("❌ ", style="red bold")
|
|
278
|
+
text.append(self.ERROR_MESSAGE_HEADER, style="bold red")
|
|
279
|
+
|
|
280
|
+
text_parts = content_to_str(self.to_llm_content)
|
|
281
|
+
if text_parts:
|
|
282
|
+
full_content = "".join(text_parts)
|
|
283
|
+
text.append(full_content)
|
|
284
|
+
else:
|
|
285
|
+
text.append("[no text content]")
|
|
286
|
+
return text
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, field_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Tool(BaseModel):
|
|
7
|
+
"""Defines a tool to be initialized for the agent.
|
|
8
|
+
|
|
9
|
+
This is only used in agent-sdk for type schema for server use.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
name: str = Field(
|
|
13
|
+
...,
|
|
14
|
+
description=(
|
|
15
|
+
"Name of the tool class, e.g., 'TerminalTool'. "
|
|
16
|
+
"Import it from an `openhands.tools.<module>` subpackage."
|
|
17
|
+
),
|
|
18
|
+
examples=["TerminalTool", "FileEditorTool", "TaskTrackerTool"],
|
|
19
|
+
)
|
|
20
|
+
params: dict[str, Any] = Field(
|
|
21
|
+
default_factory=dict,
|
|
22
|
+
description="Parameters for the tool's .create() method,"
|
|
23
|
+
" e.g., {'working_dir': '/app'}",
|
|
24
|
+
examples=[{"working_dir": "/workspace"}],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@field_validator("name")
|
|
28
|
+
@classmethod
|
|
29
|
+
def validate_name(cls, v: str) -> str:
|
|
30
|
+
"""Validate that name is not empty."""
|
|
31
|
+
if not v or not v.strip():
|
|
32
|
+
raise ValueError("Tool name cannot be empty")
|
|
33
|
+
return v
|
|
34
|
+
|
|
35
|
+
@field_validator("params", mode="before")
|
|
36
|
+
@classmethod
|
|
37
|
+
def validate_params(cls, v: dict[str, Any] | None) -> dict[str, Any]:
|
|
38
|
+
"""Convert None params to empty dict."""
|
|
39
|
+
return v if v is not None else {}
|