openhands-sdk 1.7.4__py3-none-any.whl → 1.8.1__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 +2 -0
- openhands/sdk/agent/agent.py +27 -0
- openhands/sdk/agent/base.py +88 -82
- openhands/sdk/agent/prompts/system_prompt.j2 +1 -1
- openhands/sdk/agent/utils.py +3 -0
- openhands/sdk/context/agent_context.py +45 -3
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +4 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +9 -0
- openhands/sdk/context/skills/__init__.py +12 -0
- openhands/sdk/context/skills/skill.py +275 -296
- openhands/sdk/context/skills/types.py +4 -0
- openhands/sdk/context/skills/utils.py +442 -0
- openhands/sdk/conversation/impl/local_conversation.py +42 -14
- openhands/sdk/conversation/state.py +52 -20
- openhands/sdk/event/llm_convertible/action.py +20 -0
- openhands/sdk/git/utils.py +31 -6
- openhands/sdk/hooks/conversation_hooks.py +57 -10
- openhands/sdk/llm/llm.py +58 -74
- openhands/sdk/llm/router/base.py +12 -0
- openhands/sdk/llm/utils/telemetry.py +2 -2
- openhands/sdk/plugin/__init__.py +22 -0
- openhands/sdk/plugin/plugin.py +299 -0
- openhands/sdk/plugin/types.py +226 -0
- openhands/sdk/tool/__init__.py +7 -1
- openhands/sdk/tool/builtins/__init__.py +4 -0
- openhands/sdk/tool/tool.py +60 -9
- openhands/sdk/workspace/remote/async_remote_workspace.py +16 -0
- openhands/sdk/workspace/remote/base.py +16 -0
- {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.1.dist-info}/METADATA +1 -1
- {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.1.dist-info}/RECORD +32 -28
- {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.1.dist-info}/WHEEL +0 -0
- {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Plugin class for loading and managing plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from openhands.sdk.context.skills import Skill
|
|
12
|
+
from openhands.sdk.context.skills.utils import (
|
|
13
|
+
discover_skill_resources,
|
|
14
|
+
find_skill_md,
|
|
15
|
+
load_mcp_config,
|
|
16
|
+
)
|
|
17
|
+
from openhands.sdk.hooks import HookConfig
|
|
18
|
+
from openhands.sdk.logger import get_logger
|
|
19
|
+
from openhands.sdk.plugin.types import (
|
|
20
|
+
AgentDefinition,
|
|
21
|
+
CommandDefinition,
|
|
22
|
+
PluginAuthor,
|
|
23
|
+
PluginManifest,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
# Directories to check for plugin manifest
|
|
30
|
+
PLUGIN_MANIFEST_DIRS = [".plugin", ".claude-plugin"]
|
|
31
|
+
PLUGIN_MANIFEST_FILE = "plugin.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Plugin(BaseModel):
|
|
35
|
+
"""A plugin that bundles skills, hooks, MCP config, agents, and commands.
|
|
36
|
+
|
|
37
|
+
Plugins follow the Claude Code plugin structure for compatibility:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
plugin-name/
|
|
41
|
+
├── .claude-plugin/ # or .plugin/
|
|
42
|
+
│ └── plugin.json # Plugin metadata
|
|
43
|
+
├── commands/ # Slash commands (optional)
|
|
44
|
+
├── agents/ # Specialized agents (optional)
|
|
45
|
+
├── skills/ # Agent Skills (optional)
|
|
46
|
+
├── hooks/ # Event handlers (optional)
|
|
47
|
+
│ └── hooks.json
|
|
48
|
+
├── .mcp.json # External tool configuration (optional)
|
|
49
|
+
└── README.md # Plugin documentation
|
|
50
|
+
```
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
manifest: PluginManifest = Field(description="Plugin manifest from plugin.json")
|
|
54
|
+
path: str = Field(description="Path to the plugin directory")
|
|
55
|
+
skills: list[Skill] = Field(
|
|
56
|
+
default_factory=list, description="Skills loaded from skills/ directory"
|
|
57
|
+
)
|
|
58
|
+
hooks: HookConfig | None = Field(
|
|
59
|
+
default=None, description="Hook configuration from hooks/hooks.json"
|
|
60
|
+
)
|
|
61
|
+
mcp_config: dict[str, Any] | None = Field(
|
|
62
|
+
default=None, description="MCP configuration from .mcp.json"
|
|
63
|
+
)
|
|
64
|
+
agents: list[AgentDefinition] = Field(
|
|
65
|
+
default_factory=list, description="Agent definitions from agents/ directory"
|
|
66
|
+
)
|
|
67
|
+
commands: list[CommandDefinition] = Field(
|
|
68
|
+
default_factory=list, description="Command definitions from commands/ directory"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def name(self) -> str:
|
|
73
|
+
"""Get the plugin name."""
|
|
74
|
+
return self.manifest.name
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def version(self) -> str:
|
|
78
|
+
"""Get the plugin version."""
|
|
79
|
+
return self.manifest.version
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def description(self) -> str:
|
|
83
|
+
"""Get the plugin description."""
|
|
84
|
+
return self.manifest.description
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def load(cls, plugin_path: str | Path) -> Plugin:
|
|
88
|
+
"""Load a plugin from a directory.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
plugin_path: Path to the plugin directory.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Loaded Plugin instance.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
FileNotFoundError: If the plugin directory doesn't exist.
|
|
98
|
+
ValueError: If the plugin manifest is invalid.
|
|
99
|
+
"""
|
|
100
|
+
plugin_dir = Path(plugin_path).resolve()
|
|
101
|
+
if not plugin_dir.is_dir():
|
|
102
|
+
raise FileNotFoundError(f"Plugin directory not found: {plugin_dir}")
|
|
103
|
+
|
|
104
|
+
# Load manifest
|
|
105
|
+
manifest = _load_manifest(plugin_dir)
|
|
106
|
+
|
|
107
|
+
# Load skills
|
|
108
|
+
skills = _load_skills(plugin_dir)
|
|
109
|
+
|
|
110
|
+
# Load hooks
|
|
111
|
+
hooks = _load_hooks(plugin_dir)
|
|
112
|
+
|
|
113
|
+
# Load MCP config
|
|
114
|
+
mcp_config = _load_mcp_config(plugin_dir)
|
|
115
|
+
|
|
116
|
+
# Load agents
|
|
117
|
+
agents = _load_agents(plugin_dir)
|
|
118
|
+
|
|
119
|
+
# Load commands
|
|
120
|
+
commands = _load_commands(plugin_dir)
|
|
121
|
+
|
|
122
|
+
return cls(
|
|
123
|
+
manifest=manifest,
|
|
124
|
+
path=str(plugin_dir),
|
|
125
|
+
skills=skills,
|
|
126
|
+
hooks=hooks,
|
|
127
|
+
mcp_config=mcp_config,
|
|
128
|
+
agents=agents,
|
|
129
|
+
commands=commands,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def load_all(cls, plugins_dir: str | Path) -> list[Plugin]:
|
|
134
|
+
"""Load all plugins from a directory.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
plugins_dir: Path to directory containing plugin subdirectories.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
List of loaded Plugin instances.
|
|
141
|
+
"""
|
|
142
|
+
plugins_path = Path(plugins_dir).resolve()
|
|
143
|
+
if not plugins_path.is_dir():
|
|
144
|
+
logger.warning(f"Plugins directory not found: {plugins_path}")
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
plugins: list[Plugin] = []
|
|
148
|
+
for item in plugins_path.iterdir():
|
|
149
|
+
if item.is_dir():
|
|
150
|
+
try:
|
|
151
|
+
plugin = cls.load(item)
|
|
152
|
+
plugins.append(plugin)
|
|
153
|
+
logger.debug(f"Loaded plugin: {plugin.name} from {item}")
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.warning(f"Failed to load plugin from {item}: {e}")
|
|
156
|
+
|
|
157
|
+
return plugins
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _load_manifest(plugin_dir: Path) -> PluginManifest:
|
|
161
|
+
"""Load plugin manifest from plugin.json.
|
|
162
|
+
|
|
163
|
+
Checks both .plugin/ and .claude-plugin/ directories.
|
|
164
|
+
Falls back to inferring from directory name if no manifest found.
|
|
165
|
+
"""
|
|
166
|
+
manifest_path = None
|
|
167
|
+
|
|
168
|
+
# Check for manifest in standard locations
|
|
169
|
+
for manifest_dir in PLUGIN_MANIFEST_DIRS:
|
|
170
|
+
candidate = plugin_dir / manifest_dir / PLUGIN_MANIFEST_FILE
|
|
171
|
+
if candidate.exists():
|
|
172
|
+
manifest_path = candidate
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
if manifest_path:
|
|
176
|
+
try:
|
|
177
|
+
with open(manifest_path) as f:
|
|
178
|
+
data = json.load(f)
|
|
179
|
+
|
|
180
|
+
# Handle author field - can be string or object
|
|
181
|
+
if "author" in data and isinstance(data["author"], str):
|
|
182
|
+
data["author"] = PluginAuthor.from_string(data["author"]).model_dump()
|
|
183
|
+
|
|
184
|
+
return PluginManifest.model_validate(data)
|
|
185
|
+
except json.JSONDecodeError as e:
|
|
186
|
+
raise ValueError(f"Invalid JSON in {manifest_path}: {e}") from e
|
|
187
|
+
except Exception as e:
|
|
188
|
+
raise ValueError(f"Failed to parse manifest {manifest_path}: {e}") from e
|
|
189
|
+
|
|
190
|
+
# Fall back to inferring from directory name
|
|
191
|
+
logger.debug(f"No manifest found for {plugin_dir}, inferring from directory name")
|
|
192
|
+
return PluginManifest(
|
|
193
|
+
name=plugin_dir.name,
|
|
194
|
+
version="1.0.0",
|
|
195
|
+
description=f"Plugin loaded from {plugin_dir.name}",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _load_skills(plugin_dir: Path) -> list[Skill]:
|
|
200
|
+
"""Load skills from the skills/ directory.
|
|
201
|
+
|
|
202
|
+
Note: Plugin skills are loaded with relaxed validation (strict=False)
|
|
203
|
+
to support Claude Code plugins which may use different naming conventions.
|
|
204
|
+
"""
|
|
205
|
+
skills_dir = plugin_dir / "skills"
|
|
206
|
+
if not skills_dir.is_dir():
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
skills: list[Skill] = []
|
|
210
|
+
for item in skills_dir.iterdir():
|
|
211
|
+
if item.is_dir():
|
|
212
|
+
skill_md = find_skill_md(item)
|
|
213
|
+
if skill_md:
|
|
214
|
+
try:
|
|
215
|
+
skill = Skill.load(skill_md, skills_dir, strict=False)
|
|
216
|
+
# Discover and attach resources
|
|
217
|
+
skill.resources = discover_skill_resources(item)
|
|
218
|
+
skills.append(skill)
|
|
219
|
+
logger.debug(f"Loaded skill: {skill.name} from {skill_md}")
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.warning(f"Failed to load skill from {item}: {e}")
|
|
222
|
+
elif item.suffix == ".md" and item.name.lower() != "readme.md":
|
|
223
|
+
# Also support single .md files in skills/ directory
|
|
224
|
+
try:
|
|
225
|
+
skill = Skill.load(item, skills_dir, strict=False)
|
|
226
|
+
skills.append(skill)
|
|
227
|
+
logger.debug(f"Loaded skill: {skill.name} from {item}")
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.warning(f"Failed to load skill from {item}: {e}")
|
|
230
|
+
|
|
231
|
+
return skills
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _load_hooks(plugin_dir: Path) -> HookConfig | None:
|
|
235
|
+
"""Load hooks configuration from hooks/hooks.json."""
|
|
236
|
+
hooks_json = plugin_dir / "hooks" / "hooks.json"
|
|
237
|
+
if not hooks_json.exists():
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
hook_config = HookConfig.load(path=hooks_json)
|
|
242
|
+
# load() returns empty config on error, check if it has hooks
|
|
243
|
+
if hook_config.hooks:
|
|
244
|
+
return hook_config
|
|
245
|
+
return None
|
|
246
|
+
except Exception as e:
|
|
247
|
+
logger.warning(f"Failed to load hooks from {hooks_json}: {e}")
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _load_mcp_config(plugin_dir: Path) -> dict[str, Any] | None:
|
|
252
|
+
"""Load MCP configuration from .mcp.json."""
|
|
253
|
+
mcp_json = plugin_dir / ".mcp.json"
|
|
254
|
+
if not mcp_json.exists():
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
return load_mcp_config(mcp_json, skill_root=plugin_dir)
|
|
259
|
+
except Exception as e:
|
|
260
|
+
logger.warning(f"Failed to load MCP config from {mcp_json}: {e}")
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _load_agents(plugin_dir: Path) -> list[AgentDefinition]:
|
|
265
|
+
"""Load agent definitions from the agents/ directory."""
|
|
266
|
+
agents_dir = plugin_dir / "agents"
|
|
267
|
+
if not agents_dir.is_dir():
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
agents: list[AgentDefinition] = []
|
|
271
|
+
for item in agents_dir.iterdir():
|
|
272
|
+
if item.suffix == ".md" and item.name.lower() != "readme.md":
|
|
273
|
+
try:
|
|
274
|
+
agent = AgentDefinition.load(item)
|
|
275
|
+
agents.append(agent)
|
|
276
|
+
logger.debug(f"Loaded agent: {agent.name} from {item}")
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.warning(f"Failed to load agent from {item}: {e}")
|
|
279
|
+
|
|
280
|
+
return agents
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _load_commands(plugin_dir: Path) -> list[CommandDefinition]:
|
|
284
|
+
"""Load command definitions from the commands/ directory."""
|
|
285
|
+
commands_dir = plugin_dir / "commands"
|
|
286
|
+
if not commands_dir.is_dir():
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
commands: list[CommandDefinition] = []
|
|
290
|
+
for item in commands_dir.iterdir():
|
|
291
|
+
if item.suffix == ".md" and item.name.lower() != "readme.md":
|
|
292
|
+
try:
|
|
293
|
+
command = CommandDefinition.load(item)
|
|
294
|
+
commands.append(command)
|
|
295
|
+
logger.debug(f"Loaded command: {command.name} from {item}")
|
|
296
|
+
except Exception as e:
|
|
297
|
+
logger.warning(f"Failed to load command from {item}: {e}")
|
|
298
|
+
|
|
299
|
+
return commands
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Type definitions for Plugin module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import frontmatter
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PluginAuthor(BaseModel):
|
|
14
|
+
"""Author information for a plugin."""
|
|
15
|
+
|
|
16
|
+
name: str = Field(description="Author's name")
|
|
17
|
+
email: str | None = Field(default=None, description="Author's email address")
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_string(cls, author_str: str) -> PluginAuthor:
|
|
21
|
+
"""Parse author from string format 'Name <email>'."""
|
|
22
|
+
if "<" in author_str and ">" in author_str:
|
|
23
|
+
name = author_str.split("<")[0].strip()
|
|
24
|
+
email = author_str.split("<")[1].split(">")[0].strip()
|
|
25
|
+
return cls(name=name, email=email)
|
|
26
|
+
return cls(name=author_str.strip())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PluginManifest(BaseModel):
|
|
30
|
+
"""Plugin manifest from plugin.json."""
|
|
31
|
+
|
|
32
|
+
name: str = Field(description="Plugin name")
|
|
33
|
+
version: str = Field(default="1.0.0", description="Plugin version")
|
|
34
|
+
description: str = Field(default="", description="Plugin description")
|
|
35
|
+
author: PluginAuthor | None = Field(default=None, description="Plugin author")
|
|
36
|
+
|
|
37
|
+
model_config = {"extra": "allow"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_examples(description: str) -> list[str]:
|
|
41
|
+
"""Extract <example> tags from description for agent triggering."""
|
|
42
|
+
pattern = r"<example>(.*?)</example>"
|
|
43
|
+
matches = re.findall(pattern, description, re.DOTALL | re.IGNORECASE)
|
|
44
|
+
return [m.strip() for m in matches if m.strip()]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AgentDefinition(BaseModel):
|
|
48
|
+
"""Agent definition loaded from markdown file.
|
|
49
|
+
|
|
50
|
+
Agents are specialized configurations that can be triggered based on
|
|
51
|
+
user input patterns. They define custom system prompts and tool access.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name: str = Field(description="Agent name (from frontmatter or filename)")
|
|
55
|
+
description: str = Field(default="", description="Agent description")
|
|
56
|
+
model: str = Field(
|
|
57
|
+
default="inherit", description="Model to use ('inherit' uses parent model)"
|
|
58
|
+
)
|
|
59
|
+
color: str | None = Field(default=None, description="Display color for the agent")
|
|
60
|
+
tools: list[str] = Field(
|
|
61
|
+
default_factory=list, description="List of allowed tools for this agent"
|
|
62
|
+
)
|
|
63
|
+
system_prompt: str = Field(default="", description="System prompt content")
|
|
64
|
+
source: str | None = Field(
|
|
65
|
+
default=None, description="Source file path for this agent"
|
|
66
|
+
)
|
|
67
|
+
# whenToUse examples extracted from description
|
|
68
|
+
when_to_use_examples: list[str] = Field(
|
|
69
|
+
default_factory=list,
|
|
70
|
+
description="Examples of when to use this agent (for triggering)",
|
|
71
|
+
)
|
|
72
|
+
# Raw frontmatter for any additional fields
|
|
73
|
+
metadata: dict[str, Any] = Field(
|
|
74
|
+
default_factory=dict, description="Additional metadata from frontmatter"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def load(cls, agent_path: Path) -> AgentDefinition:
|
|
79
|
+
"""Load an agent definition from a markdown file.
|
|
80
|
+
|
|
81
|
+
Agent markdown files have YAML frontmatter with:
|
|
82
|
+
- name: Agent name
|
|
83
|
+
- description: Description with optional <example> tags for triggering
|
|
84
|
+
- model: Model to use (default: 'inherit')
|
|
85
|
+
- color: Display color
|
|
86
|
+
- tools: List of allowed tools
|
|
87
|
+
|
|
88
|
+
The body of the markdown is the system prompt.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
agent_path: Path to the agent markdown file.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Loaded AgentDefinition instance.
|
|
95
|
+
"""
|
|
96
|
+
with open(agent_path) as f:
|
|
97
|
+
post = frontmatter.load(f)
|
|
98
|
+
|
|
99
|
+
fm = post.metadata
|
|
100
|
+
content = post.content.strip()
|
|
101
|
+
|
|
102
|
+
# Extract frontmatter fields with proper type handling
|
|
103
|
+
name = str(fm.get("name", agent_path.stem))
|
|
104
|
+
description = str(fm.get("description", ""))
|
|
105
|
+
model = str(fm.get("model", "inherit"))
|
|
106
|
+
color_raw = fm.get("color")
|
|
107
|
+
color: str | None = str(color_raw) if color_raw is not None else None
|
|
108
|
+
tools_raw = fm.get("tools", [])
|
|
109
|
+
|
|
110
|
+
# Ensure tools is a list of strings
|
|
111
|
+
tools: list[str]
|
|
112
|
+
if isinstance(tools_raw, str):
|
|
113
|
+
tools = [tools_raw]
|
|
114
|
+
elif isinstance(tools_raw, list):
|
|
115
|
+
tools = [str(t) for t in tools_raw]
|
|
116
|
+
else:
|
|
117
|
+
tools = []
|
|
118
|
+
|
|
119
|
+
# Extract whenToUse examples from description
|
|
120
|
+
when_to_use_examples = _extract_examples(description)
|
|
121
|
+
|
|
122
|
+
# Remove known fields from metadata to get extras
|
|
123
|
+
known_fields = {"name", "description", "model", "color", "tools"}
|
|
124
|
+
metadata = {k: v for k, v in fm.items() if k not in known_fields}
|
|
125
|
+
|
|
126
|
+
return cls(
|
|
127
|
+
name=name,
|
|
128
|
+
description=description,
|
|
129
|
+
model=model,
|
|
130
|
+
color=color,
|
|
131
|
+
tools=tools,
|
|
132
|
+
system_prompt=content,
|
|
133
|
+
source=str(agent_path),
|
|
134
|
+
when_to_use_examples=when_to_use_examples,
|
|
135
|
+
metadata=metadata,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class CommandDefinition(BaseModel):
|
|
140
|
+
"""Command definition loaded from markdown file.
|
|
141
|
+
|
|
142
|
+
Commands are slash commands that users can invoke directly.
|
|
143
|
+
They define instructions for the agent to follow.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
name: str = Field(description="Command name (from filename, e.g., 'review')")
|
|
147
|
+
description: str = Field(default="", description="Command description")
|
|
148
|
+
argument_hint: str | None = Field(
|
|
149
|
+
default=None, description="Hint for command arguments"
|
|
150
|
+
)
|
|
151
|
+
allowed_tools: list[str] = Field(
|
|
152
|
+
default_factory=list, description="List of allowed tools for this command"
|
|
153
|
+
)
|
|
154
|
+
content: str = Field(default="", description="Command instructions/content")
|
|
155
|
+
source: str | None = Field(
|
|
156
|
+
default=None, description="Source file path for this command"
|
|
157
|
+
)
|
|
158
|
+
# Raw frontmatter for any additional fields
|
|
159
|
+
metadata: dict[str, Any] = Field(
|
|
160
|
+
default_factory=dict, description="Additional metadata from frontmatter"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def load(cls, command_path: Path) -> CommandDefinition:
|
|
165
|
+
"""Load a command definition from a markdown file.
|
|
166
|
+
|
|
167
|
+
Command markdown files have YAML frontmatter with:
|
|
168
|
+
- description: Command description
|
|
169
|
+
- argument-hint: Hint for command arguments (string or list)
|
|
170
|
+
- allowed-tools: List of allowed tools
|
|
171
|
+
|
|
172
|
+
The body of the markdown is the command instructions.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
command_path: Path to the command markdown file.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Loaded CommandDefinition instance.
|
|
179
|
+
"""
|
|
180
|
+
with open(command_path) as f:
|
|
181
|
+
post = frontmatter.load(f)
|
|
182
|
+
|
|
183
|
+
# Extract frontmatter fields with proper type handling
|
|
184
|
+
fm = post.metadata
|
|
185
|
+
name = command_path.stem # Command name from filename
|
|
186
|
+
description = str(fm.get("description", ""))
|
|
187
|
+
argument_hint_raw = fm.get("argument-hint") or fm.get("argumentHint")
|
|
188
|
+
allowed_tools_raw = fm.get("allowed-tools") or fm.get("allowedTools") or []
|
|
189
|
+
|
|
190
|
+
# Handle argument_hint as list (join with space) or string
|
|
191
|
+
argument_hint: str | None
|
|
192
|
+
if isinstance(argument_hint_raw, list):
|
|
193
|
+
argument_hint = " ".join(str(h) for h in argument_hint_raw)
|
|
194
|
+
elif argument_hint_raw is not None:
|
|
195
|
+
argument_hint = str(argument_hint_raw)
|
|
196
|
+
else:
|
|
197
|
+
argument_hint = None
|
|
198
|
+
|
|
199
|
+
# Ensure allowed_tools is a list of strings
|
|
200
|
+
allowed_tools: list[str]
|
|
201
|
+
if isinstance(allowed_tools_raw, str):
|
|
202
|
+
allowed_tools = [allowed_tools_raw]
|
|
203
|
+
elif isinstance(allowed_tools_raw, list):
|
|
204
|
+
allowed_tools = [str(t) for t in allowed_tools_raw]
|
|
205
|
+
else:
|
|
206
|
+
allowed_tools = []
|
|
207
|
+
|
|
208
|
+
# Remove known fields from metadata to get extras
|
|
209
|
+
known_fields = {
|
|
210
|
+
"description",
|
|
211
|
+
"argument-hint",
|
|
212
|
+
"argumentHint",
|
|
213
|
+
"allowed-tools",
|
|
214
|
+
"allowedTools",
|
|
215
|
+
}
|
|
216
|
+
metadata = {k: v for k, v in fm.items() if k not in known_fields}
|
|
217
|
+
|
|
218
|
+
return cls(
|
|
219
|
+
name=name,
|
|
220
|
+
description=description,
|
|
221
|
+
argument_hint=argument_hint,
|
|
222
|
+
allowed_tools=allowed_tools,
|
|
223
|
+
content=post.content.strip(),
|
|
224
|
+
source=str(command_path),
|
|
225
|
+
metadata=metadata,
|
|
226
|
+
)
|
openhands/sdk/tool/__init__.py
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
from openhands.sdk.tool.builtins import
|
|
1
|
+
from openhands.sdk.tool.builtins import (
|
|
2
|
+
BUILT_IN_TOOL_CLASSES,
|
|
3
|
+
BUILT_IN_TOOLS,
|
|
4
|
+
FinishTool,
|
|
5
|
+
ThinkTool,
|
|
6
|
+
)
|
|
2
7
|
from openhands.sdk.tool.registry import (
|
|
3
8
|
list_registered_tools,
|
|
4
9
|
register_tool,
|
|
@@ -28,6 +33,7 @@ __all__ = [
|
|
|
28
33
|
"FinishTool",
|
|
29
34
|
"ThinkTool",
|
|
30
35
|
"BUILT_IN_TOOLS",
|
|
36
|
+
"BUILT_IN_TOOL_CLASSES",
|
|
31
37
|
"register_tool",
|
|
32
38
|
"resolve_tool",
|
|
33
39
|
"list_registered_tools",
|
|
@@ -21,8 +21,12 @@ from openhands.sdk.tool.builtins.think import (
|
|
|
21
21
|
|
|
22
22
|
BUILT_IN_TOOLS = [FinishTool, ThinkTool]
|
|
23
23
|
|
|
24
|
+
# Mapping of built-in tool class names to their classes, generated dynamically
|
|
25
|
+
BUILT_IN_TOOL_CLASSES = {tool.__name__: tool for tool in BUILT_IN_TOOLS}
|
|
26
|
+
|
|
24
27
|
__all__ = [
|
|
25
28
|
"BUILT_IN_TOOLS",
|
|
29
|
+
"BUILT_IN_TOOL_CLASSES",
|
|
26
30
|
"FinishTool",
|
|
27
31
|
"FinishAction",
|
|
28
32
|
"FinishObservation",
|
openhands/sdk/tool/tool.py
CHANGED
|
@@ -41,6 +41,7 @@ if TYPE_CHECKING:
|
|
|
41
41
|
ActionT = TypeVar("ActionT", bound=Action)
|
|
42
42
|
ObservationT = TypeVar("ObservationT", bound=Observation)
|
|
43
43
|
_action_types_with_risk: dict[type, type] = {}
|
|
44
|
+
_action_types_with_summary: dict[type, type] = {}
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
def _camel_to_snake(name: str) -> str:
|
|
@@ -364,17 +365,18 @@ class ToolDefinition[ActionT, ObservationT](DiscriminatedUnionMixin, ABC):
|
|
|
364
365
|
action_type: type[Schema] | None = None,
|
|
365
366
|
) -> dict[str, Any]:
|
|
366
367
|
action_type = action_type or self.action_type
|
|
367
|
-
action_type_with_risk = create_action_type_with_risk(action_type)
|
|
368
368
|
|
|
369
|
+
# Apply security risk enhancement if enabled
|
|
369
370
|
add_security_risk_prediction = add_security_risk_prediction and (
|
|
370
371
|
self.annotations is None or (not self.annotations.readOnlyHint)
|
|
371
372
|
)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
)
|
|
377
|
-
|
|
373
|
+
if add_security_risk_prediction:
|
|
374
|
+
action_type = create_action_type_with_risk(action_type)
|
|
375
|
+
|
|
376
|
+
# Always add summary field for transparency and explainability
|
|
377
|
+
action_type = _create_action_type_with_summary(action_type)
|
|
378
|
+
|
|
379
|
+
return action_type.to_mcp_schema()
|
|
378
380
|
|
|
379
381
|
def to_openai_tool(
|
|
380
382
|
self,
|
|
@@ -391,6 +393,10 @@ class ToolDefinition[ActionT, ObservationT](DiscriminatedUnionMixin, ABC):
|
|
|
391
393
|
action_type: Optionally override the action_type to use for the schema.
|
|
392
394
|
This is useful for MCPTool to use a dynamically created action type
|
|
393
395
|
based on the tool's input schema.
|
|
396
|
+
|
|
397
|
+
Note:
|
|
398
|
+
Summary field is always added to the schema for transparency and
|
|
399
|
+
explainability of agent actions.
|
|
394
400
|
"""
|
|
395
401
|
return ChatCompletionToolParam(
|
|
396
402
|
type="function",
|
|
@@ -398,7 +404,8 @@ class ToolDefinition[ActionT, ObservationT](DiscriminatedUnionMixin, ABC):
|
|
|
398
404
|
name=self.name,
|
|
399
405
|
description=self.description,
|
|
400
406
|
parameters=self._get_tool_schema(
|
|
401
|
-
add_security_risk_prediction,
|
|
407
|
+
add_security_risk_prediction,
|
|
408
|
+
action_type,
|
|
402
409
|
),
|
|
403
410
|
),
|
|
404
411
|
)
|
|
@@ -412,6 +419,14 @@ class ToolDefinition[ActionT, ObservationT](DiscriminatedUnionMixin, ABC):
|
|
|
412
419
|
|
|
413
420
|
For Responses API, function tools expect top-level keys:
|
|
414
421
|
{ "type": "function", "name": ..., "description": ..., "parameters": ... }
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
add_security_risk_prediction: Whether to add a `security_risk` field
|
|
425
|
+
action_type: Optional override for the action type
|
|
426
|
+
|
|
427
|
+
Note:
|
|
428
|
+
Summary field is always added to the schema for transparency and
|
|
429
|
+
explainability of agent actions.
|
|
415
430
|
"""
|
|
416
431
|
|
|
417
432
|
return {
|
|
@@ -419,7 +434,8 @@ class ToolDefinition[ActionT, ObservationT](DiscriminatedUnionMixin, ABC):
|
|
|
419
434
|
"name": self.name,
|
|
420
435
|
"description": self.description,
|
|
421
436
|
"parameters": self._get_tool_schema(
|
|
422
|
-
add_security_risk_prediction,
|
|
437
|
+
add_security_risk_prediction,
|
|
438
|
+
action_type,
|
|
423
439
|
),
|
|
424
440
|
"strict": False,
|
|
425
441
|
}
|
|
@@ -479,3 +495,38 @@ def create_action_type_with_risk(action_type: type[Schema]) -> type[Schema]:
|
|
|
479
495
|
)
|
|
480
496
|
_action_types_with_risk[action_type] = action_type_with_risk
|
|
481
497
|
return action_type_with_risk
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _create_action_type_with_summary(action_type: type[Schema]) -> type[Schema]:
|
|
501
|
+
"""Create a new action type with summary field for LLM to predict.
|
|
502
|
+
|
|
503
|
+
This dynamically adds a 'summary' field to the action schema, allowing
|
|
504
|
+
the LLM to provide a brief explanation of what each action does.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
action_type: The original action type to enhance
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
A new type that includes the summary field
|
|
511
|
+
"""
|
|
512
|
+
action_type_with_summary = _action_types_with_summary.get(action_type)
|
|
513
|
+
if action_type_with_summary:
|
|
514
|
+
return action_type_with_summary
|
|
515
|
+
|
|
516
|
+
action_type_with_summary = type(
|
|
517
|
+
f"{action_type.__name__}WithSummary",
|
|
518
|
+
(action_type,),
|
|
519
|
+
{
|
|
520
|
+
"summary": Field(
|
|
521
|
+
default=None,
|
|
522
|
+
description=(
|
|
523
|
+
"A concise summary (approximately 10 words) describing what "
|
|
524
|
+
"this specific action does. Focus on the key operation and target. "
|
|
525
|
+
"Example: 'List all Python files in current directory'"
|
|
526
|
+
),
|
|
527
|
+
),
|
|
528
|
+
"__annotations__": {"summary": str | None},
|
|
529
|
+
},
|
|
530
|
+
)
|
|
531
|
+
_action_types_with_summary[action_type] = action_type_with_summary
|
|
532
|
+
return action_type_with_summary
|