openhands-sdk 1.7.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhands/sdk/__init__.py +111 -0
- openhands/sdk/agent/__init__.py +8 -0
- openhands/sdk/agent/agent.py +650 -0
- openhands/sdk/agent/base.py +457 -0
- openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
- openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
- openhands/sdk/agent/prompts/security_policy.j2 +22 -0
- openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
- openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
- openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
- openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
- openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
- openhands/sdk/agent/utils.py +228 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +264 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +100 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
- openhands/sdk/context/condenser/no_op_condenser.py +14 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/condenser/utils.py +149 -0
- openhands/sdk/context/prompts/__init__.py +6 -0
- openhands/sdk/context/prompts/prompt.py +114 -0
- openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
- openhands/sdk/context/skills/__init__.py +28 -0
- openhands/sdk/context/skills/exceptions.py +11 -0
- openhands/sdk/context/skills/skill.py +720 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +503 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +152 -0
- openhands/sdk/conversation/conversation_stats.py +85 -0
- openhands/sdk/conversation/event_store.py +157 -0
- openhands/sdk/conversation/events_list_base.py +17 -0
- openhands/sdk/conversation/exceptions.py +50 -0
- openhands/sdk/conversation/fifo_lock.py +133 -0
- openhands/sdk/conversation/impl/__init__.py +5 -0
- openhands/sdk/conversation/impl/local_conversation.py +665 -0
- openhands/sdk/conversation/impl/remote_conversation.py +956 -0
- openhands/sdk/conversation/persistence_const.py +9 -0
- openhands/sdk/conversation/response_utils.py +41 -0
- openhands/sdk/conversation/secret_registry.py +126 -0
- openhands/sdk/conversation/serialization_diff.py +0 -0
- openhands/sdk/conversation/state.py +392 -0
- openhands/sdk/conversation/stuck_detector.py +311 -0
- openhands/sdk/conversation/title_utils.py +191 -0
- openhands/sdk/conversation/types.py +45 -0
- openhands/sdk/conversation/visualizer/__init__.py +12 -0
- openhands/sdk/conversation/visualizer/base.py +67 -0
- openhands/sdk/conversation/visualizer/default.py +373 -0
- openhands/sdk/critic/__init__.py +15 -0
- openhands/sdk/critic/base.py +38 -0
- openhands/sdk/critic/impl/__init__.py +12 -0
- openhands/sdk/critic/impl/agent_finished.py +83 -0
- openhands/sdk/critic/impl/empty_patch.py +49 -0
- openhands/sdk/critic/impl/pass_critic.py +42 -0
- openhands/sdk/event/__init__.py +42 -0
- openhands/sdk/event/base.py +149 -0
- openhands/sdk/event/condenser.py +82 -0
- openhands/sdk/event/conversation_error.py +25 -0
- openhands/sdk/event/conversation_state.py +104 -0
- openhands/sdk/event/llm_completion_log.py +39 -0
- openhands/sdk/event/llm_convertible/__init__.py +20 -0
- openhands/sdk/event/llm_convertible/action.py +139 -0
- openhands/sdk/event/llm_convertible/message.py +142 -0
- openhands/sdk/event/llm_convertible/observation.py +141 -0
- openhands/sdk/event/llm_convertible/system.py +61 -0
- openhands/sdk/event/token.py +16 -0
- openhands/sdk/event/types.py +11 -0
- openhands/sdk/event/user_action.py +21 -0
- openhands/sdk/git/exceptions.py +43 -0
- openhands/sdk/git/git_changes.py +249 -0
- openhands/sdk/git/git_diff.py +129 -0
- openhands/sdk/git/models.py +21 -0
- openhands/sdk/git/utils.py +189 -0
- openhands/sdk/hooks/__init__.py +30 -0
- openhands/sdk/hooks/config.py +180 -0
- openhands/sdk/hooks/conversation_hooks.py +227 -0
- openhands/sdk/hooks/executor.py +155 -0
- openhands/sdk/hooks/manager.py +170 -0
- openhands/sdk/hooks/types.py +40 -0
- openhands/sdk/io/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/cache.py +85 -0
- openhands/sdk/io/local.py +119 -0
- openhands/sdk/io/memory.py +54 -0
- openhands/sdk/llm/__init__.py +45 -0
- openhands/sdk/llm/exceptions/__init__.py +45 -0
- openhands/sdk/llm/exceptions/classifier.py +50 -0
- openhands/sdk/llm/exceptions/mapping.py +54 -0
- openhands/sdk/llm/exceptions/types.py +101 -0
- openhands/sdk/llm/llm.py +1140 -0
- openhands/sdk/llm/llm_registry.py +122 -0
- openhands/sdk/llm/llm_response.py +59 -0
- openhands/sdk/llm/message.py +656 -0
- openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
- openhands/sdk/llm/mixins/non_native_fc.py +97 -0
- openhands/sdk/llm/options/__init__.py +1 -0
- openhands/sdk/llm/options/chat_options.py +93 -0
- openhands/sdk/llm/options/common.py +19 -0
- openhands/sdk/llm/options/responses_options.py +67 -0
- openhands/sdk/llm/router/__init__.py +10 -0
- openhands/sdk/llm/router/base.py +117 -0
- openhands/sdk/llm/router/impl/multimodal.py +76 -0
- openhands/sdk/llm/router/impl/random.py +22 -0
- openhands/sdk/llm/streaming.py +9 -0
- openhands/sdk/llm/utils/metrics.py +312 -0
- openhands/sdk/llm/utils/model_features.py +192 -0
- openhands/sdk/llm/utils/model_info.py +90 -0
- openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
- openhands/sdk/llm/utils/retry_mixin.py +128 -0
- openhands/sdk/llm/utils/telemetry.py +362 -0
- openhands/sdk/llm/utils/unverified_models.py +156 -0
- openhands/sdk/llm/utils/verified_models.py +65 -0
- openhands/sdk/logger/__init__.py +22 -0
- openhands/sdk/logger/logger.py +195 -0
- openhands/sdk/logger/rolling.py +113 -0
- openhands/sdk/mcp/__init__.py +24 -0
- openhands/sdk/mcp/client.py +76 -0
- openhands/sdk/mcp/definition.py +106 -0
- openhands/sdk/mcp/exceptions.py +19 -0
- openhands/sdk/mcp/tool.py +270 -0
- openhands/sdk/mcp/utils.py +83 -0
- openhands/sdk/observability/__init__.py +4 -0
- openhands/sdk/observability/laminar.py +166 -0
- openhands/sdk/observability/utils.py +20 -0
- openhands/sdk/py.typed +0 -0
- openhands/sdk/secret/__init__.py +19 -0
- openhands/sdk/secret/secrets.py +92 -0
- openhands/sdk/security/__init__.py +6 -0
- openhands/sdk/security/analyzer.py +111 -0
- openhands/sdk/security/confirmation_policy.py +61 -0
- openhands/sdk/security/llm_analyzer.py +29 -0
- openhands/sdk/security/risk.py +100 -0
- openhands/sdk/tool/__init__.py +34 -0
- openhands/sdk/tool/builtins/__init__.py +34 -0
- openhands/sdk/tool/builtins/finish.py +106 -0
- openhands/sdk/tool/builtins/think.py +117 -0
- openhands/sdk/tool/registry.py +184 -0
- openhands/sdk/tool/schema.py +286 -0
- openhands/sdk/tool/spec.py +39 -0
- openhands/sdk/tool/tool.py +481 -0
- openhands/sdk/utils/__init__.py +22 -0
- openhands/sdk/utils/async_executor.py +115 -0
- openhands/sdk/utils/async_utils.py +39 -0
- openhands/sdk/utils/cipher.py +68 -0
- openhands/sdk/utils/command.py +90 -0
- openhands/sdk/utils/deprecation.py +166 -0
- openhands/sdk/utils/github.py +44 -0
- openhands/sdk/utils/json.py +48 -0
- openhands/sdk/utils/models.py +570 -0
- openhands/sdk/utils/paging.py +63 -0
- openhands/sdk/utils/pydantic_diff.py +85 -0
- openhands/sdk/utils/pydantic_secrets.py +64 -0
- openhands/sdk/utils/truncate.py +117 -0
- openhands/sdk/utils/visualize.py +58 -0
- openhands/sdk/workspace/__init__.py +17 -0
- openhands/sdk/workspace/base.py +158 -0
- openhands/sdk/workspace/local.py +189 -0
- openhands/sdk/workspace/models.py +35 -0
- openhands/sdk/workspace/remote/__init__.py +8 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
- openhands/sdk/workspace/remote/base.py +164 -0
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
- openhands/sdk/workspace/workspace.py +49 -0
- openhands_sdk-1.7.3.dist-info/METADATA +17 -0
- openhands_sdk-1.7.3.dist-info/RECORD +180 -0
- openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
- openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|