openhands-sdk 1.9.1__py3-none-any.whl → 1.11.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 (47) hide show
  1. openhands/sdk/agent/agent.py +90 -16
  2. openhands/sdk/agent/base.py +33 -46
  3. openhands/sdk/context/condenser/base.py +36 -3
  4. openhands/sdk/context/condenser/llm_summarizing_condenser.py +65 -24
  5. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
  6. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +2 -1
  7. openhands/sdk/context/skills/skill.py +2 -25
  8. openhands/sdk/context/view.py +108 -122
  9. openhands/sdk/conversation/__init__.py +2 -0
  10. openhands/sdk/conversation/conversation.py +18 -3
  11. openhands/sdk/conversation/exceptions.py +18 -0
  12. openhands/sdk/conversation/impl/local_conversation.py +211 -36
  13. openhands/sdk/conversation/impl/remote_conversation.py +151 -12
  14. openhands/sdk/conversation/stuck_detector.py +18 -9
  15. openhands/sdk/critic/impl/api/critic.py +10 -7
  16. openhands/sdk/event/condenser.py +52 -2
  17. openhands/sdk/git/cached_repo.py +19 -0
  18. openhands/sdk/hooks/__init__.py +2 -0
  19. openhands/sdk/hooks/config.py +44 -4
  20. openhands/sdk/hooks/executor.py +2 -1
  21. openhands/sdk/llm/__init__.py +16 -0
  22. openhands/sdk/llm/auth/__init__.py +28 -0
  23. openhands/sdk/llm/auth/credentials.py +157 -0
  24. openhands/sdk/llm/auth/openai.py +762 -0
  25. openhands/sdk/llm/llm.py +222 -33
  26. openhands/sdk/llm/message.py +65 -27
  27. openhands/sdk/llm/options/chat_options.py +2 -1
  28. openhands/sdk/llm/options/responses_options.py +8 -7
  29. openhands/sdk/llm/utils/model_features.py +2 -0
  30. openhands/sdk/mcp/client.py +53 -6
  31. openhands/sdk/mcp/tool.py +24 -21
  32. openhands/sdk/mcp/utils.py +31 -23
  33. openhands/sdk/plugin/__init__.py +12 -1
  34. openhands/sdk/plugin/fetch.py +118 -14
  35. openhands/sdk/plugin/loader.py +111 -0
  36. openhands/sdk/plugin/plugin.py +155 -13
  37. openhands/sdk/plugin/types.py +163 -1
  38. openhands/sdk/secret/secrets.py +13 -1
  39. openhands/sdk/utils/__init__.py +2 -0
  40. openhands/sdk/utils/async_utils.py +36 -1
  41. openhands/sdk/utils/command.py +28 -1
  42. openhands/sdk/workspace/remote/base.py +8 -3
  43. openhands/sdk/workspace/remote/remote_workspace_mixin.py +40 -7
  44. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/METADATA +1 -1
  45. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/RECORD +47 -43
  46. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/WHEEL +1 -1
  47. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/top_level.txt +0 -0
@@ -49,13 +49,16 @@ class APIBasedCritic(CriticBase, CriticClient):
49
49
  messages = LLMConvertibleEvent.events_to_messages(llm_convertible_events)
50
50
 
51
51
  # Serialize messages to dicts for API
52
- for message in messages:
53
- message.cache_enabled = False
54
- message.vision_enabled = False # Critic does not support vision currently
55
- message.function_calling_enabled = True
56
- message.force_string_serializer = False
57
- message.send_reasoning_content = False
58
- formatted_messages = [message.to_chat_dict() for message in messages]
52
+ formatted_messages = [
53
+ message.to_chat_dict(
54
+ cache_enabled=False,
55
+ vision_enabled=False, # Critic does not support vision currently
56
+ function_calling_enabled=True,
57
+ force_string_serializer=False,
58
+ send_reasoning_content=False,
59
+ )
60
+ for message in messages
61
+ ]
59
62
 
60
63
  # Convert ToolDefinition objects to ChatCompletionToolParam format
61
64
  tools_for_api = [tool.to_openai_tool() for tool in tools]
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from pydantic import Field
2
4
  from rich.text import Text
3
5
 
@@ -22,8 +24,9 @@ class Condensation(Event):
22
24
  summary_offset: int | None = Field(
23
25
  default=None,
24
26
  ge=0,
25
- description="An optional offset to the start of the resulting view"
26
- " indicating where the summary should be inserted.",
27
+ description="An optional offset to the start of the resulting view (after"
28
+ " forgotten events have been removed) indicating where the summary should be"
29
+ " inserted. If not provided, the summary will not be inserted into the view.",
27
30
  )
28
31
  llm_response_id: EventID = Field(
29
32
  description=(
@@ -45,6 +48,53 @@ class Condensation(Event):
45
48
  text.append(f"{self.summary}\n")
46
49
  return text
47
50
 
51
+ @property
52
+ def summary_event(self) -> CondensationSummaryEvent:
53
+ """Generates a CondensationSummaryEvent.
54
+
55
+ Since summary events are not part of the main event store and are generated
56
+ dynamically, this property ensures the created event has a unique and consistent
57
+ ID based on the condensation event's ID.
58
+
59
+ Raises:
60
+ ValueError: If no summary is present.
61
+ """
62
+ if self.summary is None:
63
+ raise ValueError("No summary present to generate CondensationSummaryEvent.")
64
+
65
+ # Create a deterministic ID for the summary event.
66
+ # This ID will be unique amongst all auto-generated IDs (by virtue of the
67
+ # "-summary" suffix).
68
+ # These events are not intended to be stored alongside regular events, but the
69
+ # ID is still compatible with the file-based event store.
70
+ summary_id = f"{self.id}-summary"
71
+
72
+ return CondensationSummaryEvent(
73
+ id=summary_id,
74
+ summary=self.summary,
75
+ source=self.source,
76
+ )
77
+
78
+ @property
79
+ def has_summary_metadata(self) -> bool:
80
+ """Checks if both summary and summary_offset are present."""
81
+ return self.summary is not None and self.summary_offset is not None
82
+
83
+ def apply(self, events: list[LLMConvertibleEvent]) -> list[LLMConvertibleEvent]:
84
+ """Applies the condensation to a list of events.
85
+
86
+ This method removes events that are marked to be forgotten and returns a new
87
+ list of events. If the summary metadata is present (both summary and offset),
88
+ the corresponding CondensationSummaryEvent will be inserted at the specified
89
+ offset _after_ the forgotten events have been removed.
90
+ """
91
+ output = [event for event in events if event.id not in self.forgotten_event_ids]
92
+ if self.has_summary_metadata:
93
+ assert self.summary_offset is not None
94
+ summary_event = self.summary_event
95
+ output.insert(self.summary_offset, summary_event)
96
+ return output
97
+
48
98
 
49
99
  class CondensationRequest(Event):
50
100
  """This action is used to request a condensation of the conversation history.
@@ -170,6 +170,25 @@ class GitHelper:
170
170
  # origin/HEAD may not be set (e.g., bare clone, or never configured)
171
171
  return None
172
172
 
173
+ def get_head_commit(self, repo_path: Path, timeout: int = 10) -> str:
174
+ """Get the current HEAD commit SHA.
175
+
176
+ Args:
177
+ repo_path: Path to the repository.
178
+ timeout: Timeout in seconds.
179
+
180
+ Returns:
181
+ Full 40-character commit SHA of HEAD.
182
+
183
+ Raises:
184
+ GitCommandError: If command fails.
185
+ """
186
+ return run_git_command(
187
+ ["git", "rev-parse", "HEAD"],
188
+ cwd=repo_path,
189
+ timeout=timeout,
190
+ )
191
+
173
192
 
174
193
  def try_cached_clone_or_update(
175
194
  url: str,
@@ -6,6 +6,7 @@ during agent execution, enabling deterministic control over agent behavior.
6
6
  """
7
7
 
8
8
  from openhands.sdk.hooks.config import (
9
+ HOOK_EVENT_FIELDS,
9
10
  HookConfig,
10
11
  HookDefinition,
11
12
  HookMatcher,
@@ -21,6 +22,7 @@ from openhands.sdk.hooks.types import HookDecision, HookEvent, HookEventType
21
22
 
22
23
 
23
24
  __all__ = [
25
+ "HOOK_EVENT_FIELDS",
24
26
  "HookConfig",
25
27
  "HookDefinition",
26
28
  "HookMatcher",
@@ -22,8 +22,9 @@ def _pascal_to_snake(name: str) -> str:
22
22
  return result
23
23
 
24
24
 
25
- # Valid snake_case field names for hook events
26
- _VALID_HOOK_FIELDS: frozenset[str] = frozenset(
25
+ # Valid snake_case field names for hook events.
26
+ # This is the single source of truth for hook event types.
27
+ HOOK_EVENT_FIELDS: frozenset[str] = frozenset(
27
28
  {
28
29
  "pre_tool_use",
29
30
  "post_tool_use",
@@ -188,8 +189,8 @@ class HookConfig(BaseModel):
188
189
 
189
190
  if is_pascal_case:
190
191
  # Validate that PascalCase key maps to a known field
191
- if snake_key not in _VALID_HOOK_FIELDS:
192
- valid_types = ", ".join(sorted(_VALID_HOOK_FIELDS))
192
+ if snake_key not in HOOK_EVENT_FIELDS:
193
+ valid_types = ", ".join(sorted(HOOK_EVENT_FIELDS))
193
194
  raise ValueError(
194
195
  f"Unknown event type '{key}'. Valid types: {valid_types}"
195
196
  )
@@ -287,3 +288,42 @@ class HookConfig(BaseModel):
287
288
 
288
289
  with open(path, "w") as f:
289
290
  json.dump(self.model_dump(mode="json", exclude_defaults=True), f, indent=2)
291
+
292
+ @classmethod
293
+ def merge(cls, configs: list["HookConfig"]) -> "HookConfig | None":
294
+ """Merge multiple hook configs by concatenating handlers per event type.
295
+
296
+ Each hook config may have multiple event types (pre_tool_use,
297
+ post_tool_use, etc.). This method combines all matchers from all
298
+ configs for each event type.
299
+
300
+ Args:
301
+ configs: List of HookConfig objects to merge.
302
+
303
+ Returns:
304
+ A merged HookConfig with all matchers concatenated, or None if no configs
305
+ or if the result is empty.
306
+
307
+ Example:
308
+ >>> config1 = HookConfig(pre_tool_use=[HookMatcher(matcher="*")])
309
+ >>> config2 = HookConfig(pre_tool_use=[HookMatcher(matcher="terminal")])
310
+ >>> merged = HookConfig.merge([config1, config2])
311
+ >>> len(merged.pre_tool_use) # Both matchers combined
312
+ 2
313
+ """
314
+ if not configs:
315
+ return None
316
+
317
+ # Collect all matchers by event type using the canonical field list
318
+ collected: dict[str, list] = {field: [] for field in HOOK_EVENT_FIELDS}
319
+ for config in configs:
320
+ for field in HOOK_EVENT_FIELDS:
321
+ collected[field].extend(getattr(config, field))
322
+
323
+ merged = cls(**collected)
324
+
325
+ # Return None if the merged config is empty
326
+ if merged.is_empty():
327
+ return None
328
+
329
+ return merged
@@ -8,6 +8,7 @@ from pydantic import BaseModel
8
8
 
9
9
  from openhands.sdk.hooks.config import HookDefinition
10
10
  from openhands.sdk.hooks.types import HookDecision, HookEvent
11
+ from openhands.sdk.utils import sanitized_env
11
12
 
12
13
 
13
14
  class HookResult(BaseModel):
@@ -50,7 +51,7 @@ class HookExecutor:
50
51
  ) -> HookResult:
51
52
  """Execute a single hook."""
52
53
  # Prepare environment
53
- hook_env = os.environ.copy()
54
+ hook_env = sanitized_env()
54
55
  hook_env["OPENHANDS_PROJECT_DIR"] = self.working_dir
55
56
  hook_env["OPENHANDS_SESSION_ID"] = event.session_id or ""
56
57
  hook_env["OPENHANDS_EVENT_TYPE"] = event.event_type
@@ -1,3 +1,9 @@
1
+ from openhands.sdk.llm.auth import (
2
+ OPENAI_CODEX_MODELS,
3
+ CredentialStore,
4
+ OAuthCredentials,
5
+ OpenAISubscriptionAuth,
6
+ )
1
7
  from openhands.sdk.llm.llm import LLM
2
8
  from openhands.sdk.llm.llm_registry import LLMRegistry, RegistryEvent
3
9
  from openhands.sdk.llm.llm_response import LLMResponse
@@ -22,11 +28,18 @@ from openhands.sdk.llm.utils.verified_models import VERIFIED_MODELS
22
28
 
23
29
 
24
30
  __all__ = [
31
+ # Auth
32
+ "CredentialStore",
33
+ "OAuthCredentials",
34
+ "OpenAISubscriptionAuth",
35
+ "OPENAI_CODEX_MODELS",
36
+ # Core
25
37
  "LLMResponse",
26
38
  "LLM",
27
39
  "LLMRegistry",
28
40
  "RouterLLM",
29
41
  "RegistryEvent",
42
+ # Messages
30
43
  "Message",
31
44
  "MessageToolCall",
32
45
  "TextContent",
@@ -35,10 +48,13 @@ __all__ = [
35
48
  "RedactedThinkingBlock",
36
49
  "ReasoningItemModel",
37
50
  "content_to_str",
51
+ # Streaming
38
52
  "LLMStreamChunk",
39
53
  "TokenCallbackType",
54
+ # Metrics
40
55
  "Metrics",
41
56
  "MetricsSnapshot",
57
+ # Models
42
58
  "VERIFIED_MODELS",
43
59
  "UNVERIFIED_MODELS_EXCLUDING_BEDROCK",
44
60
  "get_unverified_models",
@@ -0,0 +1,28 @@
1
+ """Authentication module for LLM subscription-based access.
2
+
3
+ This module provides OAuth-based authentication for LLM providers that support
4
+ subscription-based access (e.g., ChatGPT Plus/Pro for OpenAI Codex models).
5
+ """
6
+
7
+ from openhands.sdk.llm.auth.credentials import (
8
+ CredentialStore,
9
+ OAuthCredentials,
10
+ )
11
+ from openhands.sdk.llm.auth.openai import (
12
+ OPENAI_CODEX_MODELS,
13
+ OpenAISubscriptionAuth,
14
+ SupportedVendor,
15
+ inject_system_prefix,
16
+ transform_for_subscription,
17
+ )
18
+
19
+
20
+ __all__ = [
21
+ "CredentialStore",
22
+ "OAuthCredentials",
23
+ "OpenAISubscriptionAuth",
24
+ "OPENAI_CODEX_MODELS",
25
+ "SupportedVendor",
26
+ "inject_system_prefix",
27
+ "transform_for_subscription",
28
+ ]
@@ -0,0 +1,157 @@
1
+ """Credential storage and retrieval for OAuth-based LLM authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import warnings
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+ from openhands.sdk.logger import get_logger
15
+
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ def get_credentials_dir() -> Path:
21
+ """Get the directory for storing credentials.
22
+
23
+ Uses XDG_DATA_HOME if set, otherwise defaults to ~/.local/share/openhands.
24
+ """
25
+ return Path.home() / ".openhands" / "auth"
26
+
27
+
28
+ class OAuthCredentials(BaseModel):
29
+ """OAuth credentials for subscription-based LLM access."""
30
+
31
+ type: Literal["oauth"] = "oauth"
32
+ vendor: str = Field(description="The vendor/provider (e.g., 'openai')")
33
+ access_token: str = Field(description="The OAuth access token")
34
+ refresh_token: str = Field(description="The OAuth refresh token")
35
+ expires_at: int = Field(
36
+ description="Unix timestamp (ms) when the access token expires"
37
+ )
38
+
39
+ def is_expired(self) -> bool:
40
+ """Check if the access token is expired."""
41
+ # Add 60 second buffer to avoid edge cases
42
+ # Add 60 second buffer to avoid edge cases where token expires during request
43
+ return self.expires_at < (int(time.time() * 1000) + 60_000)
44
+
45
+
46
+ class CredentialStore:
47
+ """Store and retrieve OAuth credentials for LLM providers."""
48
+
49
+ def __init__(self, credentials_dir: Path | None = None):
50
+ """Initialize the credential store.
51
+
52
+ Args:
53
+ credentials_dir: Optional custom directory for storing credentials.
54
+ Defaults to ~/.local/share/openhands/auth/
55
+ """
56
+ self._credentials_dir = credentials_dir or get_credentials_dir()
57
+ logger.info(f"Using credentials directory: {self._credentials_dir}")
58
+
59
+ @property
60
+ def credentials_dir(self) -> Path:
61
+ """Get the credentials directory, creating it if necessary."""
62
+ self._credentials_dir.mkdir(parents=True, exist_ok=True)
63
+ # Set directory permissions to owner-only (rwx------)
64
+ if os.name != "nt":
65
+ self._credentials_dir.chmod(0o700)
66
+ return self._credentials_dir
67
+
68
+ def _get_credentials_file(self, vendor: str) -> Path:
69
+ """Get the path to the credentials file for a vendor."""
70
+ return self.credentials_dir / f"{vendor}_oauth.json"
71
+
72
+ def get(self, vendor: str) -> OAuthCredentials | None:
73
+ """Get stored credentials for a vendor.
74
+
75
+ Args:
76
+ vendor: The vendor/provider name (e.g., 'openai')
77
+
78
+ Returns:
79
+ OAuthCredentials if found and valid, None otherwise
80
+ """
81
+ creds_file = self._get_credentials_file(vendor)
82
+ if not creds_file.exists():
83
+ return None
84
+
85
+ try:
86
+ with open(creds_file) as f:
87
+ data = json.load(f)
88
+ return OAuthCredentials.model_validate(data)
89
+ except (json.JSONDecodeError, ValueError):
90
+ # Invalid credentials file, remove it
91
+ creds_file.unlink(missing_ok=True)
92
+ return None
93
+
94
+ def save(self, credentials: OAuthCredentials) -> None:
95
+ """Save credentials for a vendor.
96
+
97
+ Args:
98
+ credentials: The OAuth credentials to save
99
+ """
100
+ creds_file = self._get_credentials_file(credentials.vendor)
101
+ with open(creds_file, "w") as f:
102
+ json.dump(credentials.model_dump(), f, indent=2)
103
+ # Set restrictive permissions (owner read/write only)
104
+ # Note: On Windows, NTFS ACLs should be used instead
105
+ if os.name != "nt": # Not Windows
106
+ creds_file.chmod(0o600)
107
+ else:
108
+ warnings.warn(
109
+ "File permissions on Windows should be manually restricted",
110
+ stacklevel=2,
111
+ )
112
+
113
+ def delete(self, vendor: str) -> bool:
114
+ """Delete stored credentials for a vendor.
115
+
116
+ Args:
117
+ vendor: The vendor/provider name
118
+
119
+ Returns:
120
+ True if credentials were deleted, False if they didn't exist
121
+ """
122
+ creds_file = self._get_credentials_file(vendor)
123
+ if creds_file.exists():
124
+ creds_file.unlink()
125
+ return True
126
+ return False
127
+
128
+ def update_tokens(
129
+ self,
130
+ vendor: str,
131
+ access_token: str,
132
+ refresh_token: str | None,
133
+ expires_in: int,
134
+ ) -> OAuthCredentials | None:
135
+ """Update tokens for an existing credential.
136
+
137
+ Args:
138
+ vendor: The vendor/provider name
139
+ access_token: New access token
140
+ refresh_token: New refresh token (if provided)
141
+ expires_in: Token expiry in seconds
142
+
143
+ Returns:
144
+ Updated credentials, or None if no existing credentials found
145
+ """
146
+ existing = self.get(vendor)
147
+ if existing is None:
148
+ return None
149
+
150
+ updated = OAuthCredentials(
151
+ vendor=vendor,
152
+ access_token=access_token,
153
+ refresh_token=refresh_token or existing.refresh_token,
154
+ expires_at=int(time.time() * 1000) + (expires_in * 1000),
155
+ )
156
+ self.save(updated)
157
+ return updated