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,720 @@
1
+ import io
2
+ import re
3
+ import shutil
4
+ import subprocess
5
+ from itertools import chain
6
+ from pathlib import Path
7
+ from typing import Annotated, ClassVar, Union
8
+
9
+ import frontmatter
10
+ from fastmcp.mcp_config import MCPConfig
11
+ from pydantic import BaseModel, Field, field_validator, model_validator
12
+
13
+ from openhands.sdk.context.skills.exceptions import SkillValidationError
14
+ from openhands.sdk.context.skills.trigger import (
15
+ KeywordTrigger,
16
+ TaskTrigger,
17
+ )
18
+ from openhands.sdk.context.skills.types import InputMetadata
19
+ from openhands.sdk.logger import get_logger
20
+ from openhands.sdk.utils import maybe_truncate
21
+
22
+
23
+ logger = get_logger(__name__)
24
+
25
+ # Maximum characters for third-party skill files (e.g., AGENTS.md, CLAUDE.md, GEMINI.md)
26
+ # These files are always active, so we want to keep them reasonably sized
27
+ THIRD_PARTY_SKILL_MAX_CHARS = 10_000
28
+
29
+ # Union type for all trigger types
30
+ TriggerType = Annotated[
31
+ KeywordTrigger | TaskTrigger,
32
+ Field(discriminator="type"),
33
+ ]
34
+
35
+
36
+ class Skill(BaseModel):
37
+ """A skill provides specialized knowledge or functionality.
38
+
39
+ Skills use triggers to determine when they should be activated:
40
+ - None: Always active, for repository-specific guidelines
41
+ - KeywordTrigger: Activated when keywords appear in user messages
42
+ - TaskTrigger: Activated for specific tasks, may require user input
43
+
44
+ This model supports both OpenHands-specific fields and AgentSkills standard
45
+ fields (https://agentskills.io/specification) for cross-platform compatibility.
46
+ """
47
+
48
+ name: str
49
+ content: str
50
+ trigger: TriggerType | None = Field(
51
+ default=None,
52
+ description=(
53
+ "Skills use triggers to determine when they should be activated. "
54
+ "None implies skill is always active. "
55
+ "Other implementations include KeywordTrigger (activated by a "
56
+ "keyword in a Message) and TaskTrigger (activated by specific tasks "
57
+ "and may require user input)"
58
+ ),
59
+ )
60
+ source: str | None = Field(
61
+ default=None,
62
+ description=(
63
+ "The source path or identifier of the skill. "
64
+ "When it is None, it is treated as a programmatically defined skill."
65
+ ),
66
+ )
67
+ mcp_tools: dict | None = Field(
68
+ default=None,
69
+ description=(
70
+ "MCP tools configuration for the skill (repo skills only). "
71
+ "It should conform to the MCPConfig schema: "
72
+ "https://gofastmcp.com/clients/client#configuration-format"
73
+ ),
74
+ )
75
+ inputs: list[InputMetadata] = Field(
76
+ default_factory=list,
77
+ description="Input metadata for the skill (task skills only)",
78
+ )
79
+
80
+ # AgentSkills standard fields (https://agentskills.io/specification)
81
+ description: str | None = Field(
82
+ default=None,
83
+ description=(
84
+ "A brief description of what the skill does and when to use it. "
85
+ "AgentSkills standard field (max 1024 characters)."
86
+ ),
87
+ )
88
+ license: str | None = Field(
89
+ default=None,
90
+ description=(
91
+ "The license under which the skill is distributed. "
92
+ "AgentSkills standard field (e.g., 'Apache-2.0', 'MIT')."
93
+ ),
94
+ )
95
+ compatibility: str | None = Field(
96
+ default=None,
97
+ description=(
98
+ "Environment requirements or compatibility notes for the skill. "
99
+ "AgentSkills standard field (e.g., 'Requires git and docker')."
100
+ ),
101
+ )
102
+ metadata: dict[str, str] | None = Field(
103
+ default=None,
104
+ description=(
105
+ "Arbitrary key-value metadata for the skill. "
106
+ "AgentSkills standard field for extensibility."
107
+ ),
108
+ )
109
+ allowed_tools: list[str] | None = Field(
110
+ default=None,
111
+ description=(
112
+ "List of pre-approved tools for this skill. "
113
+ "AgentSkills standard field (parsed from space-delimited string)."
114
+ ),
115
+ )
116
+
117
+ @field_validator("allowed_tools", mode="before")
118
+ @classmethod
119
+ def _parse_allowed_tools(cls, v: str | list | None) -> list[str] | None:
120
+ """Parse allowed_tools from space-delimited string or list."""
121
+ if v is None:
122
+ return None
123
+ if isinstance(v, str):
124
+ return v.split()
125
+ if isinstance(v, list):
126
+ return [str(t) for t in v]
127
+ raise SkillValidationError("allowed-tools must be a string or list")
128
+
129
+ @field_validator("metadata", mode="before")
130
+ @classmethod
131
+ def _convert_metadata_values(cls, v: dict | None) -> dict[str, str] | None:
132
+ """Convert metadata values to strings."""
133
+ if v is None:
134
+ return None
135
+ if isinstance(v, dict):
136
+ return {str(k): str(val) for k, val in v.items()}
137
+ raise SkillValidationError("metadata must be a dictionary")
138
+
139
+ PATH_TO_THIRD_PARTY_SKILL_NAME: ClassVar[dict[str, str]] = {
140
+ ".cursorrules": "cursorrules",
141
+ "agents.md": "agents",
142
+ "agent.md": "agents",
143
+ "claude.md": "claude",
144
+ "gemini.md": "gemini",
145
+ }
146
+
147
+ @classmethod
148
+ def _handle_third_party(cls, path: Path, file_content: str) -> Union["Skill", None]:
149
+ # Determine the agent name based on file type
150
+ skill_name = cls.PATH_TO_THIRD_PARTY_SKILL_NAME.get(path.name.lower())
151
+
152
+ # Create Skill with None trigger (always active) if we recognized the file type
153
+ if skill_name is not None:
154
+ # Truncate content if it exceeds the limit
155
+ # Third-party files are always active, so we want to keep them
156
+ # reasonably sized
157
+ truncated_content = maybe_truncate(
158
+ file_content,
159
+ truncate_after=THIRD_PARTY_SKILL_MAX_CHARS,
160
+ truncate_notice=(
161
+ f"\n\n<TRUNCATED><NOTE>The file {path} exceeded the "
162
+ f"maximum length ({THIRD_PARTY_SKILL_MAX_CHARS} "
163
+ f"characters) and has been truncated. Only the "
164
+ f"beginning and end are shown. You can read the full "
165
+ f"file if needed.</NOTE>\n\n"
166
+ ),
167
+ )
168
+
169
+ if len(file_content) > THIRD_PARTY_SKILL_MAX_CHARS:
170
+ logger.warning(
171
+ f"Third-party skill file {path} ({len(file_content)} chars) "
172
+ f"exceeded limit ({THIRD_PARTY_SKILL_MAX_CHARS} chars), truncating"
173
+ )
174
+
175
+ return Skill(
176
+ name=skill_name,
177
+ content=truncated_content,
178
+ source=str(path),
179
+ trigger=None,
180
+ )
181
+
182
+ return None
183
+
184
+ @classmethod
185
+ def load(
186
+ cls,
187
+ path: str | Path,
188
+ skill_dir: Path | None = None,
189
+ file_content: str | None = None,
190
+ ) -> "Skill":
191
+ """Load a skill from a markdown file with frontmatter.
192
+
193
+ The agent's name is derived from its path relative to the skill_dir.
194
+
195
+ Supports both OpenHands-specific frontmatter fields and AgentSkills
196
+ standard fields (https://agentskills.io/specification).
197
+ """
198
+ path = Path(path) if isinstance(path, str) else path
199
+
200
+ # Calculate derived name from relative path if skill_dir is provided
201
+ skill_name = None
202
+ if skill_dir is not None:
203
+ # Special handling for files which are not in skill_dir
204
+ skill_name = cls.PATH_TO_THIRD_PARTY_SKILL_NAME.get(
205
+ path.name.lower()
206
+ ) or str(path.relative_to(skill_dir).with_suffix(""))
207
+ else:
208
+ skill_name = path.stem
209
+
210
+ # Only load directly from path if file_content is not provided
211
+ if file_content is None:
212
+ with open(path) as f:
213
+ file_content = f.read()
214
+
215
+ # Handle third-party agent instruction files
216
+ third_party_agent = cls._handle_third_party(path, file_content)
217
+ if third_party_agent is not None:
218
+ return third_party_agent
219
+
220
+ file_io = io.StringIO(file_content)
221
+ loaded = frontmatter.load(file_io)
222
+ content = loaded.content
223
+
224
+ # Handle case where there's no frontmatter or empty frontmatter
225
+ metadata_dict = loaded.metadata or {}
226
+
227
+ # Use name from frontmatter if provided, otherwise use derived name
228
+ agent_name = str(metadata_dict.get("name", skill_name))
229
+
230
+ # Extract AgentSkills standard fields (Pydantic validators handle
231
+ # transformation). Handle "allowed-tools" to "allowed_tools" key mapping.
232
+ allowed_tools_value = metadata_dict.get(
233
+ "allowed-tools", metadata_dict.get("allowed_tools")
234
+ )
235
+ agentskills_fields = {
236
+ "description": metadata_dict.get("description"),
237
+ "license": metadata_dict.get("license"),
238
+ "compatibility": metadata_dict.get("compatibility"),
239
+ "metadata": metadata_dict.get("metadata"),
240
+ "allowed_tools": allowed_tools_value,
241
+ }
242
+ # Remove None values to avoid passing unnecessary kwargs
243
+ agentskills_fields = {
244
+ k: v for k, v in agentskills_fields.items() if v is not None
245
+ }
246
+
247
+ # Get trigger keywords from metadata
248
+ keywords = metadata_dict.get("triggers", [])
249
+ if not isinstance(keywords, list):
250
+ raise SkillValidationError("Triggers must be a list of strings")
251
+
252
+ # Infer the trigger type:
253
+ # 1. If inputs exist -> TaskTrigger
254
+ # 2. If keywords exist -> KeywordTrigger
255
+ # 3. Else (no keywords) -> None (always active)
256
+ if "inputs" in metadata_dict:
257
+ # Add a trigger for the agent name if not already present
258
+ trigger_keyword = f"/{agent_name}"
259
+ if trigger_keyword not in keywords:
260
+ keywords.append(trigger_keyword)
261
+ inputs_raw = metadata_dict.get("inputs", [])
262
+ if not isinstance(inputs_raw, list):
263
+ raise SkillValidationError("inputs must be a list")
264
+ inputs: list[InputMetadata] = [
265
+ InputMetadata.model_validate(i) for i in inputs_raw
266
+ ]
267
+ return Skill(
268
+ name=agent_name,
269
+ content=content,
270
+ source=str(path),
271
+ trigger=TaskTrigger(triggers=keywords),
272
+ inputs=inputs,
273
+ **agentskills_fields,
274
+ )
275
+
276
+ elif metadata_dict.get("triggers", None):
277
+ return Skill(
278
+ name=agent_name,
279
+ content=content,
280
+ source=str(path),
281
+ trigger=KeywordTrigger(keywords=keywords),
282
+ **agentskills_fields,
283
+ )
284
+ else:
285
+ # No triggers, default to None (always active)
286
+ mcp_tools = metadata_dict.get("mcp_tools")
287
+ if not isinstance(mcp_tools, dict | None):
288
+ raise SkillValidationError("mcp_tools must be a dictionary or None")
289
+ return Skill(
290
+ name=agent_name,
291
+ content=content,
292
+ source=str(path),
293
+ trigger=None,
294
+ mcp_tools=mcp_tools,
295
+ **agentskills_fields,
296
+ )
297
+
298
+ # Field-level validation for mcp_tools
299
+ @field_validator("mcp_tools")
300
+ @classmethod
301
+ def _validate_mcp_tools(cls, v: dict | None, _info):
302
+ if v is None:
303
+ return v
304
+ if isinstance(v, dict):
305
+ try:
306
+ MCPConfig.model_validate(v)
307
+ except Exception as e:
308
+ raise SkillValidationError(f"Invalid MCPConfig dictionary: {e}") from e
309
+ return v
310
+
311
+ @model_validator(mode="after")
312
+ def _append_missing_variables_prompt(self):
313
+ """Append a prompt to ask for missing variables after model construction."""
314
+ # Only apply to task skills
315
+ if not isinstance(self.trigger, TaskTrigger):
316
+ return self
317
+
318
+ # If no variables and no inputs, nothing to do
319
+ if not self.requires_user_input() and not self.inputs:
320
+ return self
321
+
322
+ prompt = (
323
+ "\n\nIf the user didn't provide any of these variables, ask the user to "
324
+ "provide them first before the agent can proceed with the task."
325
+ )
326
+
327
+ # Avoid duplicating the prompt if content already includes it
328
+ if self.content and prompt not in self.content:
329
+ self.content += prompt
330
+
331
+ return self
332
+
333
+ def match_trigger(self, message: str) -> str | None:
334
+ """Match a trigger in the message.
335
+
336
+ Returns the first trigger that matches the message, or None if no match.
337
+ Only applies to KeywordTrigger and TaskTrigger types.
338
+ """
339
+ if isinstance(self.trigger, KeywordTrigger):
340
+ message_lower = message.lower()
341
+ for keyword in self.trigger.keywords:
342
+ if keyword.lower() in message_lower:
343
+ return keyword
344
+ elif isinstance(self.trigger, TaskTrigger):
345
+ message_lower = message.lower()
346
+ for trigger_str in self.trigger.triggers:
347
+ if trigger_str.lower() in message_lower:
348
+ return trigger_str
349
+ return None
350
+
351
+ def extract_variables(self, content: str) -> list[str]:
352
+ """Extract variables from the content.
353
+
354
+ Variables are in the format ${variable_name}.
355
+ """
356
+ pattern = r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}"
357
+ matches = re.findall(pattern, content)
358
+ return matches
359
+
360
+ def requires_user_input(self) -> bool:
361
+ """Check if this skill requires user input.
362
+
363
+ Returns True if the content contains variables in the format ${variable_name}.
364
+ """
365
+ # Check if the content contains any variables
366
+ variables = self.extract_variables(self.content)
367
+ logger.debug(f"This skill requires user input: {variables}")
368
+ return len(variables) > 0
369
+
370
+
371
+ def load_skills_from_dir(
372
+ skill_dir: str | Path,
373
+ ) -> tuple[dict[str, Skill], dict[str, Skill]]:
374
+ """Load all skills from the given directory.
375
+
376
+ Note, legacy repo instructions will not be loaded here.
377
+
378
+ Args:
379
+ skill_dir: Path to the skills directory (e.g. .openhands/skills)
380
+
381
+ Returns:
382
+ Tuple of (repo_skills, knowledge_skills) dictionaries.
383
+ repo_skills have trigger=None, knowledge_skills have KeywordTrigger
384
+ or TaskTrigger.
385
+ """
386
+ if isinstance(skill_dir, str):
387
+ skill_dir = Path(skill_dir)
388
+
389
+ repo_skills = {}
390
+ knowledge_skills = {}
391
+
392
+ # Load all agents from skills directory
393
+ logger.debug(f"Loading agents from {skill_dir}")
394
+
395
+ # Always check for .cursorrules and AGENTS.md files in repo root
396
+ special_files = []
397
+ repo_root = skill_dir.parent.parent
398
+
399
+ # Check for third party rules: .cursorrules, AGENTS.md, etc
400
+ for filename in Skill.PATH_TO_THIRD_PARTY_SKILL_NAME.keys():
401
+ for variant in [filename, filename.lower(), filename.upper()]:
402
+ if (repo_root / variant).exists():
403
+ special_files.append(repo_root / variant)
404
+ break # Only add the first one found to avoid duplicates
405
+
406
+ # Collect .md files from skills directory if it exists
407
+ md_files = []
408
+ if skill_dir.exists():
409
+ md_files = [f for f in skill_dir.rglob("*.md") if f.name != "README.md"]
410
+
411
+ # Process all files in one loop
412
+ for file in chain(special_files, md_files):
413
+ try:
414
+ skill = Skill.load(
415
+ file,
416
+ skill_dir,
417
+ )
418
+ if skill.trigger is None:
419
+ repo_skills[skill.name] = skill
420
+ else:
421
+ # KeywordTrigger and TaskTrigger skills
422
+ knowledge_skills[skill.name] = skill
423
+ except SkillValidationError as e:
424
+ # For validation errors, include the original exception
425
+ error_msg = f"Error loading skill from {file}: {str(e)}"
426
+ raise SkillValidationError(error_msg) from e
427
+ except Exception as e:
428
+ # For other errors, wrap in a ValueError with detailed message
429
+ error_msg = f"Error loading skill from {file}: {str(e)}"
430
+ raise ValueError(error_msg) from e
431
+
432
+ logger.debug(
433
+ f"Loaded {len(repo_skills) + len(knowledge_skills)} skills: "
434
+ f"{[*repo_skills.keys(), *knowledge_skills.keys()]}"
435
+ )
436
+ return repo_skills, knowledge_skills
437
+
438
+
439
+ # Default user skills directories (in order of priority)
440
+ USER_SKILLS_DIRS = [
441
+ Path.home() / ".openhands" / "skills",
442
+ Path.home() / ".openhands" / "microagents", # Legacy support
443
+ ]
444
+
445
+
446
+ def load_user_skills() -> list[Skill]:
447
+ """Load skills from user's home directory.
448
+
449
+ Searches for skills in ~/.openhands/skills/ and ~/.openhands/microagents/
450
+ (legacy). Skills from both directories are merged, with skills/ taking
451
+ precedence for duplicate names.
452
+
453
+ Returns:
454
+ List of Skill objects loaded from user directories.
455
+ Returns empty list if no skills found or loading fails.
456
+ """
457
+ all_skills = []
458
+ seen_names = set()
459
+
460
+ for skills_dir in USER_SKILLS_DIRS:
461
+ if not skills_dir.exists():
462
+ logger.debug(f"User skills directory does not exist: {skills_dir}")
463
+ continue
464
+
465
+ try:
466
+ logger.debug(f"Loading user skills from {skills_dir}")
467
+ repo_skills, knowledge_skills = load_skills_from_dir(skills_dir)
468
+
469
+ # Merge repo and knowledge skills
470
+ for skills_dict in [repo_skills, knowledge_skills]:
471
+ for name, skill in skills_dict.items():
472
+ if name not in seen_names:
473
+ all_skills.append(skill)
474
+ seen_names.add(name)
475
+ else:
476
+ logger.warning(
477
+ f"Skipping duplicate skill '{name}' from {skills_dir}"
478
+ )
479
+
480
+ except Exception as e:
481
+ logger.warning(f"Failed to load user skills from {skills_dir}: {str(e)}")
482
+
483
+ logger.debug(
484
+ f"Loaded {len(all_skills)} user skills: {[s.name for s in all_skills]}"
485
+ )
486
+ return all_skills
487
+
488
+
489
+ def load_project_skills(work_dir: str | Path) -> list[Skill]:
490
+ """Load skills from project-specific directories.
491
+
492
+ Searches for skills in {work_dir}/.openhands/skills/ and
493
+ {work_dir}/.openhands/microagents/ (legacy). Skills from both
494
+ directories are merged, with skills/ taking precedence for
495
+ duplicate names.
496
+
497
+ Args:
498
+ work_dir: Path to the project/working directory.
499
+
500
+ Returns:
501
+ List of Skill objects loaded from project directories.
502
+ Returns empty list if no skills found or loading fails.
503
+ """
504
+ if isinstance(work_dir, str):
505
+ work_dir = Path(work_dir)
506
+
507
+ all_skills = []
508
+ seen_names = set()
509
+
510
+ # Load project-specific skills from .openhands/skills and legacy microagents
511
+ project_skills_dirs = [
512
+ work_dir / ".openhands" / "skills",
513
+ work_dir / ".openhands" / "microagents", # Legacy support
514
+ ]
515
+
516
+ for project_skills_dir in project_skills_dirs:
517
+ if not project_skills_dir.exists():
518
+ logger.debug(
519
+ f"Project skills directory does not exist: {project_skills_dir}"
520
+ )
521
+ continue
522
+
523
+ try:
524
+ logger.debug(f"Loading project skills from {project_skills_dir}")
525
+ repo_skills, knowledge_skills = load_skills_from_dir(project_skills_dir)
526
+
527
+ # Merge repo and knowledge skills
528
+ for skills_dict in [repo_skills, knowledge_skills]:
529
+ for name, skill in skills_dict.items():
530
+ if name not in seen_names:
531
+ all_skills.append(skill)
532
+ seen_names.add(name)
533
+ else:
534
+ logger.warning(
535
+ f"Skipping duplicate skill '{name}' from "
536
+ f"{project_skills_dir}"
537
+ )
538
+
539
+ except Exception as e:
540
+ logger.warning(
541
+ f"Failed to load project skills from {project_skills_dir}: {str(e)}"
542
+ )
543
+
544
+ logger.debug(
545
+ f"Loaded {len(all_skills)} project skills: {[s.name for s in all_skills]}"
546
+ )
547
+ return all_skills
548
+
549
+
550
+ # Public skills repository configuration
551
+ PUBLIC_SKILLS_REPO = "https://github.com/OpenHands/skills"
552
+ PUBLIC_SKILLS_BRANCH = "main"
553
+
554
+
555
+ def _get_skills_cache_dir() -> Path:
556
+ """Get the local cache directory for public skills repository.
557
+
558
+ Returns:
559
+ Path to the skills cache directory (~/.openhands/cache/skills).
560
+ """
561
+ cache_dir = Path.home() / ".openhands" / "cache" / "skills"
562
+ cache_dir.mkdir(parents=True, exist_ok=True)
563
+ return cache_dir
564
+
565
+
566
+ def _update_skills_repository(
567
+ repo_url: str,
568
+ branch: str,
569
+ cache_dir: Path,
570
+ ) -> Path | None:
571
+ """Clone or update the local skills repository.
572
+
573
+ Args:
574
+ repo_url: URL of the skills repository.
575
+ branch: Branch name to use.
576
+ cache_dir: Directory where the repository should be cached.
577
+
578
+ Returns:
579
+ Path to the local repository if successful, None otherwise.
580
+ """
581
+ repo_path = cache_dir / "public-skills"
582
+
583
+ try:
584
+ if repo_path.exists() and (repo_path / ".git").exists():
585
+ logger.debug(f"Updating skills repository at {repo_path}")
586
+ try:
587
+ subprocess.run(
588
+ ["git", "fetch", "origin"],
589
+ cwd=repo_path,
590
+ check=True,
591
+ capture_output=True,
592
+ timeout=30,
593
+ )
594
+ subprocess.run(
595
+ ["git", "reset", "--hard", f"origin/{branch}"],
596
+ cwd=repo_path,
597
+ check=True,
598
+ capture_output=True,
599
+ timeout=10,
600
+ )
601
+ logger.debug("Skills repository updated successfully")
602
+ except subprocess.TimeoutExpired:
603
+ logger.warning("Git pull timed out, using existing cached repository")
604
+ except subprocess.CalledProcessError as e:
605
+ logger.warning(
606
+ f"Failed to update repository: {e.stderr.decode()}, "
607
+ f"using existing cached version"
608
+ )
609
+ else:
610
+ logger.info(f"Cloning public skills repository from {repo_url}")
611
+ if repo_path.exists():
612
+ shutil.rmtree(repo_path)
613
+
614
+ subprocess.run(
615
+ [
616
+ "git",
617
+ "clone",
618
+ "--depth",
619
+ "1",
620
+ "--branch",
621
+ branch,
622
+ repo_url,
623
+ str(repo_path),
624
+ ],
625
+ check=True,
626
+ capture_output=True,
627
+ timeout=60,
628
+ )
629
+ logger.debug(f"Skills repository cloned to {repo_path}")
630
+
631
+ return repo_path
632
+
633
+ except subprocess.TimeoutExpired:
634
+ logger.warning(f"Git operation timed out for {repo_url}")
635
+ return None
636
+ except subprocess.CalledProcessError as e:
637
+ logger.warning(
638
+ f"Failed to clone/update repository {repo_url}: {e.stderr.decode()}"
639
+ )
640
+ return None
641
+ except Exception as e:
642
+ logger.warning(f"Error managing skills repository: {str(e)}")
643
+ return None
644
+
645
+
646
+ def load_public_skills(
647
+ repo_url: str = PUBLIC_SKILLS_REPO,
648
+ branch: str = PUBLIC_SKILLS_BRANCH,
649
+ ) -> list[Skill]:
650
+ """Load skills from the public OpenHands skills repository.
651
+
652
+ This function maintains a local git clone of the public skills registry at
653
+ https://github.com/OpenHands/skills. On first run, it clones the repository
654
+ to ~/.openhands/skills-cache/. On subsequent runs, it pulls the latest changes
655
+ to keep the skills up-to-date. This approach is more efficient than fetching
656
+ individual files via HTTP.
657
+
658
+ Args:
659
+ repo_url: URL of the skills repository. Defaults to the official
660
+ OpenHands skills repository.
661
+ branch: Branch name to load skills from. Defaults to 'main'.
662
+
663
+ Returns:
664
+ List of Skill objects loaded from the public repository.
665
+ Returns empty list if loading fails.
666
+
667
+ Example:
668
+ >>> from openhands.sdk.context import AgentContext
669
+ >>> from openhands.sdk.context.skills import load_public_skills
670
+ >>>
671
+ >>> # Load public skills
672
+ >>> public_skills = load_public_skills()
673
+ >>>
674
+ >>> # Use with AgentContext
675
+ >>> context = AgentContext(skills=public_skills)
676
+ """
677
+ all_skills = []
678
+
679
+ try:
680
+ # Get or update the local repository
681
+ cache_dir = _get_skills_cache_dir()
682
+ repo_path = _update_skills_repository(repo_url, branch, cache_dir)
683
+
684
+ if repo_path is None:
685
+ logger.warning("Failed to access public skills repository")
686
+ return all_skills
687
+
688
+ # Load skills from the local repository
689
+ skills_dir = repo_path / "skills"
690
+ if not skills_dir.exists():
691
+ logger.warning(f"Skills directory not found in repository: {skills_dir}")
692
+ return all_skills
693
+
694
+ # Find all .md files in the skills directory
695
+ md_files = [f for f in skills_dir.rglob("*.md") if f.name != "README.md"]
696
+
697
+ logger.info(f"Found {len(md_files)} skill files in public skills repository")
698
+
699
+ # Load each skill file
700
+ for skill_file in md_files:
701
+ try:
702
+ skill = Skill.load(
703
+ path=skill_file,
704
+ skill_dir=repo_path,
705
+ )
706
+ if skill is None:
707
+ continue
708
+ all_skills.append(skill)
709
+ logger.debug(f"Loaded public skill: {skill.name}")
710
+ except Exception as e:
711
+ logger.warning(f"Failed to load skill from {skill_file.name}: {str(e)}")
712
+ continue
713
+
714
+ except Exception as e:
715
+ logger.warning(f"Failed to load public skills from {repo_url}: {str(e)}")
716
+
717
+ logger.info(
718
+ f"Loaded {len(all_skills)} public skills: {[s.name for s in all_skills]}"
719
+ )
720
+ return all_skills