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.
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.1.dist-info}/METADATA +1 -1
  30. {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.1.dist-info}/RECORD +32 -28
  31. {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.1.dist-info}/WHEEL +0 -0
  32. {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.1.dist-info}/top_level.txt +0 -0
@@ -73,6 +73,28 @@ def run_git_command(args: list[str], cwd: str | Path) -> str:
73
73
  ) from e
74
74
 
75
75
 
76
+ def _repo_has_commits(repo_dir: str | Path) -> bool:
77
+ """Check if a git repository has any commits.
78
+
79
+ Uses 'git rev-list --count --all' which returns "0" for empty repos
80
+ without failing, avoiding ERROR logs for expected conditions.
81
+
82
+ Args:
83
+ repo_dir: Path to the git repository
84
+
85
+ Returns:
86
+ True if the repository has at least one commit, False otherwise
87
+ """
88
+ try:
89
+ count = run_git_command(
90
+ ["git", "--no-pager", "rev-list", "--count", "--all"], repo_dir
91
+ )
92
+ return count.strip() != "0"
93
+ except GitCommandError:
94
+ logger.debug("Could not check commit count")
95
+ return False
96
+
97
+
76
98
  def get_valid_ref(repo_dir: str | Path) -> str | None:
77
99
  """Get a valid git reference to compare against.
78
100
 
@@ -90,6 +112,12 @@ def get_valid_ref(repo_dir: str | Path) -> str | None:
90
112
  """
91
113
  refs_to_try = []
92
114
 
115
+ # Check if repo has any commits first. Empty repos (created with git init)
116
+ # won't have commits or remotes, so we can skip directly to the empty tree fallback.
117
+ if not _repo_has_commits(repo_dir):
118
+ logger.debug("Repository has no commits yet, using empty tree reference")
119
+ return GIT_EMPTY_TREE_HASH
120
+
93
121
  # Try current branch's origin
94
122
  try:
95
123
  current_branch = run_git_command(
@@ -136,10 +164,6 @@ def get_valid_ref(repo_dir: str | Path) -> str | None:
136
164
  except GitCommandError:
137
165
  logger.debug("Could not get remote information")
138
166
 
139
- # Add empty tree as fallback for new repositories
140
- refs_to_try.append(GIT_EMPTY_TREE_HASH)
141
- logger.debug(f"Added empty tree reference: {GIT_EMPTY_TREE_HASH}")
142
-
143
167
  # Find the first valid reference
144
168
  for ref in refs_to_try:
145
169
  try:
@@ -153,8 +177,9 @@ def get_valid_ref(repo_dir: str | Path) -> str | None:
153
177
  logger.debug(f"Reference not valid: {ref}")
154
178
  continue
155
179
 
156
- logger.warning("No valid git reference found")
157
- return None
180
+ # Fallback to empty tree hash (always valid, no verification needed)
181
+ logger.debug(f"Using empty tree reference: {GIT_EMPTY_TREE_HASH}")
182
+ return GIT_EMPTY_TREE_HASH
158
183
 
159
184
 
160
185
  def validate_git_repository(repo_dir: str | Path) -> Path:
@@ -6,6 +6,7 @@ from openhands.sdk.event import ActionEvent, Event, MessageEvent, ObservationEve
6
6
  from openhands.sdk.hooks.config import HookConfig
7
7
  from openhands.sdk.hooks.manager import HookManager
8
8
  from openhands.sdk.hooks.types import HookEventType
9
+ from openhands.sdk.llm import TextContent
9
10
  from openhands.sdk.logger import get_logger
10
11
 
11
12
 
@@ -41,6 +42,9 @@ class HookEventProcessor:
41
42
 
42
43
  def on_event(self, event: Event) -> None:
43
44
  """Process an event and run appropriate hooks."""
45
+ # Track the event to pass to callbacks (may be modified by hooks)
46
+ callback_event = event
47
+
44
48
  # Run PreToolUse hooks for action events
45
49
  if isinstance(event, ActionEvent) and event.action is not None:
46
50
  self._handle_pre_tool_use(event)
@@ -51,11 +55,11 @@ class HookEventProcessor:
51
55
 
52
56
  # Run UserPromptSubmit hooks for user messages
53
57
  if isinstance(event, MessageEvent) and event.source == "user":
54
- self._handle_user_prompt_submit(event)
58
+ callback_event = self._handle_user_prompt_submit(event)
55
59
 
56
- # Call original callback
60
+ # Call original callback with (possibly modified) event
57
61
  if self.original_callback:
58
- self.original_callback(event)
62
+ self.original_callback(callback_event)
59
63
 
60
64
  def _handle_pre_tool_use(self, event: ActionEvent) -> None:
61
65
  """Handle PreToolUse hooks. Blocked actions are marked in conversation state."""
@@ -141,16 +145,18 @@ class HookEventProcessor:
141
145
  if result.error:
142
146
  logger.warning(f"PostToolUse hook error: {result.error}")
143
147
 
144
- def _handle_user_prompt_submit(self, event: MessageEvent) -> None:
145
- """Handle UserPromptSubmit hooks before processing a user message."""
148
+ def _handle_user_prompt_submit(self, event: MessageEvent) -> MessageEvent:
149
+ """Handle UserPromptSubmit hooks before processing a user message.
150
+
151
+ Returns the (possibly modified) event. If hooks inject additional_context,
152
+ a new MessageEvent is created with the context appended to extended_content.
153
+ """
146
154
  if not self.hook_manager.has_hooks(HookEventType.USER_PROMPT_SUBMIT):
147
- return
155
+ return event
148
156
 
149
157
  # Extract message text
150
158
  message = ""
151
159
  if event.llm_message and event.llm_message.content:
152
- from openhands.sdk.llm import TextContent
153
-
154
160
  for content in event.llm_message.content:
155
161
  if isinstance(content, TextContent):
156
162
  message += content.text
@@ -175,9 +181,23 @@ class HookEventProcessor:
175
181
  "after creating the Conversation."
176
182
  )
177
183
 
178
- # TODO: Inject additional_context into the message
184
+ # Inject additional_context into extended_content
179
185
  if additional_context:
180
- logger.info(f"Hook injected context: {additional_context[:100]}...")
186
+ logger.debug(f"Hook injecting context: {additional_context[:100]}...")
187
+ new_extended_content = list(event.extended_content) + [
188
+ TextContent(text=additional_context)
189
+ ]
190
+ # MessageEvent is frozen, so create a new one
191
+ event = MessageEvent(
192
+ source=event.source,
193
+ llm_message=event.llm_message,
194
+ llm_response_id=event.llm_response_id,
195
+ activated_skills=event.activated_skills,
196
+ extended_content=new_extended_content,
197
+ sender=event.sender,
198
+ )
199
+
200
+ return event
181
201
 
182
202
  def is_action_blocked(self, action_id: str) -> bool:
183
203
  """Check if an action was blocked by a hook."""
@@ -205,6 +225,33 @@ class HookEventProcessor:
205
225
  if r.error:
206
226
  logger.warning(f"SessionEnd hook error: {r.error}")
207
227
 
228
+ def run_stop(self, reason: str | None = None) -> tuple[bool, str | None]:
229
+ """Run Stop hooks. Returns (should_stop, feedback)."""
230
+ if not self.hook_manager.has_hooks(HookEventType.STOP):
231
+ return True, None
232
+
233
+ should_stop, results = self.hook_manager.run_stop(reason=reason)
234
+
235
+ # Log any errors
236
+ for r in results:
237
+ if r.error:
238
+ logger.warning(f"Stop hook error: {r.error}")
239
+
240
+ # Collect feedback if denied
241
+ feedback = None
242
+ if not should_stop:
243
+ reason_text = self.hook_manager.get_blocking_reason(results)
244
+ logger.info(f"Stop hook denied stopping: {reason_text}")
245
+ feedback_parts = [
246
+ r.additional_context for r in results if r.additional_context
247
+ ]
248
+ if feedback_parts:
249
+ feedback = "\n".join(feedback_parts)
250
+ elif reason_text:
251
+ feedback = reason_text
252
+
253
+ return should_stop, feedback
254
+
208
255
 
209
256
  def create_hook_callback(
210
257
  hook_config: HookConfig | None = None,
openhands/sdk/llm/llm.py CHANGED
@@ -28,8 +28,6 @@ from openhands.sdk.utils.pydantic_secrets import serialize_secret, validate_secr
28
28
  if TYPE_CHECKING: # type hints only, avoid runtime import cycle
29
29
  from openhands.sdk.tool.tool import ToolDefinition
30
30
 
31
- from openhands.sdk.utils.pydantic_diff import pretty_pydantic_diff
32
-
33
31
 
34
32
  with warnings.catch_warnings():
35
33
  warnings.simplefilter("ignore")
@@ -139,7 +137,12 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
139
137
  retry_min_wait: int = Field(default=8, ge=0)
140
138
  retry_max_wait: int = Field(default=64, ge=0)
141
139
 
142
- timeout: int | None = Field(default=None, ge=0, description="HTTP timeout (s).")
140
+ timeout: int | None = Field(
141
+ default=300,
142
+ ge=0,
143
+ description="HTTP timeout in seconds. Default is 300s (5 minutes). "
144
+ "Set to None to disable timeout (not recommended for production).",
145
+ )
143
146
 
144
147
  max_message_chars: int = Field(
145
148
  default=30_000,
@@ -322,19 +325,6 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
322
325
  exclude=True,
323
326
  )
324
327
  _metrics: Metrics | None = PrivateAttr(default=None)
325
- # ===== Plain class vars (NOT Fields) =====
326
- # When serializing, these fields (SecretStr) will be dump to "****"
327
- # When deserializing, these fields will be ignored and we will override
328
- # them from the LLM instance provided at runtime.
329
- OVERRIDE_ON_SERIALIZE: tuple[str, ...] = (
330
- "api_key",
331
- "aws_access_key_id",
332
- "aws_secret_access_key",
333
- # Dynamic runtime metadata for telemetry/routing that can differ across sessions
334
- # and should not cause resume-time diffs. Always prefer the runtime value.
335
- "litellm_extra_body",
336
- )
337
-
338
328
  # Runtime-only private attrs
339
329
  _model_info: Any = PrivateAttr(default=None)
340
330
  _tokenizer: Any = PrivateAttr(default=None)
@@ -498,9 +488,21 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
498
488
  This is the method for getting responses from the model via Completion API.
499
489
  It handles message formatting, tool calling, and response processing.
500
490
 
491
+ Args:
492
+ messages: List of conversation messages
493
+ tools: Optional list of tools available to the model
494
+ _return_metrics: Whether to return usage metrics
495
+ add_security_risk_prediction: Add security_risk field to tool schemas
496
+ on_token: Optional callback for streaming tokens
497
+ **kwargs: Additional arguments passed to the LLM API
498
+
501
499
  Returns:
502
500
  LLMResponse containing the model's response and metadata.
503
501
 
502
+ Note:
503
+ Summary field is always added to tool schemas for transparency and
504
+ explainability of agent actions.
505
+
504
506
  Raises:
505
507
  ValueError: If streaming is requested (not supported).
506
508
 
@@ -528,7 +530,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
528
530
  if tools:
529
531
  cc_tools = [
530
532
  t.to_openai_tool(
531
- add_security_risk_prediction=add_security_risk_prediction
533
+ add_security_risk_prediction=add_security_risk_prediction,
532
534
  )
533
535
  for t in tools
534
536
  ]
@@ -550,18 +552,20 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
550
552
  # Behavior-preserving: delegate to select_chat_options
551
553
  call_kwargs = select_chat_options(self, kwargs, has_tools=has_tools_flag)
552
554
 
553
- # 4) optional request logging context (kept small)
555
+ # 4) request context for telemetry (always include context_window for metrics)
554
556
  assert self._telemetry is not None
555
- log_ctx = None
557
+ # Always pass context_window so metrics are tracked even when logging disabled
558
+ telemetry_ctx: dict[str, Any] = {"context_window": self.max_input_tokens or 0}
556
559
  if self._telemetry.log_enabled:
557
- log_ctx = {
558
- "messages": formatted_messages[:], # already simple dicts
559
- "tools": tools,
560
- "kwargs": {k: v for k, v in call_kwargs.items()},
561
- "context_window": self.max_input_tokens or 0,
562
- }
560
+ telemetry_ctx.update(
561
+ {
562
+ "messages": formatted_messages[:], # already simple dicts
563
+ "tools": tools,
564
+ "kwargs": {k: v for k, v in call_kwargs.items()},
565
+ }
566
+ )
563
567
  if tools and not use_native_fc:
564
- log_ctx["raw_messages"] = original_fncall_msgs
568
+ telemetry_ctx["raw_messages"] = original_fncall_msgs
565
569
 
566
570
  # 5) do the call with retries
567
571
  @self.retry_decorator(
@@ -574,7 +578,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
574
578
  )
575
579
  def _one_attempt(**retry_kwargs) -> ModelResponse:
576
580
  assert self._telemetry is not None
577
- self._telemetry.on_request(log_ctx=log_ctx)
581
+ self._telemetry.on_request(telemetry_ctx=telemetry_ctx)
578
582
  # Merge retry-modified kwargs (like temperature) with call_kwargs
579
583
  final_kwargs = {**call_kwargs, **retry_kwargs}
580
584
  resp = self._transport_call(
@@ -645,6 +649,20 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
645
649
  """Alternative invocation path using OpenAI Responses API via LiteLLM.
646
650
 
647
651
  Maps Message[] -> (instructions, input[]) and returns LLMResponse.
652
+
653
+ Args:
654
+ messages: List of conversation messages
655
+ tools: Optional list of tools available to the model
656
+ include: Optional list of fields to include in response
657
+ store: Whether to store the conversation
658
+ _return_metrics: Whether to return usage metrics
659
+ add_security_risk_prediction: Add security_risk field to tool schemas
660
+ on_token: Optional callback for streaming tokens (not yet supported)
661
+ **kwargs: Additional arguments passed to the API
662
+
663
+ Note:
664
+ Summary field is always added to tool schemas for transparency and
665
+ explainability of agent actions.
648
666
  """
649
667
  # Streaming not yet supported
650
668
  if kwargs.get("stream", False) or self.stream or on_token is not None:
@@ -658,7 +676,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
658
676
  resp_tools = (
659
677
  [
660
678
  t.to_responses_tool(
661
- add_security_risk_prediction=add_security_risk_prediction
679
+ add_security_risk_prediction=add_security_risk_prediction,
662
680
  )
663
681
  for t in tools
664
682
  ]
@@ -671,17 +689,19 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
671
689
  self, kwargs, include=include, store=store
672
690
  )
673
691
 
674
- # Optional request logging
692
+ # Request context for telemetry (always include context_window for metrics)
675
693
  assert self._telemetry is not None
676
- log_ctx = None
694
+ # Always pass context_window so metrics are tracked even when logging disabled
695
+ telemetry_ctx: dict[str, Any] = {"context_window": self.max_input_tokens or 0}
677
696
  if self._telemetry.log_enabled:
678
- log_ctx = {
679
- "llm_path": "responses",
680
- "input": input_items[:],
681
- "tools": tools,
682
- "kwargs": {k: v for k, v in call_kwargs.items()},
683
- "context_window": self.max_input_tokens or 0,
684
- }
697
+ telemetry_ctx.update(
698
+ {
699
+ "llm_path": "responses",
700
+ "input": input_items[:],
701
+ "tools": tools,
702
+ "kwargs": {k: v for k, v in call_kwargs.items()},
703
+ }
704
+ )
685
705
 
686
706
  # Perform call with retries
687
707
  @self.retry_decorator(
@@ -694,7 +714,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
694
714
  )
695
715
  def _one_attempt(**retry_kwargs) -> ResponsesAPIResponse:
696
716
  assert self._telemetry is not None
697
- self._telemetry.on_request(log_ctx=log_ctx)
717
+ self._telemetry.on_request(telemetry_ctx=telemetry_ctx)
698
718
  final_kwargs = {**call_kwargs, **retry_kwargs}
699
719
  with self._litellm_modify_params_ctx(self.modify_params):
700
720
  with warnings.catch_warnings():
@@ -1101,39 +1121,3 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
1101
1121
  if v is not None:
1102
1122
  data[field_name] = v
1103
1123
  return cls(**data)
1104
-
1105
- def resolve_diff_from_deserialized(self, persisted: LLM) -> LLM:
1106
- """Resolve differences between a deserialized LLM and the current instance.
1107
-
1108
- This is due to fields like api_key being serialized to "****" in dumps,
1109
- and we want to ensure that when loading from a file, we still use the
1110
- runtime-provided api_key in the self instance.
1111
-
1112
- Return a new LLM instance equivalent to `persisted` but with
1113
- explicitly whitelisted fields (e.g. api_key) taken from `self`.
1114
- """
1115
- if persisted.__class__ is not self.__class__:
1116
- raise ValueError(
1117
- f"Cannot resolve_diff_from_deserialized between {self.__class__} "
1118
- f"and {persisted.__class__}"
1119
- )
1120
-
1121
- # Copy allowed fields from runtime llm into the persisted llm
1122
- llm_updates = {}
1123
- persisted_dump = persisted.model_dump(context={"expose_secrets": True})
1124
- for field in self.OVERRIDE_ON_SERIALIZE:
1125
- if field in persisted_dump.keys():
1126
- llm_updates[field] = getattr(self, field)
1127
- if llm_updates:
1128
- reconciled = persisted.model_copy(update=llm_updates)
1129
- else:
1130
- reconciled = persisted
1131
-
1132
- dump = self.model_dump(context={"expose_secrets": True})
1133
- reconciled_dump = reconciled.model_dump(context={"expose_secrets": True})
1134
- if dump != reconciled_dump:
1135
- raise ValueError(
1136
- "The LLM provided is different from the one in persisted state.\n"
1137
- f"Diff: {pretty_pydantic_diff(self, reconciled)}"
1138
- )
1139
- return reconciled
@@ -59,6 +59,18 @@ class RouterLLM(LLM):
59
59
  """
60
60
  This method intercepts completion calls and routes them to the appropriate
61
61
  underlying LLM based on the routing logic implemented in select_llm().
62
+
63
+ Args:
64
+ messages: List of conversation messages
65
+ tools: Optional list of tools available to the model
66
+ return_metrics: Whether to return usage metrics
67
+ add_security_risk_prediction: Add security_risk field to tool schemas
68
+ on_token: Optional callback for streaming tokens
69
+ **kwargs: Additional arguments passed to the LLM API
70
+
71
+ Note:
72
+ Summary field is always added to tool schemas for transparency and
73
+ explainability of agent actions.
62
74
  """
63
75
  # Select appropriate LLM
64
76
  selected_model = self.select_llm(messages)
@@ -73,9 +73,9 @@ class Telemetry(BaseModel):
73
73
  """
74
74
  self._stats_update_callback = callback
75
75
 
76
- def on_request(self, log_ctx: dict | None) -> None:
76
+ def on_request(self, telemetry_ctx: dict | None) -> None:
77
77
  self._req_start = time.time()
78
- self._req_ctx = log_ctx or {}
78
+ self._req_ctx = telemetry_ctx or {}
79
79
 
80
80
  def on_response(
81
81
  self,
@@ -0,0 +1,22 @@
1
+ """Plugin module for OpenHands SDK.
2
+
3
+ This module provides support for loading and managing plugins that bundle
4
+ skills, hooks, MCP configurations, agents, and commands together.
5
+ """
6
+
7
+ from openhands.sdk.plugin.plugin import Plugin
8
+ from openhands.sdk.plugin.types import (
9
+ AgentDefinition,
10
+ CommandDefinition,
11
+ PluginAuthor,
12
+ PluginManifest,
13
+ )
14
+
15
+
16
+ __all__ = [
17
+ "Plugin",
18
+ "PluginManifest",
19
+ "PluginAuthor",
20
+ "AgentDefinition",
21
+ "CommandDefinition",
22
+ ]