openhands-sdk 1.7.4__py3-none-any.whl → 1.8.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 (32) hide show
  1. openhands/sdk/__init__.py +2 -0
  2. openhands/sdk/agent/agent.py +27 -0
  3. openhands/sdk/agent/base.py +88 -82
  4. openhands/sdk/agent/prompts/system_prompt.j2 +1 -1
  5. openhands/sdk/agent/utils.py +3 -0
  6. openhands/sdk/context/agent_context.py +45 -3
  7. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +4 -0
  8. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +9 -0
  9. openhands/sdk/context/skills/__init__.py +12 -0
  10. openhands/sdk/context/skills/skill.py +275 -296
  11. openhands/sdk/context/skills/types.py +4 -0
  12. openhands/sdk/context/skills/utils.py +442 -0
  13. openhands/sdk/conversation/impl/local_conversation.py +42 -14
  14. openhands/sdk/conversation/state.py +52 -20
  15. openhands/sdk/event/llm_convertible/action.py +20 -0
  16. openhands/sdk/git/utils.py +31 -6
  17. openhands/sdk/hooks/conversation_hooks.py +57 -10
  18. openhands/sdk/llm/llm.py +58 -74
  19. openhands/sdk/llm/router/base.py +12 -0
  20. openhands/sdk/llm/utils/telemetry.py +2 -2
  21. openhands/sdk/plugin/__init__.py +22 -0
  22. openhands/sdk/plugin/plugin.py +299 -0
  23. openhands/sdk/plugin/types.py +226 -0
  24. openhands/sdk/tool/__init__.py +7 -1
  25. openhands/sdk/tool/builtins/__init__.py +4 -0
  26. openhands/sdk/tool/tool.py +60 -9
  27. openhands/sdk/workspace/remote/async_remote_workspace.py +16 -0
  28. openhands/sdk/workspace/remote/base.py +16 -0
  29. {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.0.dist-info}/METADATA +1 -1
  30. {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.0.dist-info}/RECORD +32 -28
  31. {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.0.dist-info}/WHEEL +0 -0
  32. {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.0.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
+ )
@@ -1,4 +1,9 @@
1
- from openhands.sdk.tool.builtins import BUILT_IN_TOOLS, FinishTool, ThinkTool
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",
@@ -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
- schema = (
373
- action_type_with_risk.to_mcp_schema()
374
- if add_security_risk_prediction
375
- else action_type.to_mcp_schema()
376
- )
377
- return schema
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, action_type
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, action_type
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