openhands-sdk 1.8.1__py3-none-any.whl → 1.8.2__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.
@@ -384,6 +384,13 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
384
384
  if isinstance(event, ActionEvent) and event.tool_name
385
385
  }
386
386
 
387
+ # Add builtin tool names from include_default_tools
388
+ # These are runtime names like 'finish', 'think'
389
+ for tool_class_name in self.include_default_tools:
390
+ tool_class = BUILT_IN_TOOL_CLASSES.get(tool_class_name)
391
+ if tool_class is not None:
392
+ runtime_names.add(tool_class.name)
393
+
387
394
  # Only require tools that were actually used in history.
388
395
  missing_used_tools = used_tools - runtime_names
389
396
  if missing_used_tools:
@@ -43,6 +43,7 @@ You are OpenHands agent, a helpful AI assistant that can interact with a compute
43
43
  * When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
44
44
  * Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.
45
45
  * If unsure about committing certain files, check for the presence of .gitignore files or ask the user for clarification.
46
+ * When running git commands that may produce paged output (e.g., `git diff`, `git log`, `git show`), use `git --no-pager <command>` or set `GIT_PAGER=cat` to prevent the command from getting stuck waiting for interactive input.
46
47
  </VERSION_CONTROL>
47
48
 
48
49
  <PULL_REQUESTS>
@@ -42,7 +42,11 @@ class LLMSummarizingCondenser(RollingCondenser):
42
42
  llm: LLM
43
43
  max_size: int = Field(default=240, gt=0)
44
44
  max_tokens: int | None = None
45
+
45
46
  keep_first: int = Field(default=2, ge=0)
47
+ """Minimum number of events to preserve at the start of the view. The first
48
+ `keep_first` events in the conversation will never be condensed or summarized.
49
+ """
46
50
 
47
51
  @model_validator(mode="after")
48
52
  def validate_keep_first_vs_max_size(self):
@@ -236,13 +240,11 @@ class LLMSummarizingCondenser(RollingCondenser):
236
240
  # Calculate naive forgetting end (without considering atomic boundaries)
237
241
  naive_end = len(view) - events_from_tail
238
242
 
239
- # Find actual forgetting_start: smallest manipulation index > keep_first
240
- forgetting_start = view.find_next_manipulation_index(
241
- self.keep_first, strict=True
242
- )
243
+ # Find actual forgetting_start: smallest manipulation index >= keep_first
244
+ forgetting_start = view.find_next_manipulation_index(self.keep_first)
243
245
 
244
246
  # Find actual forgetting_end: smallest manipulation index >= naive_end
245
- forgetting_end = view.find_next_manipulation_index(naive_end, strict=False)
247
+ forgetting_end = view.find_next_manipulation_index(naive_end)
246
248
 
247
249
  # Extract events to forget using boundary-aware indices
248
250
  forgotten_events = view[forgetting_start:forgetting_end]
@@ -416,27 +416,22 @@ class View(BaseModel):
416
416
  else:
417
417
  return True
418
418
 
419
- def find_next_manipulation_index(self, threshold: int, strict: bool = False) -> int:
420
- """Find the smallest manipulation index greater than (or equal to) a threshold.
419
+ def find_next_manipulation_index(self, threshold: int) -> int:
420
+ """Find the smallest manipulation index greater than or equal to a threshold.
421
421
 
422
422
  This is a helper method for condensation logic that needs to find safe
423
423
  boundaries for forgetting events. Uses the cached manipulation_indices property.
424
424
 
425
425
  Args:
426
426
  threshold: The threshold value to compare against
427
- strict: If True, finds index > threshold. If False, finds index >= threshold
428
427
 
429
428
  Returns:
430
- The smallest manipulation index that satisfies the condition, or the
431
- threshold itself if no such index exists
429
+ The smallest manipulation index >= threshold, or the threshold itself
430
+ if no such index exists
432
431
  """
433
432
  for idx in self.manipulation_indices:
434
- if strict:
435
- if idx > threshold:
436
- return idx
437
- else:
438
- if idx >= threshold:
439
- return idx
433
+ if idx >= threshold:
434
+ return idx
440
435
  return threshold
441
436
 
442
437
  @staticmethod
@@ -16,17 +16,34 @@ from openhands.sdk.logger import get_logger
16
16
 
17
17
  logger = get_logger(__name__)
18
18
 
19
+ LOCK_FILE_NAME = ".eventlog.lock"
20
+ LOCK_TIMEOUT_SECONDS = 30
21
+
19
22
 
20
23
  class EventLog(EventsListBase):
24
+ """Persistent event log with locking for concurrent writes.
25
+
26
+ This class provides thread-safe and process-safe event storage using
27
+ the FileStore's locking mechanism. Events are persisted to disk and
28
+ can be accessed by index or event ID.
29
+
30
+ Note:
31
+ For LocalFileStore, file locking via flock() does NOT work reliably
32
+ on NFS mounts or network filesystems. Users deploying with shared
33
+ storage should use alternative coordination mechanisms.
34
+ """
35
+
21
36
  _fs: FileStore
22
37
  _dir: str
23
38
  _length: int
39
+ _lock_path: str
24
40
 
25
41
  def __init__(self, fs: FileStore, dir_path: str = EVENTS_DIR) -> None:
26
42
  self._fs = fs
27
43
  self._dir = dir_path
28
44
  self._id_to_idx: dict[EventID, int] = {}
29
45
  self._idx_to_id: dict[int, EventID] = {}
46
+ self._lock_path = f"{dir_path}/{LOCK_FILE_NAME}"
30
47
  self._length = self._scan_and_build_index()
31
48
 
32
49
  def get_index(self, event_id: EventID) -> int:
@@ -54,7 +71,6 @@ class EventLog(EventsListBase):
54
71
  if isinstance(idx, slice):
55
72
  start, stop, step = idx.indices(self._length)
56
73
  return [self._get_single_item(i) for i in range(start, stop, step)]
57
- # idx is int-like (SupportsIndex)
58
74
  return self._get_single_item(idx)
59
75
 
60
76
  def _get_single_item(self, idx: SupportsIndex) -> Event:
@@ -75,26 +91,82 @@ class EventLog(EventsListBase):
75
91
  continue
76
92
  evt = Event.model_validate_json(txt)
77
93
  evt_id = evt.id
78
- # only backfill mapping if missing
79
94
  if i not in self._idx_to_id:
80
95
  self._idx_to_id[i] = evt_id
81
96
  self._id_to_idx.setdefault(evt_id, i)
82
97
  yield evt
83
98
 
84
99
  def append(self, event: Event) -> None:
100
+ """Append an event with locking for thread/process safety.
101
+
102
+ Raises:
103
+ TimeoutError: If the lock cannot be acquired within LOCK_TIMEOUT_SECONDS.
104
+ ValueError: If an event with the same ID already exists.
105
+ """
85
106
  evt_id = event.id
86
- # Check for duplicate ID
87
- if evt_id in self._id_to_idx:
88
- existing_idx = self._id_to_idx[evt_id]
89
- raise ValueError(
90
- f"Event with ID '{evt_id}' already exists at index {existing_idx}"
107
+
108
+ try:
109
+ with self._fs.lock(self._lock_path, timeout=LOCK_TIMEOUT_SECONDS):
110
+ # Sync with disk in case another process wrote while we waited
111
+ disk_length = self._count_events_on_disk()
112
+ if disk_length > self._length:
113
+ self._sync_from_disk(disk_length)
114
+
115
+ if evt_id in self._id_to_idx:
116
+ existing_idx = self._id_to_idx[evt_id]
117
+ raise ValueError(
118
+ f"Event with ID '{evt_id}' already exists at index "
119
+ f"{existing_idx}"
120
+ )
121
+
122
+ target_path = self._path(self._length, event_id=evt_id)
123
+ self._fs.write(target_path, event.model_dump_json(exclude_none=True))
124
+ self._idx_to_id[self._length] = evt_id
125
+ self._id_to_idx[evt_id] = self._length
126
+ self._length += 1
127
+ except TimeoutError:
128
+ logger.error(
129
+ f"Failed to acquire EventLog lock within {LOCK_TIMEOUT_SECONDS}s "
130
+ f"for event {evt_id}"
91
131
  )
132
+ raise
92
133
 
93
- path = self._path(self._length, event_id=evt_id)
94
- self._fs.write(path, event.model_dump_json(exclude_none=True))
95
- self._idx_to_id[self._length] = evt_id
96
- self._id_to_idx[evt_id] = self._length
97
- self._length += 1
134
+ def _count_events_on_disk(self) -> int:
135
+ """Count event files on disk."""
136
+ try:
137
+ paths = self._fs.list(self._dir)
138
+ except FileNotFoundError:
139
+ # Directory doesn't exist yet - expected for new event logs
140
+ return 0
141
+ except Exception as e:
142
+ logger.warning("Error listing event directory %s: %s", self._dir, e)
143
+ return 0
144
+ return sum(
145
+ 1
146
+ for p in paths
147
+ if p.rsplit("/", 1)[-1].startswith("event-") and p.endswith(".json")
148
+ )
149
+
150
+ def _sync_from_disk(self, disk_length: int) -> None:
151
+ """Sync state for events written by other processes.
152
+
153
+ Preserves existing index mappings and only scans new events.
154
+ """
155
+ # Preserve existing mappings
156
+ existing_idx_to_id = dict(self._idx_to_id)
157
+
158
+ # Re-scan to pick up new events
159
+ scanned_length = self._scan_and_build_index()
160
+
161
+ # Restore any mappings that were lost (e.g., for non-UUID event IDs)
162
+ for idx, evt_id in existing_idx_to_id.items():
163
+ if idx not in self._idx_to_id:
164
+ self._idx_to_id[idx] = evt_id
165
+ if evt_id not in self._id_to_idx:
166
+ self._id_to_idx[evt_id] = idx
167
+
168
+ # Use the higher of scanned length or disk_length
169
+ self._length = max(scanned_length, disk_length)
98
170
 
99
171
  def __len__(self) -> int:
100
172
  return self._length
@@ -40,6 +40,7 @@ from openhands.sdk.security.analyzer import SecurityAnalyzerBase
40
40
  from openhands.sdk.security.confirmation_policy import (
41
41
  ConfirmationPolicyBase,
42
42
  )
43
+ from openhands.sdk.utils.cipher import Cipher
43
44
  from openhands.sdk.workspace import LocalWorkspace
44
45
 
45
46
 
@@ -77,6 +78,7 @@ class LocalConversation(BaseConversation):
77
78
  type[ConversationVisualizerBase] | ConversationVisualizerBase | None
78
79
  ) = DefaultConversationVisualizer,
79
80
  secrets: Mapping[str, SecretValue] | None = None,
81
+ cipher: Cipher | None = None,
80
82
  **_: object,
81
83
  ):
82
84
  """Initialize the conversation.
@@ -105,6 +107,10 @@ class LocalConversation(BaseConversation):
105
107
  a dict with keys: 'action_observation', 'action_error',
106
108
  'monologue', 'alternating_pattern'. Values are integers
107
109
  representing the number of repetitions before triggering.
110
+ cipher: Optional cipher for encrypting/decrypting secrets in persisted
111
+ state. If provided, secrets are encrypted when saving and
112
+ decrypted when loading. If not provided, secrets are redacted
113
+ (lost) on serialization.
108
114
  """
109
115
  super().__init__() # Initialize with span tracking
110
116
  # Mark cleanup as initiated as early as possible to avoid races or partially
@@ -134,6 +140,7 @@ class LocalConversation(BaseConversation):
134
140
  else None,
135
141
  max_iterations=max_iteration_per_run,
136
142
  stuck_detection=stuck_detection,
143
+ cipher=cipher,
137
144
  )
138
145
 
139
146
  # Default callback: persist every event to state
@@ -23,6 +23,7 @@ from openhands.sdk.security.confirmation_policy import (
23
23
  ConfirmationPolicyBase,
24
24
  NeverConfirm,
25
25
  )
26
+ from openhands.sdk.utils.cipher import Cipher
26
27
  from openhands.sdk.utils.models import OpenHandsModel
27
28
  from openhands.sdk.workspace.base import BaseWorkspace
28
29
 
@@ -124,6 +125,7 @@ class ConversationState(OpenHandsModel):
124
125
  # ===== Private attrs (NOT Fields) =====
125
126
  _fs: FileStore = PrivateAttr() # filestore for persistence
126
127
  _events: EventLog = PrivateAttr() # now the storage for events
128
+ _cipher: Cipher | None = PrivateAttr(default=None) # cipher for secret encryption
127
129
  _autosave_enabled: bool = PrivateAttr(
128
130
  default=False
129
131
  ) # to avoid recursion during init
@@ -166,8 +168,20 @@ class ConversationState(OpenHandsModel):
166
168
  def _save_base_state(self, fs: FileStore) -> None:
167
169
  """
168
170
  Persist base state snapshot (no events; events are file-backed).
171
+
172
+ If a cipher is configured, secrets will be encrypted. Otherwise, they
173
+ will be redacted (serialized as '**********').
169
174
  """
170
- payload = self.model_dump_json(exclude_none=True)
175
+ context = {"cipher": self._cipher} if self._cipher else None
176
+ # Warn if secrets exist but no cipher is configured
177
+ if not self._cipher and self.secret_registry.secret_sources:
178
+ logger.warning(
179
+ f"Saving conversation state without cipher - "
180
+ f"{len(self.secret_registry.secret_sources)} secret(s) will be "
181
+ "redacted and lost on restore. Consider providing a cipher to "
182
+ "preserve secrets."
183
+ )
184
+ payload = self.model_dump_json(exclude_none=True, context=context)
171
185
  fs.write(BASE_STATE, payload)
172
186
 
173
187
  # ===== Factory: open-or-create (no load/save methods needed) =====
@@ -180,6 +194,7 @@ class ConversationState(OpenHandsModel):
180
194
  persistence_dir: str | None = None,
181
195
  max_iterations: int = 500,
182
196
  stuck_detection: bool = True,
197
+ cipher: Cipher | None = None,
183
198
  ) -> "ConversationState":
184
199
  """Create a new conversation state or resume from persistence.
185
200
 
@@ -203,6 +218,10 @@ class ConversationState(OpenHandsModel):
203
218
  persistence_dir: Directory for persisting state and events
204
219
  max_iterations: Maximum iterations per run
205
220
  stuck_detection: Whether to enable stuck detection
221
+ cipher: Optional cipher for encrypting/decrypting secrets in
222
+ persisted state. If provided, secrets are encrypted when
223
+ saving and decrypted when loading. If not provided, secrets
224
+ are redacted (lost) on serialization.
206
225
 
207
226
  Returns:
208
227
  ConversationState ready for use
@@ -224,7 +243,9 @@ class ConversationState(OpenHandsModel):
224
243
 
225
244
  # ---- Resume path ----
226
245
  if base_text:
227
- state = cls.model_validate(json.loads(base_text))
246
+ # Use cipher context for decrypting secrets if provided
247
+ context = {"cipher": cipher} if cipher else None
248
+ state = cls.model_validate(json.loads(base_text), context=context)
228
249
 
229
250
  # Restore the conversation with the same id
230
251
  if state.id != id:
@@ -236,6 +257,7 @@ class ConversationState(OpenHandsModel):
236
257
  # Attach event log early so we can read history for tool verification
237
258
  state._fs = file_store
238
259
  state._events = EventLog(file_store, dir_path=EVENTS_DIR)
260
+ state._cipher = cipher
239
261
 
240
262
  # Verify compatibility (agent class + tools)
241
263
  agent.verify(state.agent, events=state._events)
@@ -272,6 +294,7 @@ class ConversationState(OpenHandsModel):
272
294
  )
273
295
  state._fs = file_store
274
296
  state._events = EventLog(file_store, dir_path=EVENTS_DIR)
297
+ state._cipher = cipher
275
298
  state.stats = ConversationStats()
276
299
 
277
300
  state._save_base_state(file_store) # initial snapshot
@@ -1,4 +1,5 @@
1
1
  from pydantic import Field
2
+ from rich.text import Text
2
3
 
3
4
  from openhands.sdk.event.base import Event
4
5
 
@@ -23,3 +24,14 @@ class ConversationErrorEvent(Event):
23
24
 
24
25
  code: str = Field(description="Code for the error - typically a type")
25
26
  detail: str = Field(description="Details about the error")
27
+
28
+ @property
29
+ def visualize(self) -> Text:
30
+ """Return Rich Text representation of this conversation error event."""
31
+ content = Text()
32
+ content.append("Conversation Error\n", style="bold")
33
+ content.append("Code: ", style="bold")
34
+ content.append(self.code)
35
+ content.append("\n\nDetail:\n", style="bold")
36
+ content.append(self.detail)
37
+ return content
openhands/sdk/io/base.py CHANGED
@@ -1,4 +1,6 @@
1
1
  from abc import ABC, abstractmethod
2
+ from collections.abc import Iterator
3
+ from contextlib import contextmanager
2
4
 
3
5
 
4
6
  class FileStore(ABC):
@@ -6,6 +8,9 @@ class FileStore(ABC):
6
8
 
7
9
  This class defines the interface for file storage backends that can
8
10
  handle basic file operations like reading, writing, listing, and deleting files.
11
+
12
+ Implementations should provide a locking mechanism via the `lock()` context
13
+ manager for thread/process-safe operations.
9
14
  """
10
15
 
11
16
  @abstractmethod
@@ -46,3 +51,50 @@ class FileStore(ABC):
46
51
  Args:
47
52
  path: The file or directory path to delete.
48
53
  """
54
+
55
+ @abstractmethod
56
+ def exists(self, path: str) -> bool:
57
+ """Check if a file or directory exists at the specified path.
58
+
59
+ Args:
60
+ path: The file or directory path to check.
61
+
62
+ Returns:
63
+ True if the path exists, False otherwise.
64
+ """
65
+
66
+ @abstractmethod
67
+ def get_absolute_path(self, path: str) -> str:
68
+ """Get the absolute filesystem path for a given relative path.
69
+
70
+ Args:
71
+ path: The relative path within the file store.
72
+
73
+ Returns:
74
+ The absolute path on the filesystem.
75
+ """
76
+
77
+ @abstractmethod
78
+ @contextmanager
79
+ def lock(self, path: str, timeout: float = 30.0) -> Iterator[None]:
80
+ """Acquire an exclusive lock for the given path.
81
+
82
+ This context manager provides thread and process-safe locking.
83
+ Implementations may use file-based locking, threading locks, or
84
+ other mechanisms as appropriate.
85
+
86
+ Args:
87
+ path: The path to lock (used to identify the lock).
88
+ timeout: Maximum seconds to wait for lock acquisition.
89
+
90
+ Yields:
91
+ None when lock is acquired.
92
+
93
+ Raises:
94
+ TimeoutError: If lock cannot be acquired within timeout.
95
+
96
+ Note:
97
+ File-based locking (flock) does NOT work reliably on NFS mounts
98
+ or network filesystems.
99
+ """
100
+ yield # pragma: no cover
openhands/sdk/io/local.py CHANGED
@@ -1,5 +1,9 @@
1
1
  import os
2
2
  import shutil
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+
6
+ from filelock import FileLock, Timeout
3
7
 
4
8
  from openhands.sdk.io.cache import MemoryLRUCache
5
9
  from openhands.sdk.logger import get_logger
@@ -117,3 +121,24 @@ class LocalFileStore(FileStore):
117
121
 
118
122
  except Exception as e:
119
123
  logger.error(f"Error clearing local file store: {str(e)}")
124
+
125
+ def exists(self, path: str) -> bool:
126
+ """Check if a file or directory exists."""
127
+ return os.path.exists(self.get_full_path(path))
128
+
129
+ def get_absolute_path(self, path: str) -> str:
130
+ """Get absolute filesystem path."""
131
+ return self.get_full_path(path)
132
+
133
+ @contextmanager
134
+ def lock(self, path: str, timeout: float = 30.0) -> Iterator[None]:
135
+ """Acquire file-based lock using flock."""
136
+ lock_path = self.get_full_path(path)
137
+ os.makedirs(os.path.dirname(lock_path), exist_ok=True)
138
+ file_lock = FileLock(lock_path)
139
+ try:
140
+ with file_lock.acquire(timeout=timeout):
141
+ yield
142
+ except Timeout:
143
+ logger.error(f"Failed to acquire lock within {timeout}s: {lock_path}")
144
+ raise TimeoutError(f"Lock acquisition timed out: {path}")
@@ -1,4 +1,8 @@
1
1
  import os
2
+ import threading
3
+ import uuid
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
2
6
 
3
7
  from openhands.sdk.io.base import FileStore
4
8
  from openhands.sdk.logger import get_logger
@@ -9,9 +13,13 @@ logger = get_logger(__name__)
9
13
 
10
14
  class InMemoryFileStore(FileStore):
11
15
  files: dict[str, str]
16
+ _instance_id: str
17
+ _lock: threading.Lock
12
18
 
13
19
  def __init__(self, files: dict[str, str] | None = None) -> None:
14
20
  self.files = {}
21
+ self._instance_id = uuid.uuid4().hex
22
+ self._lock = threading.Lock()
15
23
  if files is not None:
16
24
  self.files = files
17
25
 
@@ -51,4 +59,29 @@ class InMemoryFileStore(FileStore):
51
59
  del self.files[key]
52
60
  logger.debug(f"Cleared in-memory file store: {path}")
53
61
  except Exception as e:
54
- logger.error(f"Error clearing in-memory file store: {str(e)}")
62
+ logger.error(f"Error clearing in-memory file store: {e}")
63
+
64
+ def exists(self, path: str) -> bool:
65
+ """Check if a file exists."""
66
+ if path in self.files:
67
+ return True
68
+ return any(f.startswith(path + "/") for f in self.files)
69
+
70
+ def get_absolute_path(self, path: str) -> str:
71
+ """Get absolute path (uses temp dir with unique instance ID)."""
72
+ import tempfile
73
+
74
+ return os.path.join(
75
+ tempfile.gettempdir(), f"openhands_inmemory_{self._instance_id}", path
76
+ )
77
+
78
+ @contextmanager
79
+ def lock(self, path: str, timeout: float = 30.0) -> Iterator[None]:
80
+ """Acquire thread lock for in-memory store."""
81
+ acquired = self._lock.acquire(timeout=timeout)
82
+ if not acquired:
83
+ raise TimeoutError(f"Lock acquisition timed out: {path}")
84
+ try:
85
+ yield
86
+ finally:
87
+ self._lock.release()
openhands/sdk/llm/llm.py CHANGED
@@ -424,8 +424,11 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
424
424
  ) -> None:
425
425
  if self.retry_listener is not None:
426
426
  self.retry_listener(attempt_number, num_retries, _err)
427
- if self._telemetry is not None and _err is not None:
428
- self._telemetry.on_error(_err)
427
+ # NOTE: don't call Telemetry.on_error here.
428
+ # This function runs for each retried failure (before the next attempt),
429
+ # which would create noisy duplicate error logs.
430
+ # The completion()/responses() exception handlers call Telemetry.on_error
431
+ # after retries are exhausted (final failure), which is what we want to log.
429
432
 
430
433
  # =========================================================================
431
434
  # Serializers
@@ -697,6 +700,7 @@ class LLM(BaseModel, RetryMixin, NonNativeToolCallingMixin):
697
700
  telemetry_ctx.update(
698
701
  {
699
702
  "llm_path": "responses",
703
+ "instructions": instructions,
700
704
  "input": input_items[:],
701
705
  "tools": tools,
702
706
  "kwargs": {k: v for k, v in call_kwargs.items()},
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  import os
3
3
  import time
4
+ import traceback
4
5
  import uuid
5
6
  import warnings
6
7
  from collections.abc import Callable
@@ -121,7 +122,46 @@ class Telemetry(BaseModel):
121
122
  return self.metrics.deep_copy()
122
123
 
123
124
  def on_error(self, _err: BaseException) -> None:
124
- # Stub for error tracking / counters
125
+ # Best-effort logging for failed requests (so we can debug malformed
126
+ # request payloads, e.g. orphaned Responses reasoning items).
127
+ self._last_latency = time.time() - (self._req_start or time.time())
128
+
129
+ if not self.log_enabled:
130
+ return
131
+ if not self.log_dir and not self._log_completions_callback:
132
+ return
133
+
134
+ try:
135
+ filename = (
136
+ f"{self.model_name.replace('/', '__')}-"
137
+ f"{time.time():.3f}-"
138
+ f"{uuid.uuid4().hex[:4]}-error.json"
139
+ )
140
+
141
+ data = self._req_ctx.copy()
142
+ data["error"] = {
143
+ "type": type(_err).__name__,
144
+ "message": str(_err),
145
+ "repr": repr(_err),
146
+ "traceback": "".join(
147
+ traceback.format_exception(type(_err), _err, _err.__traceback__)
148
+ ),
149
+ }
150
+ data["timestamp"] = time.time()
151
+ data["latency_sec"] = self._last_latency
152
+ data["cost"] = 0.0
153
+
154
+ log_data = json.dumps(data, default=_safe_json, ensure_ascii=False)
155
+
156
+ if self._log_completions_callback:
157
+ self._log_completions_callback(filename, log_data)
158
+ elif self.log_dir:
159
+ os.makedirs(self.log_dir, exist_ok=True)
160
+ fname = os.path.join(self.log_dir, filename)
161
+ with open(fname, "w", encoding="utf-8") as f:
162
+ f.write(log_data)
163
+ except Exception as e:
164
+ warnings.warn(f"Telemetry error logging failed: {e}")
125
165
  return
126
166
 
127
167
  # ---------- Helpers ----------
@@ -335,7 +375,6 @@ class Telemetry(BaseModel):
335
375
  os.makedirs(self.log_dir, exist_ok=True)
336
376
  if not os.access(self.log_dir, os.W_OK):
337
377
  raise PermissionError(f"log_dir is not writable: {self.log_dir}")
338
-
339
378
  fname = os.path.join(self.log_dir, filename)
340
379
  with open(fname, "w", encoding="utf-8") as f:
341
380
  f.write(log_data)
@@ -5,10 +5,14 @@ from abc import ABC, abstractmethod
5
5
  import httpx
6
6
  from pydantic import Field, SecretStr, field_serializer, field_validator
7
7
 
8
+ from openhands.sdk.logger import get_logger
8
9
  from openhands.sdk.utils.models import DiscriminatedUnionMixin
9
10
  from openhands.sdk.utils.pydantic_secrets import serialize_secret, validate_secret
10
11
 
11
12
 
13
+ logger = get_logger(__name__)
14
+
15
+
12
16
  class SecretSource(DiscriminatedUnionMixin, ABC):
13
17
  """Source for a named secret which may be obtained dynamically"""
14
18
 
@@ -25,9 +29,11 @@ class SecretSource(DiscriminatedUnionMixin, ABC):
25
29
  class StaticSecret(SecretSource):
26
30
  """A secret stored locally"""
27
31
 
28
- value: SecretStr
32
+ value: SecretStr | None = None
29
33
 
30
- def get_value(self):
34
+ def get_value(self) -> str | None:
35
+ if self.value is None:
36
+ return None
31
37
  return self.value.get_secret_value()
32
38
 
33
39
  @field_validator("value")
@@ -58,7 +64,12 @@ class LookupSecret(SecretSource):
58
64
  for key, value in headers.items():
59
65
  if _is_secret_header(key):
60
66
  secret_value = validate_secret(SecretStr(value), info)
61
- assert secret_value is not None
67
+ # Skip headers with redacted/empty secret values
68
+ if secret_value is None:
69
+ logger.debug(
70
+ f"Skipping redacted header '{key}' during deserialization"
71
+ )
72
+ continue
62
73
  result[key] = secret_value.get_secret_value()
63
74
  else:
64
75
  result[key] = value
@@ -70,7 +81,11 @@ class LookupSecret(SecretSource):
70
81
  for key, value in headers.items():
71
82
  if _is_secret_header(key):
72
83
  secret_value = serialize_secret(SecretStr(value), info)
73
- assert secret_value is not None
84
+ if secret_value is None:
85
+ logger.debug(
86
+ f"Skipping redacted header '{key}' during serialization"
87
+ )
88
+ continue
74
89
  result[key] = secret_value
75
90
  else:
76
91
  result[key] = value
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-sdk
3
- Version: 1.8.1
3
+ Version: 1.8.2
4
4
  Summary: OpenHands SDK - Core functionality for building AI agents
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: deprecation>=2.1.0
7
7
  Requires-Dist: fastmcp>=2.11.3
8
+ Requires-Dist: filelock>=3.20.1
8
9
  Requires-Dist: httpx>=0.27.0
9
10
  Requires-Dist: litellm>=1.80.10
10
11
  Requires-Dist: pydantic>=2.12.5
@@ -2,14 +2,14 @@ openhands/sdk/__init__.py,sha256=FNRcPyCbvwYGrbSCxox_UWIHK5JuyCLHCyPLPZRd5sA,258
2
2
  openhands/sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  openhands/sdk/agent/__init__.py,sha256=yOn1ZCgTTq2VJlTzKDSzmWVPli1siBzqV89vlEHCwOg,137
4
4
  openhands/sdk/agent/agent.py,sha256=X00fM2FSjlZF36Y5E_-JkYS-n_x5RoEupTj2Rp6Gj_k,26361
5
- openhands/sdk/agent/base.py,sha256=ctAB3MnbnDLLLg1xlWJw6AovK494wiQrFigT57Eo_Z0,18991
5
+ openhands/sdk/agent/base.py,sha256=GDT1sJsU3wIya0pYhfibuwQI3eejTNJ6DCFVUZnvg3k,19350
6
6
  openhands/sdk/agent/utils.py,sha256=AY_7VBFaJ2iorfh0MiS-mXaK6f8ONDejNcbkde_xTGg,8274
7
7
  openhands/sdk/agent/prompts/in_context_learning_example.j2,sha256=MGB0dPUlh6pwLoR_dBK-M3e5dtETX6C6WNjPcPixZmU,5512
8
8
  openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2,sha256=k3Zwnd7Iq7kL4lo307RDuu1mxWXn6pSLsEdvKEXN3BU,164
9
9
  openhands/sdk/agent/prompts/security_policy.j2,sha256=K56d2aaZ88DI-y2DsMSDaiZRTTnkkzuBLjbzXfKHGA8,993
10
10
  openhands/sdk/agent/prompts/security_risk_assessment.j2,sha256=7o1tk6MIQpVD7sAES-sBhw4ckYLGQydzYnjjNitP5iY,1196
11
11
  openhands/sdk/agent/prompts/self_documentation.j2,sha256=Q0nLryVYnGg8Qk056-3tW2WJ2rwBijC5iqJByEdGAT0,1063
12
- openhands/sdk/agent/prompts/system_prompt.j2,sha256=KTBEZuez91aqLPX926kEzpRKoVHfXj8XVVUcxZjODhw,9055
12
+ openhands/sdk/agent/prompts/system_prompt.j2,sha256=3-LMccbsoPXeX21Nbxy3xrTkP2wYSExrFr7GSKbWsJk,9283
13
13
  openhands/sdk/agent/prompts/system_prompt_interactive.j2,sha256=AW3rGuqu82BqbS1XMXVO4Fp-Apa8DPYZV3_nQYkzVtM,1388
14
14
  openhands/sdk/agent/prompts/system_prompt_long_horizon.j2,sha256=_oOHRIer_FSuRrBOSOPpe5Ueo9KgSTba5SPoHHpghCI,2995
15
15
  openhands/sdk/agent/prompts/system_prompt_planning.j2,sha256=wh01KX7yzeUSn5PtG3Ij0keRM6vmQuZZM6I0Fod4DK4,2934
@@ -20,10 +20,10 @@ openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2,sha256=5iJv
20
20
  openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2,sha256=iMnSKjg2nrFoRUCkNMlGLoMmNWv_9bzK7D82dkJZCbQ,466
21
21
  openhands/sdk/context/__init__.py,sha256=r2YtpZMVFIIaKYVh2yjicTXYPURGhIUUdbliaLYbnnA,609
22
22
  openhands/sdk/context/agent_context.py,sha256=TUQB-RBmkKvnwMLKvG9eOc5TVlRnDtBOSeoXRA28JoA,12425
23
- openhands/sdk/context/view.py,sha256=Qi0H8vsCv2RDBpKBEurlknU2F37WvkI5xofvHAfd_v4,20489
23
+ openhands/sdk/context/view.py,sha256=PtGPJunWSylUY3eSa0K1Oyrij2JpYH-Nv5wywLZHcYg,20244
24
24
  openhands/sdk/context/condenser/__init__.py,sha256=peHKPk51AujZhXvR2H0j1vBUJsCc8D6-OMHZ4Vk-pxU,568
25
25
  openhands/sdk/context/condenser/base.py,sha256=4_heKNni0BW3Lib3WMVRpzm8G03opW6VcHH9M_mV-j0,5869
26
- openhands/sdk/context/condenser/llm_summarizing_condenser.py,sha256=LjILEVsg5XB49Z5tig2c4QyRRPK4KzPM0b8-MKuIOVE,10688
26
+ openhands/sdk/context/condenser/llm_summarizing_condenser.py,sha256=x8Z0hDDyFcCUwypkavQyLYh3YHRCzcRwi2tpS0bttGI,10812
27
27
  openhands/sdk/context/condenser/no_op_condenser.py,sha256=T87bTtJw4dqlOIZZZ4R_JFPXeSymDqlbsZtH6ng7N1E,474
28
28
  openhands/sdk/context/condenser/pipeline_condenser.py,sha256=wkbEA6R8u8u3Wi1AQmx1AKF9hQ25dLSeKuvB56N1Ohc,2145
29
29
  openhands/sdk/context/condenser/utils.py,sha256=kI4oechGeozHRTFPcq6UVbGgLL-6msR3D2-4fPssFVU,5599
@@ -43,7 +43,7 @@ openhands/sdk/conversation/__init__.py,sha256=1-xh49S2KJhtAjkHDaDHU28UWhfQLl3CeF
43
43
  openhands/sdk/conversation/base.py,sha256=sVpfqtyTVi7_d8rzI_gemZGHusVrNy9S_yNJmTGMEq8,8964
44
44
  openhands/sdk/conversation/conversation.py,sha256=KN4mqYqrzc5LWksliDmRV-wNg1FdF0IVyaopOKeajas,6079
45
45
  openhands/sdk/conversation/conversation_stats.py,sha256=ZlQ99kgG5YVCrZ4rqJlq63JaiInxX8jqv-q5lS7RN68,3038
46
- openhands/sdk/conversation/event_store.py,sha256=he-bwP823s5zAIdua_0ZgkkHQCJoAqbtV2SN0hibX30,5207
46
+ openhands/sdk/conversation/event_store.py,sha256=JZF6AibFezcIzEw4IQqKHG8T8s53SfD_LkZycsOH6xY,7992
47
47
  openhands/sdk/conversation/events_list_base.py,sha256=n_YvgbhBPOPDbw4Kp68J0EKFM39vg95ng09GMfTz29s,505
48
48
  openhands/sdk/conversation/exceptions.py,sha256=9XkDVj2Q1PbB9GAIPLQ03M-r0r94ZRY1PmKZpSTDCY8,1757
49
49
  openhands/sdk/conversation/fifo_lock.py,sha256=nY5RsobNvVXBbAzwjqIxyQwPUh0AzffbTZw4PewhTTI,4240
@@ -51,12 +51,12 @@ openhands/sdk/conversation/persistence_const.py,sha256=om3pOQa5sGK8t_NUYb3Tz-7sK
51
51
  openhands/sdk/conversation/response_utils.py,sha256=rPlC3cDSmoQte6NZ0kK6h6-9ho5cbF8jEw-DiyEhgIM,1548
52
52
  openhands/sdk/conversation/secret_registry.py,sha256=6fY1zRxb55rC4uIMFcR0lDssIyyjaPh9pCWGqDikrek,4446
53
53
  openhands/sdk/conversation/serialization_diff.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
54
- openhands/sdk/conversation/state.py,sha256=1TsOYXfXSKQ4odsZH8yFJ_d_zGZKPseOGvDVUaf1MzQ,16235
54
+ openhands/sdk/conversation/state.py,sha256=VF3PD2THqgci6mA-meMaNGpB1KuCEW_4AOtHEIQ0flQ,17557
55
55
  openhands/sdk/conversation/stuck_detector.py,sha256=4UNU7Bh9M2yrw3RavMzZeFGQ5RutqavkckW3exFPqQw,11673
56
56
  openhands/sdk/conversation/title_utils.py,sha256=j40-dP-Oes-mhU2xUC7fCC8cB0wkMdbbDJU7WLHiVIo,7063
57
57
  openhands/sdk/conversation/types.py,sha256=q_yc3VNc8r3cdmvPXzpj7HvdLeDqv-37hCgOWMU65a4,1507
58
58
  openhands/sdk/conversation/impl/__init__.py,sha256=DmDFyNR4RU8eiMocKf2j9eBQomipP-rrJgU1LoVWTDA,220
59
- openhands/sdk/conversation/impl/local_conversation.py,sha256=-3ZfsxWgmC0lSf78rbylHJZlGXUdLorAADq-PJhX4ds,29163
59
+ openhands/sdk/conversation/impl/local_conversation.py,sha256=Egdr7YLdZNcjmKlijracQigaoSBoiR2roinIWV2Ubnc,29559
60
60
  openhands/sdk/conversation/impl/remote_conversation.py,sha256=G8Asgg99gBxmQa2wsp-S6u8nEqJOq9DYjpzVNAi6AEI,38011
61
61
  openhands/sdk/conversation/visualizer/__init__.py,sha256=0LXpKlt2eJcrqP1z6jQP_nLx23V8ErnQkKYSxvUp0_A,275
62
62
  openhands/sdk/conversation/visualizer/base.py,sha256=77DdRdHAPSESxRCYyRRSOK7ROBlljscxogkFFr4YgM0,2323
@@ -70,7 +70,7 @@ openhands/sdk/critic/impl/pass_critic.py,sha256=kikTVZmve0DCTkQnhRutdUSff0OLbqH0
70
70
  openhands/sdk/event/__init__.py,sha256=ir-jRVA0QjEbFuDzJOYRq2kXTgUHs8eJ7_skoCRNzr8,1141
71
71
  openhands/sdk/event/base.py,sha256=QpQtYfYiZqaKWA-2xa4GEmk2qkUHKD6x-OIAk7SMnDs,5568
72
72
  openhands/sdk/event/condenser.py,sha256=sne9CxhhNq9UvJMKuKcw8tXuMgutuGh85TbbZHwYhPQ,2481
73
- openhands/sdk/event/conversation_error.py,sha256=gZMyliJx1xbyoJFYH0-AEHqznyiA8LZ7olPSidaNfuc,957
73
+ openhands/sdk/event/conversation_error.py,sha256=73HDgrPDXba2_IMk3sMgk85_glkaKlZzEOk0afhm47Q,1392
74
74
  openhands/sdk/event/conversation_state.py,sha256=V-ti5SLL5SL330sEOQgpy-U8tErVwZYG0iC3AqjTUwo,3650
75
75
  openhands/sdk/event/llm_completion_log.py,sha256=VCxJiZBsn1F6TRV6fwvsPs6W9DpjghfIFmJJlGKztXg,1232
76
76
  openhands/sdk/event/token.py,sha256=QlEbBrfZaHe9tp8-Ot4vTd0T-_Vj7lpUqKfVJcVHOeI,497
@@ -93,12 +93,12 @@ openhands/sdk/hooks/executor.py,sha256=K-nE92r_6dsNV_ISEhoqIyxGvYzD9wgwSyHFM456N
93
93
  openhands/sdk/hooks/manager.py,sha256=woce5tBGaygQvKL_p3K77Xq9K8QMwsbZYjEmDCGHoIY,5864
94
94
  openhands/sdk/hooks/types.py,sha256=ZQp2_HVvOWI9YQPZInFJ0QdhpKo4UxGmq4YLYz9zfOY,1066
95
95
  openhands/sdk/io/__init__.py,sha256=6pXTWP03Wn5S7b6fOT0g3PYn-qSoEGGdrrwBqALYGA4,165
96
- openhands/sdk/io/base.py,sha256=kAcX0chfCswakiieJlKiHWoJgL3zOtaQauRqMPNYfW8,1355
96
+ openhands/sdk/io/base.py,sha256=m-tMq3Em4DOd17cP0T6-gc9c_7FE-HakcPLScGGycVE,2958
97
97
  openhands/sdk/io/cache.py,sha256=TZD9Px-WK303WGjm5nin9n3TCKGiAJ-PIqrM5MFeg5c,2908
98
- openhands/sdk/io/local.py,sha256=IhU4OLP3-S8UCCWTm_xqw7XDYgeYms77b6I_FhZq0iU,4232
99
- openhands/sdk/io/memory.py,sha256=XIsdXsSyF-PzoYVmvJuO7Vtz-k3D5jMOFoZ5gHw8tbA,1712
98
+ openhands/sdk/io/local.py,sha256=5ZibVqYj7hV5gpgYr2SKq2aSsAL1m2JykhtmRyVKyf8,5189
99
+ openhands/sdk/io/memory.py,sha256=_GQ8WqilLJgdxtRccfVS653VDy1bo6CyZJDHGUQXEm8,2793
100
100
  openhands/sdk/llm/__init__.py,sha256=k8UneyfoDUMe0lSP4GSlYzrL2Fe3MkDUKpSg2OIDi_I,1206
101
- openhands/sdk/llm/llm.py,sha256=TqnqMd-i2EEF74y_3oOuOEbei912QY66JRFjG8REJt4,44139
101
+ openhands/sdk/llm/llm.py,sha256=5T89gYuqvrsEqlyr_5j060muvXgpvia9FVhP0uM6Duo,44442
102
102
  openhands/sdk/llm/llm_registry.py,sha256=DL9yqSbAM7OBkzdIChLuxG2qk_oElW2tC2xem6mq0F8,3530
103
103
  openhands/sdk/llm/llm_response.py,sha256=DaBVBkij4Sz-RsYhRb3UUcvJCTzCBcOYQ9IhFwN4ukI,1988
104
104
  openhands/sdk/llm/message.py,sha256=Tw7sIw5A-rZqIH8dPI0UnoXf0KuPB8tURcJMQyv9JZg,25507
@@ -122,7 +122,7 @@ openhands/sdk/llm/utils/model_features.py,sha256=vAsRbD0dTIVM7SZUjO3Vwyd2CfDYxcR
122
122
  openhands/sdk/llm/utils/model_info.py,sha256=1mFYA7OcEyUB6k1doao8_w1XT7UMM_DAm57HcTpKkLw,2628
123
123
  openhands/sdk/llm/utils/model_prompt_spec.py,sha256=onw9-y7x0aJS8IOjNzeqhdvcFNwK1l_s0XgurWlnj5o,2587
124
124
  openhands/sdk/llm/utils/retry_mixin.py,sha256=M-hXp8EwP1FjNN6tgHiv133BtUQgRr9Kz_ZWxeAJLGA,4765
125
- openhands/sdk/llm/utils/telemetry.py,sha256=_9QedIEgHy_y-iadaofzDYU1_nQBjvbMR-H-KO6C3ss,13982
125
+ openhands/sdk/llm/utils/telemetry.py,sha256=E7fDPXFdy3u3IVPTOijUCDw8vtqtMPjyhhlIg-NwU-M,15533
126
126
  openhands/sdk/llm/utils/unverified_models.py,sha256=SmYrX_WxXOJBanTviztqy1xPjOcLY4i3qvwNBEga_Dk,4797
127
127
  openhands/sdk/llm/utils/verified_models.py,sha256=_ioY2NHay8MQmfeiWRkT_53pA7DFhldopfQotTwORbk,1462
128
128
  openhands/sdk/logger/__init__.py,sha256=vZvFDYfW01Y8Act3tveMs3XxTysJlt4HeT-n6X_ujYk,330
@@ -141,7 +141,7 @@ openhands/sdk/plugin/__init__.py,sha256=NCzotHxOH7FfNVcJFy0ttNHOXf3xesHbDQ5sBa1t
141
141
  openhands/sdk/plugin/plugin.py,sha256=WiY_3n69kDYDPwY2srurYWbOLYO5ZiPtwJ0yOHc45jU,9970
142
142
  openhands/sdk/plugin/types.py,sha256=kYrlSKNnxZJiIB2XaJ2j4iKnG9jTXT8DIz54IzU6qyE,8122
143
143
  openhands/sdk/secret/__init__.py,sha256=Y-M2WPfYLfVYZSFZGTf6MDOnjsL5SayETtA4t9X0gjw,348
144
- openhands/sdk/secret/secrets.py,sha256=1PkD9oXJJlPgsN4D3brv3_HjIc2IQGy5BbcqZKJ_8Ew,2737
144
+ openhands/sdk/secret/secrets.py,sha256=RFhyTBxwGgeYoojD56DtI_4CL43R8emo8WzvUH-8Z4w,3281
145
145
  openhands/sdk/security/__init__.py,sha256=x2-a0fsImxSM2msHl7gV0fENWyZbpY7-HW_CBwNrPZY,89
146
146
  openhands/sdk/security/analyzer.py,sha256=V6Fowh83sfEr1SdwK3UW1PNQHahHxwHfla0KndTombk,3994
147
147
  openhands/sdk/security/confirmation_policy.py,sha256=bsrIOfo3QOXNyKrmUVzQz070F3xbJ7uZp9WTr4RMdl8,2145
@@ -178,7 +178,7 @@ openhands/sdk/workspace/remote/__init__.py,sha256=eKkj6NOESMUBGDVC6_L2Wfuc4K6G-m
178
178
  openhands/sdk/workspace/remote/async_remote_workspace.py,sha256=MfnYoXvx_tZ7MKDGJCofnkYAJxfBKqNtM2Qprx3QQRk,5608
179
179
  openhands/sdk/workspace/remote/base.py,sha256=t-qbouLxkAsPKvVblXWWHKixHsLFz4bw3l9LW_n9_6o,6152
180
180
  openhands/sdk/workspace/remote/remote_workspace_mixin.py,sha256=CzHfnLUIra5sgPkP9kcggb1vHGOPpYQzLsHvGO2rRt0,10963
181
- openhands_sdk-1.8.1.dist-info/METADATA,sha256=RPyL9m_vuHKOVTQoTqsT6bVZFGbTuEh4ka4J-fXA41Q,546
182
- openhands_sdk-1.8.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
183
- openhands_sdk-1.8.1.dist-info/top_level.txt,sha256=jHgVu9I0Blam8BXFgedoGKfglPF8XvW1TsJFIjcgP4E,10
184
- openhands_sdk-1.8.1.dist-info/RECORD,,
181
+ openhands_sdk-1.8.2.dist-info/METADATA,sha256=FMs6gTS3Ot5Y62nDPD0H77XKgFgvvD0mj2b97cXrSQk,578
182
+ openhands_sdk-1.8.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
183
+ openhands_sdk-1.8.2.dist-info/top_level.txt,sha256=jHgVu9I0Blam8BXFgedoGKfglPF8XvW1TsJFIjcgP4E,10
184
+ openhands_sdk-1.8.2.dist-info/RECORD,,