openhands-sdk 1.7.3__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 (44) hide show
  1. openhands/sdk/__init__.py +2 -0
  2. openhands/sdk/agent/agent.py +31 -1
  3. openhands/sdk/agent/base.py +111 -67
  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/condenser/__init__.py +2 -0
  8. openhands/sdk/context/condenser/base.py +59 -8
  9. openhands/sdk/context/condenser/llm_summarizing_condenser.py +38 -10
  10. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +4 -0
  11. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +9 -0
  12. openhands/sdk/context/skills/__init__.py +12 -0
  13. openhands/sdk/context/skills/skill.py +425 -228
  14. openhands/sdk/context/skills/types.py +4 -0
  15. openhands/sdk/context/skills/utils.py +442 -0
  16. openhands/sdk/context/view.py +2 -0
  17. openhands/sdk/conversation/impl/local_conversation.py +42 -14
  18. openhands/sdk/conversation/impl/remote_conversation.py +99 -55
  19. openhands/sdk/conversation/state.py +54 -18
  20. openhands/sdk/event/llm_convertible/action.py +20 -0
  21. openhands/sdk/git/utils.py +31 -6
  22. openhands/sdk/hooks/conversation_hooks.py +57 -10
  23. openhands/sdk/llm/llm.py +59 -76
  24. openhands/sdk/llm/options/chat_options.py +4 -1
  25. openhands/sdk/llm/router/base.py +12 -0
  26. openhands/sdk/llm/utils/telemetry.py +2 -2
  27. openhands/sdk/llm/utils/verified_models.py +1 -1
  28. openhands/sdk/mcp/tool.py +3 -1
  29. openhands/sdk/plugin/__init__.py +22 -0
  30. openhands/sdk/plugin/plugin.py +299 -0
  31. openhands/sdk/plugin/types.py +226 -0
  32. openhands/sdk/tool/__init__.py +7 -1
  33. openhands/sdk/tool/builtins/__init__.py +4 -0
  34. openhands/sdk/tool/schema.py +6 -3
  35. openhands/sdk/tool/tool.py +60 -9
  36. openhands/sdk/utils/models.py +198 -472
  37. openhands/sdk/workspace/base.py +22 -0
  38. openhands/sdk/workspace/local.py +16 -0
  39. openhands/sdk/workspace/remote/async_remote_workspace.py +16 -0
  40. openhands/sdk/workspace/remote/base.py +16 -0
  41. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/METADATA +2 -2
  42. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/RECORD +44 -40
  43. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/WHEEL +0 -0
  44. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/top_level.txt +0 -0
@@ -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",
@@ -121,9 +121,12 @@ class Schema(DiscriminatedUnionMixin):
121
121
  # so it is fully compatible with MCP tool schema
122
122
  result = _process_schema_node(full_schema, full_schema.get("$defs", {}))
123
123
 
124
- # Remove 'kind' from properties if present (discriminator field, not for LLM)
125
- EXCLUDE_FIELDS = DiscriminatedUnionMixin.model_fields.keys()
126
- for f in EXCLUDE_FIELDS:
124
+ # Remove discriminator fields from properties (not for LLM)
125
+ # Need to exclude both regular fields and computed fields (like 'kind')
126
+ exclude_fields = set(DiscriminatedUnionMixin.model_fields.keys()) | set(
127
+ DiscriminatedUnionMixin.model_computed_fields.keys()
128
+ )
129
+ for f in exclude_fields:
127
130
  if "properties" in result and f in result["properties"]:
128
131
  result["properties"].pop(f)
129
132
  # Also remove from required if present
@@ -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