openhands-sdk 1.7.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +650 -0
  4. openhands/sdk/agent/base.py +457 -0
  5. openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
  6. openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
  7. openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
  8. openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
  9. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
  10. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
  11. openhands/sdk/agent/prompts/security_policy.j2 +22 -0
  12. openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
  13. openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
  14. openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
  15. openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
  16. openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
  17. openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
  18. openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
  19. openhands/sdk/agent/utils.py +228 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +264 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +100 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +14 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/condenser/utils.py +149 -0
  29. openhands/sdk/context/prompts/__init__.py +6 -0
  30. openhands/sdk/context/prompts/prompt.py +114 -0
  31. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  32. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  33. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  34. openhands/sdk/context/skills/__init__.py +28 -0
  35. openhands/sdk/context/skills/exceptions.py +11 -0
  36. openhands/sdk/context/skills/skill.py +720 -0
  37. openhands/sdk/context/skills/trigger.py +36 -0
  38. openhands/sdk/context/skills/types.py +48 -0
  39. openhands/sdk/context/view.py +503 -0
  40. openhands/sdk/conversation/__init__.py +40 -0
  41. openhands/sdk/conversation/base.py +281 -0
  42. openhands/sdk/conversation/conversation.py +152 -0
  43. openhands/sdk/conversation/conversation_stats.py +85 -0
  44. openhands/sdk/conversation/event_store.py +157 -0
  45. openhands/sdk/conversation/events_list_base.py +17 -0
  46. openhands/sdk/conversation/exceptions.py +50 -0
  47. openhands/sdk/conversation/fifo_lock.py +133 -0
  48. openhands/sdk/conversation/impl/__init__.py +5 -0
  49. openhands/sdk/conversation/impl/local_conversation.py +665 -0
  50. openhands/sdk/conversation/impl/remote_conversation.py +956 -0
  51. openhands/sdk/conversation/persistence_const.py +9 -0
  52. openhands/sdk/conversation/response_utils.py +41 -0
  53. openhands/sdk/conversation/secret_registry.py +126 -0
  54. openhands/sdk/conversation/serialization_diff.py +0 -0
  55. openhands/sdk/conversation/state.py +392 -0
  56. openhands/sdk/conversation/stuck_detector.py +311 -0
  57. openhands/sdk/conversation/title_utils.py +191 -0
  58. openhands/sdk/conversation/types.py +45 -0
  59. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  60. openhands/sdk/conversation/visualizer/base.py +67 -0
  61. openhands/sdk/conversation/visualizer/default.py +373 -0
  62. openhands/sdk/critic/__init__.py +15 -0
  63. openhands/sdk/critic/base.py +38 -0
  64. openhands/sdk/critic/impl/__init__.py +12 -0
  65. openhands/sdk/critic/impl/agent_finished.py +83 -0
  66. openhands/sdk/critic/impl/empty_patch.py +49 -0
  67. openhands/sdk/critic/impl/pass_critic.py +42 -0
  68. openhands/sdk/event/__init__.py +42 -0
  69. openhands/sdk/event/base.py +149 -0
  70. openhands/sdk/event/condenser.py +82 -0
  71. openhands/sdk/event/conversation_error.py +25 -0
  72. openhands/sdk/event/conversation_state.py +104 -0
  73. openhands/sdk/event/llm_completion_log.py +39 -0
  74. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  75. openhands/sdk/event/llm_convertible/action.py +139 -0
  76. openhands/sdk/event/llm_convertible/message.py +142 -0
  77. openhands/sdk/event/llm_convertible/observation.py +141 -0
  78. openhands/sdk/event/llm_convertible/system.py +61 -0
  79. openhands/sdk/event/token.py +16 -0
  80. openhands/sdk/event/types.py +11 -0
  81. openhands/sdk/event/user_action.py +21 -0
  82. openhands/sdk/git/exceptions.py +43 -0
  83. openhands/sdk/git/git_changes.py +249 -0
  84. openhands/sdk/git/git_diff.py +129 -0
  85. openhands/sdk/git/models.py +21 -0
  86. openhands/sdk/git/utils.py +189 -0
  87. openhands/sdk/hooks/__init__.py +30 -0
  88. openhands/sdk/hooks/config.py +180 -0
  89. openhands/sdk/hooks/conversation_hooks.py +227 -0
  90. openhands/sdk/hooks/executor.py +155 -0
  91. openhands/sdk/hooks/manager.py +170 -0
  92. openhands/sdk/hooks/types.py +40 -0
  93. openhands/sdk/io/__init__.py +6 -0
  94. openhands/sdk/io/base.py +48 -0
  95. openhands/sdk/io/cache.py +85 -0
  96. openhands/sdk/io/local.py +119 -0
  97. openhands/sdk/io/memory.py +54 -0
  98. openhands/sdk/llm/__init__.py +45 -0
  99. openhands/sdk/llm/exceptions/__init__.py +45 -0
  100. openhands/sdk/llm/exceptions/classifier.py +50 -0
  101. openhands/sdk/llm/exceptions/mapping.py +54 -0
  102. openhands/sdk/llm/exceptions/types.py +101 -0
  103. openhands/sdk/llm/llm.py +1140 -0
  104. openhands/sdk/llm/llm_registry.py +122 -0
  105. openhands/sdk/llm/llm_response.py +59 -0
  106. openhands/sdk/llm/message.py +656 -0
  107. openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
  108. openhands/sdk/llm/mixins/non_native_fc.py +97 -0
  109. openhands/sdk/llm/options/__init__.py +1 -0
  110. openhands/sdk/llm/options/chat_options.py +93 -0
  111. openhands/sdk/llm/options/common.py +19 -0
  112. openhands/sdk/llm/options/responses_options.py +67 -0
  113. openhands/sdk/llm/router/__init__.py +10 -0
  114. openhands/sdk/llm/router/base.py +117 -0
  115. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  116. openhands/sdk/llm/router/impl/random.py +22 -0
  117. openhands/sdk/llm/streaming.py +9 -0
  118. openhands/sdk/llm/utils/metrics.py +312 -0
  119. openhands/sdk/llm/utils/model_features.py +192 -0
  120. openhands/sdk/llm/utils/model_info.py +90 -0
  121. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  122. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  123. openhands/sdk/llm/utils/telemetry.py +362 -0
  124. openhands/sdk/llm/utils/unverified_models.py +156 -0
  125. openhands/sdk/llm/utils/verified_models.py +65 -0
  126. openhands/sdk/logger/__init__.py +22 -0
  127. openhands/sdk/logger/logger.py +195 -0
  128. openhands/sdk/logger/rolling.py +113 -0
  129. openhands/sdk/mcp/__init__.py +24 -0
  130. openhands/sdk/mcp/client.py +76 -0
  131. openhands/sdk/mcp/definition.py +106 -0
  132. openhands/sdk/mcp/exceptions.py +19 -0
  133. openhands/sdk/mcp/tool.py +270 -0
  134. openhands/sdk/mcp/utils.py +83 -0
  135. openhands/sdk/observability/__init__.py +4 -0
  136. openhands/sdk/observability/laminar.py +166 -0
  137. openhands/sdk/observability/utils.py +20 -0
  138. openhands/sdk/py.typed +0 -0
  139. openhands/sdk/secret/__init__.py +19 -0
  140. openhands/sdk/secret/secrets.py +92 -0
  141. openhands/sdk/security/__init__.py +6 -0
  142. openhands/sdk/security/analyzer.py +111 -0
  143. openhands/sdk/security/confirmation_policy.py +61 -0
  144. openhands/sdk/security/llm_analyzer.py +29 -0
  145. openhands/sdk/security/risk.py +100 -0
  146. openhands/sdk/tool/__init__.py +34 -0
  147. openhands/sdk/tool/builtins/__init__.py +34 -0
  148. openhands/sdk/tool/builtins/finish.py +106 -0
  149. openhands/sdk/tool/builtins/think.py +117 -0
  150. openhands/sdk/tool/registry.py +184 -0
  151. openhands/sdk/tool/schema.py +286 -0
  152. openhands/sdk/tool/spec.py +39 -0
  153. openhands/sdk/tool/tool.py +481 -0
  154. openhands/sdk/utils/__init__.py +22 -0
  155. openhands/sdk/utils/async_executor.py +115 -0
  156. openhands/sdk/utils/async_utils.py +39 -0
  157. openhands/sdk/utils/cipher.py +68 -0
  158. openhands/sdk/utils/command.py +90 -0
  159. openhands/sdk/utils/deprecation.py +166 -0
  160. openhands/sdk/utils/github.py +44 -0
  161. openhands/sdk/utils/json.py +48 -0
  162. openhands/sdk/utils/models.py +570 -0
  163. openhands/sdk/utils/paging.py +63 -0
  164. openhands/sdk/utils/pydantic_diff.py +85 -0
  165. openhands/sdk/utils/pydantic_secrets.py +64 -0
  166. openhands/sdk/utils/truncate.py +117 -0
  167. openhands/sdk/utils/visualize.py +58 -0
  168. openhands/sdk/workspace/__init__.py +17 -0
  169. openhands/sdk/workspace/base.py +158 -0
  170. openhands/sdk/workspace/local.py +189 -0
  171. openhands/sdk/workspace/models.py +35 -0
  172. openhands/sdk/workspace/remote/__init__.py +8 -0
  173. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  174. openhands/sdk/workspace/remote/base.py +164 -0
  175. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  176. openhands/sdk/workspace/workspace.py +49 -0
  177. openhands_sdk-1.7.3.dist-info/METADATA +17 -0
  178. openhands_sdk-1.7.3.dist-info/RECORD +180 -0
  179. openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
  180. openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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 {}