openhands-sdk 1.7.0__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 (172) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +607 -0
  4. openhands/sdk/agent/base.py +454 -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 +3 -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 +223 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +240 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +95 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +89 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +13 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +55 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/prompts/__init__.py +6 -0
  29. openhands/sdk/context/prompts/prompt.py +114 -0
  30. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  31. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  32. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  33. openhands/sdk/context/skills/__init__.py +28 -0
  34. openhands/sdk/context/skills/exceptions.py +11 -0
  35. openhands/sdk/context/skills/skill.py +630 -0
  36. openhands/sdk/context/skills/trigger.py +36 -0
  37. openhands/sdk/context/skills/types.py +48 -0
  38. openhands/sdk/context/view.py +306 -0
  39. openhands/sdk/conversation/__init__.py +40 -0
  40. openhands/sdk/conversation/base.py +281 -0
  41. openhands/sdk/conversation/conversation.py +146 -0
  42. openhands/sdk/conversation/conversation_stats.py +85 -0
  43. openhands/sdk/conversation/event_store.py +157 -0
  44. openhands/sdk/conversation/events_list_base.py +17 -0
  45. openhands/sdk/conversation/exceptions.py +50 -0
  46. openhands/sdk/conversation/fifo_lock.py +133 -0
  47. openhands/sdk/conversation/impl/__init__.py +5 -0
  48. openhands/sdk/conversation/impl/local_conversation.py +620 -0
  49. openhands/sdk/conversation/impl/remote_conversation.py +883 -0
  50. openhands/sdk/conversation/persistence_const.py +9 -0
  51. openhands/sdk/conversation/response_utils.py +41 -0
  52. openhands/sdk/conversation/secret_registry.py +126 -0
  53. openhands/sdk/conversation/serialization_diff.py +0 -0
  54. openhands/sdk/conversation/state.py +352 -0
  55. openhands/sdk/conversation/stuck_detector.py +311 -0
  56. openhands/sdk/conversation/title_utils.py +191 -0
  57. openhands/sdk/conversation/types.py +45 -0
  58. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  59. openhands/sdk/conversation/visualizer/base.py +67 -0
  60. openhands/sdk/conversation/visualizer/default.py +373 -0
  61. openhands/sdk/critic/__init__.py +15 -0
  62. openhands/sdk/critic/base.py +38 -0
  63. openhands/sdk/critic/impl/__init__.py +12 -0
  64. openhands/sdk/critic/impl/agent_finished.py +83 -0
  65. openhands/sdk/critic/impl/empty_patch.py +49 -0
  66. openhands/sdk/critic/impl/pass_critic.py +42 -0
  67. openhands/sdk/event/__init__.py +42 -0
  68. openhands/sdk/event/base.py +149 -0
  69. openhands/sdk/event/condenser.py +82 -0
  70. openhands/sdk/event/conversation_error.py +25 -0
  71. openhands/sdk/event/conversation_state.py +104 -0
  72. openhands/sdk/event/llm_completion_log.py +39 -0
  73. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  74. openhands/sdk/event/llm_convertible/action.py +139 -0
  75. openhands/sdk/event/llm_convertible/message.py +142 -0
  76. openhands/sdk/event/llm_convertible/observation.py +141 -0
  77. openhands/sdk/event/llm_convertible/system.py +61 -0
  78. openhands/sdk/event/token.py +16 -0
  79. openhands/sdk/event/types.py +11 -0
  80. openhands/sdk/event/user_action.py +21 -0
  81. openhands/sdk/git/exceptions.py +43 -0
  82. openhands/sdk/git/git_changes.py +249 -0
  83. openhands/sdk/git/git_diff.py +129 -0
  84. openhands/sdk/git/models.py +21 -0
  85. openhands/sdk/git/utils.py +189 -0
  86. openhands/sdk/io/__init__.py +6 -0
  87. openhands/sdk/io/base.py +48 -0
  88. openhands/sdk/io/local.py +82 -0
  89. openhands/sdk/io/memory.py +54 -0
  90. openhands/sdk/llm/__init__.py +45 -0
  91. openhands/sdk/llm/exceptions/__init__.py +45 -0
  92. openhands/sdk/llm/exceptions/classifier.py +50 -0
  93. openhands/sdk/llm/exceptions/mapping.py +54 -0
  94. openhands/sdk/llm/exceptions/types.py +101 -0
  95. openhands/sdk/llm/llm.py +1140 -0
  96. openhands/sdk/llm/llm_registry.py +122 -0
  97. openhands/sdk/llm/llm_response.py +59 -0
  98. openhands/sdk/llm/message.py +656 -0
  99. openhands/sdk/llm/mixins/fn_call_converter.py +1243 -0
  100. openhands/sdk/llm/mixins/non_native_fc.py +93 -0
  101. openhands/sdk/llm/options/__init__.py +1 -0
  102. openhands/sdk/llm/options/chat_options.py +93 -0
  103. openhands/sdk/llm/options/common.py +19 -0
  104. openhands/sdk/llm/options/responses_options.py +67 -0
  105. openhands/sdk/llm/router/__init__.py +10 -0
  106. openhands/sdk/llm/router/base.py +117 -0
  107. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  108. openhands/sdk/llm/router/impl/random.py +22 -0
  109. openhands/sdk/llm/streaming.py +9 -0
  110. openhands/sdk/llm/utils/metrics.py +312 -0
  111. openhands/sdk/llm/utils/model_features.py +191 -0
  112. openhands/sdk/llm/utils/model_info.py +90 -0
  113. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  114. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  115. openhands/sdk/llm/utils/telemetry.py +362 -0
  116. openhands/sdk/llm/utils/unverified_models.py +156 -0
  117. openhands/sdk/llm/utils/verified_models.py +66 -0
  118. openhands/sdk/logger/__init__.py +22 -0
  119. openhands/sdk/logger/logger.py +195 -0
  120. openhands/sdk/logger/rolling.py +113 -0
  121. openhands/sdk/mcp/__init__.py +24 -0
  122. openhands/sdk/mcp/client.py +76 -0
  123. openhands/sdk/mcp/definition.py +106 -0
  124. openhands/sdk/mcp/exceptions.py +19 -0
  125. openhands/sdk/mcp/tool.py +270 -0
  126. openhands/sdk/mcp/utils.py +83 -0
  127. openhands/sdk/observability/__init__.py +4 -0
  128. openhands/sdk/observability/laminar.py +166 -0
  129. openhands/sdk/observability/utils.py +20 -0
  130. openhands/sdk/py.typed +0 -0
  131. openhands/sdk/secret/__init__.py +19 -0
  132. openhands/sdk/secret/secrets.py +92 -0
  133. openhands/sdk/security/__init__.py +6 -0
  134. openhands/sdk/security/analyzer.py +111 -0
  135. openhands/sdk/security/confirmation_policy.py +61 -0
  136. openhands/sdk/security/llm_analyzer.py +29 -0
  137. openhands/sdk/security/risk.py +100 -0
  138. openhands/sdk/tool/__init__.py +34 -0
  139. openhands/sdk/tool/builtins/__init__.py +34 -0
  140. openhands/sdk/tool/builtins/finish.py +106 -0
  141. openhands/sdk/tool/builtins/think.py +117 -0
  142. openhands/sdk/tool/registry.py +161 -0
  143. openhands/sdk/tool/schema.py +276 -0
  144. openhands/sdk/tool/spec.py +39 -0
  145. openhands/sdk/tool/tool.py +481 -0
  146. openhands/sdk/utils/__init__.py +22 -0
  147. openhands/sdk/utils/async_executor.py +115 -0
  148. openhands/sdk/utils/async_utils.py +39 -0
  149. openhands/sdk/utils/cipher.py +68 -0
  150. openhands/sdk/utils/command.py +90 -0
  151. openhands/sdk/utils/deprecation.py +166 -0
  152. openhands/sdk/utils/github.py +44 -0
  153. openhands/sdk/utils/json.py +48 -0
  154. openhands/sdk/utils/models.py +570 -0
  155. openhands/sdk/utils/paging.py +63 -0
  156. openhands/sdk/utils/pydantic_diff.py +85 -0
  157. openhands/sdk/utils/pydantic_secrets.py +64 -0
  158. openhands/sdk/utils/truncate.py +117 -0
  159. openhands/sdk/utils/visualize.py +58 -0
  160. openhands/sdk/workspace/__init__.py +17 -0
  161. openhands/sdk/workspace/base.py +158 -0
  162. openhands/sdk/workspace/local.py +189 -0
  163. openhands/sdk/workspace/models.py +35 -0
  164. openhands/sdk/workspace/remote/__init__.py +8 -0
  165. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  166. openhands/sdk/workspace/remote/base.py +164 -0
  167. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  168. openhands/sdk/workspace/workspace.py +49 -0
  169. openhands_sdk-1.7.0.dist-info/METADATA +17 -0
  170. openhands_sdk-1.7.0.dist-info/RECORD +172 -0
  171. openhands_sdk-1.7.0.dist-info/WHEEL +5 -0
  172. openhands_sdk-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,161 @@
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
+
33
+
34
+ def _resolver_from_instance(name: str, tool: ToolDefinition) -> Resolver:
35
+ if tool.executor is None:
36
+ raise ValueError(
37
+ "Unable to register tool: "
38
+ f"ToolDefinition instance '{name}' must have a non-None .executor"
39
+ )
40
+
41
+ def _resolve(
42
+ params: dict[str, Any], _conv_state: "ConversationState"
43
+ ) -> Sequence[ToolDefinition]:
44
+ if params:
45
+ raise ValueError(
46
+ f"ToolDefinition '{name}' is a fixed instance; params not supported"
47
+ )
48
+ return [tool]
49
+
50
+ return _resolve
51
+
52
+
53
+ def _resolver_from_callable(
54
+ name: str, factory: Callable[..., Sequence[ToolDefinition]]
55
+ ) -> Resolver:
56
+ def _resolve(
57
+ params: dict[str, Any], conv_state: "ConversationState"
58
+ ) -> Sequence[ToolDefinition]:
59
+ try:
60
+ # Try to call with conv_state parameter first
61
+ created = factory(conv_state=conv_state, **params)
62
+ except TypeError as exc:
63
+ raise TypeError(
64
+ f"Unable to resolve tool '{name}': factory could not be called with "
65
+ f"params {params}."
66
+ ) from exc
67
+ if not isinstance(created, Sequence) or not all(
68
+ isinstance(t, ToolDefinition) for t in created
69
+ ):
70
+ raise TypeError(
71
+ f"Factory '{name}' must return Sequence[ToolDefinition], "
72
+ f"got {type(created)}"
73
+ )
74
+ return created
75
+
76
+ return _resolve
77
+
78
+
79
+ def _is_abstract_method(cls: type, name: str) -> bool:
80
+ try:
81
+ attr = inspect.getattr_static(cls, name)
82
+ except AttributeError:
83
+ return False
84
+ # Unwrap classmethod/staticmethod
85
+ if isinstance(attr, (classmethod, staticmethod)):
86
+ attr = attr.__func__
87
+ return getattr(attr, "__isabstractmethod__", False)
88
+
89
+
90
+ def _resolver_from_subclass(_name: str, cls: type[ToolDefinition]) -> Resolver:
91
+ create = getattr(cls, "create", None)
92
+
93
+ if create is None or not callable(create) or _is_abstract_method(cls, "create"):
94
+ raise TypeError(
95
+ "Unable to register tool: "
96
+ f"ToolDefinition subclass '{cls.__name__}' must define .create(**params)"
97
+ f" as a concrete classmethod"
98
+ )
99
+
100
+ def _resolve(
101
+ params: dict[str, Any], conv_state: "ConversationState"
102
+ ) -> Sequence[ToolDefinition]:
103
+ created = create(conv_state=conv_state, **params)
104
+ if not isinstance(created, Sequence) or not all(
105
+ isinstance(t, ToolDefinition) for t in created
106
+ ):
107
+ raise TypeError(
108
+ f"ToolDefinition subclass '{cls.__name__}' create() must return "
109
+ f"Sequence[ToolDefinition], "
110
+ f"got {type(created)}"
111
+ )
112
+ # Optional sanity: permit tools without executor; they'll fail at .call()
113
+ return created
114
+
115
+ return _resolve
116
+
117
+
118
+ def register_tool(
119
+ name: str,
120
+ factory: ToolDefinition
121
+ | type[ToolDefinition]
122
+ | Callable[..., Sequence[ToolDefinition]],
123
+ ) -> None:
124
+ if not isinstance(name, str) or not name.strip():
125
+ raise ValueError("ToolDefinition name must be a non-empty string")
126
+
127
+ if isinstance(factory, ToolDefinition):
128
+ resolver = _resolver_from_instance(name, factory)
129
+ elif isinstance(factory, type) and issubclass(factory, ToolDefinition):
130
+ resolver = _resolver_from_subclass(name, factory)
131
+ elif callable(factory):
132
+ resolver = _resolver_from_callable(name, factory)
133
+ else:
134
+ raise TypeError(
135
+ "register_tool(...) only accepts: (1) a ToolDefinition instance with "
136
+ ".executor, (2) a ToolDefinition subclass with .create(**params), or "
137
+ "(3) a callable factory returning a Sequence[ToolDefinition]"
138
+ )
139
+
140
+ with _LOCK:
141
+ # TODO: throw exception when registering duplicate name tools
142
+ if name in _REG:
143
+ logger.warning(f"Duplicate tool name registerd {name}")
144
+ _REG[name] = resolver
145
+
146
+
147
+ def resolve_tool(
148
+ tool_spec: Tool, conv_state: "ConversationState"
149
+ ) -> Sequence[ToolDefinition]:
150
+ with _LOCK:
151
+ resolver = _REG.get(tool_spec.name)
152
+
153
+ if resolver is None:
154
+ raise KeyError(f"ToolDefinition '{tool_spec.name}' is not registered")
155
+
156
+ return resolver(tool_spec.params, conv_state)
157
+
158
+
159
+ def list_registered_tools() -> list[str]:
160
+ with _LOCK:
161
+ return list(_REG.keys())
@@ -0,0 +1,276 @@
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
+ if t == "array":
26
+ items = spec.get("items", {})
27
+ inner = py_type(items) if isinstance(items, dict) else Any
28
+ return list[inner] # type: ignore[index]
29
+ if t == "object":
30
+ return dict[str, Any]
31
+ _map = {
32
+ "string": str,
33
+ "integer": int,
34
+ "number": float,
35
+ "boolean": bool,
36
+ }
37
+ if t in _map:
38
+ return _map[t]
39
+ return Any
40
+
41
+
42
+ def _process_schema_node(node, defs):
43
+ """Recursively process a schema node to simplify and resolve $ref.
44
+
45
+ https://www.reddit.com/r/mcp/comments/1kjo9gt/toolinputschema_conversion_from_pydanticmodel/
46
+ https://gist.github.com/leandromoreira/3de4819e4e4df9422d87f1d3e7465c16
47
+ """
48
+ # Handle $ref references
49
+ if "$ref" in node:
50
+ ref_path = node["$ref"]
51
+ if ref_path.startswith("#/$defs/"):
52
+ ref_name = ref_path.split("/")[-1]
53
+ if ref_name in defs:
54
+ # Process the referenced definition
55
+ return _process_schema_node(defs[ref_name], defs)
56
+
57
+ # Start with a new schema object
58
+ result = {}
59
+
60
+ # Copy the basic properties
61
+ if "type" in node:
62
+ result["type"] = node["type"]
63
+
64
+ # Handle anyOf (often used for optional fields with None)
65
+ if "anyOf" in node:
66
+ non_null_types = [t for t in node["anyOf"] if t.get("type") != "null"]
67
+ if non_null_types:
68
+ # Process the first non-null type
69
+ processed = _process_schema_node(non_null_types[0], defs)
70
+ result.update(processed)
71
+
72
+ # Handle description
73
+ if "description" in node:
74
+ result["description"] = node["description"]
75
+
76
+ # Handle object properties recursively
77
+ if node.get("type") == "object" and "properties" in node:
78
+ result["type"] = "object"
79
+ result["properties"] = {}
80
+
81
+ # Process each property
82
+ for prop_name, prop_schema in node["properties"].items():
83
+ result["properties"][prop_name] = _process_schema_node(prop_schema, defs)
84
+
85
+ # Add required fields if present
86
+ if "required" in node:
87
+ result["required"] = node["required"]
88
+
89
+ # Handle arrays
90
+ if node.get("type") == "array" and "items" in node:
91
+ result["type"] = "array"
92
+ result["items"] = _process_schema_node(node["items"], defs)
93
+
94
+ # Handle enum
95
+ if "enum" in node:
96
+ result["enum"] = node["enum"]
97
+
98
+ return result
99
+
100
+
101
+ class Schema(DiscriminatedUnionMixin):
102
+ """Base schema for input action / output observation."""
103
+
104
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", frozen=True)
105
+
106
+ @classmethod
107
+ def to_mcp_schema(cls) -> dict[str, Any]:
108
+ """Convert to JSON schema format compatible with MCP."""
109
+ full_schema = cls.model_json_schema()
110
+ # This will get rid of all "anyOf" in the schema,
111
+ # so it is fully compatible with MCP tool schema
112
+ result = _process_schema_node(full_schema, full_schema.get("$defs", {}))
113
+
114
+ # Remove 'kind' from properties if present (discriminator field, not for LLM)
115
+ EXCLUDE_FIELDS = DiscriminatedUnionMixin.model_fields.keys()
116
+ for f in EXCLUDE_FIELDS:
117
+ if "properties" in result and f in result["properties"]:
118
+ result["properties"].pop(f)
119
+ # Also remove from required if present
120
+ if "required" in result and f in result["required"]:
121
+ result["required"].remove(f)
122
+
123
+ return result
124
+
125
+ @classmethod
126
+ def from_mcp_schema(
127
+ cls: type[S], model_name: str, schema: dict[str, Any]
128
+ ) -> type["S"]:
129
+ """Create a Schema subclass from an MCP/JSON Schema object.
130
+
131
+ For non-required fields, we annotate as `T | None`
132
+ so explicit nulls are allowed.
133
+ """
134
+ assert isinstance(schema, dict), "Schema must be a dict"
135
+ assert schema.get("type") == "object", "Only object schemas are supported"
136
+
137
+ props: dict[str, Any] = schema.get("properties", {}) or {}
138
+ required = set(schema.get("required", []) or [])
139
+
140
+ fields: dict[str, tuple] = {}
141
+ for fname, spec in props.items():
142
+ spec = spec if isinstance(spec, dict) else {}
143
+ tp = py_type(spec)
144
+
145
+ # Add description if present
146
+ desc: str | None = spec.get("description")
147
+
148
+ # Required → bare type, ellipsis sentinel
149
+ # Optional → make nullable via `| None`, default None
150
+ if fname in required:
151
+ anno = tp
152
+ default = ...
153
+ else:
154
+ anno = tp | None # allow explicit null in addition to omission
155
+ default = None
156
+
157
+ fields[fname] = (
158
+ anno,
159
+ Field(default=default, description=desc)
160
+ if desc
161
+ else Field(default=default),
162
+ )
163
+
164
+ return create_model(model_name, __base__=cls, **fields) # type: ignore[return-value]
165
+
166
+
167
+ class Action(Schema, ABC):
168
+ """Base schema for input action."""
169
+
170
+ @property
171
+ def visualize(self) -> Text:
172
+ """Return Rich Text representation of this action.
173
+
174
+ This method can be overridden by subclasses to customize visualization.
175
+ The base implementation displays all action fields systematically.
176
+ """
177
+ content = Text()
178
+
179
+ # Display action name
180
+ action_name = self.__class__.__name__
181
+ content.append("Action: ", style="bold")
182
+ content.append(action_name)
183
+ content.append("\n\n")
184
+
185
+ # Display all action fields systematically
186
+ content.append("Arguments:", style="bold")
187
+ action_fields = self.model_dump()
188
+ content.append(display_dict(action_fields))
189
+
190
+ return content
191
+
192
+
193
+ class Observation(Schema, ABC):
194
+ """Base schema for output observation."""
195
+
196
+ ERROR_MESSAGE_HEADER: ClassVar[str] = "[An error occurred during execution.]\n"
197
+
198
+ content: list[TextContent | ImageContent] = Field(
199
+ default_factory=list,
200
+ description=(
201
+ "Content returned from the tool as a list of "
202
+ "TextContent/ImageContent objects. "
203
+ "When there is an error, it should be written in this field."
204
+ ),
205
+ )
206
+ is_error: bool = Field(
207
+ default=False, description="Whether the observation indicates an error"
208
+ )
209
+
210
+ @classmethod
211
+ def from_text(
212
+ cls,
213
+ text: str,
214
+ is_error: bool = False,
215
+ **kwargs: Any,
216
+ ) -> "Self":
217
+ """Utility to create an Observation from a simple text string.
218
+
219
+ Args:
220
+ text: The text content to include in the observation.
221
+ is_error: Whether this observation represents an error.
222
+ **kwargs: Additional fields for the observation subclass.
223
+
224
+ Returns:
225
+ An Observation instance with the text wrapped in a TextContent.
226
+ """
227
+ return cls(content=[TextContent(text=text)], is_error=is_error, **kwargs)
228
+
229
+ @property
230
+ def text(self) -> str:
231
+ """Extract all text content from the observation.
232
+
233
+ Returns:
234
+ Concatenated text from all TextContent items in content.
235
+ """
236
+ return "".join(
237
+ item.text for item in self.content if isinstance(item, TextContent)
238
+ )
239
+
240
+ @property
241
+ def to_llm_content(self) -> Sequence[TextContent | ImageContent]:
242
+ """
243
+ Default content formatting for converting observation to LLM readable content.
244
+ Subclasses can override to provide richer content (e.g., images, diffs).
245
+ """
246
+ llm_content: list[TextContent | ImageContent] = []
247
+
248
+ # If is_error is true, prepend error message
249
+ if self.is_error:
250
+ llm_content.append(TextContent(text=self.ERROR_MESSAGE_HEADER))
251
+
252
+ # Add content (now always a list)
253
+ llm_content.extend(self.content)
254
+
255
+ return llm_content
256
+
257
+ @property
258
+ def visualize(self) -> Text:
259
+ """Return Rich Text representation of this observation.
260
+
261
+ Subclasses can override for custom visualization; by default we show the
262
+ same text that would be sent to the LLM.
263
+ """
264
+ text = Text()
265
+
266
+ if self.is_error:
267
+ text.append("❌ ", style="red bold")
268
+ text.append(self.ERROR_MESSAGE_HEADER, style="bold red")
269
+
270
+ text_parts = content_to_str(self.to_llm_content)
271
+ if text_parts:
272
+ full_content = "".join(text_parts)
273
+ text.append(full_content)
274
+ else:
275
+ text.append("[no text content]")
276
+ 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 {}