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